Host-side (vitest):
- Routed message preserves platformId/channelType/threadId on messages_in
- Fan-out gives each agent correct per-agent routing
- writeSessionRouting populates session_routing from messaging group
- writeSessionRouting writes null routing for agent-shared sessions
- Per-thread session includes thread_id in session_routing
- Agent-shared resolves to same session on repeated calls
- Agent-shared session has null messaging_group_id
- findSessionByAgentGroup returns channel-bound session (documents #2332)
- Skip: agent-shared/channel-bound coexistence (blocked on #2332 fix)
Container-side (bun:test):
- Internal tags stripped between message blocks
- Mixed task + chat batch with correct routing
The agent-shared tests uncovered the exact bug from #2332:
findSessionByAgentGroup doesn't distinguish agent-shared from
channel-bound sessions, so A2A resolution reuses a channel session
when one exists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 14 tests covering key routing and dispatch flows that previously had
zero direct coverage:
dispatchResultText:
- bare text produces no outbound (scratchpad only)
- unknown destination dropped, valid destination sent
- multiple <message> blocks each produce correct outbound
- internal tags stripped from scratchpad
originAttr / from= metadata:
- chat/task/webhook/system messages include from= when destination matches
- fallback to raw unknown:channel:platform when no match
- from= omitted when routing is null
resolveDestinationThread:
- null thread_id when no prior inbound from destination
- most recent thread_id wins with multiple inbound messages
Also fix merge issue: restore getAllDestinations import removed by our PR
but still needed by #2327's compaction reminder. Fix stale destinations
test assertion from #2328 ("no special wrapping needed" → "Every response
must be wrapped").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add integration test for per-destination thread_id resolution: seeds two
destinations with different thread IDs, verifies each outbound message
carries the correct thread_id (not a global one from the batch routing).
- Add log line in resolveDestinationThread catch block for debuggability.
- Remove stray "(ensurePreCompactHook is defined after the main function.)"
comment from group-init.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The poll loop had a bare-text routing fallback in dispatchResultText: when
the agent produced text without <message to="..."> wrapping, it would auto-
route to the session's originating channel (via a frozen RoutingContext) or
to the single configured destination. This caused three problems:
1. Routing drift: RoutingContext was extracted once from the initial batch
and never refreshed. When the initial batch was a null-routed cron task
and a real chat arrived mid-query, replies were silently dropped to
scratchpad because the frozen routing had all-null fields.
2. Cross-channel thread bleed: sendToDestination applied a single
routing.threadId to every outbound message regardless of destination.
In agent-shared sessions (multiple channels sharing one session), one
channel's thread ID was stamped onto messages to a different channel.
3. Inconsistent formatting: task, webhook, and system messages had no
origin metadata in their formatted output, so the agent couldn't tell
which destination they came from — even when the underlying messages_in
rows carried routing fields.
Changes:
- Remove the bare-text routing fallbacks in dispatchResultText (both the
routing-based and single-destination shortcuts). All agent output must
be wrapped in <message to="name">...</message>. Bare text is scratchpad.
- Update buildDestinationsSection() to require explicit wrapping for all
groups, including single-destination. No more "no special wrapping
needed" shortcut.
- Resolve thread_id per-destination via resolveDestinationThread(), which
queries messages_in for the most recent message matching the target
channel+platform. Falls back to null (top-level channel message) when
no prior inbound exists for that destination.
- Extract originAttr() helper in formatter.ts and apply it to all message
types. Tasks now render as <task from="dest" time="...">, webhooks as
<webhook from="dest" source="..." event="...">, system responses as
<system_response from="dest" ...>. The agent always sees where a
message originated.
- Add a PreCompact shell hook (compact-instructions.ts) that outputs
custom compaction instructions, telling the compactor to preserve
recent message XML structure and routing metadata in the summary.
Wired via settings.json in the .claude-shared scaffold, with a
migration path (ensurePreCompactHook) for existing groups.
Relation to open PRs:
- #2277 (mergeRouting) becomes unnecessary — the routing fallback it
patches no longer exists. Can be closed.
- #2327 (post-compaction destination reminder) is complementary — it
handles the post-compaction push, this handles pre-compaction
instructions. Both can merge independently.
- #2328 (default routing instruction) is complementary — it adds "reply
to the from= destination" guidance to the multi-destination section.
Compatible with the unified instruction format here.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The first-keyword check (`WITH` → SELECT path) was wrong for CTEs that
precede mutations (e.g. `WITH stale AS (...) DELETE FROM t WHERE ...`).
These would be routed through `db.prepare().all()` instead of executing
the mutation.
Use better-sqlite3's `stmt.reader` property, which asks SQLite's own
parser whether the statement returns data. Single mutations go through
`stmt.run()`; compound statements (which `prepare()` rejects) fall back
to `db.exec()`.
Add a regression test for WITH...DELETE.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
openInboundDb() always opened /workspace/inbound.db which doesn't exist
in CI. In test mode, return a thin wrapper over the in-memory singleton
that delegates prepare/exec but no-ops close(), so callers' try/finally
cleanup doesn't destroy the shared DB mid-test.
One flag (_testMode), no monkey-patching, no saved-close bookkeeping.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test wrapper forwards the in-memory outDb as the writable handle,
avoiding the filesystem reopen that fails in CI. The function stays
private — the optional writableOutDb param is an internal detail, not
a public API.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
nodeenv doesn't support major-only version specifiers. Use lts
which resolves to the latest LTS release.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Skill files only — copied from PR #2192 (channels branch).
Source adapter (src/channels/deltachat.ts) lives on the channels
branch and is installed by the skill.
Co-Authored-By: Axel McLaren <scm@axml.uk>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If 1b-db is re-run after the v2 service has already started (e.g.
recovering from an earlier failure), the messaging_group it would
otherwise create may already exist — auto-created by the runtime router
on the first inbound message, with the router's default
unknown_sender_policy ('request_approval'), not the migration's intent
('public'). The previous reuse path skipped creation but never updated
the policy, so re-runs left the bot hanging every message waiting for
an approver that wasn't seeded yet.
When reusing an existing row that has zero wired agent_groups (signal
of a router auto-create), reset the policy to 'public'. Once any wiring
exists, the user has had a chance to tighten via the skill — leave it.
Also adds a CHANGELOG entry covering this and the two sibling fixes
(Discord DM resolution, symlink skip in copyTree).
fs.copyFileSync follows symlinks, so a single broken/dangling link in v1
(e.g. .claude-shared.md → /app/CLAUDE.md, a container-side path that
doesn't resolve on the host) crashed the alphabetical traversal with
ENOENT — preventing later folders, including the actual registered
group, from being copied.
Check entry.isSymbolicLink() and skip with a one-line log. v2 uses
composed CLAUDE.md fragments, so v1's container-path symlinks have no v2
meaning and don't need to be carried forward.
The resolver only enumerated guild channels, so any v1 install whose
registered Discord chat was a DM (a common case for personal-bot
installs) failed 1b-db with "not found in any guild" — leaving the
migration without an agent_group or wiring, and the user with a bot that
received messages but had nowhere to route them.
Add an unresolved-channel classification pass: for any v1 channel id not
found in a guild, GET /channels/<id> and emit discord:@me:<id> when the
type is DM (1) or GROUP_DM (3). Matches the runtime adapter's
guild_id || "@me" encoding. Other types / 404 / 403 keep current
skip-with-warning behavior.
Caller passes the v1 channel id list (already on hand). Test coverage
extends the existing mock-fetch pattern with DM, GROUP_DM, orphan, and
dedupe cases.
Trust the agent to figure out which failed steps actually stop
routing. The rule is the goal ("can the bot route one message?"),
not a hardcoded list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2b-channel-auth: copies the Baileys keystore + channel-specific env
keys. Without it WhatsApp can't connect — saw this firsthand when
the original candidatePaths bug left env_keys=0,files=0.
3c-auth: registers Anthropic credentials in OneCLI. 3b installs the
gateway; 3c puts the secret in the vault. Without 3c every agent
request 401s regardless of 3b's status.
1c-groups stays deferred — agent runs on stock CLAUDE.md without it,
but routing works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous version spelled out launchctl/systemctl commands, log lines
to grep for, diagnostic recipes — the agent reading this skill knows
all of that. Keep only the parts that aren't obvious from the rest of
the codebase: which steps are blocking vs deferred, the smoke-test
ordering, and the non-destructive framing for the user.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 used to be "triage every failed step before doing anything
else", which front-loaded a bunch of fixes for things that don't
actually block the user from proving v2 works. Restructure:
- 0a — fix blockers only (1b/1d/2c/2d/3a/3b/3e). Defer non-blockers
(1a, 1c, 1e, 2b, 3c) — most surface naturally in later phases.
- 0b — smoke test: switch v1 → v2, send a real message, verify the
routing chain in logs/nanoclaw.log. AskUserQuestion gates whether
to continue.
- Revert recipe (launchctl/systemctl) called out as always-available,
not destructive — v1 process, data, and credentials are untouched.
Up-front list of what the script handled now also mentions the
WhatsApp LID resolution and Baileys keystore copy, so users see
exactly what continuity they're getting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README: replace the one-line v1 migration note with a collapsed
<details> block. Quick Start stays compact for the common case (fresh
install) while v1 users get the actual instructions. Calls out
explicitly that the script must be run from a real terminal — not from
inside a Claude session — so the channel-select / switchover prompts
and the Node/pnpm/Docker bootstrap all work.
migrate-from-v1 skill: add a Preflight section that aborts if
logs/setup-migration/handoff.json is missing. Without this, invoking
the skill before the script just leads Claude to start guessing /
running shell commands. The new message redirects them to the script
and tells them it'll hand back to Claude on completion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1 stored every WhatsApp DM as `<phone>@s.whatsapp.net`. v2's WA
adapter sometimes resolves the chat to `<lid>@lid` instead — when
WhatsApp delivers via the LID protocol and Baileys hasn't yet learned
a LID→phone mapping for that contact (cold cache after migration).
The router then can't find the phone-keyed messaging_group and
silently drops the message at router.ts:184.
Baileys persists every LID↔phone pair it has ever learned to disk as
`store/auth/lid-mapping-<phone>.json` (forward) and
`lid-mapping-<lid>_reverse.json` (reverse). v1 will already have these
populated for every contact it has talked to. New step 2d-whatsapp-lids
parses the reverse files and writes paired LID-keyed `messaging_groups`
+ `messaging_group_agents` rows so both `<phone>@s.whatsapp.net` and
`<lid>@lid` route to the same agent_group with the same engage rules.
No Baileys boot, no WhatsApp connectivity required — pure filesystem
read of files we've already copied via 2b-channel-auth. Step is
no-op-on-skip if either store/auth or whatsapp DM rows are missing.
Anything that slips through (a contact whose LID v1 never learned)
falls back to the runtime approval flow once the WA adapter sets
isMention=true on DMs — each unknown LID DM auto-creates an
approval-required messaging_group and the owner gets a one-tap
register prompt.
Verified end-to-end on a 12-group v1 install: 3 DM rows aliased,
inbound DM routed via the LID-keyed row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1 didn't track is_group separately; db.ts hardcoded `is_group: 1` for
every messaging_group. v2 uses is_group=0 to collapse DM sub-thread
sessions and to drive routing decisions, so getting it wrong is latent
risk on otherwise-working installs.
New helper inferIsGroup(channelType, platformId) lives in shared.ts so
tasks.ts and any future migration step can reuse it. Inferred per
channel:
- whatsapp: `<id>@g.us` is a group, anything else is a DM
- telegram: negative chat IDs are groups, positive are DMs
- everything else: default to 1 (least surprising for chats v1 chose
to register, where DM auto-create paths weren't used)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migrate-v2.sh
Replace `declare -A STEP_RESULTS` with two parallel indexed arrays
(STEP_NAMES + STEP_STATUSES) plus a `record_step` helper. macOS ships
bash 3.2 which has no associative arrays — `declare -A` errored out
silently and every `STEP_RESULTS["1a-env"]=...` triggered a fatal
bash arithmetic error (interpreting "1a" as a number). Visible
symptom: `steps: {}` in handoff.json. Latent symptom: phase 2c's
install loop sometimes bailed mid-iteration before invoking the
channel install script, leaving channel code uninstalled while
reporting `overall_status: success`.
migrate-v2-reset.sh
Cover the gaps that left install side-effects in place between
iterations:
- Remove untracked adapter files in src/channels/ (mirror the
pattern already used for container/skills/).
- Restore tracked setup helpers that channel installs overwrite
(setup/whatsapp-auth.ts, setup/pair-telegram.ts, setup/index.ts)
and remove untracked ones they create (setup/groups.ts).
- Restore package.json + pnpm-lock.yaml (channel installs add
deps like @whiskeysockets/baileys).
Setup/migrate-v2/* is intentionally not touched — that's where user
WIP lives.
Verified end-to-end: reset → migrate → all 9 steps reported in
handoff.json with status "success", phase 2c install actually runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- shared.ts: parseJid now recognizes raw Baileys WhatsApp JIDs
(`<id>@s.whatsapp.net`, `@g.us`, etc.); v2PlatformId returns the raw
JID for whatsapp to match what the runtime adapter emits. Without this,
every WhatsApp group in a v1 install was silently skipped.
- discord-resolver.ts: new helper that uses DISCORD_BOT_TOKEN to look up
channelId → guildId via the Discord API, since v1 stored only the
channel id but v2 needs `discord:<guildId>:<channelId>`. Best-effort:
on missing/invalid token or network error, returns empty resolver and
the affected groups are skipped with the reason surfaced per channel.
- db.ts, tasks.ts: route Discord groups through the resolver; other
channels go through v2PlatformId unchanged. Resolver only built when
at least one Discord group exists, so non-Discord installs incur no
network.
- db.ts: when every v1 group is skipped, exit non-zero with a FAIL line
instead of `OK:groups=N,...,skipped=N`, so the wrapper doesn't hide
total failure under a successful-looking summary.
- migrate-v2.sh: run_step now surfaces ERROR: lines from successful
steps (with count + first 3 + raw log path); phase 2c install loop
populates STEP_RESULTS so install failures show in handoff.json
instead of silently passing.
- sessions.ts: copyTree skips dangling symlinks (e.g. v1's
`.claude/debug/latest`) instead of crashing the entire step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the four defenses on the outbound side onto extractAttachmentFiles:
1. Reject unsafe messageId via isSafeAttachmentName before any inbox path
is built. WhatsApp passes msg.key.id through raw and that field is
client generated, so a peer can craft it; future end to end encrypted
adapters will have the same property.
2. lstatSync on the inbox dir refuses a pre placed symlink before
mkdirSync would silently follow it.
3. realpathSync + isPathInside contains the resolved dir under the
session inbox root.
4. writeFileSync uses the wx flag so a pre placed symlink at the file
path is refused atomically by the kernel; EEXIST surfaces as a
logged skip.
Threat: the session dir is mounted writable into the container at
/workspace, so a compromised agent can pre place inbox/<future msgId>/
as a symlink and wait for a chat message with a matching id to redirect
the host write. The four guards together close that window.
Consolidates with the existing isSafeAttachmentName helper from
attachment-safety.ts rather than introducing a duplicate basename
validator inside session-manager.
Co-Authored-By: Daisuke Tsuji <dim0627@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes on top of the follow-up pre-task-script work:
1. The void async IIFE inside the interval handler had no catch, so a
throw from the dynamic import or applyPreTaskScripts escaped as an
unhandled rejection — terminating the container. The initial-batch
path is wrapped by processQuery's outer try/catch; the follow-up
path needs its own. Now logs the error and lets the next tick retry.
2. Re-check `done` immediately before query.push. The flag can flip
true while applyPreTaskScripts is awaited (outer stream finishes
during the script execution); without the re-check we'd push into a
closed query. Claimed messages get released by the host's
processing-claim sweep — same recovery posture as the rest of the
poller.
Co-Authored-By: Michael Zazon <mzazon@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes the post-ping `_ping-test` cleanup through `spawnQuiet` +
`setupLog.step` so a non-zero exit from `delete-cli-agent.ts` lands
in `logs/setup-steps/cleanup-cli-agent.log` and the progression log,
and prints a one-line warn to the user. Previously the spawnSync was
fire-and-forget with `stdio: 'ignore'`, leaving an orphan agent group
silently if cleanup failed.
Restores the original copy on the cli-agent step labels, the ping
explainer paragraph, and the post-ping spinner stop line — those
copy changes are out of scope for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>