Compare commits

...

175 Commits

Author SHA1 Message Date
Gabi Simons a26abd6b6b feat(slack): guided setup picks webhook vs Socket Mode
setup:auto's Slack flow now asks the delivery mode up front (brightSelect),
then collects only the credential that mode needs:
- Socket Mode → SLACK_APP_TOKEN (xapp-…); skips the public-URL checklist
- Webhook → SLACK_SIGNING_SECRET + the public Request URL checklist (unchanged)

add-slack.sh now requires either SLACK_APP_TOKEN or SLACK_SIGNING_SECRET (was:
signing secret mandatory) and upserts whichever is present. The adapter already
selects the mode from SLACK_APP_TOKEN's presence (this PR) — this is the
guided-setup surface for it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:34:18 +03:00
Gabi Simons e9712d033a feat(slack): add Socket Mode (SLACK_APP_TOKEN)
Set SLACK_APP_TOKEN (xapp-…) and the adapter connects over an outbound
WebSocket (mode: 'socket') instead of an inbound HTTPS webhook — no public
endpoint required. Without the app token, behavior is unchanged (webhook mode);
the signing secret becomes optional under Socket Mode.

Requires @chat-adapter/slack@4.29.0, where Socket Mode is implemented — that pin
landed in the chat-SDK 4.29.0 bump this stacks on. SKILL.md documents the
app-level token (connections:write), enabling Socket Mode, and the
no-public-URL path.

Verified: this exact adapter change builds + passes the slack registration test
on current main at chat@4.29.0 / @chat-adapter/slack@4.29.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:16:10 +03:00
Gabi Simons cf5ac09320 chore(deps): bump @chat-adapter/* + chat to 4.29.0
Companion to the core `chat` 4.29.0 bump on main. Bumps every channel adapter
install pin (8 /add-* SKILL.md + the setup/*.sh install scripts) and the
package.json deps to the matched 4.29.0 release, so `/add-<channel>` installs an
adapter whose ChatInstance matches main's Chat SDK bridge.

`chat` and @chat-adapter/* are version-locked, so all are pinned exactly to
4.29.0. The Slack adapter is proven on current main at chat@4.29.0 (build +
registration test green). Pre-existing standalone build/test failures on this
branch (optional native-adapter packages not installed, branch behind main) are
unchanged by this bump — identical failure set at the 4.26.0 baseline.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses Codex Review P1 on qwibitai/nanoclaw#1967.

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

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

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

## Scope

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pieces:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two changes:

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:18:34 +03:00
gavrielc 677cc47bd1 Merge pull request #1929 from qwibitai/add-slack-imessage
Add Slack and iMessage channel flows (experimental)
2026-04-23 16:00:09 +03:00
exe.dev user 40f5683c36 fix(approvals): show correct post-click labels on channel/sender cards
getAskQuestionRender only checked pending_questions and
pending_approvals, missing the channel and sender approval tables.
Approval button clicks showed the raw value ("approve") instead of
the selectedLabel (" Wired"). Extend the lookup to also check
pending_channel_approvals and pending_sender_approvals.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:12:30 +03:00
gavrielc dee7e0be32 feat(setup): Yes-default + session-persist on claude-assist, quieter first-chat
Three UX tweaks after watching a user walk through setup:

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:50:21 +03:00
gavrielc 092f16dfaa Merge pull request #1927 from qwibitai/setup-feedback-fixes
Clarify setup flow from user-feedback session
2026-04-23 10:43:27 +03:00
gavrielc 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
Ira Abramov ad97829151 docs(add-signal-v2): replace inline voice section with reference to add-voice-transcription-free-whisper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 13:17:57 +03:00
Ira Abramov bc0b559461 docs(add-signal-v2): document XDG attachment path behaviour and voice fix
signal-sdk launches signal-cli without --config, so attachments land at
~/.local/share/signal-cli/attachments/ (XDG default) rather than
data/signal/. Document this in the Channel Info section and add a
troubleshooting entry explaining the symptom (voice messages silently
skipped, no transcript), how to confirm (ps aux | grep signal-cli), and
the automatic fallback the adapter uses.
2026-04-20 12:27:10 +03:00
Ira Abramov 06918f35e0 feat(channels): add Signal channel adapter (v2) — skill and docs
Adds the /add-signal-v2 skill: a native Signal channel adapter wrapping
signal-sdk (signal-cli under the hood). No bot API — NanoClaw registers
as a full Signal account on a dedicated number or as a linked device.

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

Troubleshooting note updated: GroupV2 group ID lives at
envelope.dataMessage.groupV2.id — not groupInfo.groupId (GroupV1/legacy).
2026-04-20 12:11:51 +03:00
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
152 changed files with 16497 additions and 460 deletions
@@ -0,0 +1,243 @@
---
name: add-atomic-chat-tool
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
---
# Add Atomic Chat Integration
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
Tools exposed:
- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
## Phase 1: Pre-flight
### Check if already applied
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
### Check prerequisites
Verify Atomic Chat is installed and its local API server is running. On the host:
```bash
curl -s http://127.0.0.1:1337/v1/models | head
```
If the request fails:
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
2. Open the app.
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
## Phase 2: Apply Code Changes
### Copy the MCP server source
```bash
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
```
### Register the MCP server in the agent-runner
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
};
```
Add an `atomic_chat` entry alongside `nanoclaw`:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
atomic_chat: {
command: 'bun',
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
env: {
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
},
},
};
```
### Add the tool glob to the allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
```ts
'mcp__nanoclaw__*',
'mcp__atomic_chat__*',
];
```
### Forward host env vars into the container
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
```
Add ATOMIC_CHAT forwarding right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
```
### Surface `[ATOMIC]` log lines at info level
In the same file, find the stderr logger:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
```
Replace it with:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (!line) continue;
if (line.includes('[ATOMIC]')) {
log.info(line, { container: agentGroup.folder });
} else {
log.debug(line, { container: agentGroup.folder });
}
}
});
```
### Add env-var stubs to `.env.example`
Append to `.env.example`:
```bash
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
# Default: http://host.docker.internal:1337 (with fallback to localhost)
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
# ATOMIC_CHAT_API_KEY=
```
### Validate code changes
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
All three must be clean before proceeding.
## Phase 3: Configure
### Set Atomic Chat host (optional)
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
```bash
ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337
```
### Set API key (optional)
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
```bash
ATOMIC_CHAT_API_KEY=sk-...
```
### Restart the service
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test inference
Tell the user:
> Send a message like: "use atomic chat to tell me the capital of France"
>
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i atomic
```
Look for:
- `[ATOMIC] Listing models...` — list request started
- `[ATOMIC] Found N models` — models discovered
- `[ATOMIC] >>> Generating with <model>` — generation started
- `[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
## Troubleshooting
### Agent says "Atomic Chat is not installed" or tries to run a CLI
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
4. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Atomic Chat"
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
2. Confirm the Local API Server is enabled in Atomic Chat's settings
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
### `model not found` / 404 on generate
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
### Slow first response
Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
### Agent doesn't use Atomic Chat tools
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
### Context window or output size issues
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
@@ -0,0 +1,229 @@
/**
* Atomic Chat MCP Server for NanoClaw
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
const ATOMIC_CHAT_HOST =
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
function log(msg: string): void {
console.error(`[ATOMIC] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
} catch {
/* best-effort */
}
}
async function atomicFetch(
apiPath: string,
options?: RequestInit,
): Promise<Response> {
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
const headers: Record<string, string> = {
...((options?.headers as Record<string, string>) || {}),
};
if (ATOMIC_CHAT_API_KEY) {
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
}
const finalOptions: RequestInit = { ...options, headers };
try {
return await fetch(url, finalOptions);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, finalOptions);
}
throw err;
}
}
const server = new McpServer({
name: 'atomic_chat',
version: '1.0.0',
});
server.tool(
'atomic_chat_list_models',
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing available models');
try {
const res = await atomicFetch('/v1/models');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
data?: Array<{ id: string; owned_by?: string }>;
};
const models = data.data || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
},
],
};
}
const list = models
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
.join('\n');
log(`Found ${models.length} models`);
return {
content: [
{ type: 'text' as const, text: `Available models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'atomic_chat_generate',
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
),
prompt: z.string().describe('The prompt to send to the model'),
system: z
.string()
.optional()
.describe('Optional system prompt to set model behavior'),
temperature: z
.number()
.optional()
.describe('Sampling temperature (0.02.0). Defaults to model default.'),
max_tokens: z
.number()
.optional()
.describe('Maximum number of tokens to generate in the response.'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const messages: Array<{ role: string; content: string }> = [];
if (args.system) {
messages.push({ role: 'system', content: args.system });
}
messages.push({ role: 'user', content: args.prompt });
const body: Record<string, unknown> = {
model: args.model,
messages,
stream: false,
};
if (args.temperature !== undefined) body.temperature = args.temperature;
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
const startedAt = Date.now();
const res = await atomicFetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Atomic Chat error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
const response = data.choices?.[0]?.message?.content ?? '';
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
const completionTokens = data.usage?.completion_tokens;
const meta = `\n\n[${args.model} | ${elapsedSec}s${
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
}]`;
log(
`<<< Done: ${args.model} | ${elapsedSec}s | ${
completionTokens ?? '?'
} tokens | ${response.length} chars`,
);
writeStatus(
'done',
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
);
return { content: [{ type: 'text' as const, text: response + meta }] };
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
+161
View File
@@ -0,0 +1,161 @@
---
name: add-codex
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
---
# Codex agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
## Install
### Pre-flight
If all of the following are already present, skip to **Configuration**:
- `src/providers/codex.ts`
- `container/agent-runner/src/providers/codex.ts`
- `container/agent-runner/src/providers/codex-app-server.ts`
- `container/agent-runner/src/providers/codex.factory.test.ts`
- `import './codex.js';` line in `src/providers/index.ts`
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch the providers branch
```bash
git fetch origin providers
```
### 2. Copy the Codex source files
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line — alphabetical placement keeps diffs small.
`src/providers/index.ts`:
```typescript
import './codex.js';
```
`container/agent-runner/src/providers/index.ts`:
```typescript
import './codex.js';
```
### 4. Add the Codex CLI to the container Dockerfile
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
```dockerfile
ARG CODEX_VERSION=0.124.0
```
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
### 5. Build
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
```
## Configuration
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
### Option A — ChatGPT subscription (recommended for individuals)
On the host (not inside the container), run Codex's OAuth login:
```bash
codex login
```
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
### Per group / per session
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
## Operational notes
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
## Verify
```bash
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
- `init` event with a stable thread ID as continuation
- One or more `activity` / `progress` events during the turn
- `result` event with the model's reply
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
+62
View File
@@ -0,0 +1,62 @@
# Remove DeltaChat
## 1. Disable the adapter
Comment out the import in `src/channels/index.ts`:
```typescript
// import './deltachat.js';
```
## 2. Remove credentials
Remove the `DC_*` lines from `.env`:
```bash
DC_EMAIL
DC_PASSWORD
DC_IMAP_HOST
DC_IMAP_PORT
DC_SMTP_HOST
DC_SMTP_PORT
```
## 3. Rebuild and restart
```bash
pnpm run build
# Linux
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
## 4. Remove account data (optional)
To fully remove all account data including DeltaChat encryption keys:
```bash
rm -rf dc-account/
```
> **Warning:** This deletes the Autocrypt keys. Contacts who have verified your bot's key will need to re-verify if the same email address is re-used with a new account.
To keep the account for later reinstall, leave `dc-account/` intact.
## 5. Remove the package (optional)
```bash
pnpm remove @deltachat/stdio-rpc-server
```
## Verification
After removal, confirm the adapter is no longer starting:
```bash
grep "deltachat" logs/nanoclaw.log | tail -5
```
Expected: no `Channel adapter started` entry after the last restart.
+254
View File
@@ -0,0 +1,254 @@
---
name: add-deltachat
description: Add DeltaChat channel integration via @deltachat/stdio-rpc-server. Native adapter — no Chat SDK bridge. Email-based messaging with end-to-end encryption.
---
# Add DeltaChat Channel
The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directly — pure Node.js against the DeltaChat core library. Messages are delivered over email with Autocrypt/OpenPGP encryption.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/deltachat.ts` exists
- `src/channels/index.ts` contains `import './deltachat.js';`
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter
```bash
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
```typescript
import './deltachat.js';
```
### 4. Install the adapter package (pinned)
```bash
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 5. Build
```bash
pnpm run build
```
## Account Setup
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
**Default security modes:** IMAP uses SSL/TLS (port 993), SMTP uses STARTTLS (port 587). Both are configurable via `.env` — see Credentials below.
To find the correct hostnames for a domain:
```bash
node -e "require('dns').resolveMx('example.com', (e,r) => console.log(r))"
```
Most providers publish their IMAP/SMTP hostnames in their help docs under "manual setup" or "IMAP access."
## Credentials
Add to `.env`:
```bash
DC_EMAIL=bot@example.com
DC_PASSWORD=your-app-password
DC_IMAP_HOST=imap.example.com
DC_IMAP_PORT=993
DC_IMAP_SECURITY=1 # 1=SSL/TLS (default), 2=STARTTLS, 3=plain
DC_SMTP_HOST=smtp.example.com
DC_SMTP_PORT=587
DC_SMTP_SECURITY=2 # 2=STARTTLS (default), 1=SSL/TLS, 3=plain
```
Security settings are applied on every startup, so changing them in `.env` and restarting takes effect without wiping the account.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Optional settings
The following are read from the process environment (not `.env`). To override them, add `Environment=` lines to the systemd service unit or your launchd plist:
| Variable | Default | Description |
|----------|---------|-------------|
| `DC_ACCOUNT_DIR` | `dc-account` | Directory for DeltaChat account data (IMAP state, keys, blobs) |
| `DC_DISPLAY_NAME` | `NanoClaw` | Bot display name shown in DeltaChat |
| `DC_AVATAR_PATH` | _(none)_ | Absolute path to avatar image; set at startup only |
The `/set-avatar` command (send an image with that caption) is the easiest way to set the avatar at runtime without modifying the service file. Only users with `owner` or global `admin` role can use it.
### Restart
```bash
# Linux
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
## Wiring
### DMs
**DeltaChat contacts cannot be added by email alone** — to start a chat, the user must open the bot's invite link in their DeltaChat app or scan its QR code. This triggers the SecureJoin handshake.
#### Step 1 — Get the invite link
After the service starts, the adapter logs the invite URL and writes a QR SVG:
```bash
grep "invite link" logs/nanoclaw.log | tail -1
# url field contains the https://i.delta.chat/... invite link
# also written to dc-account/invite-qr.svg (or $DC_ACCOUNT_DIR/invite-qr.svg)
```
The invite URL is stable (tied to the bot's email and encryption keys) so it stays valid across restarts.
#### Step 2 — Add the bot in DeltaChat
Two options for the user to connect:
- **Link**: Copy the `https://i.delta.chat/...` URL and open it on the device running DeltaChat. The app recognises it and shows a "Start chat" prompt.
- **QR code**: Open `dc-account/invite-qr.svg` in a browser or image viewer, display it on screen, and scan it from the DeltaChat app using the QR-scan button on the new-chat screen.
After accepting, DeltaChat exchanges keys and creates the chat automatically.
#### Step 3 — Wire the chat to an agent
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
```bash
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"
```
Then run `/init-first-agent` — it creates the agent group, grants the user owner access, and wires the messaging group in one step:
```bash
pnpm exec tsx scripts/init-first-agent.ts \
--channel deltachat \
--user-id deltachat:user@example.com \
--platform-id <platform_id from above> \
--display-name "Your Name"
```
### Groups
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaChat DM (see Wiring above), or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `deltachat`
- **terminology**: DeltaChat calls them "chats" (1:1 DMs) and "groups"
- **supports-threads**: no — DeltaChat has no thread model
- **platform-id-format**: numeric chat ID as a string (e.g. `"12"`) — the DeltaChat core's internal chat identifier
- **user-id-format**: `deltachat:{email}` — the contact's email address
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- File attachments — inbound and outbound; inbound waits up to 30 seconds for large-message download to complete
- Invite link logged on every startup — URL + QR SVG written to `dc-account/invite-qr.svg`; see Wiring for the bootstrap flow
- `/set-avatar` — send an image with this caption to change the bot's DeltaChat avatar (admin/owner only)
- Connectivity watchdog — restarts IO if IMAP goes quiet for 20 minutes or connectivity drops below threshold for two consecutive 5-minute checks
- Network nudge — `maybeNetwork()` called every 10 minutes to recover from prolonged idle
Not supported: DeltaChat reactions, message editing/deletion, read receipts.
### Connectivity model
`isConnected()` returns `true` when the internal connectivity value is ≥ 3000:
| Range | Meaning |
|-------|---------|
| 10001999 | Not connected |
| 20002999 | Connecting |
| 30003999 | Working (IMAP fetching) |
| ≥ 4000 | Fully connected (IMAP IDLE) |
## Troubleshooting
### Adapter not starting — credentials missing
```bash
grep "Channel credentials missing" logs/nanoclaw.log | grep deltachat
```
All six required vars (`DC_EMAIL`, `DC_PASSWORD`, `DC_IMAP_HOST`, `DC_IMAP_PORT`, `DC_SMTP_HOST`, `DC_SMTP_PORT`) must be present in `.env`.
### Account configure fails
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
```
Common causes:
- Wrong IMAP/SMTP hostnames — double-check provider docs
- App password not generated — Gmail and some others require this when 2FA is enabled
- Port/security mismatch — defaults are port 993 + SSL/TLS for IMAP and port 587 + STARTTLS for SMTP; override with `DC_IMAP_PORT`/`DC_IMAP_SECURITY` or `DC_SMTP_PORT`/`DC_SMTP_SECURITY` in `.env`
### Provider uses SMTP port 465 (SSL/TLS) instead of 587
Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
### Messages not arriving
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
4. Verify the messaging group is wired: `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
```bash
rm -f dc-account/accounts.lock
systemctl --user restart nanoclaw
```
### Bot not responding after restart
The account is already configured — IO restarts automatically on service start. If the RPC subprocess is stuck, restart the service. Check for errors:
```bash
grep "DeltaChat" logs/nanoclaw.error.log | tail -20
```
### Messages received but agent not responding
The messaging group exists but may not be wired to an agent group. Run:
```bash
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`.
+54
View File
@@ -0,0 +1,54 @@
# Verify DeltaChat
## 1. Check the adapter started
```bash
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
```
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
## 2. Check IMAP/SMTP connectivity
Replace with your provider's hostnames from `.env`:
```bash
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
```
## 3. End-to-end message test
1. Open DeltaChat on your device
2. Add the bot email address as a contact
3. Send a message
4. The bot should respond within a few seconds
If nothing arrives, check:
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
```
## 4. Check messaging group was created
```bash
sqlite3 data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
## 5. Verify user access
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
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.26.0
pnpm install @chat-adapter/discord@4.29.0
```
### 5. Build
+210
View File
@@ -0,0 +1,210 @@
---
name: add-gcal-tool
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
---
# Add Google Calendar Tool (OneCLI-native)
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
## Phase 1: Pre-flight
### Verify OneCLI has Google Calendar connected
```bash
onecli apps get --provider google-calendar
```
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
### Verify stub credentials exist
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
```bash
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
```
If both exist with `onecli-managed`:
```bash
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
```
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
If absent, write them:
```bash
mkdir -p ~/.calendar-mcp
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.calendar-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
}
EOF
chmod 600 ~/.calendar-mcp/*.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject the Google Calendar token:
```bash
onecli agents list
```
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
## Phase 2: Apply Code Changes
### Check if already applied
```bash
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
```dockerfile
ARG CALENDAR_MCP_VERSION=2.6.1
```
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
### Rebuild the container image
```bash
./container/build.sh
```
## Phase 3: Wire Per-Agent-Group
For each agent group, merge into `groups/<folder>/container.json`:
```jsonc
{
"mcpServers": {
"calendar": {
"command": "google-calendar-mcp",
"args": [],
"env": {
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.calendar-mcp",
"containerPath": ".calendar-mcp",
"readonly": false
}
]
}
```
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
## Phase 4: Build and Restart
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
Kill any existing agent containers so they respawn with the new mcpServers config:
```bash
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
```
## Phase 5: Verify
### Test from a wired agent
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
>
> First call takes 23s while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
```
Common signals:
- `command not found: google-calendar-mcp` → image not rebuilt.
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
## Removal
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
## Credits & references
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
+1 -1
View File
@@ -44,7 +44,7 @@ import './gchat.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/gchat@4.26.0
pnpm install @chat-adapter/gchat@4.29.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.26.0
pnpm install @chat-adapter/github@4.29.0
```
### 5. Build
+229
View File
@@ -0,0 +1,229 @@
---
name: add-gmail-tool
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
---
# Add Gmail Tool (OneCLI-native)
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
## Phase 1: Pre-flight
### Verify OneCLI has Gmail connected
```bash
onecli apps get --provider gmail
```
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
### Verify stub credentials exist
```bash
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
```
If both exist and contain `"onecli-managed"`:
```bash
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
...skip to Phase 2.
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
If both files are absent, write them now:
```bash
mkdir -p ~/.gmail-mcp
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.gmail-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
}
EOF
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
```bash
onecli agents list
```
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
```bash
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
### Check if already applied
```bash
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block:
```dockerfile
ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG BUN_VERSION=1.3.12
```
Add a new line:
```dockerfile
ARG GMAIL_MCP_VERSION=1.1.11
```
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
### Rebuild the container image
```bash
./container/build.sh
```
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
## Phase 3: Wire Per-Agent-Group
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
Merge these into the group's `container.json`:
```jsonc
{
"mcpServers": {
"gmail": {
"command": "gmail-mcp",
"args": [],
"env": {
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.gmail-mcp",
"containerPath": ".gmail-mcp",
"readonly": false
}
]
}
```
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
## Phase 4: Build and Restart
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
## Phase 5: Verify
### Test from the wired agent
Tell the user:
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
>
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
# Per-container logs — session-scoped:
ls data/v2-sessions/*/stderr.log | head
```
Common signals:
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
## Removal
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
## Notes
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
## Credits & references
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/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.
+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.26.0
pnpm install @chat-adapter/linear@4.29.0
```
### 6. Build
+1 -1
View File
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
### Per group / per session
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
+13
View File
@@ -0,0 +1,13 @@
# Remove Signal
1. Comment out `import './signal.js'` in `src/channels/index.ts`
2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env`
3. Rebuild and restart
If you also want to unlink the Signal account from `signal-cli`:
```bash
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
```
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
+318
View File
@@ -0,0 +1,318 @@
---
name: add-signal
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
## Prerequisites
### Java
signal-cli requires Java 17+:
```bash
java -version
```
If missing:
- **macOS:** `brew install --cask temurin@17`
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
Java 1725 all work.
### signal-cli
- **macOS:** `brew install signal-cli`
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
```bash
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar -xz -C ~/.local
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
signal-cli --version
```
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
## Registration
Two paths. The new-number path is recommended and battle-tested.
### Path A: Register a new number (recommended)
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
**Step 1: Solve the CAPTCHA**
Signal requires a CAPTCHA on first registration:
1. Open `https://signalcaptchas.org/registration/generate.html` in a browser
2. Solve the captcha
3. Right-click the **"Open Signal"** button → **Copy Link**
4. The link starts with `signalcaptcha://` — the token is everything after that prefix
**Step 2: Request SMS verification**
```bash
signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE"
```
**Step 3: Voice call fallback (if your number can't receive SMS)**
Wait ~60 seconds after the SMS request, then:
```bash
signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN"
```
Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one.
> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…`
**Step 4: Verify**
```bash
signal-cli -a +1YOURNUMBER verify CODE
```
No output = success.
**Step 5: Set profile name (optional)**
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
```bash
# macOS
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
# optionally: --avatar /path/to/avatar.jpg
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux
systemctl --user stop nanoclaw
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start nanoclaw
```
### Path B: Link as secondary device
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
```bash
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
```
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist
- `src/channels/index.ts` contains `import './signal.js';`
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter and tests
```bash
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './signal.js';
```
### 4. Build
```bash
pnpm run build
```
No npm packages to install — the adapter uses only Node.js builtins.
## Credentials
Add to `.env`:
```bash
SIGNAL_ACCOUNT=+1YOURNUMBER
```
### Optional settings
```bash
# TCP daemon host and port (default: 127.0.0.1:7583)
SIGNAL_TCP_HOST=127.0.0.1
SIGNAL_TCP_PORT=7583
# Path to the signal-cli binary (default: resolved on PATH)
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# Whether NanoClaw manages the daemon lifecycle (default: true).
# Set to false if you run signal-cli daemon externally.
SIGNAL_MANAGE_DAEMON=true
# signal-cli data directory (default: ~/.local/share/signal-cli)
SIGNAL_DATA_DIR=~/.local/share/signal-cli
```
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
```bash
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux
systemctl --user restart nanoclaw
```
## Wiring
### DMs
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
```bash
sqlite3 data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
### Groups
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
sqlite3 data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
"
```
### Grant user access
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
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)
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
"
```
Find the UUID from `messaging_groups.platform_id` or the `users` table.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
- **supports-threads**: no
- **platform-id-format**:
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
- Quoted replies — `replyTo*` fields populated from Signal quotes
- Typing indicators — DMs only (Signal doesn't support group typing)
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Troubleshooting
### Daemon not reachable
```bash
grep "Signal" logs/nanoclaw.log | tail
```
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
### Bot not responding
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
2. Channel wired: `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)
### Lost connection mid-session
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish.
### Messages dropped with `not_member`
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
### Captcha required
Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`.
### `Invalid verification method: Before requesting voice verification…`
You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token.
### Config file in use / daemon lock
signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward.
### Group replies going to DM instead of group
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
### Java not found
Install Java 17+ — see the Prerequisites section above.
### QR code expired (Path B)
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
+5
View File
@@ -0,0 +1,5 @@
# Verify Signal
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
+20 -5
View File
@@ -44,7 +44,7 @@ import './slack.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/slack@4.26.0
pnpm install @chat-adapter/slack@4.29.0
```
### 5. Build
@@ -72,26 +72,41 @@ pnpm run build
### Event Subscriptions
8. Go to **Event Subscriptions** and toggle **Enable Events**
9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save
9. **Webhook mode:** set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save. For **Socket Mode** (below), skip the Request URL.
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Socket Mode (optional — no public URL)
Socket Mode delivers events over an outbound WebSocket the bot opens to Slack, so the host needs **no public HTTPS endpoint** — ideal for local dev or a host behind NAT/a firewall. Setting `SLACK_APP_TOKEN` is what flips the adapter into Socket Mode; without it the adapter stays in webhook mode.
13. Go to **Basic Information** > **App-Level Tokens** > **Generate Token and Scopes**, add the `connections:write` scope, and copy the token (`xapp-...`)
14. Go to **Socket Mode** and toggle **Enable Socket Mode** on
15. Keep **Event Subscriptions** enabled with the bot events above — under Socket Mode no Request URL is required
### Configure environment
Add to `.env`:
Add to `.env`**webhook mode**:
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
```
…or **Socket Mode** (no public URL; signing secret optional):
```bash
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_APP_TOKEN=xapp-your-app-level-token
```
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Webhook server
### Webhook server (webhook mode only)
The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events.
In **webhook mode** the Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. **In Socket Mode this is not needed** — skip this section if you set `SLACK_APP_TOKEN`.
If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`.
+1 -1
View File
@@ -44,7 +44,7 @@ import './teams.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/teams@4.26.0
pnpm install @chat-adapter/teams@4.29.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.26.0
pnpm install @chat-adapter/telegram@4.29.0
```
### 6. Build
+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.26.0
pnpm install @chat-adapter/whatsapp@4.29.0
```
### 5. Build
+1 -1
View File
@@ -57,7 +57,7 @@ groups: () => import('./groups.js'),
### 5. Install the adapter packages (pinned)
```bash
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
```
### 6. Build
+6 -1
View File
@@ -1,7 +1,12 @@
name: Label PR
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
# because `pull_request_target` executes in the context of the base branch.
# Keep it metadata-only — do NOT add actions/checkout or any step that
# executes PR-supplied content (install scripts, build commands, etc.).
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
on:
pull_request:
pull_request_target:
types: [opened, edited]
jobs:
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { getOutboundDb, initTestSessionDb } from './connection.js';
import {
clearContinuation,
getContinuation,
migrateLegacyContinuation,
setContinuation,
} from './session-state.js';
beforeEach(() => {
initTestSessionDb();
});
function seedLegacy(value: string): void {
getOutboundDb()
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.run('sdk_session_id', value, new Date().toISOString());
}
describe('session-state — per-provider continuations', () => {
test('set/get round-trip, case-insensitive provider key', () => {
setContinuation('claude', 'claude-conv-1');
expect(getContinuation('claude')).toBe('claude-conv-1');
expect(getContinuation('Claude')).toBe('claude-conv-1');
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
});
test('providers are isolated — switching reads the right slot', () => {
setContinuation('claude', 'claude-conv-1');
setContinuation('codex', 'codex-thread-xyz');
expect(getContinuation('claude')).toBe('claude-conv-1');
expect(getContinuation('codex')).toBe('codex-thread-xyz');
});
test('clearContinuation only affects the specified provider', () => {
setContinuation('claude', 'keep-me');
setContinuation('codex', 'drop-me');
clearContinuation('codex');
expect(getContinuation('claude')).toBe('keep-me');
expect(getContinuation('codex')).toBeUndefined();
});
test('unknown provider returns undefined', () => {
expect(getContinuation('never-used')).toBeUndefined();
});
});
describe('session-state — legacy migration', () => {
test('adopts legacy value into current provider when current is empty', () => {
seedLegacy('old-session-id');
const adopted = migrateLegacyContinuation('claude');
expect(adopted).toBe('old-session-id');
expect(getContinuation('claude')).toBe('old-session-id');
});
test('always deletes legacy row regardless of migration outcome', () => {
seedLegacy('old-session-id');
setContinuation('claude', 'existing');
migrateLegacyContinuation('claude');
// After migration the legacy key must be gone, whether or not it was adopted.
// A subsequent migration for a different provider must not see it.
const resultAfterSecondCall = migrateLegacyContinuation('codex');
expect(resultAfterSecondCall).toBeUndefined();
});
test('prefers existing current-provider slot over legacy', () => {
seedLegacy('legacy-value');
setContinuation('claude', 'claude-value');
const result = migrateLegacyContinuation('claude');
expect(result).toBe('claude-value');
expect(getContinuation('claude')).toBe('claude-value');
});
test('no legacy row — returns current provider value (possibly undefined)', () => {
expect(migrateLegacyContinuation('claude')).toBeUndefined();
setContinuation('codex', 'codex-value');
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
});
test('migration is idempotent on a second call (legacy already gone)', () => {
seedLegacy('once');
const first = migrateLegacyContinuation('claude');
expect(first).toBe('once');
const second = migrateLegacyContinuation('claude');
expect(second).toBe('once');
});
});
+50 -12
View File
@@ -2,12 +2,20 @@
* Persistent key/value state for the container. Lives in outbound.db
* (container-owned, already scoped per channel/thread).
*
* Primary use: remember the SDK session ID so the agent's conversation
* resumes across container restarts. Cleared by /clear.
* Primary use: remember each provider's opaque continuation id so the
* agent's conversation resumes across container restarts. Keyed per
* provider because continuations are provider-private a Claude
* conversation id means nothing to Codex and vice versa. Switching
* providers is therefore lossless: each provider's last thread stays
* on file and resumes cleanly if the user flips back.
*/
import { getOutboundDb } from './connection.js';
const SDK_SESSION_KEY = 'sdk_session_id';
const LEGACY_KEY = 'sdk_session_id';
function continuationKey(providerName: string): string {
return `continuation:${providerName.toLowerCase()}`;
}
function getValue(key: string): string | undefined {
const row = getOutboundDb()
@@ -18,9 +26,7 @@ function getValue(key: string): string | undefined {
function setValue(key: string, value: string): void {
getOutboundDb()
.prepare(
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
)
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.run(key, value, new Date().toISOString());
}
@@ -28,14 +34,46 @@ function deleteValue(key: string): void {
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
}
export function getStoredSessionId(): string | undefined {
return getValue(SDK_SESSION_KEY);
/**
* One-time migration of the pre-per-provider continuation row.
*
* Before this was keyed per provider, continuations lived under the
* single key `sdk_session_id`. On container start, if that legacy row
* exists and the current provider has no continuation of its own, adopt
* the legacy value into the current provider's slot (best-guess the
* legacy row was written by whatever provider ran last). The legacy row
* is always deleted so future provider flips never re-read a stale id
* through the wrong lens.
*
* Returns the continuation the caller should use at startup (either the
* current provider's existing value, the adopted legacy value, or
* undefined).
*/
export function migrateLegacyContinuation(providerName: string): string | undefined {
const legacy = getValue(LEGACY_KEY);
const currentKey = continuationKey(providerName);
const current = getValue(currentKey);
if (legacy === undefined) return current;
// Always drop the legacy row so no future provider reads it.
deleteValue(LEGACY_KEY);
// Prefer the current provider's own slot if one already exists.
if (current !== undefined) return current;
setValue(currentKey, legacy);
return legacy;
}
export function setStoredSessionId(sessionId: string): void {
setValue(SDK_SESSION_KEY, sessionId);
export function getContinuation(providerName: string): string | undefined {
return getValue(continuationKey(providerName));
}
export function clearStoredSessionId(): void {
deleteValue(SDK_SESSION_KEY);
export function setContinuation(providerName: string, id: string): void {
setValue(continuationKey(providerName), id);
}
export function clearContinuation(providerName: string): void {
deleteValue(continuationKey(providerName));
}
+1
View File
@@ -95,6 +95,7 @@ async function main(): Promise<void> {
await runPollLoop({
provider,
providerName,
cwd: CWD,
systemContext: { instructions },
});
@@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
return Promise.race([
runPollLoop({
provider,
providerName: 'mock',
cwd: '/tmp',
}),
new Promise<void>((_, reject) => {
+20 -8
View File
@@ -2,7 +2,11 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
import {
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';
@@ -19,6 +23,12 @@ function generateId(): string {
export interface PollLoopConfig {
provider: AgentProvider;
/**
* Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
* the stored continuation per-provider so flipping providers doesn't
* resurrect a stale id from a different backend.
*/
providerName: string;
cwd: string;
systemContext?: {
instructions?: string;
@@ -39,8 +49,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Resume the agent's prior session from a previous container run if one
// was persisted. The continuation is opaque to the poll-loop — the
// provider decides how to use it (Claude resumes a .jsonl transcript,
// other providers may reload a thread ID, etc.).
let continuation: string | undefined = getStoredSessionId();
// other providers may reload a thread ID, etc.). Keyed per-provider so
// a Codex thread id never gets handed to Claude or vice versa.
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
if (continuation) {
log(`Resuming agent session ${continuation}`);
@@ -94,7 +105,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
log('Clearing session (resetting continuation)');
continuation = undefined;
clearStoredSessionId();
clearContinuation(config.providerName);
writeMessageOut({
id: generateId(),
kind: 'chat',
@@ -160,10 +171,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
const skippedSet = new Set(skipped);
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
try {
const result = await processQuery(query, routing, processingIds);
const result = await processQuery(query, routing, processingIds, config.providerName);
if (result.continuation && result.continuation !== continuation) {
continuation = result.continuation;
setStoredSessionId(continuation);
setContinuation(config.providerName, continuation);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -175,7 +186,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
if (continuation && config.provider.isSessionInvalid(err)) {
log(`Stale session detected (${continuation}) — clearing for next retry`);
continuation = undefined;
clearStoredSessionId();
clearContinuation(config.providerName);
}
// Write error response so the user knows something went wrong
@@ -238,6 +249,7 @@ async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
providerName: string,
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
@@ -288,7 +300,7 @@ async function processQuery(
// container died between `init` and `result`, the SDK session was
// effectively orphaned and the next message started a blank
// Claude session with no prior context.
setStoredSessionId(event.continuation);
setContinuation(providerName, event.continuation);
} else if (event.type === 'result') {
// A result — with or without text — means the turn is done. Mark
// the initial batch completed now so the host sweep doesn't see
+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
+19 -7
View File
@@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG"
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
write_header
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
# and skip printing these again, so the flow stays visually continuous.
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
# 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
@@ -190,7 +190,7 @@ BOOTSTRAP_START=$(date +%s)
# One-line "why" that teaches a differentiator while the user waits.
printf '%s %s\n' "$(gray '│')" \
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
"$(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
@@ -222,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
else
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
@@ -245,7 +245,19 @@ fi
# wipe it.
export NANOCLAW_BOOTSTRAPPED=1
# setup.sh may have just installed pnpm via npm into a prefix that's not on
# our PATH (custom `npm config set prefix`, or the default prefix missing
# from the shell's login PATH). Its PATH mutation doesn't propagate back
# to us — so replay the same lookup here before the exec.
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
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
# preamble so the flow continues visually from "Basics installed" straight
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
exec pnpm --silent run setup:auto
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
exec pnpm --silent run setup:auto -- "$@"
+21 -3
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.4",
"version": "2.0.14",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
@@ -24,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.29.0",
"@chat-adapter/gchat": "4.29.0",
"@chat-adapter/github": "4.29.0",
"@chat-adapter/linear": "4.29.0",
"@chat-adapter/slack": "4.29.0",
"@chat-adapter/state-memory": "4.29.0",
"@chat-adapter/teams": "4.29.0",
"@chat-adapter/telegram": "4.29.0",
"@chat-adapter/whatsapp": "4.29.0",
"@clack/core": "^1.2.0",
"@clack/prompts": "^1.2.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": "4.29.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",
+3925 -4
View File
File diff suppressed because it is too large Load Diff
+4 -4
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="128k tokens, 64% of context window">
<title>128k tokens, 64% 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"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
<text x="71" y="14">128k</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

+1 -1
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
+1 -10
View File
@@ -48,6 +48,7 @@ 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 { initGroupFilesystem } from '../src/group-init.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
type Role = 'owner' | 'admin' | 'member';
@@ -137,16 +138,6 @@ function namespacedUserId(channel: string, raw: string): string {
return raw.includes(':') ? raw : `${channel}:${raw}`;
}
function namespacedPlatformId(channel: string, raw: string): string {
if (raw.startsWith(`${channel}:`)) return raw;
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
// <groupId>@g.us) store platform_id without a channel prefix. The '@' is
// the discriminator — telegram/discord platform_ids don't contain it
// except after a channel prefix, which is already handled above.
if (raw.includes('@')) return raw;
return `${channel}:${raw}`;
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
+14
View File
@@ -120,6 +120,20 @@ install_deps() {
|| true
fi
# `npm install -g` writes to npm's global prefix, which isn't always on the
# shell PATH — common on macOS where the user has `npm config set prefix
# ~/.npm-global` to avoid sudo, or on Linux where /usr/local/bin isn't in
# PATH. Discover the prefix and prepend its bin dir so `command -v pnpm`
# sees the new install.
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
local npm_prefix
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"
log "Prepended npm prefix bin to PATH: $npm_prefix/bin"
fi
fi
if ! command -v pnpm >/dev/null 2>&1; then
log "pnpm not on PATH after corepack + npm fallback"
return
+1 -1
View File
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-discord/SKILL.md.
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
#
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
# and restart the service. Non-interactive — the Full Disk Access walkthrough
# (local mode) and Photon URL/key prompts (remote mode) live in
# setup/channels/imessage.ts. Creds come in via env vars:
# IMESSAGE_LOCAL 'true' | 'false' (required)
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
#
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
local mode=${IMESSAGE_LOCAL:-}
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-imessage] $*" >&2; }
# Validate creds based on mode.
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
exit 1
fi
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
exit 1
fi
if [ "$(uname -s)" != "Darwin" ]; then
emit_status failed "local mode requires macOS"
exit 1
fi
else
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
exit 1
fi
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
exit 1
fi
fi
need_install() {
[ ! -f src/channels/imessage.ts ] && return 0
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
# Append self-registration import if missing.
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
echo "import './imessage.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
remove_env() {
local key=$1
if grep -q "^${key}=" .env 2>/dev/null; then
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
fi
}
# Write the canonical keys for the chosen mode, strip the opposite mode's
# keys so stale values can't confuse the adapter's factory.
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
if [ "$IMESSAGE_LOCAL" = "true" ]; then
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
remove_env IMESSAGE_SERVER_URL
remove_env IMESSAGE_API_KEY
else
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
remove_env IMESSAGE_ENABLED
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the creds…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the adapter a moment to open chat.db (local) or handshake with
# Photon (remote) before emitting success.
sleep 3
emit_status success
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Install the Signal adapter in an already-running NanoClaw checkout.
# Non-interactive — the operator-facing "install signal-cli" + QR scan
# live in setup/channels/signal.ts. This script only:
#
# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels
# branch.
# 2. Appends the self-registration import to src/channels/index.ts.
# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has
# no npm deps).
# 4. Builds.
#
# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli
# link has produced a number; that keeps this script idempotent and
# re-runnable without re-auth.
#
# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All
# chatty progress goes to stderr so setup:auto's raw-log capture sees
# the full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-signal/SKILL.md.
QRCODE_VERSION="qrcode@1.5.4"
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_SIGNAL ==="
echo "STATUS: ${status}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-signal] $*" >&2; }
need_install() {
[ ! -f src/channels/signal.ts ] && return 0
! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter files from ${CHANNELS_BRANCH}"
for f in \
src/channels/signal.ts \
src/channels/signal.test.ts
do
git show "${CHANNELS_BRANCH}:$f" > "$f" || {
emit_status failed "git show ${CHANNELS_BRANCH}:$f failed"
exit 1
}
done
if ! grep -q "^import './signal.js';" src/channels/index.ts; then
echo "import './signal.js';" >> src/channels/index.ts
fi
fi
# qrcode is needed by setup/signal-auth.ts to render the linking URL as a
# terminal QR. Install idempotently — if it's already present (e.g. from a
# prior WhatsApp install) pnpm is a no-op.
if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then
log "Installing ${QRCODE_VERSION}"
pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${QRCODE_VERSION} failed"
exit 1
}
fi
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
emit_status success
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
# .env + data/env/env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
#
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
# progress messages go to stderr so setup:auto's raw-log capture sees the full
# story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-slack/SKILL.md.
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-slack] $*" >&2; }
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
emit_status failed "SLACK_BOT_TOKEN env var not set"
exit 1
fi
# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with
# SLACK_SIGNING_SECRET. Require at least one.
if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)"
exit 1
fi
need_install() {
[ ! -f src/channels/slack.ts ] && return 0
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
# Append self-registration import if missing.
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
echo "import './slack.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates via auth.test before this point, so
# bad values here would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
if [ -n "${SLACK_APP_TOKEN:-}" ]; then
upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN"
fi
if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Slack adapter a moment to finish starting the webhook listener
# before emitting success.
sleep 3
emit_status success
+1 -1
View File
@@ -18,7 +18,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-teams/SKILL.md.
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
+1 -1
View File
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
+1 -1
View File
@@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9"
QRCODE_VERSION="qrcode@1.5.4"
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
PINO_VERSION="pino@9.6.0"
+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);
+334 -131
View File
@@ -22,23 +22,33 @@
* headless `claude -p` call for IANA-zone resolution.
*/
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
import { runSlackChannel } from './channels/slack.js';
import { runTeamsChannel } from './channels/teams.js';
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 { offerClaudeAssist } from './lib/claude-assist.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
claudeCliAvailable,
resolveTimezoneViaClaude,
} from './lib/tz-from-claude.js';
applyToEnv,
parseFlags,
printHelp,
readFromEnv,
} from './lib/setup-config-parse.js';
import { runAdvancedScreen } from './lib/setup-config-screen.js';
import { runWindowedStep } from './lib/windowed-runner.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 } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js';
@@ -48,11 +58,48 @@ 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' | 'skip';
async function main(): Promise<void> {
// 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.
const flagResult = parseFlags(process.argv.slice(2));
if (flagResult.help) {
printHelp();
process.exit(0);
}
if (flagResult.errors.length > 0) {
for (const err of flagResult.errors) console.error(`error: ${err}`);
console.error('');
console.error('Run with --help for the full list of supported flags.');
process.exit(1);
}
let configValues = { ...readFromEnv(), ...flagResult.values };
applyToEnv(configValues);
printIntro();
initProgressionLog();
phEmit('auto_started');
// Welcome menu — default path or open advanced overrides before any setup
// work begins. Default lands on standard so Enter is the happy path.
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);
}
const skip = new Set(
(process.env.NANOCLAW_SKIP ?? '')
.split(',')
@@ -75,12 +122,7 @@ async function main(): Promise<void> {
}
if (!skip.has('container')) {
p.log.message(
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(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
@@ -125,57 +167,95 @@ async function main(): Promise<void> {
),
);
// Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli();
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
value: 'reuse',
label: 'Use the existing instance',
hint: 'recommended — keeps other apps bound to this vault working',
},
{
value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: 'reinstalls onecli; other apps may need to reconnect',
},
],
}),
) as 'reuse' | 'fresh';
setupLog.userInput('onecli_choice', choice);
reuse = choice === 'reuse';
}
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
const res = await runQuietStep(
'onecli',
{
running: reuse
? 'Hooking up to your existing OneCLI…'
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
},
reuse ? ['--reuse'] : [],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
if (remoteHost) {
// Advanced-settings override: user has already named a remote vault,
// so skip the local-vs-fresh prompt entirely. Health-check it here
// rather than letting the step fail silently — a typo in the URL is a
// common mistake and the answer is human-fixable.
const s = p.spinner();
s.start(`Checking remote OneCLI at ${remoteHost}`);
const healthy = await pollHealth(remoteHost, 5000);
if (!healthy) {
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
await fail(
'onecli',
'OneCLI was installed but your shell needs to refresh to see it.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
`Couldn't reach OneCLI at ${remoteHost}.`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
);
}
await fail(
s.stop('Remote OneCLI is reachable.');
const res = await runQuietStep(
'onecli',
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
{
running: `Connecting to remote OneCLI at ${remoteHost}`,
done: 'OneCLI vault ready.',
},
['--remote-url', remoteHost],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
await fail(
'onecli',
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
);
}
} else {
// Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli();
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
value: 'reuse',
label: 'Use the existing instance',
hint: 'recommended — keeps other apps bound to this vault working',
},
{
value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: 'reinstalls onecli; other apps may need to reconnect',
},
],
}),
) as 'reuse' | 'fresh';
setupLog.userInput('onecli_choice', choice);
reuse = choice === 'reuse';
}
const res = await runQuietStep(
'onecli',
{
running: reuse
? 'Hooking up to your existing OneCLI…'
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
},
reuse ? ['--reuse'] : [],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
await fail(
'onecli',
'OneCLI was installed but your shell needs to refresh to see it.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
);
}
await fail(
'onecli',
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
);
}
}
}
@@ -204,21 +284,12 @@ async function main(): Promise<void> {
done: 'NanoClaw is running.',
});
if (!res.ok) {
await fail(
'service',
"Couldn't start NanoClaw.",
'See logs/nanoclaw.error.log for details.',
);
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(
"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(
k.dim(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
' systemctl --user restart nanoclaw',
),
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
);
}
}
@@ -248,10 +319,33 @@ async function main(): Promise<void> {
);
}
if (!skip.has('first-chat')) {
p.log.message(
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');
await runFirstChat();
const next = ensureAnswer(
await p.select({
message: 'What next?',
options: [
{
value: 'continue',
label: 'Continue with setup',
hint: 'recommended',
},
{
value: 'chat',
label: 'Pause here and chat with your agent from the terminal',
},
],
}),
) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next);
if (next === 'chat') await runFirstChat();
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
@@ -260,7 +354,7 @@ async function main(): Promise<void> {
msg:
ping === 'socket_error'
? "NanoClaw service isn't listening on its CLI socket."
: "No reply from the assistant within 30 seconds.",
: 'No reply from the assistant within 30 seconds.',
hint:
ping === 'socket_error'
? 'Socket at data/cli.sock did not accept a connection.'
@@ -274,8 +368,7 @@ async function main(): Promise<void> {
await runTimezoneStep();
}
let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' =
'skip';
let channelChoice: ChannelChoice = 'skip';
if (!skip.has('channel')) {
channelChoice = await askChannelChoice();
if (channelChoice === 'telegram') {
@@ -284,12 +377,18 @@ async function main(): Promise<void> {
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, or Slack).',
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
);
@@ -305,7 +404,7 @@ async function main(): Promise<void> {
if (!res.ok) {
const notes: string[] = [];
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
notes.push("• Your Claude account isn't connected. Re-run setup and try again.");
}
const service = res.terminal?.fields.SERVICE;
if (service === 'running_other_checkout') {
@@ -331,7 +430,9 @@ async function main(): Promise<void> {
}
}
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
notes.push(
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
);
}
if (notes.length > 0) {
p.note(notes.join('\n'), "What's left");
@@ -365,9 +466,7 @@ async function main(): Promise<void> {
['Open Claude Code:', 'claude'],
];
const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
.join('\n');
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
p.note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the
@@ -389,19 +488,14 @@ 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.
p.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`."));
}
}
function channelDmLabel(
choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip',
): string | null {
function channelDmLabel(choice: ChannelChoice): string | null {
switch (choice) {
case 'telegram':
return 'Telegram';
@@ -409,8 +503,17 @@ function channelDmLabel(
return 'Discord DMs';
case 'whatsapp':
return 'WhatsApp';
case 'signal':
return 'Signal';
case 'teams':
return 'Teams';
case 'imessage':
return 'iMessage';
case 'slack':
// 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;
}
@@ -442,13 +545,11 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
if (result === 'ok') {
s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`);
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else {
const msg =
result === 'socket_error'
? "Couldn't reach the NanoClaw service."
: "Your assistant didn't reply in time.";
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1);
result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
}
return result;
}
@@ -462,8 +563,8 @@ function renderPingFailureNote(result: PingResult): void {
6,
),
'',
k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`),
k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`),
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
` Linux: systemctl --user restart ${getSystemdUnit()}`,
].join('\n')
: wrapForGutter(
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
@@ -503,9 +604,7 @@ async function runFirstChat(): Promise<void> {
message: first
? 'Try a quick hello — or press Enter to continue setup'
: 'Another message? Press Enter to continue setup',
placeholder: first
? 'e.g. "hi, what can you do?"'
: 'press Enter to continue',
placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
}),
);
first = false;
@@ -521,11 +620,9 @@ function sendChatMessage(message: string): Promise<void> {
// agent's reply reads as a clean block under the prompt. Splitting on
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
// with spaces on the far side.
const child = spawn(
'pnpm',
['--silent', 'run', 'chat', ...message.split(/\s+/)],
{ stdio: ['ignore', 'inherit', 'inherit'] },
);
const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
stdio: ['ignore', 'inherit', 'inherit'],
});
child.on('close', () => resolve());
child.on('error', () => resolve());
});
@@ -540,6 +637,16 @@ async function runAuthStep(): Promise<void> {
return;
}
// Custom Anthropic-compatible endpoint flow. Both URL and token must be set;
// OneCLI stores the token as a generic Bearer secret keyed to the URL host,
// so the container only ever sees ANTHROPIC_BASE_URL + a placeholder.
const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim();
const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim();
if (customBaseUrl && customAuthToken) {
await runCustomEndpointAuth(customBaseUrl, customAuthToken);
return;
}
const method = ensureAnswer(
await brightSelect({
message: 'How would you like to connect to Claude?',
@@ -573,15 +680,11 @@ async function runAuthStep(): Promise<void> {
}
async function runSubscriptionAuth(): Promise<void> {
p.log.step("Opening the Claude sign-in flow…");
console.log(
k.dim(' (a browser will open for sign-in; this part is interactive)'),
);
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();
const code = await runInheritScript('bash', [
'setup/register-claude-token.sh',
]);
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
const durationMs = Date.now() - start;
console.log();
if (code !== 0) {
@@ -621,11 +724,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
'auth',
'onecli',
[
'secrets', 'create',
'--name', 'Anthropic',
'--type', 'anthropic',
'--value', token,
'--host-pattern', 'api.anthropic.com',
'secrets',
'create',
'--name',
'Anthropic',
'--type',
'anthropic',
'--value',
token,
'--host-pattern',
'api.anthropic.com',
],
{
running: `Saving your ${label} to your OneCLI vault…`,
@@ -644,6 +752,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
}
}
/**
* Set up Anthropic auth for a custom endpoint. The token is stored as a
* OneCLI generic secret with header injection so the proxy rewrites the
* Authorization header on the wire the container only ever sees
* ANTHROPIC_BASE_URL + a placeholder bearer.
*/
async function runCustomEndpointAuth(
baseUrl: string,
token: string,
): Promise<void> {
let host: string;
try {
host = new URL(baseUrl).hostname;
} catch {
await fail(
'auth',
`Invalid Anthropic base URL: ${baseUrl}`,
'Check --anthropic-base-url and retry.',
);
return;
}
const res = await runQuietChild(
'auth',
'onecli',
[
'secrets',
'create',
'--name',
'Anthropic',
'--type',
'generic',
'--value',
token,
'--host-pattern',
host,
'--header-name',
'Authorization',
'--value-format',
'Bearer {value}',
],
{
running: `Saving your Anthropic auth token to your OneCLI vault…`,
done: 'Claude account connected.',
},
{ extraFields: { METHOD: 'custom-endpoint', HOST: host } },
);
if (!res.ok) {
await fail(
'auth',
`Couldn't save your Anthropic auth token to the vault.`,
'Make sure OneCLI is running (`onecli version`), then retry.',
);
}
// ANTHROPIC_BASE_URL has to be in .env so the runtime provider config
// reads it when building container env. The token is *not* written —
// OneCLI holds it.
writeEnvLine('ANTHROPIC_BASE_URL', baseUrl);
// Register the claude provider so the runtime passes ANTHROPIC_BASE_URL
// and the placeholder bearer into the container. Only appended when the
// user has configured a custom endpoint; standard installs don't load
// the file at all.
appendProviderImport('./claude.js');
}
function writeEnvLine(key: string, value: string): void {
const envFile = path.join(process.cwd(), '.env');
const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
const re = new RegExp(`^${key}=.*$`, 'm');
const next = re.test(content)
? content.replace(re, `${key}=${value}`)
: content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`;
fs.writeFileSync(envFile, next);
}
function appendProviderImport(modulePath: string): void {
const file = path.join(process.cwd(), 'src', 'providers', 'index.ts');
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
const line = `import '${modulePath}';`;
if (content.includes(line)) return;
const sep = content && !content.endsWith('\n') ? '\n' : '';
fs.writeFileSync(file, content + sep + line + '\n');
}
// ─── timezone step ─────────────────────────────────────────────────────
/**
@@ -664,10 +858,7 @@ async function runTimezoneStep(): Promise<void> {
const fields = res.terminal?.fields ?? {};
const resolvedTz = fields.RESOLVED_TZ;
const needsInput = fields.NEEDS_USER_INPUT === 'true';
const isUtc =
resolvedTz === 'UTC' ||
resolvedTz === 'Etc/UTC' ||
resolvedTz === 'Universal';
const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
// Three branches:
// - no TZ detected: ask where they are (or leave as UTC)
@@ -689,8 +880,8 @@ async function runTimezoneStep(): Promise<void> {
const message = needsInput
? "Your system didn't expose a timezone. Which one are you in?"
: !isUtc
? "Where are you, then?"
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
? 'Where are you, then?'
: 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
// straight to the free-text prompt — the user already said "not that".
@@ -717,7 +908,7 @@ async function runTimezoneStep(): Promise<void> {
const answer = ensureAnswer(
await p.text({
message: "Where are you? (city, region, or IANA zone)",
message: 'Where are you? (city, region, or IANA zone)',
placeholder: 'e.g. New York, London, Asia/Tokyo',
validate: (v) => (v && v.trim() ? undefined : 'Required'),
}),
@@ -786,16 +977,30 @@ async function askDisplayName(fallback: string): Promise<string> {
return value;
}
async function askChannelChoice(): Promise<
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
> {
async function askChannelChoice(): Promise<ChannelChoice> {
const isMac = process.platform === 'darwin';
const choice = ensureAnswer(
await brightSelect({
await brightSelect<ChannelChoice>({
message: 'Want to chat with your assistant from your phone?',
options: [
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
{ value: 'discord', label: 'Yes, connect Discord' },
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
{
value: 'signal',
label: 'Yes, connect Signal',
hint: 'needs signal-cli installed',
},
{
value: 'imessage',
label: 'Yes, connect iMessage (experimental)',
hint: isMac ? 'local macOS mode' : 'remote Photon only',
},
{
value: 'slack',
label: 'Yes, connect Slack (experimental)',
hint: 'needs public URL',
},
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
],
@@ -803,7 +1008,7 @@ async function askChannelChoice(): Promise<
);
setupLog.userInput('channel_choice', String(choice));
phEmit('channel_chosen', { channel: String(choice) });
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
return choice;
}
// ─── interactive / env helpers ─────────────────────────────────────────
@@ -899,17 +1104,15 @@ function printIntro(): void {
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) {
p.intro(
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
);
p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
return;
}
// Always include the wordmark inside the clack intro line. When bash ran
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
// above us; the small repeat is worth it to keep the brand anchored at
// the visible top of the clack session once the bash output scrolls away.
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
// bash already printed the wordmark above us; the clack intro carries the
// welcome framing alone so the two don't double up. Standalone runs of
// setup:auto still see this as the first line — fine without the wordmark
// since the line itself signals the start of the flow.
p.intro("Let's get you set up.");
}
/**
+314
View File
@@ -0,0 +1,314 @@
/**
* iMessage channel flow for setup:auto.
*
* `runIMessageChannel(displayName)` covers both deployment modes:
*
* Local (macOS): the bot runs on this Mac and talks via the signed-in
* iMessage account. Reading chat.db needs Full Disk Access granted to
* the Node binary we open the directory for them so they can drag
* the `node` file into System Settings.
*
* Remote (Photon API): the bot talks to a separate server (Photon)
* that owns an iMessage account on another Mac. Used when this host
* is Linux, or when the operator wants to keep their daily-driver
* Mac's chat history out of the loop.
*
* Flow:
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
* Remote: prompt for Photon server URL + API key
* 3. Ask for the phone or email the operator messages from this is
* the platform-id for first-agent wiring
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
* 5. Wire the agent via scripts/init-first-agent.ts the welcome
* iMessage goes out through the normal delivery path
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'child_process';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
type Mode = 'local' | 'remote';
interface RemoteCreds {
serverUrl: string;
apiKey: string;
}
export async function runIMessageChannel(displayName: string): Promise<void> {
const isMac = os.platform() === 'darwin';
const mode = await askMode(isMac);
let remoteCreds: RemoteCreds | null = null;
if (mode === 'local') {
if (!isMac) {
await fail(
'imessage',
"Local iMessage mode only works on macOS.",
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
);
}
await walkThroughFullDiskAccess();
} else {
remoteCreds = await collectRemoteCreds();
}
const handle = await askOperatorHandle();
const install = await runQuietChild(
'imessage-install',
'bash',
['setup/add-imessage.sh'],
{
running:
mode === 'local'
? "Connecting the iMessage adapter to this Mac…"
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
done: 'iMessage adapter installed.',
},
{
env:
mode === 'local'
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
: {
IMESSAGE_LOCAL: 'false',
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
IMESSAGE_API_KEY: remoteCreds!.apiKey,
},
extraFields: { MODE: mode },
},
);
if (!install.ok) {
await fail(
'imessage-install',
"Couldn't install the iMessage adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const role = await askOperatorRole('iMessage');
setupLog.userInput('imessage_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'imessage',
'--user-id', handle,
'--platform-id', handle,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to iMessage…`,
done: `${agentName} is ready. Check iMessage for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'imessage',
AGENT_NAME: agentName,
PLATFORM_ID: handle,
MODE: mode,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
);
}
}
async function askMode(isMac: boolean): Promise<Mode> {
const choice = ensureAnswer(
await brightSelect<Mode>({
message: 'How should iMessage run?',
initialValue: isMac ? 'local' : 'remote',
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',
},
],
}),
);
setupLog.userInput('imessage_mode', String(choice));
return choice;
}
/**
* Grant Full Disk Access to the Node binary the host runs under without
* it, the adapter can't read chat.db and inbound messages never arrive.
* Opening the containing directory in Finder makes the drag-and-drop
* target obvious; falling back to printing the path keeps us working in
* SSH/headless contexts where `open` is a no-op.
*/
async function walkThroughFullDiskAccess(): Promise<void> {
let nodePath = process.execPath;
try {
// `which node` picks up the user's shell-resolved node, which may differ
// from process.execPath (e.g. they launched setup under a different
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
const which = execSync('which node', { encoding: 'utf-8' }).trim();
if (which) nodePath = which;
} catch {
// fall back to process.execPath
}
const nodeDir = path.dirname(nodePath);
p.note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
'',
` ${nodePath}`,
'',
' 1. System Settings → Privacy & Security → Full Disk Access',
` 2. Click +, then drag the "node" file from the Finder window`,
' we just opened for you',
' 3. Toggle it on, then come back here',
].join('\n'),
6,
),
'Grant Full Disk Access',
);
try {
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
} catch {
// No Finder (SSH/headless) — user sees the path in the note above.
}
ensureAnswer(
await p.confirm({
message: "Granted Full Disk Access?",
initialValue: true,
}),
);
setupLog.userInput('imessage_fda_confirmed', 'true');
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
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.",
'',
' 1. Set up a Photon server: https://photon.im',
' 2. Copy the server URL and API key from your Photon dashboard',
].join('\n'),
'Remote iMessage via Photon',
);
const urlAnswer = ensureAnswer(
await p.text({
message: 'Photon server URL',
placeholder: 'https://photon.example.com',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'URL is required';
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
return undefined;
},
}),
);
const serverUrl = (urlAnswer as string).trim();
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
const apiKey = (keyAnswer as string).trim();
setupLog.userInput('imessage_server_url', serverUrl);
setupLog.userInput(
'imessage_api_key',
`${apiKey.slice(0, 4)}${apiKey.slice(-4)}`,
);
return { serverUrl, apiKey };
}
async function askOperatorHandle(): Promise<string> {
p.note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
'',
k.dim(' • Phone: full E.164, e.g. +15551234567'),
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
].join('\n'),
'Your iMessage handle',
);
const answer = ensureAnswer(
await p.text({
message: 'Phone number or email',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
const isPhone = /^\+\d{8,15}$/.test(t);
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
if (!isPhone && !isEmail) {
return "Use a +E.164 phone number or an email address";
}
return undefined;
},
}),
);
const handle = (answer as string).trim();
setupLog.userInput('imessage_handle', handle);
return handle;
}
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 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;
}
+357
View File
@@ -0,0 +1,357 @@
/**
* Signal channel flow for setup:auto.
*
* `runSignalChannel(displayName)` owns the full branch from signal-cli
* presence check through the welcome DM:
*
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
* offer `brew install signal-cli` inline. On Linux, surface the
* GitHub releases URL and bail with an actionable error.
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
* a terminal QR the operator scans from Signal Linked Devices.
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
* 5. Kick the service so the adapter picks up the new credentials.
* 6. Ask operator role + agent name.
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* DM path delivers the greeting through the adapter.
*
* Signal's `link` flow creates a *secondary* device. The phone number
* comes from the primary (the phone that scanned the QR); this host then
* sends/receives as that primary number. No registration of new numbers.
*
* Output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runSignalChannel(displayName: string): Promise<void> {
await ensureSignalCli();
const install = await runQuietChild(
'signal-install',
'bash',
['setup/add-signal.sh'],
{
running: 'Installing the Signal adapter…',
done: 'Signal adapter installed.',
skipped: 'Signal adapter already installed.',
},
);
if (!install.ok) {
await fail(
'signal-install',
"Couldn't install the Signal adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const auth = await runSignalAuth();
if (!auth.ok) {
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
await fail(
'signal-auth',
`Signal link failed (${reason}).`,
reason === 'qr_timeout'
? 'The code expired. Re-run setup to get a fresh one.'
: 'Re-run setup to try again.',
);
}
const account = auth.terminal?.fields.ACCOUNT;
if (!account) {
await fail(
'signal-auth',
'Linked with Signal but couldn\'t read the phone number back.',
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
);
}
writeSignalAccount(account!);
await restartService();
const role = await askOperatorRole('Signal');
setupLog.userInput('signal_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'signal',
'--user-id', account!,
'--platform-id', account!,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to Signal…`,
done: `${agentName} is ready. Check Signal for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'signal',
AGENT_NAME: agentName,
PLATFORM_ID: account!,
ROLE: role,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function ensureSignalCli(): Promise<void> {
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
const probe = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!probe.error && probe.status === 0) return;
if (process.platform === 'darwin') {
p.note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'The quickest way on macOS is Homebrew:',
'',
k.cyan(' brew install signal-cli'),
'',
"Install it in another terminal, then re-run setup.",
].join('\n'),
'signal-cli not found',
);
} else {
p.note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'Grab the latest release from GitHub:',
'',
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
'',
"Install it, make sure `signal-cli --version` works, then re-run setup.",
].join('\n'),
'signal-cli not found',
);
}
await fail(
'signal-install',
'signal-cli is required but not installed.',
'Install it and re-run setup.',
);
}
async function runSignalAuth(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('signal-auth');
const start = Date.now();
const s = p.spinner();
s.start('Starting Signal link…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number): void => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
// Tracks how many lines the QR block occupies so we can wipe it in-place
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
// but we still want to erase the QR from screen once it's served).
let qrLinesPrinted = 0;
const result = await spawnStep(
'signal-auth',
[],
(block: Block) => {
if (block.type === 'SIGNAL_AUTH_QR') {
const qr = block.fields.QR ?? '';
if (!qr) return;
void renderQr(qr).then((lines) => {
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
process.stdout.write(lines.join('\n') + '\n');
qrLinesPrinted = lines.length;
s.start('Waiting for you to scan…');
spinnerActive = true;
});
} else if (block.type === 'SIGNAL_AUTH') {
const status = block.fields.STATUS;
// Wipe the QR block regardless of outcome — it's either scanned
// and useless, or expired and misleading.
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
const account = block.fields.ACCOUNT;
if (status === 'skipped') {
stopSpinner(
account
? `Signal already linked as ${k.cyan(account)}.`
: 'Signal already linked.',
);
} else if (status === 'success') {
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
} else if (status === 'failed') {
const err = block.fields.ERROR ?? 'unknown';
stopSpinner(`Signal link failed: ${err}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('signal-auth', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/**
* Render the raw linking URL as a block-art QR, returned line-by-line so
* the caller can count lines for in-place cleanup. Uses small-mode so the
* code stays scannable on 24-row terminals. If qrcode isn't installed
* (add-signal.sh should have handled it, but we're defensive), fall back
* to the raw URL and ask the user to paste it into an external renderer.
*/
async function renderQr(url: string): Promise<string[]> {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
const caption = k.dim(
' Signal → Settings → Linked Devices → Link New Device → scan.',
);
return [...qrText.trimEnd().split('\n'), '', caption];
} catch {
return [
'Linking URL (render at https://qr.io or similar):',
'',
url,
'',
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
];
}
}
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
function writeSignalAccount(account: string): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
contents = contents.replace(
/^SIGNAL_ACCOUNT=.*$/m,
`SIGNAL_ACCOUNT=${account}`,
);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += `SIGNAL_ACCOUNT=${account}\n`;
}
fs.writeFileSync(envPath, contents);
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
setupLog.userInput('signal_account', account);
}
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your Signal account…');
const start = Date.now();
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
stdio: 'ignore',
});
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
}
}
// 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));
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,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop(`Restart may have failed: ${message}`, 1);
setupLog.step('signal-restart', 'failed', Date.now() - start, {
ERROR: message,
});
// Non-fatal — the user can restart manually if init-first-agent fails.
}
}
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 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;
}
+317
View File
@@ -0,0 +1,317 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` walks the operator from a bare Slack
* workspace through a running bot, then stops before wiring an agent:
*
* 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public
* URL) or a public webhook
* 2. Walk through creating a Slack app (api.slack.com/apps) scopes,
* events, and the mode-specific credential (app-level token for
* Socket Mode, signing secret for webhook)
* 3. Paste the bot token + that credential (clack password prompts)
* 4. Validate via auth.test resolves workspace + bot identity
* 5. Install the adapter (setup/add-slack.sh, non-interactive)
* 6. Print the post-install checklist (Socket Mode: just DM the bot;
* webhook: set the public Request URL in Event Subscriptions), then
* `/manage-channels` to wire an agent.
*
* Why no welcome DM here: opening an unsolicited DM would need `im:write`
* scope we don't force the SKILL.md to require and in webhook mode inbound
* events don't flow until the public Event Subscriptions URL is configured.
* Shipping an honest "here's what's left" note is better than a welcome DM
* the user won't receive until they finish wiring Slack up.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
interface WorkspaceInfo {
teamName: string;
teamId: string;
botName: string;
botUserId: string;
}
// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode
// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode
// purely from SLACK_APP_TOKEN's presence — this choice just decides which
// credential to collect and which post-install guidance to show.
type SlackMode = 'socket' | 'webhook';
// 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> {
const mode = await askSlackMode();
await walkThroughAppCreation(mode);
const token = await collectBotToken();
const appToken = mode === 'socket' ? await collectAppToken() : undefined;
const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined;
const info = await validateSlackToken(token);
const env: Record<string, string> = { SLACK_BOT_TOKEN: token };
if (appToken) env.SLACK_APP_TOKEN = appToken;
if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret;
const install = await runQuietChild(
'slack-install',
'bash',
['setup/add-slack.sh'],
{
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
done: 'Slack adapter installed.',
},
{
env,
extraFields: {
MODE: mode,
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
},
},
);
if (!install.ok) {
await fail('slack-install', "Couldn't connect Slack.", 'See logs/setup-steps/ for details, then retry setup.');
}
showPostInstallChecklist(info, mode);
}
async function askSlackMode(): Promise<SlackMode> {
const choice = ensureAnswer(
await brightSelect<SlackMode>({
message: 'How should Slack deliver events to NanoClaw?',
initialValue: 'socket',
options: [
{
value: 'socket',
label: 'Socket Mode',
hint: 'no public URL — recommended for local or behind NAT',
},
{
value: 'webhook',
label: 'Public webhook',
hint: 'needs a public HTTPS Request URL',
},
],
}),
);
setupLog.userInput('slack_mode', String(choice));
return choice;
}
async function walkThroughAppCreation(mode: SlackMode): Promise<void> {
const credSteps =
mode === 'socket'
? [
' 4. Basic Information → App-Level Tokens → "Generate Token and',
' Scopes" → add the connections:write scope → copy it (xapp-…)',
' 5. Socket Mode → toggle "Enable Socket Mode" on',
' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
]
: [
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
];
p.note(
[
"You'll create a Slack app that the assistant talks through.",
'Free and stays inside the workspaces you pick.',
'',
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' 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"',
...credSteps,
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
'Create a Slack app',
);
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
ensureAnswer(
await p.confirm({
message: mode === 'socket' ? 'Got your bot token and app-level token?' : 'Got your bot token and signing secret?',
initialValue: true,
}),
);
}
async function collectBotToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
if (t.length < 24) return "That's shorter than a real Slack bot token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput('slack_bot_token', `${token.slice(0, 10)}${token.slice(-4)}`);
return token;
}
async function collectSigningSecret(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
// Slack signing secrets are 32-char hex strings, but newer apps
// sometimes emit longer variants — leniently require hex only.
if (!/^[a-f0-9]{16,}$/i.test(t)) {
return 'Signing secrets are a string of hex characters';
}
return undefined;
},
}),
);
const secret = (answer as string).trim();
setupLog.userInput('slack_signing_secret', `${secret.slice(0, 4)}${secret.slice(-4)}`);
return secret;
}
async function collectAppToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack app-level token (Socket Mode)',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'App-level token is required for Socket Mode';
if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-';
if (t.length < 24) return "That's shorter than a real Slack app-level token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput('slack_app_token', `${token.slice(0, 10)}${token.slice(-4)}`);
return token;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${SLACK_API}/auth.test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const data = (await res.json()) as {
ok?: boolean;
team?: string;
team_id?: string;
user?: string;
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(`(${elapsedS}s)`)}`);
const info: WorkspaceInfo = {
teamName: data.team,
teamId: data.team_id ?? '',
botName: data.user,
botUserId: data.user_id ?? '',
};
setupLog.step('slack-validate', 'success', Date.now() - start, {
BOT_NAME: info.botName,
BOT_USER_ID: info.botUserId,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return info;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Slack didn't accept that token: ${reason}`, 1);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'slack-validate',
"Slack didn't accept that token.",
reason === 'invalid_auth' || reason === 'token_revoked'
? 'Copy the token again from OAuth & Permissions and retry setup.'
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
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,
});
await fail('slack-validate', "Couldn't reach Slack.", 'Check your internet connection and retry setup.');
}
}
function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void {
if (mode === 'socket') {
p.note(
wrapForGutter(
[
`The Slack adapter is installed in Socket Mode and your creds are saved. No public URL needed — ${info.teamName} reaches NanoClaw over an outbound WebSocket.`,
'',
` 1. DM @${info.botName} from Slack once — that bootstraps the`,
' messaging group. Then run `/manage-channels` in `claude` to',
' wire an agent to it.',
'',
' Note: keep the NanoClaw host running to hold the socket open —',
' Slack does not retry delivery while it is down.',
].join('\n'),
6,
),
'Finish setting up Slack',
);
return;
}
p.note(
wrapForGutter(
[
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
'',
' 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, then reinstall the app when Slack prompts',
'',
` 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,
),
'Finish setting up Slack',
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Step: cli-agent Create the scratch CLI agent for `/new-setup`.
* Step: cli-agent Create the scratch CLI agent for `/setup`.
*
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
* /new-setup SKILL.md can parse the result without having to read the
* /setup SKILL.md can parse the result without having to read the
* script's plain stdout.
*
* Args:
+52 -45
View File
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
@@ -17,58 +19,63 @@ describe('environment detection', () => {
});
});
describe('registered groups DB query', () => {
let db: Database.Database;
describe('detectRegisteredGroups', () => {
let tempDir: string;
beforeEach(() => {
db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
});
it('returns 0 for empty table', () => {
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
expect(row.count).toBe(0);
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns correct count after inserts', () => {
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
'123@g.us',
'Group 1',
'group-1',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
it('returns false when no registration state exists', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
expect(detectRegisteredGroups(tempDir)).toBe(false);
});
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
'456@g.us',
'Group 2',
'group-2',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
it('detects pre-migration registered_groups.json', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
expect(detectRegisteredGroups(tempDir)).toBe(true);
});
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
expect(row.count).toBe(2);
it('returns false for an empty v2 central DB', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
db.exec(`
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL,
agent_group_id TEXT NOT NULL
);
`);
db.close();
expect(detectRegisteredGroups(tempDir)).toBe(false);
});
it('detects wired agent groups in the v2 central DB', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
db.exec(`
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL,
agent_group_id TEXT NOT NULL
);
`);
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
db.prepare(
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
).run('mga-1', 'mg-1', 'ag-1');
db.close();
expect(detectRegisteredGroups(tempDir)).toBe(true);
});
});
+26 -21
View File
@@ -7,11 +7,35 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
export function detectRegisteredGroups(projectRoot: string): boolean {
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
return true;
}
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return false;
let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare(
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
)
.get() as { count: number };
return row.count > 0;
} catch {
return false;
} finally {
db?.close();
}
}
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
@@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise<void> {
const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false;
// Check JSON file first (pre-migration)
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
hasRegisteredGroups = true;
} else {
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
if (row.count > 0) hasRegisteredGroups = true;
db.close();
} catch {
// Table might not exist yet
}
}
}
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
// Check for existing OpenClaw installation
const homedir = (await import('os')).homedir();
+229
View File
@@ -0,0 +1,229 @@
/**
* Step: groups Fetch group metadata from messaging platforms, write to DB.
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
* Other channels discover group names at runtime this step auto-skips for them.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { list: boolean; limit: number } {
let list = false;
let limit = 30;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--list') list = true;
if (args[i] === '--limit' && args[i + 1]) {
limit = parseInt(args[i + 1], 10);
i++;
}
}
return { list, limit };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
await listGroups(limit);
return;
}
await syncGroups(projectRoot);
}
async function listGroups(limit: number): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
if (!fs.existsSync(dbPath)) {
console.error('ERROR: database not found');
process.exit(1);
}
const db = new Database(dbPath, { readonly: true });
const rows = db
.prepare(
`SELECT jid, name FROM chats
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
)
.all(limit) as Array<{ jid: string; name: string }>;
db.close();
for (const row of rows) {
console.log(`${row.jid}|${row.name}`);
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
// Detect WhatsApp by checking for auth credentials on disk.
const authDir = path.join(projectRoot, 'store', 'auth');
const hasWhatsAppAuth =
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
if (!hasWhatsAppAuth) {
log.info('WhatsApp auth not found — skipping group sync');
emitStatus('SYNC_GROUPS', {
BUILD: 'skipped',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
REASON: 'whatsapp_not_configured',
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
// Build TypeScript first
log.info('Building TypeScript');
let buildOk = false;
try {
execSync('pnpm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
log.info('Build succeeded');
} catch {
log.error('Build failed');
emitStatus('SYNC_GROUPS', {
BUILD: 'failed',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Run sync script via a temp file to avoid shell escaping issues with node -e
log.info('Fetching group metadata');
let syncOk = false;
try {
const syncScript = `
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
`;
const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
try {
const output = execSync(`node ${tmpScript}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
log.info('Sync output', { output: output.trim() });
} finally {
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
}
} catch (err) {
log.error('Sync failed', { err });
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
let groupsInDb = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db
.prepare(
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
)
.get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
}
}
const status = syncOk ? 'success' : 'failed';
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}
+3 -1
View File
@@ -13,9 +13,11 @@ const STEPS: Record<
'set-env': () => import('./set-env.js'),
environment: () => import('./environment.js'),
container: () => import('./container.js'),
register: () => import('./register.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
'pair-telegram': () => import('./pair-telegram.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
'signal-auth': () => import('./signal-auth.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-discord — bundles the preflight + install commands
# from the /add-discord skill into one idempotent script so /new-setup can
# from the /add-discord skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Discord adapter in from the `channels` branch; appends the
@@ -37,7 +37,7 @@ if ! grep -q "import './discord.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/discord@4.26.0
pnpm install @chat-adapter/discord@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-docker — bundles Docker install into one idempotent
# script so /new-setup can run it without needing `curl | sh` in the allowlist
# script so /setup can run it without needing `curl | sh` in the allowlist
# (pipelines split at matching time, and `sh` receiving stdin can't be
# pre-approved safely).
#
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-gchat — bundles the preflight + install commands
# from the /add-gchat skill into one idempotent script so /new-setup can
# from the /add-gchat skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Google Chat adapter in from the `channels` branch; appends the
@@ -37,7 +37,7 @@ if ! grep -q "import './gchat.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/gchat@4.26.0
pnpm install @chat-adapter/gchat@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-github — bundles the preflight + install commands
# from the /add-github skill into one idempotent script so /new-setup can
# from the /add-github skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the GitHub adapter in from the `channels` branch; appends the
@@ -37,7 +37,7 @@ if ! grep -q "import './github.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/github@4.26.0
pnpm install @chat-adapter/github@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-imessage — bundles the preflight + install commands
# from the /add-imessage skill into one idempotent script so /new-setup can
# from the /add-imessage skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the iMessage adapter in from the `channels` branch; appends the
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-linear — bundles the preflight + install commands
# from the /add-linear skill into one idempotent script so /new-setup can
# from the /add-linear skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Linear adapter in from the `channels` branch; appends the
@@ -86,7 +86,7 @@ if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/linear@4.26.0
pnpm install @chat-adapter/linear@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-matrix — bundles the preflight + install commands
# from the /add-matrix skill into one idempotent script so /new-setup can
# from the /add-matrix skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Matrix adapter in from the `channels` branch; appends the
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-node — bundles Node 22 install into one idempotent
# script so /new-setup can run it without needing `curl | sudo -E bash -` in
# script so /setup can run it without needing `curl | sudo -E bash -` in
# the allowlist (that pattern is inherently unmatchable — bash reads from
# stdin, so pre-approval can't inspect what's being executed).
#
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-resend — bundles the preflight + install commands
# from the /add-resend skill into one idempotent script so /new-setup can
# from the /add-resend skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Resend adapter in from the `channels` branch; appends the
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-slack — bundles the preflight + install commands
# from the /add-slack skill into one idempotent script so /new-setup can
# from the /add-slack skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Slack adapter in from the `channels` branch; appends the
@@ -37,7 +37,7 @@ if ! grep -q "import './slack.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/slack@4.26.0
pnpm install @chat-adapter/slack@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-teams — bundles the preflight + install commands
# from the /add-teams skill into one idempotent script so /new-setup can
# from the /add-teams skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Teams adapter in from the `channels` branch; appends the
@@ -37,7 +37,7 @@ if ! grep -q "import './teams.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/teams@4.26.0
pnpm install @chat-adapter/teams@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-telegram — bundles the preflight + install commands
# from the /add-telegram skill into one idempotent script so /new-setup can
# from the /add-telegram skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials and pairing.
#
# Copies the Telegram adapter, helpers, tests, and the pair-telegram setup
@@ -63,7 +63,7 @@ if ! grep -q "'pair-telegram':" setup/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/telegram@4.26.0
pnpm install @chat-adapter/telegram@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-webex — bundles the preflight + install commands
# from the /add-webex skill into one idempotent script so /new-setup can
# from the /add-webex skill into one idempotent script so /setup can
# run them programmatically before continuing to credentials.
#
# Copies the Webex adapter in from the `channels` branch; appends the
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp-cloud — bundles the preflight + install
# commands from the /add-whatsapp-cloud skill into one idempotent script so
# /new-setup can run them programmatically before continuing to credentials.
# /setup can run them programmatically before continuing to credentials.
#
# Copies the WhatsApp Cloud adapter in from the `channels` branch; appends the
# self-registration import; installs the pinned @chat-adapter/whatsapp package;
@@ -37,7 +37,7 @@ if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/whatsapp@4.26.0
pnpm install @chat-adapter/whatsapp@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Setup helper: install-whatsapp — bundles the preflight + install commands
# from the /add-whatsapp skill into one idempotent script so /new-setup can
# from the /add-whatsapp skill into one idempotent script so /setup can
# run them programmatically before continuing to QR/pairing-code auth.
#
# Copies the native Baileys WhatsApp adapter, its whatsapp-auth and groups
@@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0
echo "STEP: pnpm-build"
pnpm run build
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { classifyPingResult } from './agent-ping.js';
describe('classifyPingResult', () => {
it('treats a normal text reply as ok', () => {
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
});
it('detects Anthropic auth errors printed as a chat reply', () => {
expect(
classifyPingResult(
0,
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
),
).toBe('auth_error');
});
it('detects auth errors on stderr too', () => {
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
});
it('preserves socket errors', () => {
expect(classifyPingResult(2, '')).toBe('socket_error');
});
it('treats empty output as no reply', () => {
expect(classifyPingResult(0, '')).toBe('no_reply');
});
});
+20 -4
View File
@@ -13,7 +13,21 @@
*/
import { spawn } from 'child_process';
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error';
export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult {
const output = `${stdout}\n${stderr}`;
if (
/Invalid bearer token/i.test(output) ||
/authentication[_ ]error/i.test(output) ||
/Failed to authenticate/i.test(output)
) {
return 'auth_error';
}
if (exitCode === 2) return 'socket_error';
if (exitCode === 0 && stdout.trim().length > 0) return 'ok';
return 'no_reply';
}
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
return new Promise((resolve) => {
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf-8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 2) resolve('socket_error');
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
else resolve('no_reply');
resolve(classifyPingResult(code, stdout, stderr));
});
child.on('error', () => {
if (settled) return;
+44 -15
View File
@@ -64,6 +64,10 @@ const STEP_FILES: Record<string, string[]> = {
'telegram-validate': ['setup/channels/telegram.ts'],
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
'imessage': ['setup/channels/imessage.ts'],
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
'init-first-agent': [
@@ -115,7 +119,7 @@ export async function offerClaudeAssist(
const run = ensureAnswer(
await p.confirm({
message: 'Run this command? (you can edit it before executing)',
initialValue: false,
initialValue: true,
}),
);
if (!run) return false;
@@ -279,18 +283,24 @@ async function queryClaudeUnderSpinner(
// No hard timeout — debugging can take a long time, and the cost of
// cutting Claude off mid-investigation is worse than letting the
// spinner run. The user can Ctrl-C if they want to abort.
const child = spawn(
'claude',
[
'-p',
'--output-format',
'stream-json',
'--verbose',
'--permission-mode',
'bypassPermissions',
],
{ cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] },
);
//
// Resume the same session on repeat invocations so Claude carries
// context across failures in one setup run.
const claudeArgs = [
'-p',
'--output-format',
'stream-json',
'--verbose',
'--permission-mode',
'bypassPermissions',
];
if (claudeSessionId) {
claudeArgs.push('--resume', claudeSessionId);
}
const child = spawn('claude', claudeArgs, {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stdout.on('data', (c: Buffer) => {
lineBuf += c.toString('utf-8');
@@ -301,6 +311,16 @@ async function queryClaudeUnderSpinner(
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as StreamEvent;
// Capture the session id on the very first claude invocation of
// this process so later calls can --resume it.
if (
!claudeSessionId &&
event.type === 'system' &&
event.subtype === 'init' &&
typeof event.session_id === 'string'
) {
claudeSessionId = event.session_id;
}
handleStreamEvent(event, {
setAction: (a) => {
actions.push(a);
@@ -335,10 +355,14 @@ async function queryClaudeUnderSpinner(
}
// Minimal shape of the stream-json events we care about. Claude emits
// many more, but we only read tool_use blocks (for breadcrumbs) and text
// blocks (to reassemble the final REASON/COMMAND answer).
// many more, but we only read tool_use blocks (for breadcrumbs), text
// blocks (to reassemble the final REASON/COMMAND answer), and the
// session_id on the init event so follow-up invocations can resume the
// same conversation.
interface StreamEvent {
type: string;
subtype?: string;
session_id?: string;
message?: {
content?: Array<
| { type: 'text'; text: string }
@@ -347,6 +371,11 @@ interface StreamEvent {
};
}
// The session id from the first claude-assist invocation in this process.
// Subsequent invocations pass `--resume <id>` so Claude sees prior failures
// as conversation history instead of treating each failure in isolation.
let claudeSessionId: string | null = null;
function handleStreamEvent(
event: StreamEvent,
cb: { setAction: (a: string) => void; appendText: (t: string) => void },
+4 -2
View File
@@ -322,10 +322,12 @@ async function runUnderSpinner<
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
// Bold the outcome so the step's headline reads stronger than the prose
// body copy around it. The trailing `(Ns)` timing stays dim.
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1);
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
dumpTranscriptOnFailure(result.transcript);
}
return result;
+161
View File
@@ -0,0 +1,161 @@
/**
* Parser/reader/writer for the advanced-config registry (setup-config.ts).
*
* readFromEnv() values found in process.env
* parseFlags() values from argv, plus --help and any pass-through args
* applyToEnv() write resolved values back to process.env so existing
* step code keeps reading env vars unchanged
* printHelp() render --help from the registry
*
* Flag parsing supports:
* --key value space form
* --key=value equals form
* --key booleans only (sets true)
* --no-key booleans only (sets false)
*/
import {
CONFIG,
envVarFor,
flagFor,
findByFlag,
type Entry,
} from './setup-config.js';
export type ConfigValues = Record<string, string | boolean | number>;
function coerce(e: Entry, raw: string): string | number | boolean | undefined {
switch (e.type) {
case 'boolean': {
const v = raw.toLowerCase();
if (['true', '1', 'yes'].includes(v)) return true;
if (['false', '0', 'no'].includes(v)) return false;
return undefined;
}
case 'integer': {
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
default:
return raw;
}
}
export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues {
const out: ConfigValues = {};
for (const e of CONFIG) {
const raw = env[envVarFor(e)];
if (raw === undefined || raw === '') continue;
const v = coerce(e, raw);
if (v !== undefined) out[e.key] = v;
}
return out;
}
export type FlagParseResult = {
values: ConfigValues;
rest: string[];
help: boolean;
errors: string[];
};
export function parseFlags(argv: string[]): FlagParseResult {
const values: ConfigValues = {};
const rest: string[] = [];
const errors: string[] = [];
let help = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
help = true;
continue;
}
// POSIX end-of-options. pnpm passes a bare `--` through when invoked as
// `pnpm run script --` with nothing after it; treat the rest as
// pass-through positional args.
if (arg === '--') {
rest.push(...argv.slice(i + 1));
break;
}
if (!arg.startsWith('--')) {
rest.push(arg);
continue;
}
const eq = arg.indexOf('=');
let name = eq === -1 ? arg : arg.slice(0, eq);
const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1);
let negated = false;
if (name.startsWith('--no-')) {
negated = true;
name = `--${name.slice(5)}`;
}
const entry = findByFlag(name);
if (!entry) {
errors.push(`Unknown flag: ${arg}`);
continue;
}
if (entry.type === 'boolean') {
if (negated) values[entry.key] = false;
else if (inline !== undefined) {
const v = coerce(entry, inline);
if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`);
else values[entry.key] = v;
} else values[entry.key] = true;
continue;
}
const raw = inline !== undefined ? inline : argv[++i];
if (raw === undefined) {
errors.push(`Missing value for ${name}`);
continue;
}
const v = coerce(entry, raw);
if (v === undefined) {
errors.push(`Invalid ${entry.type} for ${name}: ${raw}`);
continue;
}
if (entry.type === 'string' || entry.type === 'url') {
const err = entry.validate?.(raw);
if (err) {
errors.push(`${name}: ${err}`);
continue;
}
}
values[entry.key] = v;
}
return { values, rest, help, errors };
}
export function applyToEnv(
values: ConfigValues,
env: NodeJS.ProcessEnv = process.env,
): void {
for (const e of CONFIG) {
if (!(e.key in values)) continue;
const v = values[e.key];
env[envVarFor(e)] =
typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v);
}
}
export function printHelp(stream: NodeJS.WritableStream = process.stdout): void {
const lines: string[] = [];
lines.push('Usage: bash nanoclaw.sh [flags...]');
lines.push('');
lines.push('Flags:');
const width = Math.max(...CONFIG.map((e) => flagFor(e).length));
for (const e of CONFIG) {
const flag = flagFor(e).padEnd(width + 2);
lines.push(` ${flag}${e.help}`);
}
lines.push('');
lines.push('Each flag also reads from its corresponding NANOCLAW_<KEY> env var.');
lines.push('Run without flags for the default interactive flow.');
stream.write(lines.join('\n') + '\n');
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Advanced-settings screen menu of UI-visible entries from the config
* registry. The user picks one entry, edits it, returns to the menu, and
* exits via "Done". Returns a fresh values object; the caller passes it to
* applyToEnv() so downstream step code reads them via env vars.
*
* Per-entry edit contract:
* - Blank input on text/password/integer = leave current value unchanged.
* - Enums get a synthetic "leave unchanged" first option.
* - Booleans use confirm with the current value as initialValue.
* - Secret entries mask the current value as bullets in hints/labels.
*/
import * as p from '@clack/prompts';
import { brightSelect } from './bright-select.js';
import { ensureAnswer } from './runner.js';
import { CONFIG, type Entry } from './setup-config.js';
import type { ConfigValues } from './setup-config-parse.js';
const SKIP_SENTINEL = '__leave_unchanged__';
const DONE_SENTINEL = '__done__';
const MASK = '••••••••';
export async function runAdvancedScreen(
initial: ConfigValues,
): Promise<ConfigValues> {
const result: ConfigValues = { ...initial };
const visible = CONFIG.filter((e) => e.surface === 'flag+ui');
while (true) {
const options = [
...visible.map((e) => ({
value: e.key,
label: e.label,
hint: hintFor(e, result),
})),
{ value: DONE_SENTINEL, label: 'Done — continue with setup' },
];
const choice = ensureAnswer(
await brightSelect<string>({
message: 'Pick a setting to override',
options,
initialValue: DONE_SENTINEL,
}),
) as string;
if (choice === DONE_SENTINEL) return result;
const entry = visible.find((e) => e.key === choice);
if (entry) await promptOne(entry, result);
}
}
function hintFor(e: Entry, values: ConfigValues): string {
const v = values[e.key];
if (v === undefined) return 'not set';
if (e.secret) return MASK;
return String(v);
}
async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
if (e.type === 'boolean') {
const init =
typeof values[e.key] === 'boolean'
? (values[e.key] as boolean)
: (e.default ?? false);
const ans = ensureAnswer(
await p.confirm({ message: e.label, initialValue: init }),
);
values[e.key] = ans as boolean;
return;
}
if (e.type === 'enum') {
const ans = ensureAnswer(
await brightSelect<string>({
message: e.label,
options: [
{ value: SKIP_SENTINEL, label: 'Leave unchanged' },
...e.options,
],
initialValue: SKIP_SENTINEL,
}),
);
if (ans !== SKIP_SENTINEL) values[e.key] = ans as string;
return;
}
if (e.type === 'integer') {
const ans = ensureAnswer(
await p.text({
message: e.label,
placeholder: e.default !== undefined ? String(e.default) : undefined,
validate: (v) => {
const s = (v ?? '').trim();
if (!s) return undefined;
const n = Number(s);
if (!Number.isFinite(n)) return 'Must be a number';
if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`;
if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`;
return undefined;
},
}),
);
const trimmed = ((ans as string) ?? '').trim();
if (trimmed) values[e.key] = Number(trimmed);
return;
}
// string | url
const validate = (v: string | undefined): string | undefined => {
const s = (v ?? '').trim();
if (!s) return undefined;
return e.validate?.(s);
};
const ans = ensureAnswer(
e.secret
? await p.password({ message: e.label, validate })
: await p.text({
message: e.label,
placeholder: e.placeholder ?? e.default,
validate,
}),
);
const trimmed = ((ans as string) ?? '').trim();
if (trimmed) values[e.key] = trimmed;
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Setup-time advanced-config registry.
*
* One source of truth for: CLI flags, env-var names, the advanced-settings
* screen, and `--help` output. The flag parser, env reader, and UI screen
* all consume this list and write resolved values back to `process.env` so
* existing step code keeps reading env vars unchanged.
*
* Default name conventions (overridable per entry):
* key 'fooBar' envVar 'NANOCLAW_FOO_BAR' flag '--foo-bar'
*
* Surface levels:
* 'flag' CLI flag + env var only (debug/internal knobs)
* 'flag+ui' also shown in the advanced-settings screen
*/
export type EntrySurface = 'flag' | 'flag+ui';
interface BaseEntry {
/** Canonical camelCase key. */
key: string;
/** Override of the auto-derived NANOCLAW_<UPPER_SNAKE> env var. */
envVar?: string;
/** Override of the auto-derived --kebab-case flag. */
flag?: string;
label: string;
help: string;
surface: EntrySurface;
/** UI section header. Entries without a group land in 'Other'. */
group?: string;
/** Mask in UI, redact in logs. */
secret?: boolean;
}
interface StringEntry extends BaseEntry {
type: 'string' | 'url';
default?: string;
placeholder?: string;
validate?: (v: string) => string | undefined;
}
interface EnumEntry extends BaseEntry {
type: 'enum';
options: { value: string; label: string; hint?: string }[];
default?: string;
}
interface BoolEntry extends BaseEntry {
type: 'boolean';
default?: boolean;
}
interface IntEntry extends BaseEntry {
type: 'integer';
default?: number;
min?: number;
max?: number;
}
export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry;
const httpUrl = (v: string): string | undefined =>
/^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…';
export const CONFIG: Entry[] = [
{
key: 'onecliApiHost',
label: 'OneCLI vault URL',
help: 'Use a remote OneCLI vault instead of installing one locally.',
surface: 'flag+ui',
group: 'OneCLI',
type: 'url',
default: 'https://app.onecli.sh',
placeholder: 'https://app.onecli.sh',
validate: httpUrl,
},
{
key: 'onecliApiToken',
label: 'OneCLI access token',
help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.',
surface: 'flag+ui',
group: 'OneCLI',
type: 'string',
secret: true,
placeholder: 'oc_…',
validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'),
},
{
key: 'anthropicBaseUrl',
label: 'Anthropic API base URL',
help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.',
surface: 'flag+ui',
group: 'Anthropic',
type: 'url',
placeholder: 'https://api.anthropic.com',
validate: httpUrl,
},
{
key: 'anthropicAuthToken',
label: 'Anthropic auth token',
help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.',
surface: 'flag+ui',
group: 'Anthropic',
type: 'string',
secret: true,
validate: (v) => (v.trim() ? undefined : 'Required'),
},
// Existing env-var knobs — flag-only so they don't clutter the UI screen.
{
key: 'skip',
envVar: 'NANOCLAW_SKIP',
label: 'Skip steps',
help: 'Comma-separated step names to skip (debugging only).',
surface: 'flag',
type: 'string',
},
{
key: 'displayName',
envVar: 'NANOCLAW_DISPLAY_NAME',
label: 'Display name',
help: 'Skip the "what should your assistant call you?" prompt.',
surface: 'flag',
type: 'string',
},
];
// ─── name derivation ───────────────────────────────────────────────────
export function envVarFor(e: Entry): string {
if (e.envVar) return e.envVar;
return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
}
export function flagFor(e: Entry): string {
if (e.flag) return e.flag;
return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
}
export function findByFlag(flag: string): Entry | null {
return CONFIG.find((e) => flagFor(e) === flag) ?? null;
}
+6 -9
View File
@@ -58,17 +58,14 @@ export function wrapForGutter(text: string, gutter: number): string {
}
/**
* Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))`
* because clack resets styling at each line break when rendering
* multi-line log content a single outer dim envelope only colors the
* first line. Applying dim per-line gives each wrapped row its own
* `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block.
* Wrap multi-line explanatory prose to the clack gutter. Previously
* dimmed its output (hence the name) that made body copy hard to read
* against dark terminals. Dim is now reserved for preview/debug blocks
* (failure transcript tails, claude-assist streams); prose renders at
* the terminal's regular weight.
*/
export function dimWrap(text: string, gutter: number): string {
return wrapForGutter(text, gutter)
.split('\n')
.map((line) => k.dim(line))
.join('\n');
return wrapForGutter(text, gutter);
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
+192 -15
View File
@@ -86,40 +86,161 @@ function ensureShellProfilePath(): void {
}
}
function writeEnvOnecliUrl(url: string): void {
function writeEnvVar(name: string, value: string): void {
const envFile = path.join(process.cwd(), '.env');
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
if (/^ONECLI_URL=/m.test(content)) {
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
const re = new RegExp(`^${name}=.*$`, 'm');
if (re.test(content)) {
content = content.replace(re, `${name}=${value}`);
} else {
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
}
fs.writeFileSync(envFile, content);
}
function writeEnvOnecliUrl(url: string): void {
writeEnvVar('ONECLI_URL', url);
}
// Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships.
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
const fallback = installOnecliCliDirect();
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
}
function installOnecli(): { stdout: string; ok: boolean } {
// OneCLI's own install script handles gateway + CLI + PATH.
// We run the two canonical installers in sequence and capture stdout so
// we can extract the printed URL as a fallback to `onecli config get`.
let stdout = '';
// Gateway install (docker-compose based, no rate-limit concerns).
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
stdout += gw.stdout;
if (!gw.ok) {
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
return { stdout: stdout + (gw.stderr ?? ''), ok: false };
}
// CLI install. The upstream script calls the GitHub releases API
// (api.github.com) to resolve the latest tag — which 403s anonymous
// callers after 60 requests/hour per IP. Try upstream first; on failure
// resolve the version ourselves (via HTTP redirect, which isn't
// API-throttled) and download the release archive directly.
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
stdout += upstream.stdout;
if (upstream.ok) return { stdout, ok: true };
log.warn('Upstream CLI installer failed — falling back to direct download', {
stderr: upstream.stderr,
});
stdout += (upstream.stderr ?? '') + '\n';
const fallback = installOnecliCliDirect();
stdout += fallback.stdout;
if (!fallback.ok) {
log.error('OneCLI CLI install failed (both upstream and direct fallback)');
return { stdout, ok: false };
}
return { stdout, ok: true };
}
function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean } {
try {
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
const stdout = execSync(cmd, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, ok: true };
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
log.error('OneCLI install failed', { stderr: e.stderr });
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
return { stdout: e.stdout ?? '', stderr: e.stderr, ok: false };
}
}
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
/**
* Reinstate the OneCLI CLI install without hitting GitHub's rate-limited
* releases API. Resolves the version via the HTTP redirect from
* /releases/latest /releases/tag/vX.Y.Z, then downloads the archive
* directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect
* probe also fails.
*/
function installOnecliCliDirect(): { stdout: string; ok: boolean } {
const lines: string[] = [];
const append = (s: string): void => {
lines.push(s);
};
const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
if (!osName) {
append(`Unsupported platform: ${process.platform}`);
return { stdout: lines.join('\n'), ok: false };
}
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
if (!arch) {
append(`Unsupported arch: ${process.arch}`);
return { stdout: lines.join('\n'), ok: false };
}
let version: string | null = null;
try {
const redirect = execSync(
`curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
).trim();
const m = redirect.match(/\/tag\/v?([^/]+)$/);
if (m) version = m[1];
} catch {
// redirect probe failed — we'll pin the fallback
}
if (!version) {
version = ONECLI_CLI_FALLBACK_VERSION;
append(`Version probe failed; installing pinned fallback ${version}.`);
} else {
append(`Resolved onecli CLI ${version} via release redirect.`);
}
const archive = `onecli_${version}_${osName}_${arch}.tar.gz`;
const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-'));
const archivePath = path.join(tmpDir, archive);
try {
append(`Downloading ${url}`);
execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
stdio: ['ignore', 'pipe', 'pipe'],
});
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let installDir = '/usr/local/bin';
try {
fs.accessSync(installDir, fs.constants.W_OK);
} catch {
installDir = LOCAL_BIN;
fs.mkdirSync(installDir, { recursive: true });
}
const binSrc = path.join(tmpDir, 'onecli');
const binDest = path.join(installDir, 'onecli');
fs.copyFileSync(binSrc, binDest);
fs.chmodSync(binDest, 0o755);
append(`onecli ${version} installed to ${binDest}.`);
return { stdout: lines.join('\n'), ok: true };
} catch (err) {
const e = err as { stdout?: string; stderr?: string; message?: string };
append(`Direct install failed: ${e.stderr ?? e.message ?? String(err)}`);
return { stdout: lines.join('\n'), ok: false };
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@@ -136,8 +257,64 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
export async function run(args: string[]): Promise<void> {
const reuse = args.includes('--reuse');
const remoteUrlIdx = args.indexOf('--remote-url');
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
ensureShellProfilePath();
if (remoteUrl) {
// Remote-mode: install only the CLI, point it at the remote gateway, and
// record the URL in .env. No local gateway is started.
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
const res = installOnecliCliOnly();
if (!res.ok || !onecliVersion()) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'cli_install_failed',
HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
try {
execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli config set api-host failed', { err });
}
writeEnvOnecliUrl(remoteUrl);
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim();
if (remoteToken) {
// Two auth surfaces: `onecli auth login` persists the key for CLI
// calls during setup itself (e.g. detecting an existing Anthropic
// secret via `onecli secrets list`), and ONECLI_API_KEY in .env is
// read by the runtime SDK at request time. Both are needed.
try {
execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli auth login failed', { err });
}
writeEnvVar('ONECLI_API_KEY', remoteToken);
log.info('Wrote ONECLI_API_KEY to .env');
}
const healthy = await pollHealth(remoteUrl, 5000);
emitStatus('ONECLI', {
INSTALLED: true,
REMOTE: true,
ONECLI_URL: remoteUrl,
HEALTHY: healthy,
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
if (reuse) {
// Reuse-mode: don't touch the running gateway at all. Just verify it
// exists, read its api-host, write ONECLI_URL to .env, and move on.
+186
View File
@@ -0,0 +1,186 @@
/**
* Detect and clean up unhealthy NanoClaw peer services.
*
* Runs as a setup preflight before we install our own service. A crash-looping
* peer install (typically the legacy v1 `com.nanoclaw` plist) silently trashes
* this install's containers on every respawn because its `cleanupOrphans()`
* reaps anything matching `nanoclaw-`. We scope our reaper by label now, but
* we still need to stop the peer from killing us on its way down.
*
* A peer is "unhealthy" when:
* - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD`
* - systemd: unit is in `failed` state, OR `activating` with many restarts
*
* Healthy peers are left alone multiple installs can coexist fine now that
* container-reaper is label-scoped.
*/
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { log } from '../src/log.js';
const UNHEALTHY_RUNS_THRESHOLD = 10;
export interface PeerStatus {
label: string;
configPath: string;
state: string;
runs: number;
unhealthy: boolean;
}
export interface PeerCleanupResult {
checked: PeerStatus[];
unloaded: PeerStatus[];
failures: Array<{ label: string; err: string }>;
}
/**
* Scan for peer NanoClaw services and unload any that are crash-looping.
* Returns a summary suitable for emitStatus / setup-log reporting.
*/
export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): PeerCleanupResult {
const platform = os.platform();
if (platform === 'darwin') {
return cleanupLaunchdPeers(projectRoot);
}
if (platform === 'linux') {
return cleanupSystemdPeers(projectRoot);
}
return { checked: [], unloaded: [], failures: [] };
}
// ---- launchd (macOS) --------------------------------------------------------
function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
const ownLabel = getLaunchdLabel(projectRoot);
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
let plists: string[];
try {
plists = fs
.readdirSync(agentsDir)
.filter((f) => /^com\.nanoclaw.*\.plist$/.test(f))
.map((f) => path.join(agentsDir, f));
} catch {
return result;
}
const uid = process.getuid?.() ?? 0;
for (const plistPath of plists) {
const label = path.basename(plistPath, '.plist');
if (label === ownLabel) continue;
const status = probeLaunchdPeer(label, plistPath, uid);
if (!status) continue;
result.checked.push(status);
if (!status.unhealthy) continue;
try {
execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' });
log.info('Unloaded unhealthy peer launchd service', {
label,
state: status.state,
runs: status.runs,
plistPath,
});
result.unloaded.push(status);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn('Failed to unload peer launchd service', { label, err: message });
result.failures.push({ label, err: message });
}
}
return result;
}
function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerStatus | null {
let output: string;
try {
output = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], {
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8',
});
} catch {
// Not loaded → not currently a threat. Skip silently.
return null;
}
const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1] ?? 'unknown';
const runsStr = /^\s*runs\s*=\s*(\d+)/m.exec(output)?.[1];
const runs = runsStr ? parseInt(runsStr, 10) : 0;
const unhealthy = state !== 'running' && runs > UNHEALTHY_RUNS_THRESHOLD;
return { label, configPath: plistPath, state, runs, unhealthy };
}
// ---- systemd (Linux) --------------------------------------------------------
function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
const ownUnit = getSystemdUnit(projectRoot);
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
let units: string[];
try {
units = fs
.readdirSync(unitDir)
.filter((f) => /^nanoclaw.*\.service$/.test(f))
.map((f) => f.replace(/\.service$/, ''));
} catch {
return result;
}
for (const unit of units) {
if (unit === ownUnit) continue;
const status = probeSystemdPeer(unit);
if (!status) continue;
result.checked.push(status);
if (!status.unhealthy) continue;
try {
execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' });
log.info('Disabled unhealthy peer systemd unit', {
unit,
state: status.state,
runs: status.runs,
});
result.unloaded.push(status);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn('Failed to disable peer systemd unit', { unit, err: message });
result.failures.push({ label: unit, err: message });
}
}
return result;
}
function probeSystemdPeer(unit: string): PeerStatus | null {
const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', `${unit}.service`);
try {
const output = execFileSync(
'systemctl',
['--user', 'show', '--property=ActiveState,NRestarts', `${unit}.service`],
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' },
);
const activeState = /^ActiveState=(.+)$/m.exec(output)?.[1]?.trim() ?? 'unknown';
const restartsStr = /^NRestarts=(\d+)/m.exec(output)?.[1];
const runs = restartsStr ? parseInt(restartsStr, 10) : 0;
const unhealthy =
activeState === 'failed' || (activeState !== 'active' && runs > UNHEALTHY_RUNS_THRESHOLD);
return { label: unit, configPath: unitPath, state: activeState, runs, unhealthy };
} catch {
return null;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# Setup step: probe — single upfront parallel-ish scan that snapshots every
# prerequisite and dependency for /new-setup's dynamic context injection.
# prerequisite and dependency for /setup's dynamic context injection.
# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
# the current system state before generating its first response.
#
+17 -15
View File
@@ -20,6 +20,7 @@ import {
import { isValidGroupFolder } from '../src/group-folder.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { log } from '../src/log.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
import { emitStatus } from './status.js';
@@ -112,12 +113,10 @@ export async function run(args: string[]): Promise<void> {
process.exit(4);
}
// Chat SDK adapters prefix platform IDs with the channel type
// (e.g. "telegram:123", "discord:guild:channel"). Normalize here so
// the stored ID always matches what the adapter sends at runtime.
if (!parsed.platformId.startsWith(`${parsed.channel}:`)) {
parsed.platformId = `${parsed.channel}:${parsed.platformId}`;
}
// Normalize platform_id to the same shape the adapter will emit at runtime,
// so the router's (channel_type, platform_id) lookup matches what we store.
// Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't.
parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId);
log.info('Registering channel', parsed);
@@ -167,19 +166,22 @@ export async function run(args: string[]): Promise<void> {
if (!existing) {
newlyWired = true;
const mgaId = generateId('mga');
const triggerRules = parsed.trigger
? JSON.stringify({
pattern: parsed.trigger,
requiresTrigger: parsed.requiresTrigger,
})
: null;
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
// create rows with the same shape. Groups default to 'mention' (bot only
// responds when addressed); DMs default to 'pattern'/'.' (respond to
// every message). An explicit --trigger overrides the pattern regex.
const isGroup = messagingGroup.is_group === 1;
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: messagingGroup.id,
agent_group_id: agentGroup.id,
trigger_rules: triggerRules,
response_scope: 'all',
session_mode: parsed.sessionMode,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
priority: 0,
created_at: new Date().toISOString(),
});
+14
View File
@@ -11,6 +11,7 @@ import path from 'path';
import { log } from '../src/log.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
import {
commandExists,
getPlatform,
@@ -53,6 +54,19 @@ export async function run(_args: string[]): Promise<void> {
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
// Peer preflight — a crash-looping peer install (most often the legacy v1
// `com.nanoclaw` plist) will keep trashing this install's containers on
// every respawn via its own cleanupOrphans. Detect and unload any peer
// that's unhealthy before we install our service. Healthy peers are left
// alone now that container reaping is install-label-scoped.
const peerReport = cleanupUnhealthyPeers(projectRoot);
if (peerReport.unloaded.length > 0) {
log.warn('Unloaded unhealthy peer NanoClaw services', {
count: peerReport.unloaded.length,
labels: peerReport.unloaded.map((p) => p.label),
});
}
if (platform === 'macos') {
setupLaunchd(projectRoot, nodePath, homeDir);
} else if (platform === 'linux') {
+182
View File
@@ -0,0 +1,182 @@
/**
* Step: signal-auth link this host to an existing Signal account via
* signal-cli's QR-code flow.
*
* signal-cli `link` opens a bi-directional handshake with the Signal
* servers: it prints one line containing a linking URL (`sgnl://linkdevice?…`
* or older `tsdevice://linkdevice?…`), then blocks until either the user
* scans it from an existing Signal install, or the code expires. On
* success, a secondary account is created under the user's signal-cli
* data directory, associated with the phone number of the scanner.
*
* Methods:
* (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR
* with the URL, wait for completion.
*
* Block schema (parent parses these):
* SIGNAL_AUTH_QR { QR: "<sgnl:// or tsdevice:// url>" } one-shot
* SIGNAL_AUTH { STATUS: success, ACCOUNT: +<digits> } terminal
* { STATUS: skipped, ACCOUNT, REASON: already-authenticated }
* { STATUS: failed, ERROR: <reason> }
*
* STATUS values match the runner's vocabulary (success/skipped/failed) so
* spawnStep recognises them and sets `ok` correctly; Signal-specific UI
* lives in setup/channels/signal.ts.
*
* If one or more accounts are already linked (discovered via
* `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH
* STATUS=skipped with the first account so the driver can reuse it.
* Selecting a different existing account is a driver concern.
*/
import { spawn, spawnSync } from 'child_process';
import { emitStatus } from './status.js';
const LINK_TIMEOUT_MS = 180_000;
const DEFAULT_DEVICE_NAME = 'NanoClaw';
interface SignalAccount {
account?: string;
registered?: boolean;
}
function cliPath(): string {
return process.env.SIGNAL_CLI_PATH || 'signal-cli';
}
/**
* Query signal-cli for currently linked accounts. Empty array if none
* configured, no binary, or the call fails for any other reason.
*/
function listAccounts(): string[] {
const cli = cliPath();
try {
const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (res.status !== 0) return [];
const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[];
return parsed
.filter((a) => a.registered !== false)
.map((a) => a.account ?? '')
.filter(Boolean);
} catch {
return [];
}
}
export async function run(_args: string[]): Promise<void> {
const cli = cliPath();
// Verify signal-cli exists before we commit to the long-running link.
// The driver checks too, but this keeps the step honest when run alone.
const probe = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (probe.error || probe.status !== 0) {
emitStatus('SIGNAL_AUTH', {
STATUS: 'failed',
ERROR: 'signal-cli not found. Install signal-cli first.',
});
return;
}
const existing = listAccounts();
if (existing.length > 0) {
emitStatus('SIGNAL_AUTH', {
STATUS: 'skipped',
ACCOUNT: existing[0],
REASON: 'already-authenticated',
});
return;
}
await new Promise<void>((resolve) => {
let settled = false;
let qrEmitted = false;
const finish = (block: Record<string, string | number | boolean>, code: number): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
emitStatus('SIGNAL_AUTH', block);
resolve();
setTimeout(() => process.exit(code), 500);
};
const timer = setTimeout(() => {
try {
child.kill('SIGTERM');
} catch {
/* ignore */
}
finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1);
}, LINK_TIMEOUT_MS);
const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// stdout carries the URL on the first line; subsequent lines may print
// status like "Associated with: +1555…". We don't strictly need to parse
// the number — listAccounts after exit is the source of truth — but the
// URL match drives the QR emit, which is the whole point.
let stdoutBuf = '';
const handleStdout = (chunk: Buffer): void => {
stdoutBuf += chunk.toString('utf-8');
let idx: number;
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, idx).trim();
stdoutBuf = stdoutBuf.slice(idx + 1);
if (!line) continue;
// Match both modern (sgnl://) and legacy (tsdevice://) schemes.
if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) {
qrEmitted = true;
emitStatus('SIGNAL_AUTH_QR', { QR: line });
}
}
};
child.stdout.on('data', handleStdout);
// Capture stderr for the transcript / log — signal-cli writes warnings
// and errors there. We don't emit on partial stderr lines since a
// successful link can still produce noise.
let stderrBuf = '';
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf-8');
});
child.on('error', (err) => {
finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1);
});
child.on('close', (code) => {
// After a successful link, signal-cli exits 0 and the newly linked
// account shows up in listAccounts. Use that as the source of truth
// rather than scraping stdout — more robust across signal-cli versions.
if (code === 0) {
const post = listAccounts();
if (post.length === 0) {
finish(
{ STATUS: 'failed', ERROR: 'link exited 0 but no account registered' },
1,
);
return;
}
finish({ STATUS: 'success', ACCOUNT: post[0] }, 0);
return;
}
// Non-zero exit. Surface the last non-empty stderr line for context;
// signal-cli's own error messages are usually informative.
const lastErr =
stderrBuf
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.slice(-1)[0] ?? `signal-cli link exited with code ${code}`;
finish({ STATUS: 'failed', ERROR: lastErr }, 1);
});
});
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { determineVerifyStatus } from './verify.js';
const healthyBase = {
service: 'running' as const,
credentials: 'configured',
anyChannelConfigured: false,
registeredGroups: 1,
agentPing: 'ok' as const,
};
describe('determineVerifyStatus', () => {
it('accepts a working CLI-only install', () => {
expect(determineVerifyStatus(healthyBase)).toBe('success');
});
it('accepts a messaging-channel install when CLI ping is skipped', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'skipped',
}),
).toBe('success');
});
it('fails when neither CLI nor messaging channels are usable', () => {
expect(
determineVerifyStatus({
...healthyBase,
agentPing: 'skipped',
}),
).toBe('failed');
});
it('fails when the CLI agent does not respond', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'no_reply',
}),
).toBe('failed');
});
it('fails when no agent groups are registered', () => {
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
}),
).toBe('failed');
});
});
+30 -11
View File
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js';
import { log } from '../src/log.js';
import { pingCliAgent } from './lib/agent-ping.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
getPlatform,
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
// everything upstream looks healthy, since a broken socket would just hang.
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
if (service === 'running' && registeredGroups > 0) {
log.info('Pinging CLI agent');
agentPing = await pingCliAgent();
log.info('Agent ping result', { agentPing });
}
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
anyChannelConfigured &&
registeredGroups > 0 &&
(agentPing === 'ok' || agentPing === 'skipped')
? 'success'
: 'failed';
// Determine overall status. A CLI-only install is valid when the local
// agent round-trip succeeds; messaging app credentials are optional.
const status = determineVerifyStatus({
service,
credentials,
anyChannelConfigured,
registeredGroups,
agentPing,
});
log.info('Verification complete', { status, channelAuth });
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
if (status === 'failed') process.exit(1);
}
export function determineVerifyStatus(input: {
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
credentials: string;
anyChannelConfigured: boolean;
registeredGroups: number;
agentPing: PingResult | 'skipped';
}): 'success' | 'failed' {
const cliAgentResponds = input.agentPing === 'ok';
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
return input.service === 'running' &&
input.credentials !== 'missing' &&
hasUsableChannel &&
input.registeredGroups > 0 &&
(cliAgentResponds || input.agentPing === 'skipped')
? 'success'
: 'failed';
}
/**
* Given a PID, resolve the script path the process is executing (i.e. the
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
+19 -25
View File
@@ -1,5 +1,5 @@
/**
* Step: whatsapp-auth standalone WhatsApp (Baileys) authentication.
* Step: whatsapp-auth standalone WhatsApp (Baileys v7) authentication.
*
* Forked from the channels-branch version so setup:auto's driver can render
* the terminal UX itself (inside clack) instead of the step dumping a raw QR
@@ -27,7 +27,6 @@
*/
import fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
// Named import (not default) — pino's d.ts under NodeNext resolves the
// default export to `typeof pino` (namespace), which isn't callable. The
// named `pino` export resolves to the callable function.
@@ -47,26 +46,23 @@ const AUTH_DIR = path.join(process.cwd(), 'store', 'auth');
const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt');
const baileysLogger = pino({ level: 'silent' });
// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1).
// Fixed in Baileys 7.x but not backported. Without this patch pairing codes
// fail with "couldn't link device" because WhatsApp receives an invalid
// platform id. createRequire because proto is not a named ESM export.
const _require = createRequire(import.meta.url);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { proto } = _require('@whiskeysockets/baileys') as { proto: any };
try {
const _generics = _require(
'@whiskeysockets/baileys/lib/Utils/generics',
) as Record<string, unknown>;
_generics.getPlatformId = (browser: string): string => {
const platformType =
proto.DeviceProps.PlatformType[
browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType
];
return platformType ? platformType.toString() : '1';
};
} catch {
// If CJS require fails, QR auth still works; only pairing code may be affected.
/** Fetch current WA Web version — wppconnect tracker, then Baileys sw.js scrape. */
async function resolveWaWebVersion(): Promise<[number, number, number]> {
try {
const res = await fetch('https://wppconnect.io/whatsapp-versions/', {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const html = await res.text();
const match = html.match(/2\.3000\.(\d+)/);
if (match) return [2, 3000, Number(match[1])];
}
} catch { /* fall through */ }
try {
const { version } = await fetchLatestWaWebVersion({});
if (version) return version as [number, number, number];
} catch { /* fall through */ }
throw new Error('Could not fetch current WhatsApp Web version — cannot connect with stale version');
}
type AuthMethod = 'qr' | 'pairing-code';
@@ -139,9 +135,7 @@ export async function run(args: string[]): Promise<void> {
async function connectSocket(isReconnect = false): Promise<void> {
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
version: undefined,
}));
const version = await resolveWaWebVersion();
const sock = makeWASocket({
version,
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
/**
* Is `name` safe to use as the last segment of a path inside an
* attachment-staging directory? Filenames originate from untrusted sources
* channel messages from any chat participant, agent-to-agent forwards from
* a possibly-compromised peer agent and land in `path.join(dir, name)`
* sinks on the host. Without this guard, a `..`-laden name escapes the
* inbox and writes anywhere the host process has filesystem permission.
*
* Rejects:
* - non-string / empty
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
* - anything containing a path separator (`/` or `\`) or NUL
* - any value where `path.basename(name) !== name`, catching OS-specific
* separators and covering drives/prefixes on Windows runtimes
*/
export function isSafeAttachmentName(name: string): boolean {
if (typeof name !== 'string' || name.length === 0) return false;
if (name === '.' || name === '..') return false;
if (/[\\/\0]/.test(name)) return false;
return path.basename(name) === name;
}
+4
View File
@@ -56,6 +56,8 @@ export interface InboundEvent {
* See InboundMessage.isMention for the full explanation.
*/
isMention?: boolean;
/** True when the source is a group/channel thread, false for DMs. */
isGroup?: boolean;
};
replyTo?: DeliveryAddress;
}
@@ -81,6 +83,8 @@ export interface InboundMessage {
* router falls back to text-match against agent_group_name.
*/
isMention?: boolean;
/** True when the source is a group/channel thread, false for DMs. */
isGroup?: boolean;
}
/** A file attachment to deliver alongside a message. */
+55 -11
View File
@@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig {
* chunk boundary will render as two independent blocks on the receiving
* platform, which is the same behavior as manually re-opening a fence.
*/
/**
* Decode the actual option value from a button callback. Buttons are encoded
* with an integer index (to keep under Telegram's 64-byte callback_data cap),
* and the real value is looked up via `getAskQuestionRender(questionId)`.
* Falls back to treating the tail as a literal value so old in-flight cards
* (encoded before this shortening landed) still resolve.
*/
function resolveSelectedOption(
render: { options: NormalizedOption[] } | undefined,
eventValue: string | undefined,
tail: string | undefined,
): string {
const candidate = eventValue ?? tail ?? '';
if (render && /^\d+$/.test(candidate)) {
const idx = Number(candidate);
if (render.options[idx]) return render.options[idx].value;
}
return candidate;
}
export function splitForLimit(text: string, limit: number): string[] {
if (text.length <= limit) return [text];
const chunks: string[] = [];
@@ -105,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
let setupConfig: ChannelSetup;
let gatewayAbort: AbortController | null = null;
async function messageToInbound(message: ChatMessage, isMention: boolean): Promise<InboundMessage> {
async function messageToInbound(
message: ChatMessage,
isMention: boolean,
isGroup?: boolean,
): Promise<InboundMessage> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serialized = message.toJSON() as Record<string, any>;
@@ -162,6 +186,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
content: serialized,
timestamp: message.metadata.dateSent.toISOString(),
isMention,
isGroup,
};
}
@@ -195,13 +220,17 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// wirings still fire on in-thread mentions.
chat.onSubscribedMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true));
await setupConfig.onInbound(
channelId,
thread.id,
await messageToInbound(message, message.isMention === true, true),
);
});
// @mention in an unsubscribed thread — SDK-confirmed bot mention.
chat.onNewMention(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true));
});
// DMs — by definition addressed to the bot. Thread id flows through
@@ -216,7 +245,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
threadId: thread.id,
});
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false));
});
// Plain messages in unsubscribed threads.
@@ -231,7 +260,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// flood gate.
chat.onNewMessage(/./, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true));
});
// Handle button clicks (ask_user_question)
@@ -240,11 +269,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
const parts = event.actionId.split(':');
if (parts.length < 3) return;
const questionId = parts[1];
const selectedOption = event.value || '';
const tail = parts.slice(2).join(':');
const userId = event.user?.userId || '';
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
const render = getAskQuestionRender(questionId);
// New format: button id/value is an integer index into options (kept
// short to fit Telegram's 64-byte callback_data cap). Old format:
// the full value is embedded in actionId/value directly.
const selectedOption = resolveSelectedOption(render, event.value, tail);
const title = render?.title ?? '❓ Question';
const matched = render?.options.find((o) => o.value === selectedOption);
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
@@ -348,8 +381,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
children: [
CardText(question),
Actions(
options.map((opt) =>
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
// Encode button id/value with the option index rather than the
// full value. Telegram caps callback_data at 64 bytes, and
// long values (e.g. ISO datetimes, URLs) push the JSON payload
// well past that. The onAction handlers resolve the index back
// to the real value via getAskQuestionRender(questionId).
options.map((opt, idx) =>
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }),
),
),
],
@@ -501,18 +539,21 @@ async function handleForwardedEvent(
// type 3 = MessageComponent (button/select)
if (interaction.type === 3) {
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
const user = (interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined;
// In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly.
const user =
((interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined) ??
(interaction.user as Record<string, string> | undefined);
const interactionId = interaction.id as string;
const interactionToken = interaction.token as string;
// Parse the selected option from custom_id
let questionId: string | undefined;
let selectedOption: string | undefined;
let tail: string | undefined;
if (customId?.startsWith('ncq:')) {
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
if (colonIdx !== -1) {
questionId = customId.slice(4, colonIdx);
selectedOption = customId.slice(colonIdx + 1);
tail = customId.slice(colonIdx + 1);
}
}
@@ -521,6 +562,9 @@ async function handleForwardedEvent(
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
const originalDescription = (originalEmbeds[0]?.description as string) || '';
const render = questionId ? getAskQuestionRender(questionId) : undefined;
// Discord custom_id mirrors the new index-based encoding (see Button
// construction). Decode back to the real option value for downstream.
const selectedOption = resolveSelectedOption(render, tail, tail);
const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
@@ -0,0 +1,28 @@
/**
* Integration test for the deltachat channel's single reach-in: the
* self-registration import in the `src/channels/index.ts` barrel. Importing the
* barrel runs deltachat.ts's top-level `registerChannelAdapter('deltachat', )`;
* without the import the channel is silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './deltachat.js';` line is deleted, or the barrel fails to evaluate for
* any reason (so the channel genuinely would not register), this goes red. A
* structural check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and
* deltachat.ts only instantiates DeltaChatOverJsonRpc inside setup() (run at host
* startup), never at import so nothing spawns here. It does require the adapter
* package to be installed, which holds in a composed install: the skill's
* `pnpm install` step runs before this test in the apply flow.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('deltachat channel registration', () => {
it('registers deltachat via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('deltachat');
});
});
+338
View File
@@ -0,0 +1,338 @@
/**
* DeltaChat channel adapter.
*
* Bridges NanoClaw with DeltaChat via the @deltachat/stdio-rpc-server JSON-RPC
* process. Each DeltaChat chat becomes a separate NanoClaw messaging group
* (platformId = chatId string, e.g. "12"). No thread model supportsThreads: false.
*
* Required env vars (.env): DC_EMAIL, DC_PASSWORD,
* DC_IMAP_HOST, DC_IMAP_PORT,
* DC_SMTP_HOST, DC_SMTP_PORT
* Optional env vars (.env): DC_IMAP_SECURITY (default: "1" = SSL/TLS),
* DC_SMTP_SECURITY (default: "2" = STARTTLS)
* Security values: 1=SSL/TLS, 2=STARTTLS, 3=plain
* Optional env vars (service unit): DC_ACCOUNT_DIR (default: "dc-account"),
* DC_DISPLAY_NAME, DC_AVATAR_PATH
*/
import { existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { basename, join, resolve } from 'path';
import { getDb, hasTable } from '../db/connection.js';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js';
import { registerChannelAdapter } from './channel-registry.js';
import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server';
const REQUIRED_ENV = [
'DC_EMAIL',
'DC_PASSWORD',
'DC_IMAP_HOST',
'DC_IMAP_PORT',
'DC_SMTP_HOST',
'DC_SMTP_PORT',
] as const;
const OPTIONAL_ENV = ['DC_IMAP_SECURITY', 'DC_SMTP_SECURITY'] as const;
type DcEnv = { [K in (typeof REQUIRED_ENV)[number]]: string } & { [K in (typeof OPTIONAL_ENV)[number]]?: string };
function isDcAdmin(userId: string): boolean {
try {
const db = getDb();
if (!hasTable(db, 'user_roles')) return true;
return (
db
.prepare(
`SELECT 1 FROM user_roles
WHERE user_id = ?
AND (role = 'owner' OR role = 'admin')
AND agent_group_id IS NULL
LIMIT 1`,
)
.get(userId) != null
);
} catch {
return false;
}
}
function createAdapter(env: DcEnv): ChannelAdapter {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let dc: any = null;
let accountId = 0;
let connectivity = 0;
let lastImapIdleTs = Date.now();
let consecutiveBadChecks = 0;
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
let networkTimer: ReturnType<typeof setInterval> | null = null;
async function restartIo(reason: string): Promise<void> {
log.warn('DeltaChat: restarting IO', { reason });
try {
await dc.rpc.stopIo(accountId);
await dc.rpc.startIo(accountId);
lastImapIdleTs = Date.now();
consecutiveBadChecks = 0;
} catch (err) {
log.error('DeltaChat: IO restart failed', { err });
}
}
const adapter: ChannelAdapter = {
name: 'deltachat',
channelType: 'deltachat',
supportsThreads: false,
async setup(config: ChannelSetup): Promise<void> {
const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account';
dc = new DeltaChatOverJsonRpc(accountDir, {});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dc.on('Error', (_: any, event: any) => log.error('DeltaChat RPC error', { msg: event.msg ?? event }));
const accounts = await dc.rpc.getAllAccounts();
accountId = accounts[0]?.id;
if (!accountId) accountId = await dc.rpc.addAccount();
const imapSecurity = env.DC_IMAP_SECURITY ?? '1';
const smtpSecurity = env.DC_SMTP_SECURITY ?? '2';
if (!(await dc.rpc.isConfigured(accountId))) {
await dc.rpc.setConfig(accountId, 'addr', env.DC_EMAIL);
await dc.rpc.setConfig(accountId, 'mail_pw', env.DC_PASSWORD);
await dc.rpc.setConfig(accountId, 'mail_server', env.DC_IMAP_HOST);
await dc.rpc.setConfig(accountId, 'mail_port', env.DC_IMAP_PORT);
await dc.rpc.setConfig(accountId, 'send_server', env.DC_SMTP_HOST);
await dc.rpc.setConfig(accountId, 'send_port', env.DC_SMTP_PORT);
await dc.rpc.configure(accountId);
log.info('DeltaChat: account configured', { email: env.DC_EMAIL });
} else {
log.info('DeltaChat: account ready', { email: env.DC_EMAIL });
}
await dc.rpc.setConfig(accountId, 'mail_security', imapSecurity);
await dc.rpc.setConfig(accountId, 'send_security', smtpSecurity);
await dc.rpc.setConfig(accountId, 'displayname', process.env.DC_DISPLAY_NAME ?? 'NanoClaw');
const avatarPath = process.env.DC_AVATAR_PATH;
if (avatarPath && existsSync(avatarPath)) {
await dc.rpc.setConfig(accountId, 'selfavatar', avatarPath);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dc.on('IncomingMsg', async (contextId: number, event: any) => {
if (contextId !== accountId) return;
try {
let msg = await dc.rpc.getMessage(accountId, event.msgId);
if (msg.isInfo) return;
// Wait for large-message download to complete
if (msg.downloadState !== 'Done') {
await dc.rpc.downloadFullMessage(accountId, event.msgId);
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
msg = await dc.rpc.getMessage(accountId, event.msgId);
if (msg.downloadState === 'Done') break;
}
}
if (!msg.text && !msg.file) return;
const contact = await dc.rpc.getContact(accountId, msg.fromId);
const chat = await dc.rpc.getBasicChatInfo(accountId, event.chatId);
if (/^\/set-avatar$/i.test((msg.text || '').trim()) && msg.file) {
const userId = `deltachat:${contact.address}`;
try {
if (isDcAdmin(userId)) {
const absPath = resolve(msg.file as string);
await dc.rpc.setConfig(accountId, 'selfavatar', absPath);
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Avatar updated.' });
} else {
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Permission denied.' });
}
} catch (avatarErr: unknown) {
log.error('DeltaChat: failed to set avatar', {
err: avatarErr instanceof Error ? avatarErr.message : JSON.stringify(avatarErr),
});
await dc.rpc.sendMsg(accountId, event.chatId, { text: 'Failed to set avatar.' }).catch(() => {});
}
return;
}
const content: Record<string, unknown> = {
text: msg.text || '',
sender: contact.displayName || contact.address,
senderId: contact.address,
};
if (msg.file) {
content.attachments = [
{
name: basename(msg.file as string),
type: 'file',
localPath: msg.file,
},
];
}
const isGroup = chat?.isGroup ?? false;
await config.onInbound(String(event.chatId), null, {
id: String(event.msgId),
kind: 'chat',
content,
timestamp: new Date().toISOString(),
isGroup,
isMention: !isGroup,
});
} catch (err: unknown) {
log.error('DeltaChat: error handling incoming message', {
err: err instanceof Error ? err.message : JSON.stringify(err),
});
}
});
dc.on('ImapInboxIdle', (contextId: number) => {
if (contextId === accountId) lastImapIdleTs = Date.now();
});
dc.on('ConnectivityChanged', async (contextId: number) => {
if (contextId !== accountId) return;
try {
connectivity = await dc.rpc.getConnectivity(accountId);
} catch {
/* ignore */
}
});
await dc.rpc.startIo(accountId);
try {
connectivity = await dc.rpc.getConnectivity(accountId);
} catch {
/* ignore */
}
log.info('DeltaChat: IO started', { email: env.DC_EMAIL });
// Log invite link on every startup so the operator can bootstrap the first contact.
// In DeltaChat, contacts can't simply be added by email — the user must open this
// https://i.delta.chat/ invite URL in their DeltaChat app (or scan invite-qr.svg) to initiate contact.
try {
// null chatId → Setup-Contact invite (not group-specific)
const [inviteUrl, svg] = await dc.rpc.getChatSecurejoinQrCodeSvg(accountId, null);
const accountDir = resolve(process.env.DC_ACCOUNT_DIR ?? 'dc-account');
const svgPath = join(accountDir, 'invite-qr.svg');
writeFileSync(svgPath, svg);
log.info('DeltaChat: invite link — open URL in DeltaChat app or scan ' + svgPath, { url: inviteUrl });
} catch (err: unknown) {
log.warn('DeltaChat: could not generate invite link', {
err: err instanceof Error ? err.message : JSON.stringify(err),
});
}
// Connectivity watchdog: restart IO if IMAP goes quiet or connectivity drops
watchdogTimer = setInterval(
async () => {
try {
const conn = await dc.rpc.getConnectivity(accountId);
connectivity = conn;
if (conn < 3000) {
consecutiveBadChecks++;
if (consecutiveBadChecks >= 2) {
await restartIo(`connectivity=${conn} for 2 consecutive checks`);
}
} else {
consecutiveBadChecks = 0;
}
const idleAgeMin = (Date.now() - lastImapIdleTs) / 60000;
if (idleAgeMin > 20) {
await restartIo(`no IMAP IDLE in ${idleAgeMin.toFixed(0)}min`);
}
} catch (err: unknown) {
log.warn('DeltaChat: watchdog error', {
err: err instanceof Error ? err.message : String(err),
});
}
},
5 * 60 * 1000,
);
// Nudge the network stack every 10 minutes (recovers from prolonged idle)
networkTimer = setInterval(
async () => {
try {
await dc.rpc.maybeNetwork();
} catch {
/* ignore */
}
},
10 * 60 * 1000,
);
},
async teardown(): Promise<void> {
if (watchdogTimer) clearInterval(watchdogTimer);
if (networkTimer) clearInterval(networkTimer);
try {
await dc?.rpc.stopIo(accountId);
} catch {
/* ignore */
}
try {
dc?.close();
} catch {
/* ignore */
}
},
isConnected(): boolean {
// 4000 = fully connected (IMAP), 3000 = connecting; treat ≥3000 as live
return connectivity >= 3000;
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
const chatId = parseInt(platformId, 10);
if (isNaN(chatId)) {
log.warn('DeltaChat: invalid platformId for delivery', { platformId });
return undefined;
}
const content = message.content as Record<string, unknown>;
const text = typeof content.text === 'string' ? content.text : '';
if (message.files && message.files.length > 0) {
const tempDir = mkdtempSync(join(tmpdir(), 'nanoclaw-dc-'));
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let firstId: any;
for (let i = 0; i < message.files.length; i++) {
const f = message.files[i];
const tempPath = join(tempDir, f.filename);
writeFileSync(tempPath, f.data);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const params: any = { file: tempPath };
if (i === 0 && text) params.text = text;
const sentId = await dc.rpc.sendMsg(accountId, chatId, params);
if (i === 0) firstId = sentId;
}
return firstId != null ? String(firstId) : undefined;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
if (!text) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sentId: any = await dc.rpc.sendMsg(accountId, chatId, { text });
return sentId != null ? String(sentId) : undefined;
},
};
return adapter;
}
registerChannelAdapter('deltachat', {
factory: () => {
const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]);
if (!env.DC_EMAIL || !env.DC_PASSWORD) return null;
return createAdapter(env as DcEnv);
},
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Integration test for the discord channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs discord.ts's
* top-level `registerChannelAdapter('discord', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './discord.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and discord.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/discord`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* discord is a Chat SDK channel: discord.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('discord channel registration', () => {
it('registers discord via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('discord');
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Discord channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*/
import { createDiscordAdapter } from '@chat-adapter/discord';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
if (!raw.referenced_message) return null;
const reply = raw.referenced_message;
return {
text: reply.content || '',
sender: reply.author?.global_name || reply.author?.username || 'Unknown',
};
}
registerChannelAdapter('discord', {
factory: () => {
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
if (!env.DISCORD_BOT_TOKEN) return null;
const discordAdapter = createDiscordAdapter({
botToken: env.DISCORD_BOT_TOKEN,
publicKey: env.DISCORD_PUBLIC_KEY,
applicationId: env.DISCORD_APPLICATION_ID,
});
return createChatSdkBridge({
adapter: discordAdapter,
concurrency: 'concurrent',
botToken: env.DISCORD_BOT_TOKEN,
extractReplyContext,
supportsThreads: true,
});
},
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Integration test for the emacs channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs emacs.ts's
* top-level `registerChannelAdapter('emacs', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './emacs.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* emacs is a native adapter with no npm dependency (it uses the Node http builtin); it talks to an Emacs HTTP client.
* Importing the barrel is safe: registration is a pure top-level call and emacs.ts
* opens connections / spawns subprocesses only inside setup() (run at host startup),
* never at import. There is no adapter package to guard here this test guards the
* one barrel reach-in (red if `import './emacs.js';` is deleted or the barrel fails
* to evaluate).
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('emacs channel registration', () => {
it('registers emacs via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('emacs');
});
});
+259
View File
@@ -0,0 +1,259 @@
/**
* Tests for the v2 emacs channel adapter.
*
* Exercises the HTTP surface (POST /api/message, GET /api/messages) and
* the ChannelAdapter lifecycle (setup / teardown / isConnected / deliver).
*/
import http from 'http';
import type { AddressInfo } from 'net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createEmacsAdapter } from './emacs.js';
import type { ChannelAdapter, ChannelSetup } from './adapter.js';
vi.mock('../log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function makeSetup(overrides: Partial<ChannelSetup> = {}): ChannelSetup {
return {
onInbound: vi.fn(),
onInboundEvent: vi.fn(),
onMetadata: vi.fn(),
onAction: vi.fn(),
...overrides,
};
}
/** Ask the OS for a free port, then immediately release it. Small race window
* before the adapter grabs it, but sufficient for local test use. */
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = http.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as AddressInfo).port;
srv.close(() => resolve(port));
});
});
}
async function req(
port: number,
method: string,
path: string,
body?: string,
extraHeaders: Record<string, string> = {},
): Promise<{ status: number; data: unknown }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extraHeaders };
const request = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => {
let raw = '';
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode!, data: raw });
}
});
});
request.on('error', reject);
if (body) request.write(body);
request.end();
});
}
describe('emacs adapter', () => {
let adapter: ChannelAdapter;
let port: number;
beforeEach(async () => {
port = await getFreePort();
adapter = createEmacsAdapter({ port, authToken: null, platformId: 'default' });
});
afterEach(async () => {
if (adapter.isConnected()) await adapter.teardown();
});
describe('lifecycle', () => {
it('isConnected is false before setup', () => {
expect(adapter.isConnected()).toBe(false);
});
it('isConnected is true after setup', async () => {
await adapter.setup(makeSetup());
expect(adapter.isConnected()).toBe(true);
});
it('isConnected is false after teardown', async () => {
await adapter.setup(makeSetup());
await adapter.teardown();
expect(adapter.isConnected()).toBe(false);
});
it('teardown is a no-op before setup', async () => {
await expect(adapter.teardown()).resolves.not.toThrow();
});
it('calls onMetadata after setup with channel name', async () => {
const onMetadata = vi.fn();
await adapter.setup(makeSetup({ onMetadata }));
expect(onMetadata).toHaveBeenCalledWith('default', 'Emacs', false);
});
});
describe('POST /api/message', () => {
let onInbound: ChannelSetup['onInbound'] & { mock: { calls: unknown[][] } };
beforeEach(async () => {
onInbound = vi.fn() as unknown as typeof onInbound;
await adapter.setup(makeSetup({ onInbound }));
});
it('fires onInbound with chat kind and sender metadata', async () => {
const { status, data } = await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hello' }));
expect(status).toBe(200);
expect((data as { messageId: string }).messageId).toMatch(/^emacs-/);
expect(onInbound).toHaveBeenCalledOnce();
const [platformId, threadId, msg] = onInbound.mock.calls[0] as [string, string | null, { content: unknown }];
expect(platformId).toBe('default');
expect(threadId).toBeNull();
expect(msg).toMatchObject({
kind: 'chat',
content: { text: 'hello', sender: 'Emacs', senderId: 'emacs:default' },
});
});
it('returns 400 for empty text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: '' }));
expect(status).toBe(400);
expect(onInbound).not.toHaveBeenCalled();
});
it('returns 400 for whitespace-only text', async () => {
const { status } = await req(port, 'POST', '/api/message', JSON.stringify({ text: ' ' }));
expect(status).toBe(400);
});
it('returns 400 for invalid JSON', async () => {
const { status } = await req(port, 'POST', '/api/message', 'not-json');
expect(status).toBe(400);
});
it('returns 404 for unknown paths', async () => {
const { status } = await req(port, 'POST', '/api/unknown', JSON.stringify({ text: 'hi' }));
expect(status).toBe(404);
});
});
describe('GET /api/messages + deliver', () => {
beforeEach(async () => {
await adapter.setup(makeSetup());
});
it('returns empty buffer initially', async () => {
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
expect(status).toBe(200);
expect(data).toEqual({ messages: [] });
});
it('deliver pushes text for the poll endpoint to return', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'reply' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string; timestamp: number }[] }).messages;
expect(messages).toHaveLength(1);
expect(messages[0]?.text).toBe('reply');
expect(typeof messages[0]?.timestamp).toBe('number');
});
it('deliver accepts plain-string content', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: 'raw text' });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: { text: string }[] }).messages[0]?.text).toBe('raw text');
});
it('deliver skips empty text silently', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: '' } });
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('deliver rejects unknown platformId', async () => {
const result = await adapter.deliver('other', null, { kind: 'chat', content: { text: 'x' } });
expect(result).toBeUndefined();
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect((data as { messages: unknown[] }).messages).toHaveLength(0);
});
it('filters out messages at or before the since cutoff', async () => {
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'old' } });
const since = Date.now();
await new Promise((r) => setTimeout(r, 5));
await adapter.deliver('default', null, { kind: 'chat', content: { text: 'new' } });
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
const texts = (data as { messages: { text: string }[] }).messages.map((m) => m.text);
expect(texts).not.toContain('old');
expect(texts).toContain('new');
});
it('caps buffer at 200 messages, evicting the oldest', async () => {
for (let i = 0; i < 205; i++) {
await adapter.deliver('default', null, { kind: 'chat', content: { text: `m-${i}` } });
}
const { data } = await req(port, 'GET', '/api/messages?since=0');
const messages = (data as { messages: { text: string }[] }).messages;
expect(messages).toHaveLength(200);
expect(messages.map((m) => m.text)).not.toContain('m-0');
expect(messages.map((m) => m.text)).toContain('m-5');
expect(messages.map((m) => m.text)).toContain('m-204');
});
});
describe('auth', () => {
let authAdapter: ChannelAdapter;
let authPort: number;
beforeEach(async () => {
authPort = await getFreePort();
authAdapter = createEmacsAdapter({ port: authPort, authToken: 'secret', platformId: 'default' });
await authAdapter.setup(makeSetup());
});
afterEach(async () => {
if (authAdapter.isConnected()) await authAdapter.teardown();
});
it('rejects POST without Authorization header', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
expect(status).toBe(401);
});
it('rejects POST with wrong token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer wrong',
});
expect(status).toBe(401);
});
it('accepts POST with correct Bearer token', async () => {
const { status } = await req(authPort, 'POST', '/api/message', JSON.stringify({ text: 'hi' }), {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
it('rejects GET without Authorization header', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0');
expect(status).toBe(401);
});
it('accepts GET with correct Bearer token', async () => {
const { status } = await req(authPort, 'GET', '/api/messages?since=0', undefined, {
Authorization: 'Bearer secret',
});
expect(status).toBe(200);
});
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* Emacs channel adapter (v2) native HTTP bridge.
*
* Stands up a localhost HTTP server that the nanoclaw.el client talks to:
* - POST /api/message user typed a message in Emacs; fire onInbound
* - GET /api/messages?since=<ms> Emacs polls for agent replies
*
* Single-user, single-chat: one adapter instance = one messaging group with
* `platform_id = "default"` (override with EMACS_PLATFORM_ID). No threads,
* no cold DM. Self-registers on import.
*/
import http from 'http';
import { readEnvFile } from '../env.js';
import { log } from '../log.js';
import { registerChannelAdapter } from './channel-registry.js';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
const OUTBOUND_BUFFER_MAX = 200;
interface BufferedMessage {
text: string;
timestamp: number;
}
interface EmacsAdapterOptions {
port: number;
authToken: string | null;
platformId: string;
}
function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter {
let server: http.Server | null = null;
let setupConfig: ChannelSetup | null = null;
const outboundBuffer: BufferedMessage[] = [];
function checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
if (!opts.authToken) return true;
if (req.headers['authorization'] === `Bearer ${opts.authToken}`) return true;
res
.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Unauthorized' }));
return false;
}
function handlePost(req: http.IncomingMessage, res: http.ServerResponse): void {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
let text: string;
try {
const parsed = JSON.parse(body) as { text?: string };
text = parsed.text ?? '';
} catch {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (!text.trim()) {
res
.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'text required' }));
return;
}
const timestamp = new Date().toISOString();
const id = `emacs-${Date.now()}`;
const inbound: InboundMessage = {
id,
kind: 'chat',
content: {
text,
sender: 'Emacs',
senderId: `emacs:${opts.platformId}`,
},
timestamp,
};
try {
setupConfig?.onInbound(opts.platformId, null, inbound);
} catch (err) {
log.error('Emacs onInbound failed', { err });
}
res
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ messageId: id, timestamp: Date.now() }));
});
}
function handlePoll(url: URL, res: http.ServerResponse): void {
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
const messages = outboundBuffer.filter((m) => m.timestamp > since);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ messages }));
}
return {
name: 'emacs',
channelType: 'emacs',
supportsThreads: false,
async setup(config: ChannelSetup): Promise<void> {
setupConfig = config;
server = http.createServer((req, res) => {
if (!checkAuth(req, res)) return;
const url = new URL(req.url ?? '/', `http://localhost:${opts.port}`);
if (req.method === 'POST' && url.pathname === '/api/message') {
handlePost(req, res);
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
handlePoll(url, res);
} else {
res
.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ error: 'Not found' }));
}
});
await new Promise<void>((resolve, reject) => {
server!.once('error', reject);
server!.listen(opts.port, '127.0.0.1', () => {
log.info('Emacs channel listening', { port: opts.port, platformId: opts.platformId });
resolve();
});
});
// Stamp a human-readable name on the messaging_groups row on first boot.
config.onMetadata(opts.platformId, 'Emacs', false);
},
async teardown(): Promise<void> {
if (!server) return;
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
log.info('Emacs channel stopped');
},
isConnected(): boolean {
return server?.listening ?? false;
},
async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise<string | undefined> {
if (platformId !== opts.platformId) {
log.warn('Emacs deliver called with unknown platformId', { platformId });
return undefined;
}
const text = extractText(message.content);
if (!text) return undefined;
const id = `emacs-out-${Date.now()}`;
outboundBuffer.push({ text, timestamp: Date.now() });
while (outboundBuffer.length > OUTBOUND_BUFFER_MAX) outboundBuffer.shift();
return id;
},
};
}
function extractText(content: unknown): string {
if (typeof content === 'string') return content;
if (content && typeof content === 'object') {
const c = content as { text?: unknown };
if (typeof c.text === 'string') return c.text;
}
return '';
}
registerChannelAdapter('emacs', {
factory: () => {
const env = readEnvFile(['EMACS_ENABLED', 'EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN', 'EMACS_PLATFORM_ID']);
const enabled = process.env.EMACS_ENABLED || env.EMACS_ENABLED;
if (!enabled || enabled === 'false') return null;
const portStr = process.env.EMACS_CHANNEL_PORT || env.EMACS_CHANNEL_PORT || '8766';
const port = parseInt(portStr, 10);
const authToken = process.env.EMACS_AUTH_TOKEN || env.EMACS_AUTH_TOKEN || null;
const platformId = process.env.EMACS_PLATFORM_ID || env.EMACS_PLATFORM_ID || 'default';
return createEmacsAdapter({ port, authToken, platformId });
},
});
export { createEmacsAdapter };
+34
View File
@@ -0,0 +1,34 @@
/**
* Integration test for the gchat channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs gchat.ts's
* top-level `registerChannelAdapter('gchat', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './gchat.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and gchat.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/gchat`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* gchat is a Chat SDK channel: gchat.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('gchat channel registration', () => {
it('registers gchat via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('gchat');
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Google Chat channel adapter (v2) uses Chat SDK bridge.
* Self-registers on import.
*/
import { createGoogleChatAdapter } from '@chat-adapter/gchat';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('gchat', {
factory: () => {
const env = readEnvFile(['GCHAT_CREDENTIALS']);
if (!env.GCHAT_CREDENTIALS) return null;
const gchatAdapter = createGoogleChatAdapter({
credentials: JSON.parse(env.GCHAT_CREDENTIALS),
});
return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true });
},
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Integration test for the github channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs github.ts's
* top-level `registerChannelAdapter('github', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './github.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and github.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`@chat-adapter/github`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* github is a Chat SDK channel: github.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('github channel registration', () => {
it('registers github via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('github');
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* GitHub channel adapter (v2) uses Chat SDK bridge.
* PR comment threads as conversations.
* Self-registers on import.
*/
import { createGitHubAdapter } from '@chat-adapter/github';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('github', {
factory: () => {
const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_BOT_USERNAME']);
if (!env.GITHUB_TOKEN) return null;
const githubAdapter = createGitHubAdapter({
token: env.GITHUB_TOKEN,
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
userName: env.GITHUB_BOT_USERNAME,
});
return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true });
},
});
@@ -0,0 +1,34 @@
/**
* Integration test for the imessage channel's single reach-in: the self-registration
* import in the `src/channels/index.ts` barrel. Importing the barrel runs imessage.ts's
* top-level `registerChannelAdapter('imessage', …)`; without the import the channel is
* silently absent.
*
* Behavior, not structural: it imports the real barrel and asserts the registry
* actually contains the channel. This reflects what happens at host boot if the
* `import './imessage.js';` line is deleted, or the barrel fails to evaluate for any
* reason (so the channel genuinely would not register), this goes red. A structural
* check of the import line would falsely pass in that second case.
*
* Importing the barrel is safe: registration is a pure top-level call, and imessage.ts
* builds the SDK adapter / bridge only inside its factory (invoked at host startup),
* never at import. It does require the adapter package (`chat-adapter-imessage`) to be installed,
* which holds in a composed install: the skill's `pnpm install` step runs before this
* test so this test also implicitly guards that dependency (an unmocked import throws
* if the package is missing).
*
* imessage is a Chat SDK channel: imessage.ts also consumes a load-bearing *core* API
* `createChatSdkBridge(...)` from ./chat-sdk-bridge.js. That core-consumption is a
* typed call, so the build/typecheck leg (`pnpm run build`) guards it against upstream
* drift, not this test. Every Chat SDK channel follows this same shape.
*/
import { describe, it, expect } from 'vitest';
import { getRegisteredChannelNames } from './channel-registry.js';
import './index.js'; // the real barrel — triggers every channel's self-registration
describe('imessage channel registration', () => {
it('registers imessage via the channel barrel', () => {
expect(getRegisteredChannelNames()).toContain('imessage');
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* iMessage channel adapter (v2) uses Chat SDK bridge.
* Supports local mode (macOS Full Disk Access) and remote mode (Photon API).
* Self-registers on import.
*/
import { createiMessageAdapter } from 'chat-adapter-imessage';
import { readEnvFile } from '../env.js';
import { createChatSdkBridge } from './chat-sdk-bridge.js';
import { registerChannelAdapter } from './channel-registry.js';
registerChannelAdapter('imessage', {
factory: () => {
const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']);
const isLocal = env.IMESSAGE_LOCAL !== 'false';
if (isLocal && !env.IMESSAGE_ENABLED) return null;
if (!isLocal && !env.IMESSAGE_SERVER_URL) return null;
const rawAdapter = createiMessageAdapter({
local: isLocal,
serverUrl: env.IMESSAGE_SERVER_URL,
apiKey: env.IMESSAGE_API_KEY,
});
// Polyfill channelIdFromThreadId (community adapter doesn't implement it)
const imessageAdapter = Object.assign(rawAdapter, {
channelIdFromThreadId: (threadId: string) => threadId,
});
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false });
},
});

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