Compare commits

..

27 Commits

Author SHA1 Message Date
github-actions[bot] a30547fb6a docs: update token count to 232k tokens · 116% of context window 2026-07-10 20:29:39 +00:00
github-actions[bot] 27de55647b chore: bump version to 2.1.46 2026-07-10 20:29:33 +00:00
gavrielc 6cd15c6d13 Merge pull request #3010 from nanocoai/feat/channel-adapter-defaults
feat: adapter-declared channel defaults (engage mode, threading, sender policy)
2026-07-10 23:29:21 +03:00
gavrielc 40a21fe8f3 fixup: address minor review findings on channel defaults
- wirings preUpdate: treat legacy pattern rows with NULL engage_pattern
  as match-all so unrelated updates no longer reject; still rejects when
  the pattern fields themselves change invalidly (+ test)
- resolveWiringDefaults: drop a declared \b adjacent to a non-word char
  in the {name} substitution so names like 'Andy (backup)' still engage
  (+ tests; mirrors selfChatEngagePattern)
- wireApprovedChannel returns whether the wiring was created; the
  free-text interceptor now reports failure instead of a false success
- ncl create hooks log a one-line notice when a channel has no declared
  defaults and legacy static defaults apply
- stale comments/docs reconciled: chat-sdk-bridge DM thread-policy note,
  seed-discord DISCORD_DEFAULTS sync note, user-dm deliberate 'strict'
  rationale, manage-channels/update-nanoclaw/init-first-agent SKILL.md
  policy wording

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:20:32 +03:00
gavrielc 91d30713b5 fixup: restart WhatsApp service after mode/name env writes
- restartService() ran before ASSISTANT_HAS_OWN_NUMBER / ASSISTANT_NAME
  landed in .env, so the adapter (which reads both once at module load)
  kept running in shared mode with the default name until an unrelated
  restart
- single restart now sits after the env writes and the late
  dedicated→shared flip, right before init-first-agent's welcome DM
- doc header + spinner copy updated to match the new ordering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:20:32 +03:00
gavrielc 1ad258622b PR12: document channel adapter defaults across docs and CLAUDE.md
- api-details.md: ChannelDefaults/ChannelContextDefaults interfaces, five-tier getChannelDefaults resolution chain, creation helpers, and messaging_group_agents.threads runtime semantics
- setup-wiring.md: two-level model, shared-identity mention-suppression pattern (reusable by iMessage/Signal), and the trunk-update back-compat contract with the one deliberate isGroup exception
- isolation-model.md: cross-reference from the entity model's engage columns to the defaults docs
- CLAUDE.md: channel-defaults.ts key-file row and a short Channel defaults paragraph

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:20:32 +03:00
gavrielc e370cae4e7 PR11: migrate skills to ncl-based wiring and channel-defaults docs
- Replace raw wiring/mg SQL with ncl invocations across eight skill artifacts (add-wechat wire-dm.ts rewritten to shell out to ncl; add-github/add-linear/add-signal/add-deltachat/add-emacs/migrate-from-openclaw snippets), each noting the running-host prerequisite and fixing the invalid 'isolated' session mode
- add-whatsapp: new "Dedicated vs personal number" section (ASSISTANT_HAS_OWN_NUMBER semantics, absent=shared inference), update path for flag-unset installs, spam-era group-wiring migration audit, stale pending_channel_approvals cleanup, stale-adapter skew troubleshooting
- manage-channels: two-level defaults model, --threads override + sticky/threads coercion, mention-capability constraints and {name}-pattern rename caveat, legacy WhatsApp group check
- update-nanoclaw: release notes for the two adapter-update behavior changes (Slack/Discord DM replies top-level, shared-identity stranger cards gone) + /add-whatsapp re-run pointer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:20:32 +03:00
gavrielc e40d43a43b PR10: steer WhatsApp setup by number ownership with shared-mode interception
- askNumberOwnership is now the first prompt: dedicated number (recommended),
  personal number, or back to channel selection; the personal path shows an
  interception screen (self-chat-only consequences + Telegram / dedicated
  number / /add-whatsapp-cloud alternatives) defaulting to '← Pick a
  different channel' via the existing BACK_TO_CHANNEL_SELECTION sentinel
- dedicated path keeps askChatPhone reframed around the operator's personal
  number; entering the bot's own number routes through the same interception
  screen and flips to shared mode
- shared path skips askChatPhone (chat number = bot number), optionally asks
  for an @<AgentName>-only self-chat engage pattern passed to
  init-first-agent --engage-pattern, and ends with self-chat-only completion
  copy pointing at /manage-channels
- writeAssistantHasOwnNumber generalized to writeEnvVar(key, value); both
  modes persist ASSISTANT_NAME (fixes the 'Andy' outbound-prefix mismatch)
  and ASSISTANT_HAS_OWN_NUMBER so mode switches on re-run don't go stale;
  WhatsApp option in setup:auto gains a 'best with a dedicated number' hint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:50 +03:00
gavrielc 642c5ecd37 PR6: deprecate ASSISTANT_NAME/ASSISTANT_HAS_OWN_NUMBER re-exports
- Mark both config.ts exports @deprecated; WhatsApp adapter copies read the .env keys directly now
- Re-exports retained one release so stale adapter copies (origin/channels whatsapp.ts:42) keep compiling
- No value or readEnvFile changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:50 +03:00
gavrielc 0bb9016e94 PR5: prettier reflow of PR3 resolveThreadPolicy call in router.ts
- formatting-only: the pre-commit prettier hook reflowed the long
  resolveThreadPolicy argument list left by the PR3 slice
- no behavior change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:50 +03:00
gavrielc f797024770 PR5: migrate offline creation paths to declared channel defaults
- setup/register.ts: import the channels barrel (registration-only, no
  factory invocation), require --channel explicitly, resolve wiring engage
  defaults and unknown_sender_policy through the declaration helpers gated
  on hasDeclaredChannelDefaults (legacy heuristic/'strict' for undeclared
  adapters), add --is-group / --engage-mode / --unknown-sender-policy flags
  with --trigger kept as the explicit pattern override
- scripts/init-first-agent.ts: same barrel import + helper gating for the
  DM wiring and messaging-group policy; new optional --engage-pattern flag;
  sender_scope/'drop' hardcodes kept as deliberate owner-bootstrap choices
- scripts/init-cli-agent.ts: mg + wiring values now come from the cli
  adapter's declaration (value-identical to the old hardcodes)
- move validateEngageAgainstChannel + EngageValues from
  src/cli/resources/wirings.ts to src/channels/channel-defaults.ts so ncl
  and the setup register step share one mention/pattern/sticky validator

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:50 +03:00
gavrielc bea2dab171 PR4: wire card-approved channels through declared channel defaults
- Factor both approve paths (connect button, new-agent name reply) into one
  wireApprovedChannel helper; engage defaults now come from
  resolveWiringDefaults with isGroup = event.message.isGroup ?? mg.is_group,
  never threadId — the one deliberate behavior change: groups on
  non-threaded platforms wire via the group default instead of pattern '.'
- Fix channel-approval adapter resolution to mg.instance ?? channel_type
  (reviewer correction 5) and enrich the approval card with the resolved
  engage rule ('will respond to @-mentions in this group' / 'all messages')
- sender_scope 'known' / accumulate / shared / priority 0 stay hardcoded as
  deliberate card-flow choices, not channel defaults
- Tests: non-threaded group wires group default (sticky→mention through the
  faithful fallback), undeclared DM stays pattern '.', both approve paths
  produce identical wirings; telegram fixture declared at registration tier

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:50 +03:00
gavrielc 53ffa3d347 PR3: apply per-wiring thread policy and declared auto-create defaults in router
- Auto-created messaging_groups take unknown_sender_policy from the channel
  declaration (DM vs group context); undeclared adapters resolve through the
  behavior-faithful fallback, identical to the old hardcoded value
- Fanout computes each wiring's effectiveThreadId via resolveThreadPolicy
  (threads override, declaration, live capability) and threads it through
  sticky engage lookup, session-mode force, resolveSession, delivery address,
  typing, and the subscribe gate; event.replyTo is operator intent and never
  policy-stripped
- Capability pre-strip for non-threaded adapters stays unchanged
- Tests: NULL-threads inherit parity, threads=0 opt-out, replyTo exemption,
  declared vs fallback auto-create policy; metadata-preservation tests now
  register a live threaded adapter (the realistic config the router sees)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:49 +03:00
gavrielc 8d39368352 PR2: add wiring threads override migration + declaration-aware ncl create/update
- Migration 019: nullable messaging_group_agents.threads (NULL = inherit
  adapter declaration, 1/0 = per-wiring override), no backfill; schema.ts
  reference DDL and MessagingGroupAgent type updated to match.
- crud.ts genericCreate restructured into two passes with a new
  ResourceDef.resolveDefaults hook between explicit-arg collection and
  static col.default, so static defaults never pre-empt context-aware
  resolution; new preUpdate hook gives update the same cross-checks.
- wirings resource: new --threads and --priority flags, engage defaults
  filled from the channel declaration ({name} substituted), validation on
  create AND update (pattern-needs-pattern, mentions:'never' rejection,
  sticky->mention coercion when the effective thread policy is off).
- messaging-groups resource fills unknown_sender_policy from the channel
  declaration; hasDeclaredChannelDefaults gates both hooks so stale
  (undeclared) adapters keep the legacy static defaults exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:49 +03:00
gavrielc 59fc8e3cc7 PR1: add channel default declarations + tiered resolution foundation
- ChannelContextDefaults/ChannelDefaults types on ChannelAdapter, ChannelRegistration, and ChatSdkBridgeConfig (bridge copies verbatim like supportsThreads)
- getChannelDefaults tiered lookup (live instance -> live channelType -> registration key -> registration channelType -> fallback) with behavior-faithful fallbackChannelDefaults(supportsThreads)
- new channel-defaults.ts helpers: resolveWiringDefaults ({name} substitution, sticky->mention coercion, pattern validation), resolveUnknownSenderPolicy, resolveThreadPolicy (hard-AND with capability)
- CLI adapter declares its defaults; fix stale isMention doc claiming a router text-match fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 23:19:49 +03:00
github-actions[bot] 45f656f21c chore: bump version to 2.1.45 2026-07-10 20:03:08 +00:00
gavrielc a4513f292f Merge pull request #3009 from nanocoai/whatsapp-formatting-to-channels
Move channel formatting skills (whatsapp, slack) from trunk to the channels branch
2026-07-10 23:02:40 +03:00
gavrielc 38eb5a44df docs: scope the formatting-skills migration to installs with the channel wired
/update-nanoclaw is agent-driven; an unscoped 're-run /add-whatsapp'
would install the whole Baileys adapter on channel-free installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 23:00:57 +03:00
gavrielc 6d397fc116 docs: tighten the channel formatting skills changelog entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 22:57:28 +03:00
gavrielc cb2bbcc7aa feat!: move slack-formatting container skill to the channels branch
Same treatment as whatsapp-formatting (previous commit): slack-formatting
is channel payload, not trunk. It ships only a SKILL.md (no composed-doc
fragment) and reaches agents via ~/.claude/skills; /add-slack and the
slack setup installers now copy it from the channels branch, which
already carries an identical copy. Changelog entry combined for both.

BREAKING CHANGE: existing Slack installs lose the slack-formatting skill
from ~/.claude/skills after updating; re-run /add-slack (idempotent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 22:45:23 +03:00
gavrielc b9998a4bf1 feat!: move whatsapp-formatting container skill to the channels branch
Trunk shipped WhatsApp formatting instructions to every install — the
skill-whatsapp-formatting.md fragment landed in every agent's composed
CLAUDE.md whether or not WhatsApp was wired. The skill is channel payload:
it now lives on the channels branch and is copied in by /add-whatsapp and
the setup installers (install-whatsapp.sh, add-whatsapp.sh).

BREAKING CHANGE: existing WhatsApp installs lose the fragment on their
next container spawn after updating; re-run /add-whatsapp (idempotent)
to restore it. See CHANGELOG.md for the manual copy alternative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 22:43:06 +03:00
github-actions[bot] 0d41f0ef94 chore: bump version to 2.1.44 2026-07-10 18:22:21 +00:00
gavrielc 3363a0be0f Merge pull request #3003 from Shufel83/fix/agent-browser-bounded-wait
docs(agent-browser): require bounded waits for custom conditions
2026-07-10 21:22:08 +03:00
gavrielc 05cf44035e Merge branch 'main' into fix/agent-browser-bounded-wait 2026-07-10 21:21:34 +03:00
github-actions[bot] c1eb1079cc docs: update token count to 225k tokens · 113% of context window 2026-07-10 18:20:35 +00:00
gavrielc 1725d86fbd Merge pull request #3006 from nanocoai/fix/local-time-display
fix: ISO storage + local-time display for all timestamps
2026-07-10 21:20:21 +03:00
exe.dev user df4929d61d docs(agent-browser): require bounded waits for custom conditions
Improvised `until … do sleep; done` polling loops with no timeout can
loop forever when a page condition never becomes true (page fails to
load, selector changes, network stalls). This doesn't just fail the
command — it wedges the whole agent turn: the runner keeps the model
stream open, later messages are pushed in and silently marked complete
without a reply, and heartbeat freshness masks it from the host's
stuck-detection, so a session can hang for hours.

Add a "Waiting for a custom condition — ALWAYS bound it" section to the
agent-browser skill: prefer built-in `wait` subcommands, ban unbounded
loops, and give a ready pattern capped by both `timeout` and a
max-attempts counter that always exits. Treat a timeout as a real
failure (snapshot + report), not grounds to re-enter the wait.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:11:34 +00:00
60 changed files with 2677 additions and 544 deletions
+9 -2
View File
@@ -167,7 +167,14 @@ pnpm exec tsx scripts/init-first-agent.ts \
### 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.
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, or wire it directly with `ncl`**the host service must be running** (`ncl` connects to it over a Unix socket):
```bash
# Engage mode/pattern default to the DeltaChat adapter's declared channel
# defaults — for DeltaChat groups that's a name pattern (the platform has no
# mention metadata), so the agent responds when addressed by name.
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id>
```
## Next Steps
@@ -184,7 +191,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaC
- **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
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
### Features
+8
View File
@@ -105,6 +105,14 @@ pnpm exec tsx setup/index.ts --step register -- \
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
Alternatively create the rows with `ncl`**the host service must be running** (`ncl` connects to it over a Unix socket). Engage mode/pattern and `unknown_sender_policy` default to the Emacs adapter's declared channel defaults:
```bash
ncl messaging-groups create --channel-type emacs --platform-id "default" --name "Emacs"
ncl wirings create --messaging-group-id <mg-id-from-above> --agent-group-id <ag-id> \
--session-mode agent-shared
```
## Configure Emacs
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
+14 -15
View File
@@ -107,16 +107,17 @@ Ask the user: **Is this a private or public repo?**
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
Run `/manage-channels` to wire the GitHub channel to an agent group, or create the rows directly with `ncl`. **The host service must be running**`ncl` connects to it over a Unix socket:
```sql
-- Create messaging group (one per repo)
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
```bash
# Create messaging group (one per repo)
ncl messaging-groups create --channel-type github --platform-id "github:owner/repo" \
--name "owner/repo" --is-group 1 --unknown-sender-policy <policy>
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
# Wire to agent group (engage mode/pattern default to the GitHub adapter's
# declared channel defaults; grab the mg id from the create output above)
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
--session-mode per-thread
```
Replace `<policy>` with `public` or `strict` based on the user's choice above.
@@ -125,14 +126,12 @@ Replace `<policy>` with `public` or `strict` based on the user's choice above.
When using `strict`, add each GitHub user who should be able to trigger the agent:
```sql
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
VALUES ('github:<user-id>', 'github', '<username>', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
```bash
# Add user (kind = 'github', id = 'github:<numeric-user-id>')
ncl users create --id "github:<user-id>" --kind github --display-name "<username>"
-- Grant membership to the agent group
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
VALUES ('github:<user-id>', '<agent-group-id>');
# Grant membership to the agent group
ncl members add --user "github:<user-id>" --group "<agent-group-id>"
```
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
+10 -9
View File
@@ -115,19 +115,20 @@ Ask the user: **Is this a private or public Linear workspace?**
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
Run `/manage-channels` to wire the Linear channel to an agent group, or create the rows directly with `ncl`. **The host service must be running**`ncl` connects to it over a Unix socket:
```sql
-- Create messaging group (one per team)
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', strftime('%Y-%m-%dT%H:%M:%fZ','now'));
```bash
# Create messaging group (one per team)
ncl messaging-groups create --channel-type linear --platform-id "linear:ENG" \
--name "Engineering" --is-group 1 --unknown-sender-policy <policy>
-- Wire to agent group
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
# Wire to agent group (engage mode/pattern default to the Linear adapter's
# declared channel defaults; grab the mg id from the create output above)
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
--session-mode per-thread
```
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
Replace `<policy>` with `public` or `strict` based on the user's choice above. The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
## Next Steps
+8 -17
View File
@@ -220,30 +220,21 @@ Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agen
### 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:
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl`**the host service must be running** (`ncl` connects to it over a Unix socket):
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
"
# Engage mode/pattern default to the Signal adapter's declared channel defaults
ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID
```
### 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`:
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` (host service running):
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
"
ncl users create --id "signal:UUID" --kind signal --display-name "<name>"
ncl roles grant --user "signal:UUID" --role owner
ncl members add --user "signal:UUID" --group ag-AGENTID
```
Find the UUID from `messaging_groups.platform_id` or the `users` table.
@@ -264,7 +255,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal
- 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
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
### Features
+8
View File
@@ -18,6 +18,7 @@ Skip to **Credentials** if all of these are already in place:
- `src/channels/slack.ts` exists
- `src/channels/slack-registration.test.ts` exists
- `src/channels/index.ts` contains `import './slack.js';`
- `container/skills/slack-formatting/SKILL.md` exists
- `@chat-adapter/slack` is listed in `package.json` dependencies
Otherwise continue. Every step below is safe to re-run.
@@ -33,8 +34,15 @@ git fetch origin channels
```bash
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
mkdir -p container/skills/slack-formatting
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
```
The `slack-formatting` container skill is part of the channel payload: it
reaches agents via `~/.claude/skills` (synced at spawn) and teaches Slack's
mrkdwn syntax. Trunk does not ship it — without this copy step agents send
Slack messages with generic markdown that renders literally.
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
+13 -5
View File
@@ -121,9 +121,11 @@ The bot is now connected as your WeChat account.
A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond.
**Prerequisite: the host service must be running.** The wire script creates the wiring through `ncl`, which talks to the running host over a Unix socket — there is no offline mode.
### 1. Trigger the first inbound message
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`.
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id` and the `unknown_sender_policy` the WeChat adapter declares.
### 2. Run the wire script
@@ -131,9 +133,9 @@ Have a different WeChat account send a message to the bot account. This auto-cre
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
```
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and runs `ncl wirings create` — engage mode/pattern and priority come from the WeChat adapter's declared channel defaults, so a wiring created here matches one created by `/manage-channels` or the approval-card flow.
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
With `request_approval` as the sender policy, the next DM from a stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
Non-interactive:
@@ -147,10 +149,16 @@ pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \
Flags:
- `--platform-id <id>` — wire a specific messaging group (default: most recent unwired)
- `--agent-group <id>` — target agent group (default: prompt; or solo admin group in non-interactive)
- `--sender-policy public|strict|request_approval`default `request_approval` (fires an admin approval card on unknown-sender DMs)
- `--agent-group <id>` — target agent group (default: prompt; auto-picked when only one exists)
- `--sender-policy public|strict|request_approval`override the messaging group's `unknown_sender_policy` (default: leave whatever the WeChat adapter declared when the row was auto-created)
- `--session-mode shared|per-thread` — default `shared`
Equivalent raw `ncl` invocation (host must be running):
```bash
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> --session-mode shared
```
### 3. Test
Have the sender message the bot again — the agent should respond.
+90 -63
View File
@@ -5,43 +5,49 @@
* After /add-wechat installs the adapter and the user scans the QR login,
* the first inbound message from another WeChat account auto-creates a
* `messaging_groups` row. This script finds that row, asks the operator
* which agent group to wire it to, and inserts the `messaging_group_agents`
* join row with sensible defaults — the "post-login wiring" step /add-wechat
* otherwise requires manual SQL for.
* which agent group to wire it to, and creates the wiring via
* `ncl wirings create` — engage mode/pattern and priority come from the
* WeChat adapter's declared channel defaults, not from SQL baked into this
* script, so it can't drift against schema migrations.
*
* Usage:
* PREREQUISITE: the NanoClaw host service must be RUNNING — `ncl` talks to
* it over a Unix socket and has no offline mode.
*
* Usage (from the project root):
* pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
*
* Flags:
* --platform-id <id> Wire a specific messaging group (default: most recent unwired)
* --agent-group <id> Target agent group (default: interactive pick; or solo admin group)
* --sender-policy <p> public | strict (default: public)
* --agent-group <id> Target agent group (default: interactive pick; auto-picked when only one exists)
* --sender-policy <p> public | strict | request_approval — overrides the
* channel-declared unknown_sender_policy on the
* messaging group (default: leave as the adapter declared)
* --session-mode <m> shared | per-thread (default: shared)
* --non-interactive Fail instead of prompting
*/
import Database from 'better-sqlite3';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db');
// <root>/.claude/skills/add-wechat/scripts/wire-dm.ts → <root>
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
type SenderPolicy = 'public' | 'strict' | 'request_approval';
interface Args {
platformId?: string;
agentGroupId?: string;
senderPolicy: SenderPolicy;
senderPolicy?: SenderPolicy;
sessionMode: 'shared' | 'per-thread';
interactive: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = {
// Default matches the router's auto-create (`request_approval`) so the
// admin gets an approval card on the next unknown-sender DM rather than
// a silent allow. Pass `--sender-policy public` to open the channel to
// anyone, or `strict` to require explicit membership.
senderPolicy: 'request_approval',
// No --sender-policy default: the router already stamped the policy the
// WeChat adapter declares when it auto-created the messaging group.
// Only an explicit flag overrides it.
sessionMode: 'shared',
interactive: true,
};
@@ -68,72 +74,90 @@ function parseArgs(argv: string[]): Args {
return args;
}
/** Run one ncl command against the running host and return its parsed data. */
function ncl(...cliArgs: string[]): unknown {
const res = spawnSync('pnpm', ['exec', 'tsx', 'src/cli/client.ts', ...cliArgs, '--json'], {
cwd: PROJECT_ROOT,
encoding: 'utf-8',
});
if (res.error) throw res.error;
let frame: { ok: boolean; data?: unknown; error?: { message: string } } | undefined;
try {
frame = JSON.parse(res.stdout);
} catch {
// No frame — transport-level failure (host not running), reported on stderr.
}
if (frame && !frame.ok) throw new Error(`ncl ${cliArgs.join(' ')} failed: ${frame.error?.message}`);
if (!frame || res.status !== 0) {
const detail = (res.stderr || res.stdout || '').trim();
throw new Error(
`ncl ${cliArgs.join(' ')} failed:\n${detail}\n\n` +
'Is the NanoClaw host service running? ncl connects to it over a Unix socket.',
);
}
return frame.data;
}
async function prompt(q: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); }));
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
interface MgRow { id: string; platform_id: string; name: string | null; is_group: number; created_at: string }
interface AgRow { id: string; name: string; created_at: string }
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
const mgs = ncl('messaging-groups', 'list', '--channel-type', 'wechat') as MgRow[];
const wirings = ncl('wirings', 'list', '--limit', '10000') as Array<{ messaging_group_id: string }>;
const wiredMgIds = new Set(wirings.map((w) => w.messaging_group_id));
// 1. Pick the messaging group
let platformId = args.platformId;
if (!platformId) {
const rows = db.prepare(`
SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at
FROM messaging_groups mg
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
WHERE mg.channel_type = 'wechat' AND mga.id IS NULL
ORDER BY mg.created_at DESC
`).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>;
let mg: MgRow | undefined;
if (args.platformId) {
mg = mgs.find((r) => r.platform_id === args.platformId);
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${args.platformId}`);
} else {
const unwired = mgs
.filter((r) => !wiredMgIds.has(r.id))
.sort((a, b) => b.created_at.localeCompare(a.created_at));
if (rows.length === 0) {
if (unwired.length === 0) {
console.error('No unwired WeChat messaging groups found.');
console.error('Send a message to the bot first (from another WeChat account), then re-run.');
process.exit(1);
}
if (rows.length === 1 || !args.interactive) {
platformId = rows[0].platform_id;
console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`);
if (unwired.length === 1 || !args.interactive) {
mg = unwired[0];
console.log(`Using most recent unwired group: ${mg.platform_id} (${mg.is_group ? 'group' : 'DM'})`);
} else {
console.log('Unwired WeChat messaging groups:');
rows.forEach((r, i) => {
unwired.forEach((r, i) => {
console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`);
});
const pick = await prompt('Pick one [1]: ');
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice');
platformId = rows[idx].platform_id;
if (Number.isNaN(idx) || idx < 0 || idx >= unwired.length) throw new Error('invalid choice');
mg = unwired[idx];
}
}
const mg = db.prepare(
'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?'
).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined;
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`);
// 2. Pick the agent group
let agentGroupId = args.agentGroupId;
if (!agentGroupId) {
const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC')
.all() as Array<{ id: string; name: string; is_admin: number }>;
const agents = (ncl('groups', 'list') as AgRow[])
.sort((a, b) => a.created_at.localeCompare(b.created_at));
if (agents.length === 0) throw new Error('no agent groups exist — create one first');
const adminAgents = agents.filter((a) => a.is_admin === 1);
if (adminAgents.length === 1 && !args.interactive) {
agentGroupId = adminAgents[0].id;
console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`);
if (agents.length === 1) {
agentGroupId = agents[0].id;
console.log(`Auto-selected sole agent group: ${agents[0].name} (${agentGroupId})`);
} else if (args.interactive) {
console.log('Agent groups:');
agents.forEach((a, i) => {
console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`);
console.log(` ${i + 1}. ${a.name} (${a.id})`);
});
const pick = await prompt('Pick one [1]: ');
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
@@ -144,26 +168,29 @@ async function main(): Promise<void> {
}
}
const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as
{ id: string; name: string } | undefined;
const ag = (ncl('groups', 'list') as AgRow[]).find((a) => a.id === agentGroupId);
if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`);
// 3. Update sender policy + wire
const tx = db.transaction(() => {
db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?')
.run(args.senderPolicy, mg.id);
db.prepare(`
INSERT INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
VALUES (?, ?, ?, '', 'all', ?, 10, ?)
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode, new Date().toISOString());
});
tx();
// 3. Wire, then apply the optional policy override. Engage mode/pattern and
// priority are filled by the wirings resolveDefaults hook from the WeChat
// adapter's declared channel defaults. Policy update runs second so a
// failed create (e.g. already wired) leaves the mg row untouched.
const wiring = ncl(
'wirings', 'create',
'--messaging-group-id', mg.id,
'--agent-group-id', ag.id,
'--session-mode', args.sessionMode,
) as { engage_mode: string; engage_pattern: string | null };
if (args.senderPolicy) {
ncl('messaging-groups', 'update', mg.id, '--unknown-sender-policy', args.senderPolicy);
}
console.log('');
console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`);
db.close();
console.log(
`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} ` +
`engage=${wiring.engage_mode}${wiring.engage_pattern ? `(${wiring.engage_pattern})` : ''} ` +
`policy=${args.senderPolicy ?? '(channel default)'} mode=${args.sessionMode}`,
);
}
main().catch((err) => {
+66 -3
View File
@@ -20,6 +20,7 @@ Skip to **Credentials** if all of these are already in place:
- `src/channels/whatsapp.test.ts` exists
- `src/channels/index.ts` contains `import './whatsapp.js';`
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
- `container/skills/whatsapp-formatting/instructions.md` exists
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
@@ -40,8 +41,17 @@ git show origin/channels:src/channels/whatsapp-registration.test.ts > src/cha
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
git show origin/channels:setup/groups.ts > setup/groups.ts
mkdir -p container/skills/whatsapp-formatting
git show origin/channels:container/skills/whatsapp-formatting/SKILL.md > container/skills/whatsapp-formatting/SKILL.md
git show origin/channels:container/skills/whatsapp-formatting/instructions.md > container/skills/whatsapp-formatting/instructions.md
```
The `whatsapp-formatting` container skill is part of the channel payload: its
`instructions.md` becomes the `skill-whatsapp-formatting.md` fragment in every
group's composed CLAUDE.md (see `src/claude-md-compose.ts`), teaching agents
WhatsApp's formatting syntax. Trunk does not ship it — without this copy step
agents format WhatsApp messages with generic markdown that renders literally.
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if already present):
@@ -82,7 +92,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
### Check current state
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
@@ -192,16 +202,60 @@ for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
```
### Shared vs dedicated number
## Dedicated vs personal number
The adapter behaves fundamentally differently depending on whether the linked number is the assistant's own or the operator's personal one. The switch is `ASSISTANT_HAS_OWN_NUMBER` in `.env`, read by the adapter itself at startup. **Inference rule: absent (or anything other than `true`) means shared/personal** — the safe default, since misreading a personal number as dedicated makes the bot claim messages addressed to the human.
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
- **Dedicated number** — a separate phone/SIM for the assistant
If dedicated, add to `.env`:
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
```bash
# Dedicated:
ASSISTANT_HAS_OWN_NUMBER=true
# Shared/personal:
ASSISTANT_HAS_OWN_NUMBER=false
```
### Update path: existing install, flag unset
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
Suggest a default by comparing the authed number against the wired DM chat:
```bash
# The number this install is authenticated as
node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0])"
# The wired WhatsApp DM chats
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=0"
```
If the wired DM's phone **equals** the authed number, the operator is talking to the bot in their own self-chat — that's a personal number: suggest **Shared**. If they differ, the operator messages the bot from a different number: suggest **Dedicated**. Confirm with the operator either way, then write the flag and restart the service.
### Migration audit: spam-era group wirings
Before the shared-number fix, group chats approved via the channel-registration card were wired `engage_mode='pattern'` with pattern `.` — respond-to-everything — because the card flow couldn't tell groups from DMs on non-threaded platforms. On a personal number this shows up as the bot answering every message in family/work groups after someone once tapped Connect on a spam-triggered card.
List the suspect wirings (host service running — `ncl` is socket-only):
```bash
ncl wirings list --engage-mode pattern --engage-pattern "." --json
```
Cross-reference against WhatsApp group chats (`ncl messaging-groups list --channel-type whatsapp --is-group 1`). For each wiring with pattern `.` on a WhatsApp group that is *not* the operator's deliberate always-on chat (e.g. their self-chat), offer:
- **Flip to name-based engagement**: `ncl wirings update <wiring-id> --engage-mode pattern --engage-pattern '\b<AgentName>\b'` (or `--engage-mode mention` on a dedicated number)
- **Delete the wiring**: `ncl wirings delete <wiring-id>`
Stale approval cards from that era can also linger. Clear pending channel approvals for chats the operator doesn't want wired:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM pending_channel_approvals WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='whatsapp')"
```
## Next Steps
@@ -279,3 +333,12 @@ systemctl --user start $(systemd_unit)
### "conflict" disconnection
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
### Trunk updated but shared-number behavior unchanged (stale adapter copy)
The shared-number behavior (no stranger approval cards, name-pattern group defaults) lives in the **adapter copy** at `src/channels/whatsapp.ts`, installed from the `channels` branch — not in trunk. If you updated trunk via `/update-nanoclaw` but skipped the skill-update step, the old adapter copy neither reads `ASSISTANT_HAS_OWN_NUMBER` itself nor declares channel defaults, so trunk falls back to the legacy behavior: approval cards still fire on a personal number, and new wirings get the channel-blind defaults. Symptoms of the skew:
- `.env` says `ASSISTANT_HAS_OWN_NUMBER=false` (or unset) but strangers' DMs still raise approval cards
- `ncl wirings create` on a WhatsApp group defaults to `mention` instead of a name pattern
Fix: re-run `/add-whatsapp` (or `/update-skills`) to pull the current adapter from the `channels` branch, then restart the service. The reverse skew (new adapter, old trunk) can't happen — the adapter's `defaults` field is optional and old trunk ignores it.
+1 -1
View File
@@ -113,7 +113,7 @@ If they say it didn't arrive, then diagnose using the DB directly (no waiting lo
**"Missing required args"** — the script wants `--channel`, `--user-id`, `--platform-id`, `--display-name` at minimum. Re-check the command you assembled.
**No `messaging_groups` row appears after the user DMs (step 3a)**the router silently drops messages from unknown senders under `strict` policy but still creates the `messaging_groups` row. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
**No `messaging_groups` row appears after the user DMs (step 3a)**auto-created rows are stamped with the channel adapter's declared `unknown_sender_policy` (two-level model: adapter declaration → per-row override; `strict` only when the adapter has no declaration). Under `strict` the router silently drops messages from unknown senders but still creates the `messaging_groups` row; under `request_approval` an approval card goes to an admin instead. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
**Owner already exists**`hasAnyOwner()` returned true, so the grant is skipped silently. That's fine; the script still creates the agent and wiring. Reassigning ownership needs a separate flow (not this skill).
+55 -2
View File
@@ -22,7 +22,7 @@ pnpm exec tsx scripts/q.ts data/v2.db "<query>"
```sql
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
SELECT messaging_group_id, agent_group_id, engage_mode, engage_pattern, session_mode, threads, priority FROM messaging_group_agents;
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
```
@@ -36,6 +36,47 @@ If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='o
**Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels.
## Channel Defaults: The Two-Level Model
Wiring defaults (engage mode/pattern, threading, `unknown_sender_policy`) resolve through **exactly two levels**:
1. **Adapter declaration** — each channel adapter declares `ChannelDefaults` (separate DM and group contexts, plus a `mentions` capability) in its source file. The adapter copy is skill-installed and user-owned: to change a default install-wide, edit `src/channels/<channel>.ts` and restart. Declarations are never persisted to the DB.
2. **Per-wiring/per-mg values chosen at creation** — every creation surface (`ncl wirings create` / `ncl messaging-groups create`, the `register` wizard step, the approval-card flow, `/init-first-agent`) fills omitted fields from the declaration and stores the result on the row. Pass explicit flags to override one wiring.
There is no third level: existing rows are never re-resolved, so editing a declaration only affects wirings created afterward. The one exception is the **`threads` column**, which stays live — `NULL` means "inherit the declaration at message time".
Channels with no declaration (stale adapter copies) fall back to the legacy behavior; run `/update-skills` to pull current adapters.
### Wiring via ncl
`ncl` requires the **host service to be running** (it connects over a Unix socket):
```bash
ncl messaging-groups create --channel-type <type> --platform-id "<id>" --name "<name>" [--is-group 1]
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> [--session-mode <mode>]
```
Omitted `engage_mode`/`engage_pattern`/`unknown_sender_policy` come from the adapter declaration for the right context (DM vs group). Run `ncl wirings help` / `ncl messaging-groups help` for the full flag list.
### Threading override (`--threads`)
`ncl wirings create/update ... --threads true|false` controls whether platform thread ids are honored for this wiring. `true` (in groups) means per-thread sessions and in-thread replies/typing/cards; `false` collapses to a flat session with top-level replies. Omitted = `NULL` = inherit the channel declaration. A wiring can *disable* threads on a threaded platform (Slack, Discord, GitHub), never enable them on a non-threaded one.
Two consequences to warn the user about:
- **Session identity**: sessions are never deleted. Flipping `threads` on a live wiring orphans existing per-thread sessions (or splinters a shared one) — history stays in the old sessions; new messages start fresh ones.
- **`mention-sticky` needs threads**: sticky engagement is keyed on per-thread session existence, so with resolved threads off it would engage once and never disengage. Creation and update coerce `mention-sticky``mention` (with a warning) when the effective thread policy is off.
### Mention capability
Each declaration states which mention signal the adapter emits: `platform` (real platform mentions), `dm-only` (only DMs are flagged), or `never`. On a `mentions: 'never'` channel (Linear OAuth apps, WhatsApp personal-number mode, Emacs), `mention`/`mention-sticky` wirings are **inert — they can never engage** — and `ncl` rejects them at create/update with an error citing the declaration. For groups on those channels, use a name pattern instead:
```bash
ncl wirings update <id> --engage-mode pattern --engage-pattern '(?i)^@?<Name>\b'
```
**Renaming an agent group does not update stored patterns.** Declared group patterns containing `{name}` are substituted with the agent group's name *at creation* and stored literally — after `ncl groups update <id> --name <NewName>`, audit that group's wirings for patterns still matching the old name and update them.
## Wire New Channel
For each unwired channel:
@@ -67,6 +108,8 @@ pnpm exec tsx setup/index.ts --step register -- \
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
Omitted engage/policy fields default from the channel adapter's declaration (see "Channel Defaults" above). Optional overrides: `--trigger "<regex>"` (explicit engage pattern), `--engage-mode <pattern|mention|mention-sticky>`, `--is-group <true|false>`, `--unknown-sender-policy <strict|request_approval|public>`. Don't pick a mention mode on a channel whose declaration says `mentions: 'never'` — it can never engage there.
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
For separate agents, also ask for a folder name and optionally a different assistant name.
@@ -75,7 +118,7 @@ For separate agents, also ask for a folder name and optionally a different assis
When adding another group/chat on an already-configured platform (e.g. a second Telegram group):
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row stamped with the Telegram adapter's declared policy (`request_approval` on current adapter copies; `strict` only on stale pre-declaration copies) and upserted the paired user — `register` only needs to add the wiring:
```bash
pnpm exec tsx setup/index.ts --step register -- \
@@ -94,6 +137,16 @@ When adding another group/chat on an already-configured platform (e.g. a second
3. Delete the old `messaging_group_agents` entry, create a new one
4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually.
## One-Time Check: Legacy Mis-Wired WhatsApp Groups
Installs that approved WhatsApp group registration cards before the channel-defaults model wired those groups as `engage_mode='pattern'`, `engage_pattern='.'` — respond-to-everything (the card flow couldn't tell groups from DMs on non-threaded platforms). Check once:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mga.id, mg.platform_id, mg.name FROM messaging_group_agents mga JOIN messaging_groups mg ON mg.id = mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=1 AND mga.engage_mode='pattern' AND mga.engage_pattern='.'"
```
For any hit the operator didn't deliberately configure as always-on, offer the repair options in `/add-whatsapp`'s "Migration audit" section (flip to mention/name-pattern engagement, or delete the wiring).
## Show Configuration
Display a readable summary showing:
+11 -6
View File
@@ -194,8 +194,10 @@ Notes:
runtime, so pass the `=>` value discovery emitted (or the raw OpenClaw id).
- Reuse a `--folder` to put a group on an existing agent (shared base/separate
conversations); use a new `--folder` for a fully separate agent.
- Group chats default to mention-only; pass `--trigger` to set a regex, or
`--no-trigger-required` for respond-to-everything.
- Engage defaults come from the channel adapter's declaration (most group
chats default to mention-based engagement; channels without a mention
signal default to a name pattern). Pass `--trigger` to set an explicit
regex, or `--no-trigger-required` for respond-to-everything.
- Register groups from channels v2 doesn't support yet too — the messaging
group and wiring persist and activate when that channel is installed.
@@ -238,10 +240,13 @@ model, which is **not** a JSON file. Each messaging group has an
- `dmPolicy: "disabled"` → don't wire that chat (or leave it registered but
unwired).
The messaging groups `register` / `init-first-agent` create already default to
`unknown_sender_policy = 'strict'`, so unknown senders are gated until you add
them. Show the user the OpenClaw allowlist and confirm who to grant before
running the commands.
The messaging groups `register` / `init-first-agent` create default their
`unknown_sender_policy` to whatever the channel adapter declares for that
context (DM vs group) — `strict` when the channel has no declaration — so
unknown senders are gated until you add them (or an admin approves the
adapter-declared approval card). Pass `--unknown-sender-policy` to `register`
to override. Show the user the OpenClaw allowlist and confirm who to grant
before running the commands.
## Phase 3: Identity and Memory
+28
View File
@@ -281,6 +281,34 @@ Keep it to these two options — the per-skill selection lives inside
its Step 4 — so nothing container-related is owed back here.)
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
## Known behavior changes when channel adapters update
Channel adapters now declare per-channel wiring defaults (engage mode, threading,
sender policy). Updating trunk alone changes nothing for existing rows, but once
`/update-skills` pulls current adapter copies, two deliberate behavior changes
land. If the user's install has Slack, Discord, or WhatsApp, tell them:
1. **Slack/Discord DM replies move top-level.** Both adapters now declare
`threads: false` for DMs, so DM replies stop chasing per-message sub-threads
and land in the main DM view, matching the DM session (which was already
flat). Group/channel threading is unchanged. To keep the old in-thread DM
behavior for a specific wiring, override it per wiring:
`ncl wirings update <wiring-id> --threads true`.
2. **Shared-identity channels stop raising stranger approval cards.** On
channels where the linked account is the operator's personal identity, the
mechanics differ by channel: WhatsApp personal-number mode suppresses the
mention signal entirely (no auto-created messaging groups, no cards);
iMessage and WeChat still emit DM mention signals — stranger DMs still
auto-create `messaging_groups` rows — but their declared `strict` policy
makes those rows drop unknown senders silently instead of raising
channel-registration cards to the admin.
**WhatsApp installs on a shared/personal number should re-run `/add-whatsapp`**
after the skill update: it now asks the dedicated-vs-personal question
explicitly (writing `ASSISTANT_HAS_OWN_NUMBER` to `.env`), audits for legacy
mis-wired group rows from spam-era approval cards, and shows how to clear
stale pending approvals.
Proceed to Step 7.9.
# Step 7.9: Stamp the upgrade marker (required)
+1
View File
@@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
+5 -2
View File
@@ -77,9 +77,10 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/channels/channel-defaults.ts` | Wiring-creation helpers over adapter-declared channel defaults (`resolveWiringDefaults`, `resolveThreadPolicy`, engage validation) |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` install with their channel) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
@@ -121,6 +122,8 @@ Trunk does not ship any specific channel adapter or non-default agent provider.
Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install <pkg>@<pinned-version>` → build.
**Channel defaults.** Each adapter declares its wiring-time defaults (`ChannelDefaults`: per DM/group context — engage mode/pattern, thread policy, unknown-sender policy — plus mention signaling). Exactly two levels: the adapter declaration, and the per-wiring override chosen at creation — no per-instance DB config table. Undeclared (stale) adapters resolve through a behavior-faithful fallback, so a trunk update alone changes nothing. See [docs/api-details.md](docs/api-details.md#channel-defaults) and `src/channels/channel-defaults.ts`.
## Self-Modification
One tier of agent self-modification today:
@@ -183,7 +186,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` are copied in by their `/add-<channel>` skill).
| Skill | When to Use |
|-------|-------------|
+1 -1
View File
@@ -92,7 +92,7 @@ Skills that run inside the agent container, not on the host. These teach the Nan
**Location:** `container/skills/<name>/`
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `slack-formatting` (Slack mrkdwn syntax), `vercel-cli`, `welcome`, `whatsapp-formatting`
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `vercel-cli`, `welcome`; channel-specific: `slack-formatting` (Slack mrkdwn syntax) and `whatsapp-formatting` (channels branch; installed by `/add-slack` / `/add-whatsapp`)
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
+35
View File
@@ -92,6 +92,41 @@ agent-browser wait --url "**/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
```
### Waiting for a custom condition — ALWAYS bound it
Prefer the built-in `wait` subcommands above. Only fall back to `eval`-polling
when you must wait on a custom JS condition (e.g. a spinner disappearing or a
"Send" button re-enabling in a chat UI).
**Never write an unbounded wait loop.** A bare `until … do sleep; done` that
polls a page condition will loop *forever* if the condition never becomes true
(page failed to load, selector changed, network stalled). That does not just
fail the command — it wedges the entire agent turn: the runner keeps the model
stream open, later messages get silently swallowed, and the container can hang
for hours without the host's stuck-detection firing.
Always cap the wait with BOTH a wall-clock `timeout` and a max-attempts counter,
and always exit the loop (never leave a `sleep` loop as the last thing running):
```bash
# Bounded wait: succeeds when the condition is met, gives up after ~90s.
timeout 90 bash -c '
for i in $(seq 1 30); do
if agent-browser eval "document.querySelector(\".loading\") === null" 2>/dev/null | grep -q true; then
echo READY; exit 0
fi
sleep 3
done
echo TIMEOUT; exit 1
'
# Check the exit status / output: on TIMEOUT, snapshot the page and decide —
# do NOT re-enter another unbounded wait.
```
If the wait times out, treat it as a real failure: take a `snapshot -i` or
`screenshot` to see the actual page state, report what you found, and move on.
Retrying the same unbounded wait is what causes the hang.
### Semantic locators (alternative to refs)
```bash
@@ -1,94 +0,0 @@
---
name: slack-formatting
description: Format messages for Slack using mrkdwn syntax. Use when responding to Slack channels (folder starts with "slack_" or JID contains slack identifiers).
---
# Slack Message Formatting (mrkdwn)
When responding to Slack channels, use Slack's mrkdwn syntax instead of standard Markdown.
## How to detect Slack context
Check your group folder name or workspace path:
- Folder starts with `slack_` (e.g., `slack_engineering`, `slack_general`)
- Or check `/workspace/group/` path for `slack_` prefix
## Formatting reference
### Text styles
| Style | Syntax | Example |
|-------|--------|---------|
| Bold | `*text*` | *bold text* |
| Italic | `_text_` | _italic text_ |
| Strikethrough | `~text~` | ~strikethrough~ |
| Code (inline) | `` `code` `` | `inline code` |
| Code block | ` ```code``` ` | Multi-line code |
### Links and mentions
```
<https://example.com|Link text> # Named link
<https://example.com> # Auto-linked URL
<@U1234567890> # Mention user by ID
<#C1234567890> # Mention channel by ID
<!here> # @here
<!channel> # @channel
```
### Lists
Slack supports simple bullet lists but NOT numbered lists:
```
• First item
• Second item
• Third item
```
Use `•` (bullet character) or `- ` or `* ` for bullets.
### Block quotes
```
> This is a block quote
> It can span multiple lines
```
### Emoji
Use standard emoji shortcodes: `:white_check_mark:`, `:x:`, `:rocket:`, `:tada:`
## What NOT to use
- **NO** `##` headings (use `*Bold text*` for headers instead)
- **NO** `**double asterisks**` for bold (use `*single asterisks*`)
- **NO** `[text](url)` links (use `<url|text>` instead)
- **NO** `1.` numbered lists (use bullets with numbers: `• 1. First`)
- **NO** tables (use code blocks or plain text alignment)
- **NO** `---` horizontal rules
## Example message
```
*Daily Standup Summary*
_March 21, 2026_
*Completed:* Fixed authentication bug in login flow
*In Progress:* Building new dashboard widgets
*Blocked:* Waiting on API access from DevOps
> Next sync: Monday 10am
:white_check_mark: All tests passing | <https://ci.example.com/builds/123|View Build>
```
## Quick rules
1. Use `*bold*` not `**bold**`
2. Use `<url|text>` not `[text](url)`
3. Use `•` bullets, avoid numbered lists
4. Use `:emoji:` shortcodes
5. Quote blocks with `>`
6. Skip headings — use bold text instead
@@ -1,61 +0,0 @@
---
name: whatsapp-formatting
description: Format messages for WhatsApp, including mentions that render as real WhatsApp tags. Use when responding in a WhatsApp conversation (platform_id / chatJid ends with @s.whatsapp.net or @g.us).
---
# WhatsApp Message Formatting
WhatsApp uses its own lightweight markup and a phone-number-based mention syntax. The host's WhatsApp adapter (Baileys) handles markdown conversion automatically, but **mentions are only protocol-level mentions if you use the right syntax** — otherwise they render as plain text and don't notify the recipient.
## How to detect WhatsApp context
You're in a WhatsApp conversation when any of these are true:
- The chat JID / platform id ends with `@s.whatsapp.net` (1-on-1 DM)
- The chat JID / platform id ends with `@g.us` (group)
- Your inbound message metadata has `chatJid` matching the above
## Mentions — the important part
To tag a user so their name appears **bold and clickable** in WhatsApp and they get a push notification, write the `@` followed by their phone number digits (no `+`, no spaces, no display name):
```
@15551234567 can you confirm?
```
The adapter scans your outgoing text for `@<digits>` (515 digits, optional leading `+` is stripped) and tells WhatsApp to render them as real mention tags.
**The sender's phone JID is always in your inbound message metadata.** When a user writes to you, inbound `content.sender` looks like `15551234567@s.whatsapp.net`. The part before the `@` is exactly what you put after `@` when tagging them back.
### Wrong vs right
| You write | What recipients see |
|-----------|---------------------|
| `@Adam can you...` | Plain text `@Adam`. No tag, no notification. |
| `@15551234567 can you...` | Bold/blue **@Adam** (or whatever name they're saved as), notification fires. |
| `@+15551234567 ...` | Same as above — adapter strips the `+`. |
### Picking who to tag
- In a DM, there's no real need to tag the recipient (they already see every message), but tagging still works if you want emphasis.
- In a group, look at the `participants` / inbound `content.sender` to find the JID of the person you mean. Don't guess from display names — pushNames can collide and are not reliable.
- If you don't know the JID, just refer to the person by name in plain prose. Don't write `@<name>` — it won't tag and it will look like a tag that failed.
## Text styles
WhatsApp uses single-character delimiters, *not* doubled like standard Markdown.
| Style | Syntax | Renders as |
|-------|--------|------------|
| Bold | `*bold*` | **bold** |
| Italic | `_italic_` | *italic* |
| Strikethrough | `~strike~` | ~strike~ |
| Monospace | `` `code` `` | `code` |
| Block monospace | ```` ```block``` ```` | preformatted block |
The adapter converts standard Markdown (`**bold**`, `[link](url)`, `# heading`) to the WhatsApp-native form automatically, so you don't have to think about it — but be aware that single asterisks become italics, not bold.
## What not to do
- Don't write `<@U123>` (that's Slack), `<@!123>` (Discord), or any other channel's mention syntax.
- Don't paste a full JID like `@15551234567@s.whatsapp.net` in the text — only the digits before the JID's `@` go after your `@`.
- Don't try to tag display names. WhatsApp has no display-name-based mention API.
@@ -1,19 +0,0 @@
## WhatsApp mentions — always use phone digits
When you are replying in a WhatsApp conversation (the inbound message's `chatJid` ends with `@s.whatsapp.net` for a DM or `@g.us` for a group), and you want to tag a person so their name appears **bold and clickable** with a push notification, write `@` followed by their phone-number digits — never the display name.
**The sender's phone JID is in your inbound message metadata** at `content.sender` (e.g. `15551234567@s.whatsapp.net`). The part before the `@` is exactly what you put after `@` when tagging them.
| You write | What recipients see |
|-----------|---------------------|
| `@Adam, can you...` | Plain text. No tag, no notification. |
| `@15551234567, can you...` | Bold/blue **@Adam** (whatever name they're saved as), notification fires. |
| `@+15551234567 ...` | Same as above — the adapter strips the `+` automatically. |
The host adapter scans your outbound text for `@<515 digits>` (with optional leading `+`) and tells WhatsApp to render those as real mention tags. If the digits aren't in the text, the tag doesn't render — no exceptions.
### In groups
Tag the person you're addressing using their JID from inbound metadata (look at the most recent message from them). Don't guess — pushNames collide and aren't reliable.
If you don't know someone's JID, refer to them by name in plain prose. Do not write `@<displayname>` hoping it works.
+46
View File
@@ -59,6 +59,52 @@ interface OutboundMessage {
}
```
### Channel Defaults
Each adapter can declare static wiring-time defaults. There are exactly two levels: the adapter declaration, and the per-wiring/per-messaging-group values chosen at creation. There is no DB config table for defaults — install-wide changes mean editing the adapter copy (skill-installed, user-owned).
```typescript
// src/channels/adapter.ts
interface ChannelContextDefaults {
engageMode: 'pattern' | 'mention' | 'mention-sticky';
engagePattern?: string; // required iff engageMode='pattern'; may contain the
// literal token {name} — creation helpers substitute the
// regex-escaped agent_group name
threads: boolean; // whether thread ids are honored in this context by default;
// must be false when the adapter's supportsThreads is false
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
}
interface ChannelDefaults {
dm: ChannelContextDefaults;
group: ChannelContextDefaults;
mentions: 'platform' | 'dm-only' | 'never';
// 'platform' — platform-confirmed mentions in groups, DMs flagged too
// 'dm-only' — only DMs flagged (no group mention metadata)
// 'never' — isMention never set: no auto-create card, mention wirings never engage
}
// ChannelAdapter and ChannelRegistration both carry an optional `defaults` field.
// The registration-level copy lets offline creation paths (setup/register.ts,
// scripts/init-first-agent.ts) resolve declarations without instantiating the
// adapter. ChatSdkBridgeConfig.defaults is copied verbatim onto the bridged
// adapter, like supportsThreads.
```
**Resolution chain** (`getChannelDefaults(key, channelType?)` in `src/channels/channel-registry.ts`, key = `mg.instance ?? mg.channel_type`, same discipline as `getChannelAdapter`):
1. Live adapter's `defaults`, instance-exact (lets an instance carry env-computed declarations, e.g. WhatsApp shared-number mode)
2. Live adapter of that channelType
3. Registration entry under the key
4. Registration entry under the channelType (from the live adapter's channelType, else the caller-supplied hint)
5. `fallbackChannelDefaults(supportsThreads)` — behavior-faithful to the pre-declaration router: dm `{ pattern '.', threads: supportsThreads, request_approval }`, group `{ mention-sticky, threads: supportsThreads, request_approval }`, mentions `'platform'`. `supportsThreads` is `false` when no adapter is live.
Never returns undefined. `hasDeclaredChannelDefaults()` reports whether tiers 14 hit; manual creation surfaces (`ncl`) gate declaration-derived defaults on it so stale (undeclared) adapters keep the legacy static schema defaults — a trunk update alone changes no behavior.
**Creation helpers** (`src/channels/channel-defaults.ts`): every wiring-creation path calls `resolveWiringDefaults(channelKey, isGroup, agentGroupName)` — it picks `decl.group` vs `decl.dm` by `isGroup = event.message.isGroup ?? (mg.is_group === 1)` (never `threadId !== null`), substitutes `{name}`, and downgrades `mention-sticky``mention` when the context's resolved threads value is false. `resolveUnknownSenderPolicy` does the same for auto-created messaging_groups rows.
**Runtime thread policy**: engage mode and sender policy are creation-time snapshots; threading is the one setting consulted live. `messaging_group_agents.threads` (migration 019) is the per-wiring override: `NULL` = inherit the adapter declaration for the wiring's context, `1`/`0` = explicit. `resolveThreadPolicy(wiring.threads, decl, isGroup, supportsThreads)` hard-ANDs the result with the adapter's raw capability — a wiring can opt out of threads on a threaded platform, never opt in on a non-threaded one. When the policy is off, event-derived thread ids are nulled at router fanout (sessions collapse, replies land top-level); `event.replyTo` is operator intent from the CLI transport and is never nulled.
### Chat SDK Bridge
Wraps a Chat SDK adapter + Chat instance to conform to the NanoClaw ChannelAdapter interface. Trunk ships the bridge and the channel registry only — platform-specific Chat SDK adapters (Discord, Slack, Telegram, etc.) and native adapters (WhatsApp/Baileys) are installed by the `/add-<channel>` skills from the `channels` branch.
+3 -1
View File
@@ -80,9 +80,11 @@ agent_groups (workspace, memory, CLAUDE.md, personality)
↕ many-to-many
messaging_groups (a specific channel/chat/group on a platform)
via
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority)
messaging_group_agents (session_mode, engage_mode, engage_pattern, sender_scope, ignored_message_policy, priority, threads)
```
Wiring-creation defaults for engage mode/pattern, thread policy, and unknown-sender policy come from the channel adapter's declaration (per DM/group context), overridable per wiring at creation — see [setup-wiring.md](setup-wiring.md#channel-defaults-two-level-model) and [api-details.md](api-details.md#channel-defaults).
- **Shared session:** multiple messaging_groups → same agent_group, `session_mode = 'agent-shared'`
- **Same agent, separate sessions:** multiple messaging_groups → same agent_group, `session_mode = 'shared'`
- **Separate agents:** each messaging_group → different agent_group
+15 -2
View File
@@ -1,6 +1,6 @@
# Setup Wiring — Status & Remaining Work
Last updated: 2026-04-09
Last updated: 2026-07-10
## What's Done
@@ -35,6 +35,19 @@ Last updated: 2026-04-09
### Router Logging
- `src/router.ts` logs `MESSAGE DROPPED` at WARN level when no agents wired, with actionable guidance
### Channel Defaults (two-level model)
Each adapter declares its own wiring-time defaults (`ChannelDefaults`, see [api-details.md](api-details.md#channel-defaults)): per-context (DM vs group) engage mode, engage pattern, thread policy, and unknown-sender policy, plus how the platform signals mentions (`'platform' | 'dm-only' | 'never'`). Exactly two levels exist:
1. **Adapter declaration** — a static const in the adapter module (and its `ChannelRegistration`, so offline scripts resolve it without credentials). Adapters are skill-installed and user-owned; install-wide changes mean editing the adapter copy. No DB config table.
2. **Per-wiring override** — the explicit value chosen at creation (`ncl` flag, wizard answer, card-flow value), stored on the row. Existing rows are never re-resolved; declarations are consulted only at creation, except threading, which stays live via `messaging_group_agents.threads` (`NULL` = inherit).
All creation paths (`ncl wirings`/`messaging-groups`, `setup/register.ts`, the router's auto-create, the channel-approval card flow, bootstrap scripts) go through the shared helpers in `src/channels/channel-defaults.ts`, so a platform's defaults are declared once and apply everywhere.
**Shared-identity pattern.** When the platform identity the adapter connects as belongs to a human (WhatsApp shared-number mode: `ASSISTANT_HAS_OWN_NUMBER` unset), the adapter itself suppresses mention signals — it never sets `isMention` and declares `mentions: 'never'`, group defaults of a name-pattern (`\b{name}\b`) and `strict` sender policy. With no mention signal, the router never auto-creates messaging groups or fires approval cards for the human's own conversations — the spam dies at the source, with zero core conditionals. The pattern is entirely channel-local and reusable by any adapter riding a personal identity (iMessage, Signal) with no core involvement.
**Back-compat contract.** A trunk update alone changes no behavior: stale (undeclared) adapters resolve through a behavior-faithful fallback at runtime, `ncl` keeps its legacy static defaults for them (gated on `hasDeclaredChannelDefaults`), existing DB rows are untouched, and the new `threads` column ships `NULL` everywhere — which reproduces today's `supportsThreads`-derived routing exactly. Updating an adapter copy (via its `/add-<channel>` skill) is what opts an install into that channel's new defaults, and even then only for wirings created afterwards. The one deliberate exception is the isGroup bugfix: card-approved groups on non-threaded platforms now wire via the group default instead of pattern `'.'` (group-ness comes from `event.message.isGroup ?? mg.is_group`, never `threadId !== null`).
---
## Previously Open — Now Resolved
@@ -69,7 +82,7 @@ agent_groups (id, name, folder, agent_provider)
↕ many-to-many (container runtime config lives in the separate container_configs table)
messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, denied_at)
via
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority)
messaging_group_agents (messaging_group_id, agent_group_id, engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority, threads)
users (id, kind, display_name) -- namespaced as "<channel>:<handle>"
user_roles (user_id, role, agent_group_id) -- owner / admin (global or scoped)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.43",
"version": "2.1.46",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+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="224k tokens, 112% of context window">
<title>224k tokens, 112% 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="232k tokens, 116% of context window">
<title>232k tokens, 116% 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">224k</text>
<text x="71" y="14">224k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">232k</text>
<text x="71" y="14">232k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+17 -4
View File
@@ -10,7 +10,8 @@
* CLAUDE.md.
*
* Runs alongside the service (WAL-mode sqlite) does NOT initialize
* channel adapters, so there's no Gateway conflict.
* channel adapters, so there's no Gateway conflict. (The channels barrel
* import below only registers factories + declarations; nothing connects.)
*
* Usage:
* pnpm exec tsx scripts/init-cli-agent.ts \
@@ -19,6 +20,13 @@
*/
import path from 'path';
// Registration-only: makes the in-tree cli adapter's declared defaults
// (pattern '.', no threads, 'public') resolvable below.
import '../src/channels/index.js';
import {
resolveUnknownSenderPolicy,
resolveWiringDefaults,
} from '../src/channels/channel-defaults.js';
import { DATA_DIR } from '../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
@@ -139,7 +147,9 @@ async function main(): Promise<void> {
platform_id: CLI_PLATFORM_ID,
name: 'Local CLI',
is_group: 0,
unknown_sender_policy: 'public',
// cli declares 'public' for DMs: the socket is chmod 0600, so
// "connected" ≈ "is the owner".
unknown_sender_policy: resolveUnknownSenderPolicy(CLI_CHANNEL, false),
created_at: now,
};
createMessagingGroup(cliMg);
@@ -148,12 +158,15 @@ async function main(): Promise<void> {
const existing = getMessagingGroupAgentByPair(cliMg.id, ag.id);
if (!existing) {
// cli declares pattern '.' for DMs — every line the operator types is
// for the agent. Identical to the pre-declaration hardcodes.
const engage = resolveWiringDefaults(CLI_CHANNEL, false, ag.name);
createMessagingGroupAgent({
id: generateId('mga'),
messaging_group_id: cliMg.id,
agent_group_id: ag.id,
engage_mode: 'pattern',
engage_pattern: '.',
engage_mode: engage.engage_mode,
engage_pattern: engage.engage_pattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
+52 -9
View File
@@ -25,7 +25,8 @@
* --display-name "Gavriel" \
* [--agent-name "Andy"] \
* [--welcome "System instruction: ..."] \
* [--role owner|admin|member] # default: owner
* [--role owner|admin|member] \ # default: owner
* [--engage-pattern "."] # explicit DM engage regex override
*
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
* is typically the same as the handle in --user-id, with the channel prefix.
@@ -34,6 +35,16 @@ import fs from 'fs';
import net from 'net';
import path from 'path';
// Registration-only barrel import: channel modules call
// registerChannelAdapter() at module scope (factories are NOT invoked, no
// adapter connects — no Gateway conflict with the running service), so
// declared channel defaults resolve here without live adapters.
import '../src/channels/index.js';
import {
resolveUnknownSenderPolicy,
resolveWiringDefaults,
} from '../src/channels/channel-defaults.js';
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
import { DATA_DIR, GROUPS_DIR } from '../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { initDb } from '../src/db/connection.js';
@@ -62,6 +73,8 @@ interface Args {
agentName: string;
welcome: string;
role: Role;
/** Explicit engage regex for the DM wiring; omitted = channel declaration / '.'. */
engagePattern?: string;
}
const DEFAULT_WELCOME =
@@ -99,6 +112,10 @@ function parseArgs(argv: string[]): Args {
out.welcome = val;
i++;
break;
case '--engage-pattern':
out.engagePattern = val;
i++;
break;
case '--role': {
const raw = (val ?? '').toLowerCase();
if (raw !== 'owner' && raw !== 'admin' && raw !== 'member') {
@@ -132,6 +149,7 @@ function parseArgs(argv: string[]): Args {
agentName: out.agentName?.trim() || out.displayName!,
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
role: out.role ?? DEFAULT_ROLE,
engagePattern: out.engagePattern?.trim() || undefined,
};
}
@@ -143,21 +161,41 @@ function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void {
function wireIfMissing(
mg: MessagingGroup,
ag: AgentGroup,
now: string,
label: string,
engagePattern?: string,
): void {
const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
if (existing) {
console.log(`Wiring already exists: ${existing.id} (${label})`);
return;
}
// Engage defaults, first hit wins: explicit --engage-pattern → the
// channel's declared defaults → the legacy heuristic for stale
// (undeclared) adapters: DMs (is_group=0) respond to everything via a '.'
// regex, group chats are mention-only; admins can reconfigure via
// /manage-channels once the agent is in use.
const isGroup = mg.is_group === 1;
const channelKey = mg.instance ?? mg.channel_type;
const engage = engagePattern
? { engage_mode: 'pattern' as const, engage_pattern: engagePattern }
: hasDeclaredChannelDefaults(channelKey, mg.channel_type)
? resolveWiringDefaults(channelKey, isGroup, ag.name, mg.channel_type)
: isGroup
? { engage_mode: 'mention' as const, engage_pattern: null }
: { engage_mode: 'pattern' as const, engage_pattern: '.' };
createMessagingGroupAgent({
id: generateId('mga'),
messaging_group_id: mg.id,
agent_group_id: ag.id,
// DM / CLI (is_group=0) default to "respond to everything" via a '.' regex.
// Group chats default to mention-only; admins can upgrade to mention-sticky
// via /manage-channels once the agent is in use.
engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
engage_pattern: mg.is_group === 0 ? '.' : null,
engage_mode: engage.engage_mode,
engage_pattern: engage.engage_pattern,
// Deliberate owner-bootstrap choices, not channel defaults: the operator
// wires their own DM, so every sender is trusted ('all') and ignored
// messages carry no value ('drop').
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
@@ -277,13 +315,18 @@ async function main(): Promise<void> {
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
if (!dmMg) {
const mgId = generateId('mg');
// Policy from the channel declaration (DM context); legacy 'strict' for
// stale (undeclared) adapters so a trunk update alone changes nothing.
const unknownSenderPolicy = hasDeclaredChannelDefaults(args.channel)
? resolveUnknownSenderPolicy(args.channel, false)
: 'strict';
createMessagingGroup({
id: mgId,
channel_type: args.channel,
platform_id: platformId,
name: args.displayName,
is_group: 0,
unknown_sender_policy: 'strict',
unknown_sender_policy: unknownSenderPolicy,
created_at: now,
});
dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
@@ -293,7 +336,7 @@ async function main(): Promise<void> {
}
// 4. Wire DM messaging group to the agent.
wireIfMissing(dmMg, ag, now, 'dm');
wireIfMissing(dmMg, ag, now, 'dm', args.engagePattern);
// 5. Welcome delivery over the CLI socket. Router picks up the line,
// writes the message into the DM session's inbound.db, and wakes the
+3
View File
@@ -60,6 +60,9 @@ try {
agent_group_id: AGENT_GROUP_ID,
// Discord group channel → mention-sticky default. Mention once, stay
// subscribed to the thread. Admins can tune via /manage-channels.
// Kept manually in sync with DISCORD_DEFAULTS.group on the channels
// branch (the adapter is skill-installed, so the declaration can't be
// imported here) — re-check this hardcode if that declaration changes.
engage_mode: 'mention-sticky',
engage_pattern: null,
sender_scope: 'all',
+5
View File
@@ -51,6 +51,7 @@ fi
need_install() {
[ ! -f src/channels/slack.ts ] && return 0
[ ! -f container/skills/slack-formatting/SKILL.md ] && return 0
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
@@ -67,6 +68,10 @@ if need_install; then
log "Copying adapter from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
# Slack formatting container skill — reaches agents via ~/.claude/skills.
mkdir -p container/skills/slack-formatting
git show "${CHANNELS_BRANCH}:container/skills/slack-formatting/SKILL.md" > container/skills/slack-formatting/SKILL.md
# 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
+7
View File
@@ -43,6 +43,7 @@ log() { echo "[add-whatsapp] $*" >&2; }
need_install() {
[ ! -f src/channels/whatsapp.ts ] && return 0
[ ! -f setup/groups.ts ] && return 0
[ ! -f container/skills/whatsapp-formatting/instructions.md ] && return 0
! grep -q "^import './whatsapp.js';" src/channels/index.ts 2>/dev/null && return 0
! grep -q "'whatsapp-auth':" setup/index.ts 2>/dev/null && return 0
! grep -q "^ groups:" setup/index.ts 2>/dev/null && return 0
@@ -64,6 +65,12 @@ if need_install; then
git show "${CHANNELS_BRANCH}:src/channels/whatsapp.ts" > src/channels/whatsapp.ts
git show "${CHANNELS_BRANCH}:setup/groups.ts" > setup/groups.ts
# WhatsApp formatting container skill — feeds the composed CLAUDE.md
# (skill-whatsapp-formatting.md fragment) and ~/.claude/skills.
mkdir -p container/skills/whatsapp-formatting
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/SKILL.md" > container/skills/whatsapp-formatting/SKILL.md
git show "${CHANNELS_BRANCH}:container/skills/whatsapp-formatting/instructions.md" > container/skills/whatsapp-formatting/instructions.md
# Append self-registration import if missing.
if ! grep -q "^import './whatsapp.js';" src/channels/index.ts; then
echo "import './whatsapp.js';" >> src/channels/index.ts
+1 -1
View File
@@ -1236,7 +1236,7 @@ async function askChannelChoice(): Promise<ChannelChoice> {
options: [
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
{ value: 'discord', label: 'Yes, connect Discord' },
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
{ value: 'whatsapp', label: 'Yes, connect WhatsApp', hint: 'best with a dedicated number' },
{
value: 'signal',
label: 'Yes, connect Signal',
+70
View File
@@ -0,0 +1,70 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { selfChatEngagePattern, writeEnvVar } from './whatsapp.js';
describe('selfChatEngagePattern', () => {
it('matches messages starting with @<name> and nothing else', () => {
const re = new RegExp(selfChatEngagePattern('Nano'));
expect(re.test('@Nano what time is it?')).toBe(true);
expect(re.test('@Nano')).toBe(true);
expect(re.test('hey @Nano')).toBe(false);
expect(re.test('grocery list')).toBe(false);
// \b guard: name must end at a word boundary, not prefix a longer word.
expect(re.test('@Nanobot hello')).toBe(false);
});
it('escapes regex metacharacters in the agent name', () => {
const re = new RegExp(selfChatEngagePattern('C-3PO (backup)'));
expect(re.test('@C-3PO (backup) status?')).toBe(true);
expect(re.test('@C-3PO Xbackup) status?')).toBe(false);
});
it('drops the trailing \\b for names ending in non-word characters', () => {
const pattern = selfChatEngagePattern('Nano!');
expect(pattern.endsWith('\\b')).toBe(false);
expect(new RegExp(pattern).test('@Nano! do the thing')).toBe(true);
});
});
describe('writeEnvVar', () => {
let dir: string;
let envPath: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-'));
envPath = path.join(dir, '.env');
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('creates the file when missing', () => {
writeEnvVar('ASSISTANT_NAME', 'Nano', envPath);
expect(fs.readFileSync(envPath, 'utf-8')).toBe('ASSISTANT_NAME=Nano\n');
});
it('appends to an existing file, adding a newline if needed', () => {
fs.writeFileSync(envPath, 'TZ=UTC');
writeEnvVar('ASSISTANT_HAS_OWN_NUMBER', 'true', envPath);
expect(fs.readFileSync(envPath, 'utf-8')).toBe(
'TZ=UTC\nASSISTANT_HAS_OWN_NUMBER=true\n',
);
});
it('replaces an existing line in place without touching neighbors', () => {
fs.writeFileSync(envPath, 'ASSISTANT_NAME=Andy\nTZ=UTC\n');
writeEnvVar('ASSISTANT_NAME', 'Nano', envPath);
expect(fs.readFileSync(envPath, 'utf-8')).toBe('ASSISTANT_NAME=Nano\nTZ=UTC\n');
});
it('keeps $-sequences in the value literal', () => {
fs.writeFileSync(envPath, 'ASSISTANT_NAME=Andy\n');
writeEnvVar('ASSISTANT_NAME', "$& $' $1", envPath);
expect(fs.readFileSync(envPath, 'utf-8')).toBe("ASSISTANT_NAME=$& $' $1\n");
});
});
+183 -45
View File
@@ -1,24 +1,32 @@
/**
* WhatsApp (community/Baileys) channel flow for setup:auto.
*
* `runWhatsAppChannel(displayName)` owns the full branch from auth-method
* picker through the welcome DM:
* `runWhatsAppChannel(displayName)` owns the full branch from number-
* ownership picker through the welcome DM:
*
* 1. Ask how to authenticate (QR code in terminal, default, or pairing code)
* 2. If pairing-code: collect the phone number
* 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
* 4. Run the whatsapp-auth step, rendering status blocks as clack UI:
* 1. Ask whether the agent gets a dedicated number or shares the
* operator's personal one. Personal interception screen that spells
* out self-chat-only mode and steers toward alternatives (default is
* back to channel selection)
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
* 3. If pairing-code: collect the phone number
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
* 5. Run the whatsapp-auth step, rendering status blocks as clack UI:
* - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art
* inside a clack note. On rotation we clear the previous QR in-place
* via ANSI escapes so the terminal doesn't fill up with stale codes.
* - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card.
* 5. Read store/auth/creds.json extract the authenticated (bot) phone
* 6. Kick the service so the adapter picks up the new credentials
* 7. Ask the operator for the phone they'll chat from (defaults to the
* authed number). Different number dedicated mode also writes
* ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed
* 8. Ask for the messaging-agent name (defaulting to "Nano")
* 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* 6. Read store/auth/creds.json extract the authenticated (bot) phone
* 7. Dedicated: ask for the operator's personal number (the one they'll
* chat from) and write ASSISTANT_HAS_OWN_NUMBER=true so outbound
* replies aren't prefixed. Entering the bot's own number routes back
* through the interception screen. Shared: chat number = bot number
* 8. Ask for the messaging-agent name (defaulting to "Nano"); persist it
* as ASSISTANT_NAME so the adapter's outbound prefix matches. Shared
* mode also offers an @<name>-only engage pattern for the self-chat
* 9. Kick the service AFTER the env writes, since the adapter reads
* ASSISTANT_HAS_OWN_NUMBER / ASSISTANT_NAME once at module load
* 10. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* DM path delivers the greeting through the adapter
*
* All output obeys the three-level contract: clack UI for the user, structured
@@ -55,6 +63,14 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
type AuthMethod = 'qr' | 'pairing-code';
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
const ownership = await askNumberOwnership();
if (ownership === 'back') return BACK_TO_CHANNEL_SELECTION;
let mode: 'dedicated' | 'shared' = ownership;
if (mode === 'shared') {
const proceed = await confirmSharedNumber();
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
}
const method = await askAuthMethod();
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
@@ -98,18 +114,35 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
);
}
await restartService();
const chatPhone = await askChatPhone(botPhone);
const isDedicated = chatPhone !== botPhone;
if (isDedicated) {
writeAssistantHasOwnNumber();
let chatPhone = botPhone;
if (mode === 'dedicated') {
chatPhone = await askChatPhone(botPhone);
if (chatPhone === botPhone) {
// Chatting from the bot's own number IS the shared-number setup —
// route through the same interception screen as the up-front pick.
const proceed = await confirmSharedNumber();
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
mode = 'shared';
}
}
// Written in both modes so a re-run that switches dedicated → shared
// doesn't leave a stale `true` behind.
writeEnvVar('ASSISTANT_HAS_OWN_NUMBER', mode === 'dedicated' ? 'true' : 'false');
const role = await askOperatorRole('WhatsApp');
setupLog.userInput('whatsapp_role', role);
const agentName = await resolveAgentName();
// Both modes: keep the adapter's outbound prefix / mention normalization
// in sync with the chosen agent name (config default is 'Andy' otherwise).
writeEnvVar('ASSISTANT_NAME', agentName);
const engagePattern = mode === 'shared' ? await askSelfChatEngage(agentName) : undefined;
// Restart only after ASSISTANT_HAS_OWN_NUMBER / ASSISTANT_NAME land in
// .env — the adapter computes its shared/dedicated mode and name once at
// module load, so restarting earlier would leave it running with defaults.
await restartService();
const platformId = `${chatPhone}@s.whatsapp.net`;
@@ -124,10 +157,11 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
...(engagePattern ? ['--engage-pattern', engagePattern] : []),
],
{
running: `Connecting ${agentName} to WhatsApp…`,
done: isDedicated
done: mode === 'dedicated'
? `${agentName} is ready. Check WhatsApp for a welcome message.`
: `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`,
},
@@ -136,7 +170,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
CHANNEL: 'whatsapp',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
MODE: isDedicated ? 'dedicated' : 'shared',
MODE: mode,
ROLE: role,
},
},
@@ -148,6 +182,112 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
'You can retry later with `/manage-channels`.',
);
}
if (mode === 'shared') {
note(
[
'Only your self-chat is connected. Messages other people send to your',
'number are ignored — never seen, never asked about.',
'',
k.dim('Wire a specific chat later with /manage-channels.'),
].join('\n'),
'Self-chat mode',
);
}
}
async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
const choice = ensureAnswer(
await brightSelect({
message: 'Which WhatsApp number will your agent use?',
options: [
{
value: 'dedicated',
label: 'A dedicated number just for the agent',
hint: 'recommended — spare SIM, eSIM, or old phone',
},
{
value: 'shared',
label: 'My own personal number',
},
{
value: 'back',
label: '← Back to channel selection',
},
],
}),
) as 'dedicated' | 'shared' | 'back';
if (choice !== 'back') setupLog.userInput('whatsapp_number_ownership', choice);
return choice;
}
/**
* Interception screen for the shared-number path: make the self-chat-only
* tradeoff explicit and steer toward alternatives before any install or
* auth happens. Default is bailing back to channel selection.
*/
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
note(
[
'On your personal number, the agent lives only in your "You" / self-chat.',
'Messages other people send you are ignored entirely — never read, never',
'answered, never flagged for approval. Nobody else can talk to the agent.',
'',
'If you want the agent reachable as its own contact, consider:',
'',
`${brandBold('Telegram')} — a bot takes ~2 minutes to set up`,
`${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
`${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
].join('\n'),
'Personal number = self-chat only',
);
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to proceed?',
options: [
{ value: 'back', label: '← Pick a different channel' },
{ value: 'continue', label: 'Continue — self-chat only' },
],
initialValue: 'back',
}),
) as 'continue' | 'back';
setupLog.userInput('whatsapp_shared_confirm', choice);
return choice;
}
/**
* Shared mode only: choose whether the agent answers everything in the
* self-chat or only messages addressed to it by name. Returns the engage
* regex for init-first-agent's --engage-pattern, or undefined for the
* respond-to-everything default.
*/
async function askSelfChatEngage(agentName: string): Promise<string | undefined> {
const choice = ensureAnswer(
await brightSelect({
message: `Respond to every self-chat message, or only messages starting with @${agentName}?`,
options: [
{
value: 'all',
label: 'Every message',
hint: "the self-chat becomes the agent's inbox",
},
{
value: 'mention',
label: `Only messages starting with @${agentName}`,
hint: 'keep the self-chat for your own notes too',
},
],
}),
) as 'all' | 'mention';
setupLog.userInput('whatsapp_selfchat_engage', choice);
return choice === 'all' ? undefined : selfChatEngagePattern(agentName);
}
/** Engage regex for "only messages starting with @<name>". Exported for tests. */
export function selfChatEngagePattern(agentName: string): string {
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// \b only terminates a match after a word character — skip it for names
// ending in punctuation, where it would never match.
return /\w$/.test(agentName) ? `^@${escaped}\\b` : `^@${escaped}`;
}
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
@@ -359,7 +499,7 @@ function readAuthedPhone(): string {
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your WhatsApp credentials…');
s.start('Restarting NanoClaw so it sees your WhatsApp credentials and settings…');
const start = Date.now();
const platform = process.platform;
try {
@@ -402,25 +542,20 @@ async function restartService(): Promise<void> {
async function askChatPhone(authedPhone: string): Promise<string> {
note(
[
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
`The agent is signed in as ${k.cyan('+' + authedPhone)}.`,
'',
"What's the phone number you'll chat with your agent from?",
'',
k.dim(
'Same number = messages will land in your "You" / self-chat on WhatsApp\n' +
"(you won't be able to reply to yourself — use a different number for a\n" +
'two-way chat).',
),
"Now, your personal number — the one you'll chat with the agent from.",
"It'll show up as a normal two-way conversation with the agent's contact.",
].join('\n'),
'Your chat number',
'Your personal number',
);
const answer = ensureAnswer(
await p.text({
message: 'Your personal phone number',
placeholder: authedPhone,
defaultValue: authedPhone,
message: "Your personal number, where you'll chat from",
placeholder: 'e.g. 14155551234',
validate: (v) => {
const t = (v ?? authedPhone).trim();
const t = (v ?? '').trim();
if (!t) return 'Phone number is required';
if (!/^\d{8,15}$/.test(t)) {
return 'Digits only, country code included.';
}
@@ -428,28 +563,31 @@ async function askChatPhone(authedPhone: string): Promise<string> {
},
}),
);
const phone = ((answer as string) || authedPhone).trim();
const phone = (answer as string).trim();
setupLog.userInput('whatsapp_chat_phone', phone);
return phone;
}
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env. */
function writeAssistantHasOwnNumber(): void {
const envPath = path.join(process.cwd(), '.env');
/** Persist KEY=value to .env, replacing any existing KEY line. Exported for tests. */
export function writeEnvVar(
key: string,
value: string,
envPath: string = path.join(process.cwd(), '.env'),
): void {
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) {
contents = contents.replace(
/^ASSISTANT_HAS_OWN_NUMBER=.*$/m,
'ASSISTANT_HAS_OWN_NUMBER=true',
);
const line = `${key}=${value}`;
const existing = new RegExp(`^${key}=.*$`, 'm');
if (existing.test(contents)) {
// Replacement via callback so `$`-sequences in the value stay literal.
contents = contents.replace(existing, () => line);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
contents += line + '\n';
}
fs.writeFileSync(envPath, contents);
}
+3
View File
@@ -15,6 +15,7 @@ echo "=== NANOCLAW SETUP: INSTALL_SLACK ==="
needs_install=false
[[ -f src/channels/slack.ts ]] || needs_install=true
[[ -f container/skills/slack-formatting/SKILL.md ]] || needs_install=true
grep -q "import './slack.js';" src/channels/index.ts || needs_install=true
grep -q '"@chat-adapter/slack"' package.json || needs_install=true
[[ -d node_modules/@chat-adapter/slack ]] || needs_install=true
@@ -30,6 +31,8 @@ git fetch origin channels
echo "STEP: copy-files"
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
mkdir -p container/skills/slack-formatting
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
echo "STEP: register-import"
if ! grep -q "import './slack.js';" src/channels/index.ts; then
+3
View File
@@ -20,6 +20,8 @@ CHANNEL_FILES=(
src/channels/whatsapp.ts
setup/whatsapp-auth.ts
setup/groups.ts
container/skills/whatsapp-formatting/SKILL.md
container/skills/whatsapp-formatting/instructions.md
)
needs_install=false
@@ -45,6 +47,7 @@ git fetch origin channels
echo "STEP: copy-files"
for f in "${CHANNEL_FILES[@]}"; do
mkdir -p "$(dirname "$f")"
git show "origin/channels:$f" > "$f"
done
+100 -12
View File
@@ -7,6 +7,16 @@
import fs from 'fs';
import path from 'path';
// Registration-only barrel import: each channel module calls
// registerChannelAdapter() at module scope (factories are NOT invoked, no
// adapter connects), so declared channel defaults resolve without the service.
import '../src/channels/index.js';
import {
resolveUnknownSenderPolicy,
resolveWiringDefaults,
validateEngageAgainstChannel,
} from '../src/channels/channel-defaults.js';
import { hasDeclaredChannelDefaults } from '../src/channels/channel-registry.js';
import { DATA_DIR } from '../src/config.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
@@ -33,7 +43,7 @@ interface RegisterArgs {
trigger: string;
/** Agent group folder name */
folder: string;
/** Channel type (discord, slack, telegram, etc.) */
/** Channel type (discord, slack, telegram, etc.) — required */
channel: string;
/** Whether messages require the trigger pattern to activate */
requiresTrigger: boolean;
@@ -41,18 +51,28 @@ interface RegisterArgs {
assistantName: string;
/** Session mode: 'shared' (one session per channel) or 'per-thread' */
sessionMode: string;
/** Whether the messaging group is a multi-user chat (default: true) */
isGroup: boolean;
/** Explicit engage mode override; omitted = channel declaration / heuristic */
engageMode?: 'pattern' | 'mention' | 'mention-sticky';
/** Explicit unknown_sender_policy override; omitted = channel declaration / 'strict' */
unknownSenderPolicy?: 'strict' | 'request_approval' | 'public';
}
const ENGAGE_MODES = ['pattern', 'mention', 'mention-sticky'] as const;
const SENDER_POLICIES = ['strict', 'request_approval', 'public'] as const;
function parseArgs(args: string[]): RegisterArgs {
const result: RegisterArgs = {
platformId: '',
name: '',
trigger: '',
folder: '',
channel: 'discord',
channel: '',
requiresTrigger: false,
assistantName: 'Andy',
sessionMode: 'shared',
isGroup: true,
};
for (let i = 0; i < args.length; i++) {
@@ -81,6 +101,32 @@ function parseArgs(args: string[]): RegisterArgs {
case '--session-mode':
result.sessionMode = args[++i] || 'shared';
break;
case '--is-group': {
const raw = (args[++i] || '').toLowerCase();
if (!['true', 'false', '1', '0'].includes(raw)) {
throw new Error(`--is-group must be true or false, got "${raw}"`);
}
result.isGroup = raw === 'true' || raw === '1';
break;
}
case '--engage-mode': {
const raw = (args[++i] || '').toLowerCase() as RegisterArgs['engageMode'];
if (!raw || !ENGAGE_MODES.includes(raw)) {
throw new Error(`--engage-mode must be one of ${ENGAGE_MODES.join('|')}, got "${raw}"`);
}
result.engageMode = raw;
break;
}
case '--unknown-sender-policy': {
const raw = (args[++i] || '').toLowerCase() as RegisterArgs['unknownSenderPolicy'];
if (!raw || !SENDER_POLICIES.includes(raw)) {
throw new Error(
`--unknown-sender-policy must be one of ${SENDER_POLICIES.join('|')}, got "${raw}"`,
);
}
result.unknownSenderPolicy = raw;
break;
}
}
}
@@ -95,6 +141,19 @@ export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const parsed = parseArgs(args);
if (!parsed.channel) {
// No silent platform default: the channel decides platform-id namespacing
// and the wiring/policy defaults below, so guessing one wires the chat to
// the wrong adapter.
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'missing_channel',
MESSAGE: '--channel is required (the channel type this chat lives on, e.g. discord, slack, telegram, whatsapp)',
LOG: 'logs/setup.log',
});
process.exit(4);
}
if (!parsed.platformId || !parsed.name || !parsed.folder) {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
@@ -150,13 +209,20 @@ export async function run(args: string[]): Promise<void> {
let messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId);
if (!messagingGroup) {
const mgId = generateId('mg');
// Policy: explicit flag → channel declaration → legacy 'strict' (stale
// adapters without a declaration must keep pre-declaration behavior).
const unknownSenderPolicy =
parsed.unknownSenderPolicy ??
(hasDeclaredChannelDefaults(parsed.channel)
? resolveUnknownSenderPolicy(parsed.channel, parsed.isGroup)
: 'strict');
createMessagingGroup({
id: mgId,
channel_type: parsed.channel,
platform_id: parsed.platformId,
name: parsed.name,
is_group: 1,
unknown_sender_policy: 'strict',
is_group: parsed.isGroup ? 1 : 0,
unknown_sender_policy: unknownSenderPolicy,
created_at: new Date().toISOString(),
});
messagingGroup = getMessagingGroupByPlatform(parsed.channel, parsed.platformId)!;
@@ -170,19 +236,41 @@ export async function run(args: string[]): Promise<void> {
if (!existing) {
newlyWired = true;
const mgaId = generateId('mga');
// 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.
// Engage defaults, first hit wins: explicit --engage-mode → explicit
// --trigger (pattern regex, the historical override) → the channel's
// declared defaults → the legacy heuristic for stale (undeclared)
// adapters, so a trunk update alone changes nothing for them: groups get
// 'mention' (respond when addressed), DMs 'pattern'/'.' (every message).
const isGroup = messagingGroup.is_group === 1;
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
const channelKey = messagingGroup.instance ?? messagingGroup.channel_type;
let engage: { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null };
if (parsed.engageMode) {
if (parsed.engageMode === 'pattern' && !parsed.trigger) {
throw new Error(`--engage-mode pattern requires --trigger (use "." to match every message)`);
}
engage = {
engage_mode: parsed.engageMode,
engage_pattern: parsed.engageMode === 'pattern' ? parsed.trigger : null,
};
} else if (parsed.trigger) {
engage = { engage_mode: 'pattern', engage_pattern: parsed.trigger };
} else if (hasDeclaredChannelDefaults(channelKey, messagingGroup.channel_type)) {
engage = resolveWiringDefaults(channelKey, isGroup, agentGroup.name, messagingGroup.channel_type);
} else {
engage = isGroup
? { engage_mode: 'mention', engage_pattern: null }
: { engage_mode: 'pattern', engage_pattern: '.' };
}
// Same cross-checks as `ncl wirings create`: rejects mention modes on
// channels declaring mentions:'never'; coerces mention-sticky→mention
// when the channel context has no thread ids.
validateEngageAgainstChannel(engage, messagingGroup);
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: messagingGroup.id,
agent_group_id: agentGroup.id,
engage_mode: engageMode,
engage_pattern: engagePattern,
engage_mode: engage.engage_mode,
engage_pattern: engage.engage_pattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
+70 -1
View File
@@ -83,7 +83,8 @@ export interface InboundMessage {
* display name (e.g. `@Andy`).
*
* Adapters that don't set it (native / legacy) leave it undefined the
* router falls back to text-match against agent_group_name.
* router treats undefined as "not a mention" (`isMention === true` check,
* src/router.ts). There is no text-match fallback.
*/
isMention?: boolean;
/** True when the source is a group/channel thread, false for DMs. */
@@ -110,6 +111,57 @@ export interface ConversationInfo {
isGroup: boolean;
}
/** Wiring/mg defaults for one conversation context (DM vs group/channel). */
export interface ChannelContextDefaults {
/** Default engage_mode for wirings created in this context. */
engageMode: 'pattern' | 'mention' | 'mention-sticky';
/**
* Default engage_pattern when engageMode === 'pattern'. May contain the
* literal token `{name}`: creation helpers replace it with the regex-escaped
* agent_group name (for platforms with no group-mention metadata, e.g.
* iMessage/DeltaChat groups, WhatsApp shared-number mode). Required iff
* engageMode === 'pattern'.
*/
engagePattern?: string;
/**
* Whether thread ids are honored in this context by default.
* true inbound thread ids flow into messages_in and (in groups) force
* per-thread session identity; replies, typing, and cards land
* in-thread.
* false thread ids are nulled per-wiring at router fanout; sessions
* collapse; replies land top-level.
* MUST be false when `supportsThreads` is false (capability bound; the
* router treats supportsThreads=false as a hard pre-strip regardless).
* Per-wiring override: messaging_group_agents.threads (NULL = inherit).
*/
threads: boolean;
/**
* unknown_sender_policy stamped on messaging_groups rows auto-created by
* the router or created by wizard/CLI paths in this context.
*/
unknownSenderPolicy: 'strict' | 'request_approval' | 'public';
}
/**
* Static per-channel declaration of wiring-time defaults. Exactly two levels
* exist: this declaration, and the per-wiring/per-mg values chosen at
* creation. Install-wide changes = edit the adapter copy (skill-installed,
* user-owned). Never persisted to the central DB.
*/
export interface ChannelDefaults {
dm: ChannelContextDefaults;
group: ChannelContextDefaults;
/**
* Which mention signal the adapter emits (InboundMessage.isMention):
* 'platform' platform-confirmed mentions in groups; DMs flagged too.
* 'dm-only' only DMs flagged (no group mention metadata).
* 'never' isMention never set: auto-create/registration card never
* fires; 'mention'/'mention-sticky' wirings never engage.
* Creation surfaces must reject/warn on mention modes that can never fire.
*/
mentions: 'platform' | 'dm-only' | 'never';
}
/** The v2 channel adapter contract. */
export interface ChannelAdapter {
name: string;
@@ -176,6 +228,15 @@ export interface ChannelAdapter {
* Returning the same platform_id on repeated calls is expected.
*/
openDM?(userHandle: string): Promise<string>;
/**
* Declared wiring-time defaults for this channel. Optional for backward
* compatibility with stale adapter copies; absent core fallback
* (fallbackChannelDefaults(supportsThreads), see channel-registry.ts).
* May be computed from adapter-internal env at module load (e.g. WhatsApp
* shared-number mode), but is immutable for the process lifetime.
*/
defaults?: ChannelDefaults;
}
/** Factory function that creates a channel adapter (returns null if credentials missing). */
@@ -184,6 +245,14 @@ export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapte
/** Registration entry for a channel adapter. */
export interface ChannelRegistration {
factory: ChannelAdapterFactory;
/**
* Same declaration as ChannelAdapter.defaults, resolvable WITHOUT
* instantiating the adapter offline creation paths (setup/register.ts,
* scripts/init-first-agent.ts, ncl against a host where the factory
* returned null for missing creds) read it from the registry. Channel
* modules pass the same const here and to the adapter/bridge.
*/
defaults?: ChannelDefaults;
containerConfig?: {
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
env?: Record<string, string>;
+297
View File
@@ -0,0 +1,297 @@
/**
* Tests for channel default declarations: getChannelDefaults tiered lookup,
* the behavior-faithful fallback, and the wiring-creation helpers.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { ChannelAdapter, ChannelDefaults, ChannelSetup } from './adapter.js';
function makeDefaults(marker: string, threads = true): ChannelDefaults {
return {
dm: { engageMode: 'pattern', engagePattern: marker, threads, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention', threads, unknownSenderPolicy: 'strict' },
mentions: 'platform',
};
}
function makeAdapter(
channelType: string,
opts: { instance?: string; supportsThreads?: boolean; defaults?: ChannelDefaults } = {},
): ChannelAdapter {
return {
name: opts.instance ?? channelType,
channelType,
instance: opts.instance,
supportsThreads: opts.supportsThreads ?? false,
defaults: opts.defaults,
async setup(_config: ChannelSetup) {},
async teardown() {},
isConnected: () => true,
async deliver() {
return undefined;
},
};
}
const mockSetup = () => ({
onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {},
onAction: () => {},
});
describe('getChannelDefaults — tiered lookup', () => {
// The registry and activeAdapters maps are module-level; fresh module per
// test so registrations don't leak across arms.
beforeEach(() => {
vi.resetModules();
});
afterEach(async () => {
const { teardownChannelAdapters } = await import('./channel-registry.js');
await teardownChannelAdapters();
vi.resetModules();
});
it('live adapter declaration wins over the registration declaration', async () => {
const reg = await import('./channel-registry.js');
const liveDecl = makeDefaults('live');
const regDecl = makeDefaults('registration');
reg.registerChannelAdapter('mock', {
factory: () => makeAdapter('mock', { defaults: liveDecl }),
defaults: regDecl,
});
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('mock')).toBe(liveDecl);
});
it('falls through a live channelType scan for a channelType key (live tier instance→channelType)', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('named-instance');
reg.registerChannelAdapter('slack-tester', {
factory: () => makeAdapter('slack', { instance: 'slack-tester', defaults: decl }),
});
await reg.initChannelAdapters(mockSetup);
// Key is the bare channelType; only a named instance is live.
expect(reg.getChannelDefaults('slack')).toBe(decl);
});
it('falls through to the registration entry when the factory returned null', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('registration');
reg.registerChannelAdapter('mock', { factory: () => null, defaults: decl });
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('mock')).toBe(decl);
});
it('resolves a stale live instance through its channelType registration (registration tier instance→channelType)', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('platform-registration');
// Stale adapter copy: live under a named-instance key with NO declaration;
// the platform's registration (keyed by channelType) carries one.
reg.registerChannelAdapter('slack-tester', {
factory: () => makeAdapter('slack', { instance: 'slack-tester' }),
});
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('slack-tester')).toBe(decl);
});
it('resolves a dead named instance through the channelType hint (registration tier instance→channelType)', async () => {
const reg = await import('./channel-registry.js');
const decl = makeDefaults('platform-registration');
// Nothing live at all: the named instance's factory returned null and its
// registration has no declaration — only mg.channel_type can bridge.
reg.registerChannelAdapter('slack-tester', { factory: () => null });
reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl });
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('slack-tester', 'slack')).toBe(decl);
// Without the hint there is no instance→channelType mapping in the registry.
expect(reg.getChannelDefaults('slack-tester')).toEqual(reg.fallbackChannelDefaults(false));
});
it('uses the live adapter supportsThreads for the fallback tier', async () => {
const reg = await import('./channel-registry.js');
reg.registerChannelAdapter('mock', {
factory: () => makeAdapter('mock', { supportsThreads: true }),
});
await reg.initChannelAdapters(mockSetup);
expect(reg.getChannelDefaults('mock')).toEqual(reg.fallbackChannelDefaults(true));
});
it('unknown channel type resolves the conservative fallback', async () => {
const reg = await import('./channel-registry.js');
expect(reg.getChannelDefaults('no-such-channel')).toEqual(reg.fallbackChannelDefaults(false));
});
});
describe('fallbackChannelDefaults — behavior-faithful values', () => {
beforeEach(() => {
vi.resetModules();
});
it('reproduces trunk behavior for undeclared adapters', async () => {
const { fallbackChannelDefaults } = await import('./channel-registry.js');
expect(fallbackChannelDefaults(true)).toEqual({
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
});
// threads track the raw capability in BOTH contexts so NULL-inherit
// wirings behave exactly like today's supportsThreads-derived routing.
const nonThreaded = fallbackChannelDefaults(false);
expect(nonThreaded.dm.threads).toBe(false);
expect(nonThreaded.group.threads).toBe(false);
expect(nonThreaded.group.engageMode).toBe('mention-sticky');
});
});
describe('resolveWiringDefaults', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(async () => {
const { teardownChannelAdapters } = await import('./channel-registry.js');
await teardownChannelAdapters();
vi.resetModules();
});
async function withDeclaration(defaults: ChannelDefaults) {
const reg = await import('./channel-registry.js');
reg.registerChannelAdapter('mock', { factory: () => null, defaults });
return import('./channel-defaults.js');
}
it('substitutes {name} with the regex-escaped agent group name', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'dm-only',
});
// Name ends in ')' (non-word) — the trailing declared \b could never
// match there, so it is dropped; the leading \b stays.
expect(resolveWiringDefaults('mock', true, 'C-3PO (dev)')).toEqual({
engage_mode: 'pattern',
engage_pattern: '\\bC-3PO \\(dev\\)',
});
// DM context: no token, pattern passes through untouched.
expect(resolveWiringDefaults('mock', false, 'C-3PO (dev)')).toEqual({
engage_mode: 'pattern',
engage_pattern: '.',
});
});
it('keeps both \\b boundaries for a plain word name and produces a matching regex', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'dm-only',
});
const word = resolveWiringDefaults('mock', true, 'Andy');
expect(word.engage_pattern).toBe('\\bAndy\\b');
expect(new RegExp(word.engage_pattern!).test('@Andy status')).toBe(true);
expect(new RegExp(word.engage_pattern!).test('@Andyboy status')).toBe(false);
// Trailing non-word char: '@Andy (backup) status' must still engage.
const punct = resolveWiringDefaults('mock', true, 'Andy (backup)');
expect(new RegExp(punct.engage_pattern!).test('@Andy (backup) status')).toBe(true);
// Leading non-word char: the leading \b is dropped instead.
const lead = resolveWiringDefaults('mock', true, '!Nano');
expect(lead.engage_pattern).toBe('!Nano\\b');
expect(new RegExp(lead.engage_pattern!).test('hey !Nano status')).toBe(true);
});
it('coerces mention-sticky to mention when the context threads=false', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'mention-sticky', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'platform',
});
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
engage_mode: 'mention',
engage_pattern: null,
});
});
it('keeps mention-sticky when the context threads=true', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'strict' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
mentions: 'platform',
});
expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({
engage_mode: 'mention-sticky',
engage_pattern: null,
});
});
it('throws on a pattern-mode declaration without a pattern', async () => {
const { resolveWiringDefaults } = await withDeclaration({
dm: { engageMode: 'pattern', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'platform',
});
expect(() => resolveWiringDefaults('mock', false, 'Andy')).toThrow(/without an engagePattern/);
});
});
describe('resolveUnknownSenderPolicy', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.resetModules();
});
it('selects the context policy from the declaration', async () => {
const reg = await import('./channel-registry.js');
reg.registerChannelAdapter('mock', {
factory: () => null,
defaults: {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'platform',
},
});
const { resolveUnknownSenderPolicy } = await import('./channel-defaults.js');
expect(resolveUnknownSenderPolicy('mock', false)).toBe('public');
expect(resolveUnknownSenderPolicy('mock', true)).toBe('strict');
});
});
describe('resolveThreadPolicy', () => {
it('ANDs the resolved value with the raw capability', async () => {
vi.resetModules();
const { resolveThreadPolicy } = await import('./channel-defaults.js');
const decl: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' },
mentions: 'platform',
};
// NULL = inherit the declaration for the context.
expect(resolveThreadPolicy(null, decl, true, true)).toBe(true);
expect(resolveThreadPolicy(null, decl, false, true)).toBe(false);
// Explicit wiring value beats the declaration…
expect(resolveThreadPolicy(1, decl, false, true)).toBe(true);
expect(resolveThreadPolicy(0, decl, true, true)).toBe(false);
// …but never the capability: no opt-in on a non-threaded platform.
expect(resolveThreadPolicy(1, decl, true, false)).toBe(false);
expect(resolveThreadPolicy(null, decl, true, false)).toBe(false);
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* Wiring-creation helpers over channel default declarations.
*
* Every path that creates a messaging_group_agents row (ncl, setup wizard,
* card-approval flow, bootstrap scripts) resolves its engage defaults through
* resolveWiringDefaults; every path that auto-creates a messaging_groups row
* resolves its policy through resolveUnknownSenderPolicy. The router's fanout
* consults resolveThreadPolicy at runtime threading is the one per-wiring
* setting that stays live (NULL = inherit the declaration) rather than being
* snapshotted at creation.
*
* Context selection everywhere: isGroup = event.message.isGroup ??
* (mg.is_group === 1) NEVER `threadId !== null` (DM sub-threads exist on
* Slack/Discord, and non-threaded group platforms have null threadIds).
*/
import type { ChannelDefaults } from './adapter.js';
import { getChannelDefaults, hasDeclaredChannelDefaults } from './channel-registry.js';
import { log } from '../log.js';
import type { MessagingGroup } from '../types.js';
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Substitute the (regex-escaped) agent name for `{name}` in a declared
* pattern. A `\b` adjacent to a non-word character can never match, so when
* the name starts/ends with one (e.g. "Nano!", "Andy (backup)") the adjacent
* declared boundary is dropped mirrors selfChatEngagePattern in
* setup/channels/whatsapp.ts.
*/
function substituteName(pattern: string, name: string): string {
let out = pattern;
if (!/^\w/.test(name)) out = out.replaceAll('\\b{name}', '{name}');
if (!/\w$/.test(name)) out = out.replaceAll('{name}\\b', '{name}');
return out.replaceAll('{name}', escapeRegex(name));
}
/**
* Resolve the engage defaults a new wiring should be created with.
*
* @param channelKey mg.instance ?? mg.channel_type (getChannelAdapter key discipline)
* @param isGroup event.message.isGroup ?? mg.is_group === 1 never derived from threadId
* @param agentGroupName substituted (regex-escaped) for the `{name}` token in declared patterns
* @param channelType mg.channel_type pass when channelKey may be a named
* instance so a dead instance still resolves its platform's declaration
* (getChannelDefaults' second-arg discipline)
*
* mention-sticky is downgraded to mention when the context's declared threads
* value is false: sticky engagement is keyed on per-thread session existence,
* so without thread ids it could engage once and never disengage.
*/
export function resolveWiringDefaults(
channelKey: string,
isGroup: boolean,
agentGroupName: string,
channelType?: string,
): { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null } {
const decl = getChannelDefaults(channelKey, channelType);
const ctx = isGroup ? decl.group : decl.dm;
let mode = ctx.engageMode;
if (mode === 'mention-sticky' && !ctx.threads) mode = 'mention';
if (mode !== 'pattern') return { engage_mode: mode, engage_pattern: null };
if (!ctx.engagePattern) {
throw new Error(
`Channel '${channelKey}' declares engageMode 'pattern' without an engagePattern (${isGroup ? 'group' : 'dm'} context)`,
);
}
return {
engage_mode: 'pattern',
engage_pattern: substituteName(ctx.engagePattern, agentGroupName),
};
}
/** unknown_sender_policy for a messaging_groups row created in this context.
* `channelType` follows the same dead-named-instance discipline as
* resolveWiringDefaults. */
export function resolveUnknownSenderPolicy(
channelKey: string,
isGroup: boolean,
channelType?: string,
): 'strict' | 'request_approval' | 'public' {
const decl = getChannelDefaults(channelKey, channelType);
return (isGroup ? decl.group : decl.dm).unknownSenderPolicy;
}
/**
* Runtime thread policy for one wiring: does its event-derived address keep
* thread ids? wiring.threads (0/1, NULL = inherit the declaration) hard-ANDed
* with the adapter's raw capability a wiring can opt out of threads on a
* threaded platform, never opt in on a non-threaded one.
*
* Applies ONLY to event-derived addresses. `event.replyTo` is operator intent
* from the CLI admin transport (src/channels/adapter.ts) and must never be
* nulled through this policy.
*/
export function resolveThreadPolicy(
wiringThreads: number | null,
decl: ChannelDefaults,
isGroup: boolean,
supportsThreads: boolean,
): boolean {
const inherited = (isGroup ? decl.group : decl.dm).threads;
const wanted = wiringThreads === null ? inherited : wiringThreads !== 0;
return wanted && supportsThreads;
}
export interface EngageValues {
engage_mode?: unknown;
engage_pattern?: unknown;
threads?: unknown;
}
/**
* Cross-column validation against the channel's declaration. Shared by every
* wiring-creation surface (`ncl wirings` create/update, the setup wizard's
* register step) so a partial update or an explicit flag can't produce a
* combination create would reject. May mutate `w.engage_mode`: the
* mention-stickymention coercion when the effective thread policy is off
* sticky engagement is keyed on per-thread session existence, so without
* thread ids it would engage once and never disengage.
*
* Declaration-derived checks are gated on hasDeclaredChannelDefaults: stale
* (undeclared) adapters keep the legacy lenient behavior the fallback
* declaration is permissive on mentions but its threads value is false when
* no adapter is live, which would wrongly coerce offline-created wirings.
*/
export function validateEngageAgainstChannel(w: EngageValues, mg: MessagingGroup): void {
if (
w.engage_mode === 'pattern' &&
(w.engage_pattern === undefined || w.engage_pattern === null || w.engage_pattern === '')
) {
throw new Error(`engage_mode 'pattern' requires --engage-pattern (use "." to match every message)`);
}
if (w.engage_mode !== 'mention' && w.engage_mode !== 'mention-sticky') return;
const channelKey = mg.instance ?? mg.channel_type;
if (!hasDeclaredChannelDefaults(channelKey, mg.channel_type)) return;
const decl = getChannelDefaults(channelKey, mg.channel_type);
if (decl.mentions === 'never') {
throw new Error(
`engage_mode '${w.engage_mode}' can never engage on channel '${channelKey}' — its adapter declares mentions: 'never' (no mention signal is emitted; use --engage-mode pattern)`,
);
}
if (w.engage_mode === 'mention-sticky') {
const ctx = mg.is_group === 1 ? decl.group : decl.dm;
const threads = w.threads === undefined || w.threads === null ? ctx.threads : w.threads !== 0;
if (!threads) {
log.warn('mention-sticky requires thread ids — coerced to mention', {
channel: channelKey,
messagingGroupId: mg.id,
});
w.engage_mode = 'mention';
}
}
}
+92 -1
View File
@@ -4,7 +4,7 @@
* Channels self-register on import. The host calls initChannelAdapters() at startup
* to instantiate and set up all registered adapters.
*/
import type { ChannelAdapter, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
import type { ChannelAdapter, ChannelDefaults, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
import type { ChannelDeliveryAdapter } from '../delivery.js';
import { log } from '../log.js';
@@ -102,6 +102,97 @@ export function createChannelDeliveryAdapter(): ChannelDeliveryAdapter {
};
}
/**
* Behavior-faithful fallback for adapters with no `defaults` declaration
* (stale skill-installed copies, unknown channel types). Values reproduce
* what trunk did before declarations existed, so a trunk update alone
* changes nothing for undeclared adapters:
* - dm: pattern '.' (every DM message engages), router auto-create policy
* 'request_approval' (src/router.ts auto-create branch).
* - group: mention-sticky (what the card-approval flow stamped on group
* channels), same 'request_approval' policy.
* - threads follow the raw capability in BOTH contexts a NULL (inherit)
* wiring resolved through this fallback behaves exactly like today's
* supportsThreads-derived routing.
* - mentions 'platform': never blocks a mention wiring at creation time.
*/
export function fallbackChannelDefaults(supportsThreads: boolean): ChannelDefaults {
return {
dm: {
engageMode: 'pattern',
engagePattern: '.',
threads: supportsThreads,
unknownSenderPolicy: 'request_approval',
},
group: {
engageMode: 'mention-sticky',
threads: supportsThreads,
unknownSenderPolicy: 'request_approval',
},
mentions: 'platform',
};
}
/**
* Resolve a channel's declared wiring defaults. Never returns undefined.
*
* `key` follows the same discipline as getChannelAdapter: mg.instance ??
* mg.channel_type. Tiers, first hit wins:
* 1. live adapter, instance-exact lets an instance carry env-computed
* declarations (e.g. WhatsApp shared-number mode);
* 2. live adapter of that channelType (mirrors getChannelAdapter's scan);
* 3. registration entry under the key covers offline scripts and
* factories that returned null for missing creds;
* 4. registration entry under the channelType resolved from the live
* adapter found in tiers 1-2 (a stale adapter copy without a declaration
* whose registration has one), else from the optional `channelType`
* hint, which callers holding a named-instance mg row should pass so a
* dead instance still resolves its platform's declaration;
* 5. fallbackChannelDefaults on the live adapter's capability (false when
* no adapter is live conservative, reachable only from manual creation
* surfaces since the router never sees events for unregistered channels).
*/
export function getChannelDefaults(key: string, channelType?: string): ChannelDefaults {
const { live, decl } = lookupDeclaredDefaults(key, channelType);
return decl ?? fallbackChannelDefaults(live?.supportsThreads ?? false);
}
/**
* True iff getChannelDefaults would resolve from an actual declaration (tiers
* 1-4) rather than fallbackChannelDefaults. Manual creation surfaces (`ncl`)
* gate declaration-derived defaults on this: for stale (undeclared) adapters
* they keep the legacy static schema defaults engage_mode 'mention',
* unknown_sender_policy 'strict' so a trunk update alone changes nothing.
* The faithful fallback exists for the ROUTER's auto-create/runtime paths,
* whose historical behavior it reproduces; it is not what `ncl` did.
*/
export function hasDeclaredChannelDefaults(key: string, channelType?: string): boolean {
return lookupDeclaredDefaults(key, channelType).decl !== undefined;
}
/** Shared tiers 1-4 of getChannelDefaults (see its doc); `decl` undefined
* means only tier 5 (fallback) remains. */
function lookupDeclaredDefaults(
key: string,
channelType?: string,
): { live: ChannelAdapter | undefined; decl: ChannelDefaults | undefined } {
let live = activeAdapters.get(key);
if (!live) {
for (const adapter of activeAdapters.values()) {
if (adapter.channelType === key) {
live = adapter;
break;
}
}
}
if (live?.defaults) return { live, decl: live.defaults };
const typeKey = live?.channelType ?? channelType;
const registered =
registry.get(key)?.defaults ?? (typeKey !== undefined ? registry.get(typeKey)?.defaults : undefined);
return { live, decl: registered };
}
/** Get all active adapters. */
export function getActiveAdapters(): ChannelAdapter[] {
return [...activeAdapters.values()];
+13 -4
View File
@@ -23,7 +23,7 @@ import { SqliteStateAdapter } from '../state-sqlite.js';
import { registerWebhookAdapter } from '../webhook-server.js';
import { getAskQuestionRender } from '../db/sessions.js';
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js';
import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js';
/** Adapter with optional gateway support (e.g., Discord). */
interface GatewayAdapter extends Adapter {
@@ -68,6 +68,12 @@ export interface ChatSdkBridgeConfig {
* way and the default depends on installation style.
*/
supportsThreads: boolean;
/**
* Declared wiring-time defaults for this channel. Copied verbatim onto the
* returned ChannelAdapter, exactly like supportsThreads. See
* `ChannelAdapter.defaults`.
*/
defaults?: ChannelDefaults;
/**
* Optional transform applied to outbound text/markdown before it reaches the
* adapter. Used by channels that need to sanitize for a platform-specific
@@ -220,6 +226,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
instance: config.instance, // undefined ⇒ default instance
supportsThreads: config.supportsThreads,
defaults: config.defaults,
async setup(hostConfig: ChannelSetup) {
setupConfig = hostConfig;
@@ -265,9 +272,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
});
// DMs — by definition addressed to the bot. Thread id flows through
// so sub-thread context reaches delivery (Slack users can open threads
// inside a DM). Router collapses DM sub-threads to one session via
// is_group=0 short-circuit.
// unmodified (Slack users can open sub-threads inside a DM); whether it
// is honored is policy, not transport: the channel's declared
// dm.threads default (ChannelDefaults) or a per-wiring threads override
// decides at router fanout whether replies land in-thread or all DM
// sub-threads collapse into the one DM session.
chat.onDirectMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
log.info('Inbound DM received', {
+22 -2
View File
@@ -39,11 +39,30 @@ import path from 'path';
import { DATA_DIR } from '../config.js';
import { log } from '../log.js';
import type { ChannelAdapter, ChannelSetup, DeliveryAddress, InboundEvent, OutboundMessage } from './adapter.js';
import type {
ChannelAdapter,
ChannelDefaults,
ChannelSetup,
DeliveryAddress,
InboundEvent,
OutboundMessage,
} from './adapter.js';
import { registerChannelAdapter } from './channel-registry.js';
const PLATFORM_ID = 'local';
/**
* Terminal transport: every line the operator types is for the agent
* (pattern '.'), the socket is owner-only so senders are trusted ('public'),
* there is no thread or mention concept. Matches what
* scripts/init-cli-agent.ts has always created.
*/
const CLI_DEFAULTS: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
mentions: 'never',
};
function socketPath(): string {
return path.join(DATA_DIR, 'cli.sock');
}
@@ -56,6 +75,7 @@ function createAdapter(): ChannelAdapter {
name: 'cli',
channelType: 'cli',
supportsThreads: false,
defaults: CLI_DEFAULTS,
async setup(config: ChannelSetup): Promise<void> {
const sock = socketPath();
@@ -273,4 +293,4 @@ function extractText(message: OutboundMessage): string | null {
return null;
}
registerChannelAdapter('cli', { factory: createAdapter });
registerChannelAdapter('cli', { factory: createAdapter, defaults: CLI_DEFAULTS });
+63 -1
View File
@@ -29,10 +29,11 @@ vi.mock('../modules/agent-to-agent/write-destinations.js', () => ({
writeDestinations: (...args: unknown[]) => writeDestinationsSpy(...args),
}));
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from '../db/index.js';
import { initTestDb, closeDb, getDb, runMigrations, createAgentGroup, createMessagingGroup } from '../db/index.js';
import { createSession } from '../db/sessions.js';
import { getContainerConfig } from '../db/container-configs.js';
import { getDestinations } from '../modules/agent-to-agent/db/agent-destinations.js';
import { registerResource } from './crud.js';
import { lookup } from './registry.js';
// Importing these for side effects: each calls `registerResource` at
@@ -43,6 +44,31 @@ import '../cli/resources/wirings.js';
const hostCtx = { caller: 'host' as const };
// Synthetic resource exercising the two-pass create: pass 1 collects explicit
// args, pass 2 runs the resolveDefaults hook, pass 3 fills static defaults.
// Registered once at module load like the real resources above; its table is
// created per-test in the describe's beforeEach.
const hookCalls: Record<string, unknown>[] = [];
registerResource({
name: 'hooktest',
plural: 'hooktests',
table: 'hooktest_rows',
description: 'Synthetic resource for resolveDefaults hook-ordering tests.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{ name: 'kind', type: 'string', description: 'test input', required: true },
{ name: 'mode', type: 'string', description: 'hook-fillable column', default: 'static' },
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { create: 'open' },
resolveDefaults: (values) => {
hookCalls.push({ ...values });
if (values.kind === 'boom') throw new Error('hook rejected');
if (values.mode === undefined && values.kind === 'fill') values.mode = 'hooked';
},
});
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
@@ -174,3 +200,39 @@ describe('genericCreate postCommit hook', () => {
expect(writeDestinationsSpy).not.toHaveBeenCalled();
});
});
describe('genericCreate resolveDefaults hook (two-pass create)', () => {
beforeEach(() => {
getDb().exec(
`CREATE TABLE hooktest_rows (id TEXT PRIMARY KEY, kind TEXT NOT NULL, mode TEXT, created_at TEXT NOT NULL)`,
);
hookCalls.length = 0;
});
it('runs between explicit args and static defaults — a hook fill beats the static default', async () => {
const row = (await lookup('hooktests-create')!.handler({ kind: 'fill' }, hostCtx)) as { mode: string };
expect(row.mode).toBe('hooked');
// The hook saw the pre-static-default state: mode still unset. Were the
// static default applied first, the hook could never fill it.
expect(hookCalls[0].mode).toBeUndefined();
});
it('static default still applies when the hook leaves the column unset', async () => {
const row = (await lookup('hooktests-create')!.handler({ kind: 'plain' }, hostCtx)) as { mode: string };
expect(row.mode).toBe('static');
});
it('explicit args always win over the hook', async () => {
const row = (await lookup('hooktests-create')!.handler({ kind: 'fill', mode: 'explicit' }, hostCtx)) as {
mode: string;
};
expect(row.mode).toBe('explicit');
expect(hookCalls[0].mode).toBe('explicit');
});
it('a hook throw rejects the create and nothing is inserted', async () => {
await expect(lookup('hooktests-create')!.handler({ kind: 'boom' }, hostCtx)).rejects.toThrow('hook rejected');
const count = getDb().prepare('SELECT COUNT(*) AS n FROM hooktest_rows').get() as { n: number };
expect(count.n).toBe(0);
});
});
+43 -2
View File
@@ -83,6 +83,26 @@ export interface ResourceDef {
};
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
customOperations?: Record<string, CustomOperation>;
/**
* Runs on `create` between explicit-arg collection and static column
* defaults (two-pass create): fills omitted columns with context-aware
* values (e.g. channel adapter declarations) and cross-validates the
* combination, throwing an actionable Error to reject. Mutates `values`
* in place. Explicit caller args are already present and must win only
* fill what's still undefined. Static `col.default` / `defaultFrom` apply
* afterwards, only to columns the hook left unset, so a static default can
* never pre-empt context-aware resolution.
*/
resolveDefaults?: (values: Record<string, unknown>) => void;
/**
* Runs on `update` after the update set is built, before the UPDATE
* executes. `current` is the existing row; `updates` holds only the
* changed columns and is mutable (coercions land here). Throw to reject.
* Mirror of the create-side validation in `resolveDefaults` for resources
* whose column combinations need cross-checks a partial update must not
* be able to produce a combination `create` would have rejected.
*/
preUpdate?: (updates: Record<string, unknown>, current: Record<string, unknown>) => void;
/**
* Runs after a successful `create` INSERT, with the row that was just
* written. Used to wire in side effects that the central row alone
@@ -172,6 +192,10 @@ function genericCreate(def: ResourceDef) {
return async (args: Record<string, unknown>) => {
const values: Record<string, unknown> = {};
// Pass 1: generated columns + explicit caller args only. Static defaults
// wait until after resolveDefaults so the hook sees exactly what the
// caller provided and a static default never pre-empts context-aware
// resolution.
for (const col of def.columns) {
if (col.generated) {
if (col.name === def.idColumn) {
@@ -190,7 +214,16 @@ function genericCreate(def: ResourceDef) {
values[col.name] = col.type === 'number' ? Number(v) : v;
} else if (col.required) {
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
} else if (col.default !== undefined) {
}
}
// Pass 2: context-aware defaults + cross-column validation.
if (def.resolveDefaults) def.resolveDefaults(values);
// Pass 3: static defaults for whatever is still unset.
for (const col of def.columns) {
if (col.generated || values[col.name] !== undefined) continue;
if (col.default !== undefined) {
values[col.name] = col.default;
} else if (col.defaultFrom !== undefined && values[col.defaultFrom] !== undefined) {
values[col.name] = values[col.defaultFrom];
@@ -217,6 +250,7 @@ function genericCreate(def: ResourceDef) {
function genericUpdate(def: ResourceDef) {
const updatableCols = def.columns.filter((c) => c.updatable);
const cols = visibleColumns(def).join(', ');
return async (args: Record<string, unknown>) => {
const id = args.id as string;
if (!id) throw new Error(`${def.name} id is required`);
@@ -237,6 +271,14 @@ function genericUpdate(def: ResourceDef) {
);
}
if (def.preUpdate) {
const current = getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id) as
| Record<string, unknown>
| undefined;
if (!current) throw new Error(`${def.name} not found: ${id}`);
def.preUpdate(updates, current);
}
const setClause = Object.keys(updates)
.map((k) => `${k} = @${k}`)
.join(', ');
@@ -245,7 +287,6 @@ function genericUpdate(def: ResourceDef) {
.run({ ...updates, _id: id });
if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`);
const cols = visibleColumns(def).join(', ');
return getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id);
};
}
@@ -24,12 +24,23 @@ vi.mock('../../config.js', async () => {
const TEST_DIR = '/tmp/nanoclaw-test-cli-msggroups';
import type { ChannelDefaults } from '../../channels/adapter.js';
import { registerChannelAdapter } from '../../channels/channel-registry.js';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
import { dispatch } from '../dispatch.js';
// Side-effect import: registers the `messaging-groups-create` command.
import './messaging-groups.js';
// Registration-tier declaration (no live adapter) — the environment `ncl`
// sees for offline instances and setup scripts.
const declared: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('declchan-mg', { factory: () => null, defaults: declared });
describe('messaging-groups CLI create defaults instance to channel_type', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
@@ -73,3 +84,46 @@ describe('messaging-groups CLI create defaults instance to channel_type', () =>
expect(getMessagingGroupByPlatform('telegram', '67890', 'work')?.instance).toBe('work');
});
});
describe('messaging-groups CLI create resolves unknown_sender_policy from the channel declaration', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
const create = (args: Record<string, unknown>, id: string) =>
dispatch({ id, command: 'messaging-groups-create', args }, { caller: 'host' });
it('DM context takes the declared dm policy', async () => {
const resp = await create({ channel_type: 'declchan-mg', platform_id: 'dm-1' }, 'req-d1');
expect(resp.ok).toBe(true);
expect(getMessagingGroupByPlatform('declchan-mg', 'dm-1')?.unknown_sender_policy).toBe('public');
});
it('group context takes the declared group policy', async () => {
const resp = await create({ channel_type: 'declchan-mg', platform_id: 'g-1', is_group: '1' }, 'req-d2');
expect(resp.ok).toBe(true);
expect(getMessagingGroupByPlatform('declchan-mg', 'g-1')?.unknown_sender_policy).toBe('request_approval');
});
it('explicit --unknown-sender-policy wins over the declaration', async () => {
const resp = await create(
{ channel_type: 'declchan-mg', platform_id: 'dm-2', unknown_sender_policy: 'strict' },
'req-d3',
);
expect(resp.ok).toBe(true);
expect(getMessagingGroupByPlatform('declchan-mg', 'dm-2')?.unknown_sender_policy).toBe('strict');
});
it("undeclared channels keep the legacy static 'strict' default (back-compat)", async () => {
const resp = await create({ channel_type: 'stalechan-mg', platform_id: 's-1' }, 'req-d4');
expect(resp.ok).toBe(true);
expect(getMessagingGroupByPlatform('stalechan-mg', 's-1')?.unknown_sender_policy).toBe('strict');
});
});
+21 -1
View File
@@ -1,3 +1,6 @@
import { resolveUnknownSenderPolicy } from '../../channels/channel-defaults.js';
import { hasDeclaredChannelDefaults } from '../../channels/channel-registry.js';
import { log } from '../../log.js';
import { registerResource } from '../crud.js';
registerResource({
@@ -48,7 +51,7 @@ registerResource({
name: 'unknown_sender_policy',
type: 'string',
description:
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.',
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone. Default: declared by the channel adapter for this context (DM vs group); "strict" when the channel has no declaration.',
enum: ['strict', 'request_approval', 'public'],
default: 'strict',
updatable: true,
@@ -63,4 +66,21 @@ registerResource({
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
resolveDefaults: (values) => {
if (values.unknown_sender_policy !== undefined) return;
const channelType = String(values.channel_type);
const channelKey = (values.instance as string | undefined) ?? channelType;
// Static 'strict' stays the no-declaration fallback: a trunk update alone
// must not change ncl's creation defaults for stale (undeclared) adapters.
if (!hasDeclaredChannelDefaults(channelKey, channelType)) {
log.warn(
`messaging-group create: channel '${channelKey}' has no declared defaults (adapter not installed or stale) — using legacy static defaults`,
);
return;
}
// is_group carries its static default (0) only after this hook runs, so
// treat "not provided" as the same DM context the static default means.
const isGroup = Number(values.is_group ?? 0) === 1;
values.unknown_sender_policy = resolveUnknownSenderPolicy(channelKey, isGroup, channelType);
},
});
+232
View File
@@ -0,0 +1,232 @@
/**
* Wiring creation/update against channel declarations: the resolveDefaults
* hook fills omitted engage defaults from the adapter declaration ({name}
* substituted), explicit flags always win, undeclared channels keep the
* legacy static defaults (back-compat contract), and the create/update
* validation rejects combinations that could never engage.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// wirings' postCommit projects destinations into live session DBs — no
// sessions run in this test, but the module must not open on-disk DB files.
vi.mock('../../modules/agent-to-agent/write-destinations.js', () => ({
writeDestinations: vi.fn(),
}));
import type { ChannelDefaults } from '../../channels/adapter.js';
import { registerChannelAdapter } from '../../channels/channel-registry.js';
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from '../../db/index.js';
import { createMessagingGroupAgent, getMessagingGroupAgent } from '../../db/messaging-groups.js';
import { lookup } from '../registry.js';
// Side-effect import: registers wirings-create / wirings-update.
import './wirings.js';
const hostCtx = { caller: 'host' as const };
const now = () => new Date().toISOString();
// Registration-tier declarations only — no adapter is live, which is exactly
// the environment `ncl` sees for offline instances and setup scripts.
const declared: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: 'hey {name}!', threads: false, unknownSenderPolicy: 'public' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('declchan', { factory: () => null, defaults: declared });
const neverDeclared: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'pattern', engagePattern: '{name}', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'never',
};
registerChannelAdapter('neverchan', { factory: () => null, defaults: neverDeclared });
function mg(id: string, channelType: string, isGroup: number) {
createMessagingGroup({
id,
channel_type: channelType,
platform_id: `pid-${id}`,
name: null,
is_group: isGroup,
unknown_sender_policy: 'strict',
created_at: now(),
});
}
async function create(args: Record<string, unknown>) {
return (await lookup('wirings-create')!.handler(args, hostCtx)) as Record<string, unknown>;
}
async function update(args: Record<string, unknown>) {
return (await lookup('wirings-update')!.handler(args, hostCtx)) as Record<string, unknown>;
}
beforeEach(() => {
runMigrations(initTestDb());
createAgentGroup({
id: 'ag-1',
name: 'Helper Bot',
folder: 'helper-bot',
agent_provider: null,
created_at: now(),
});
mg('mg-dm', 'declchan', 0);
mg('mg-group', 'declchan', 1);
mg('mg-never', 'neverchan', 1);
mg('mg-stale', 'stalechan', 1); // no declaration anywhere
});
afterEach(() => {
closeDb();
});
describe('wirings-create — declaration-derived defaults', () => {
it('fills DM defaults from the declaration with {name} substituted', async () => {
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
expect(row.engage_mode).toBe('pattern');
expect(row.engage_pattern).toBe('hey Helper Bot!');
});
it('fills group defaults from the declaration', async () => {
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' });
expect(row.engage_mode).toBe('mention-sticky');
const persisted = getMessagingGroupAgent(row.id as string);
expect(persisted!.engage_pattern).toBeNull();
});
it('explicit --engage-mode wins over the declaration', async () => {
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', engage_mode: 'mention' });
expect(row.engage_mode).toBe('mention');
});
it('undeclared channels keep the legacy static default (back-compat)', async () => {
const row = await create({ messaging_group_id: 'mg-stale', agent_group_id: 'ag-1' });
expect(row.engage_mode).toBe('mention');
expect(row.engage_pattern).toBeUndefined();
});
});
describe('wirings-create — validation', () => {
it('rejects pattern mode without --engage-pattern', async () => {
await expect(
create({ messaging_group_id: 'mg-stale', agent_group_id: 'ag-1', engage_mode: 'pattern' }),
).rejects.toThrow(/--engage-pattern/);
});
it("rejects mention modes on a channel declaring mentions: 'never'", async () => {
await expect(
create({ messaging_group_id: 'mg-never', agent_group_id: 'ag-1', engage_mode: 'mention' }),
).rejects.toThrow(/mentions: 'never'/);
});
it('coerces explicit mention-sticky to mention when the declared context has threads=false', async () => {
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1', engage_mode: 'mention-sticky' });
expect(row.engage_mode).toBe('mention');
});
it('coerces mention-sticky when --threads false overrides a threaded declaration', async () => {
const row = await create({
messaging_group_id: 'mg-group',
agent_group_id: 'ag-1',
engage_mode: 'mention-sticky',
threads: 'false',
});
expect(row.engage_mode).toBe('mention');
expect(row.threads).toBe(0);
});
it('keeps mention-sticky when the declared group context has threads=true', async () => {
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', engage_mode: 'mention-sticky' });
expect(row.engage_mode).toBe('mention-sticky');
});
});
describe('wirings — threads and priority columns', () => {
it('omitted --threads stores NULL (inherit declaration)', async () => {
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' });
expect(getMessagingGroupAgent(row.id as string)!.threads).toBeNull();
});
it('--threads true/false stores 1/0', async () => {
const on = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', threads: 'true' });
expect(getMessagingGroupAgent(on.id as string)!.threads).toBe(1);
const off = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1', threads: 'false' });
expect(getMessagingGroupAgent(off.id as string)!.threads).toBe(0);
});
it('rejects a non-boolean --threads value', async () => {
await expect(create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', threads: 'bogus' })).rejects.toThrow(
/--threads must be true or false/,
);
});
it('--priority is settable on create and defaults to 0', async () => {
const dflt = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
expect(dflt.priority).toBe(0);
const high = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1', priority: '5' });
expect(high.priority).toBe(5);
});
});
describe('wirings-update — same validation as create', () => {
it('rejects switching to pattern mode when no engage_pattern exists', async () => {
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' }); // sticky, no pattern
await expect(update({ id: row.id, engage_mode: 'pattern' })).rejects.toThrow(/--engage-pattern/);
});
it("rejects switching to a mention mode on a mentions:'never' channel", async () => {
const row = await create({
messaging_group_id: 'mg-never',
agent_group_id: 'ag-1',
engage_mode: 'pattern',
engage_pattern: '.',
});
await expect(update({ id: row.id, engage_mode: 'mention' })).rejects.toThrow(/mentions: 'never'/);
});
it('coerces an existing sticky wiring to mention when --threads is turned off', async () => {
const row = await create({ messaging_group_id: 'mg-group', agent_group_id: 'ag-1' }); // mention-sticky
const updated = (await update({ id: row.id, threads: 'false' })) as { engage_mode: string; threads: number };
expect(updated.threads).toBe(0);
expect(updated.engage_mode).toBe('mention');
});
it('updates threads and priority', async () => {
const row = await create({ messaging_group_id: 'mg-dm', agent_group_id: 'ag-1' });
const updated = (await update({ id: row.id, threads: 'true', priority: '3' })) as {
threads: number;
priority: number;
};
expect(updated.threads).toBe(1);
expect(updated.priority).toBe(3);
});
it('allows unrelated updates to a legacy pattern row with NULL engage_pattern', async () => {
// Rows created on main before engage_pattern defaults existed: pattern
// mode + NULL pattern, which the router evaluates as match-all.
createMessagingGroupAgent({
id: 'mga-legacy',
messaging_group_id: 'mg-stale',
agent_group_id: 'ag-1',
engage_mode: 'pattern',
engage_pattern: null,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now(),
});
const updated = (await update({ id: 'mga-legacy', priority: '5' })) as { priority: number };
expect(updated.priority).toBe(5);
// The pattern fields stay untouched — no silent backfill.
expect(getMessagingGroupAgent('mga-legacy')!.engage_pattern).toBeNull();
// But actually changing the pattern fields to an invalid combination
// still rejects.
await expect(update({ id: 'mga-legacy', engage_pattern: '' })).rejects.toThrow(/--engage-pattern/);
});
});
+86 -3
View File
@@ -1,8 +1,30 @@
import { ensureAgentDestinationForWiring } from '../../db/messaging-groups.js';
import type { MessagingGroupAgent } from '../../types.js';
import {
resolveWiringDefaults,
validateEngageAgainstChannel,
type EngageValues,
} from '../../channels/channel-defaults.js';
import { hasDeclaredChannelDefaults } from '../../channels/channel-registry.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { ensureAgentDestinationForWiring, getMessagingGroup } from '../../db/messaging-groups.js';
import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { registerResource } from '../crud.js';
import { projectDestinationsToSessions } from './destinations.js';
function requireMessagingGroup(id: unknown): MessagingGroup {
const mg = getMessagingGroup(String(id));
if (!mg) throw new Error(`messaging group not found: ${id}`);
return mg;
}
/** --threads accepts true/false (or 1/0); stored as INTEGER 1/0. Omitted =
* column NULL = inherit the channel declaration. */
function normalizeThreads(v: unknown): number {
if (v === true || v === 'true' || v === '1' || v === 1) return 1;
if (v === false || v === 'false' || v === '0' || v === 0) return 0;
throw new Error(`--threads must be true or false, got "${v}"`);
}
registerResource({
name: 'wiring',
plural: 'wirings',
@@ -28,7 +50,7 @@ registerResource({
name: 'engage_mode',
type: 'string',
description:
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.',
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex. Default: declared by the channel adapter for the target chat (DM vs group); "mention" when the channel has no declaration.',
enum: ['pattern', 'mention', 'mention-sticky'],
default: 'mention',
updatable: true,
@@ -67,9 +89,70 @@ registerResource({
default: 'shared',
updatable: true,
},
{
name: 'threads',
type: 'boolean',
description:
'Per-wiring thread override: honor platform thread ids for this wiring (per-thread sessions in groups; replies, typing, and cards land in-thread). NULL = inherit channel default. Can disable threads on a threaded platform, never enable them on a non-threaded one.',
updatable: true,
},
{
name: 'priority',
type: 'number',
description: 'Fanout order when multiple agents are wired to the same messaging group — higher priority first.',
default: 0,
updatable: true,
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
resolveDefaults: (values) => {
const mg = requireMessagingGroup(values.messaging_group_id);
if (values.threads !== undefined) values.threads = normalizeThreads(values.threads);
const channelKey = mg.instance ?? mg.channel_type;
// Undeclared (stale) channels: leave engage_mode unset so the static
// 'mention' default applies afterwards — a trunk update alone must not
// change ncl's creation defaults for adapters without a declaration.
if (values.engage_mode === undefined) {
if (hasDeclaredChannelDefaults(channelKey, mg.channel_type)) {
const ag = getAgentGroup(String(values.agent_group_id));
if (!ag) throw new Error(`agent group not found: ${values.agent_group_id}`);
const resolved = resolveWiringDefaults(channelKey, mg.is_group === 1, ag.name, mg.channel_type);
values.engage_mode = resolved.engage_mode;
if (values.engage_pattern === undefined && resolved.engage_pattern !== null) {
values.engage_pattern = resolved.engage_pattern;
}
} else {
log.warn(
`wiring create: channel '${channelKey}' has no declared defaults (adapter not installed or stale) — using legacy static defaults`,
);
}
}
validateEngageAgainstChannel(values, mg);
},
preUpdate: (updates, current) => {
const mg = requireMessagingGroup(current.messaging_group_id);
if (updates.threads !== undefined) updates.threads = normalizeThreads(updates.threads);
const merged: EngageValues = { ...current, ...updates };
// Legacy rows can be engage_mode='pattern' with a NULL pattern (the
// router treats that as match-all). Don't reject unrelated updates to
// them — only enforce the pairing when the pattern fields change.
if (
updates.engage_mode === undefined &&
updates.engage_pattern === undefined &&
merged.engage_mode === 'pattern' &&
(merged.engage_pattern === undefined || merged.engage_pattern === null)
) {
merged.engage_pattern = '.';
}
validateEngageAgainstChannel(merged, mg);
// Carry the sticky→mention coercion (if any) back into the update set.
if (merged.engage_mode !== (updates.engage_mode ?? current.engage_mode)) {
updates.engage_mode = merged.engage_mode;
}
},
postCreate: (row) => {
// Create the companion `agent_destinations` row so the agent has a
// local name it can address this chat by. Without this, the agent
+10
View File
@@ -19,7 +19,17 @@ const envConfig = readEnvFile([
'ONECLI_GATEWAY_CONTAINER',
]);
/**
* @deprecated WhatsApp adapter copies now read the ASSISTANT_NAME .env key
* directly. Re-export retained one release for stale adapter copies
* (origin/channels whatsapp.ts:42 imports it); scheduled for deletion.
*/
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
/**
* @deprecated WhatsApp adapter copies now read the ASSISTANT_HAS_OWN_NUMBER
* .env key directly. Re-export retained one release for stale adapter copies
* (origin/channels whatsapp.ts:42 imports it); scheduled for deletion.
*/
export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
+16
View File
@@ -55,6 +55,22 @@ describe('migrations', () => {
// Running again should not throw
runMigrations(db);
});
it('adds messaging_group_agents.threads as a nullable, default-free override column (019)', () => {
const db = initTestDb();
runMigrations(db);
const col = db
.prepare(
`SELECT type, "notnull", dflt_value FROM pragma_table_info('messaging_group_agents') WHERE name = 'threads'`,
)
.get() as { type: string; notnull: number; dflt_value: unknown } | undefined;
expect(col).toBeDefined();
// NULL must remain expressible (= inherit the adapter declaration) with
// no default — a backfill would freeze today's behavior into rows.
expect(col!.type).toBe('INTEGER');
expect(col!.notnull).toBe(0);
expect(col!.dflt_value).toBeNull();
});
});
// ── Agent Groups ──
+20
View File
@@ -0,0 +1,20 @@
import type { Migration } from './index.js';
/**
* Per-wiring thread-policy override on `messaging_group_agents`.
*
* NULL = inherit the channel adapter's declared default for the wiring's
* context (DM vs group); 1/0 = explicit per-wiring override, hard-ANDed with
* the adapter's raw thread capability at router fanout (resolveThreadPolicy
* in src/channels/channel-defaults.ts). Deliberately no backfill: existing
* rows stay NULL and resolve through the declaration or, for undeclared
* adapters, the behavior-faithful fallback whose threads value tracks
* supportsThreads reproducing pre-migration routing exactly.
*/
export const migration019: Migration = {
version: 19,
name: 'wiring-threads-override',
up(db) {
db.exec(`ALTER TABLE messaging_group_agents ADD COLUMN threads INTEGER;`);
},
};
+2
View File
@@ -17,6 +17,7 @@ import { migration016 } from './016-messaging-group-instance.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
import { migration018 } from './018-approvals-approver-user-id.js';
import { migration019 } from './019-wiring-threads.js';
export interface Migration {
version: number;
@@ -50,6 +51,7 @@ export const migrations: Migration[] = [
migration014,
migration015,
migration016,
migration019,
];
/** Row shape of PRAGMA foreign_key_check. Child rowids are stable across a
+3
View File
@@ -55,6 +55,9 @@ CREATE TABLE messaging_group_agents (
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
session_mode TEXT DEFAULT 'shared',
priority INTEGER DEFAULT 0,
threads INTEGER, -- NULL = inherit the channel adapter's declared
-- thread default; 1/0 = per-wiring override
-- (migration 019)
created_at TEXT NOT NULL,
UNIQUE(messaging_group_id, agent_group_id)
);
+221 -2
View File
@@ -780,8 +780,198 @@ describe('router — channel instances', () => {
});
});
describe('routing metadata preservation', () => {
describe('router — per-wiring thread policy', () => {
// Slack-like threaded adapter on a unique channel type (the registry maps
// are module-global; unique names avoid cross-test collisions).
const makeThreadedAdapter = () => ({
name: 'tp-slack',
channelType: 'tp-slack',
supportsThreads: true,
async setup() {},
async teardown() {},
isConnected: () => true,
async deliver() {
return undefined;
},
});
beforeEach(() => {
createAgentGroup({
id: 'ag-tp',
name: 'Thread Agent',
folder: 'thread-agent',
agent_provider: null,
created_at: now(),
});
createMessagingGroup({
id: 'mg-tp',
channel_type: 'tp-slack',
platform_id: 'tp:C1',
name: 'Threaded chat',
is_group: 1,
unknown_sender_policy: 'public',
created_at: now(),
});
createMessagingGroupAgent({
id: 'mga-tp',
messaging_group_id: 'mg-tp',
agent_group_id: 'ag-tp',
engage_mode: 'pattern',
engage_pattern: '.',
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now(),
});
});
async function withThreadedAdapter(fn: () => Promise<void>): Promise<void> {
const { registerChannelAdapter, initChannelAdapters, teardownChannelAdapters } =
await import('./channels/channel-registry.js');
registerChannelAdapter('tp-slack', { factory: makeThreadedAdapter });
await initChannelAdapters(() => ({
onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {},
onAction: () => {},
}));
try {
await fn();
} finally {
await teardownChannelAdapters();
}
}
const threadedEvent = (id: string): InboundEvent => ({
channelType: 'tp-slack',
platformId: 'tp:C1',
threadId: 'thread-42',
message: {
id,
kind: 'chat',
content: JSON.stringify({ sender: 'U', text: 'hi' }),
timestamp: now(),
isGroup: true,
},
});
it('NULL-threads wiring (inherit) on a threaded adapter keeps thread routing as before', async () => {
await withThreadedAdapter(async () => {
const { routeInbound } = await import('./router.js');
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
await routeInbound(threadedEvent('msg-null-threads'));
// threads=NULL inherits the (fallback) declaration → supportsThreads →
// per-thread session with the platform thread id, message addressed
// in-thread. Identical to pre-declaration routing.
const sessions = getSessionsByAgentGroup('ag-tp');
expect(sessions).toHaveLength(1);
expect(sessions[0].thread_id).toBe('thread-42');
const db = new Database(inboundDbPath('ag-tp', sessions[0].id));
const row = db.prepare('SELECT thread_id FROM messages_in').get() as { thread_id: string | null };
db.close();
expect(row.thread_id).toBe('thread-42');
});
});
it('wiring threads=0 nulls the event-derived thread for session and delivery', async () => {
getDb().prepare("UPDATE messaging_group_agents SET threads = 0 WHERE id = 'mga-tp'").run();
await withThreadedAdapter(async () => {
const { routeInbound } = await import('./router.js');
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
await routeInbound(threadedEvent('msg-opt-out'));
// Session collapses (no per-thread force, thread id stripped) and the
// reply address is top-level.
const sessions = getSessionsByAgentGroup('ag-tp');
expect(sessions).toHaveLength(1);
expect(sessions[0].thread_id).toBeNull();
const db = new Database(inboundDbPath('ag-tp', sessions[0].id));
const row = db.prepare('SELECT thread_id FROM messages_in').get() as { thread_id: string | null };
db.close();
expect(row.thread_id).toBeNull();
});
});
it('wiring threads=0 never strips replyTo (operator intent)', async () => {
getDb().prepare("UPDATE messaging_group_agents SET threads = 0 WHERE id = 'mga-tp'").run();
await withThreadedAdapter(async () => {
const { routeInbound } = await import('./router.js');
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
await routeInbound({
...threadedEvent('msg-replyto'),
replyTo: { channelType: 'cli', platformId: 'cli:operator', threadId: 'term-1' },
});
const sessions = getSessionsByAgentGroup('ag-tp');
expect(sessions).toHaveLength(1);
const db = new Database(inboundDbPath('ag-tp', sessions[0].id));
const row = db.prepare('SELECT channel_type, thread_id FROM messages_in').get() as {
channel_type: string;
thread_id: string | null;
};
db.close();
// The reply address is the operator's, thread id intact — only the
// event-derived address is policy-stripped.
expect(row.channel_type).toBe('cli');
expect(row.thread_id).toBe('term-1');
});
});
it('auto-create takes unknown_sender_policy from the declaration and falls back faithfully', async () => {
const { registerChannelAdapter } = await import('./channels/channel-registry.js');
const { routeInbound } = await import('./router.js');
const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js');
// Registration-tier declaration is enough — no live adapter needed.
registerChannelAdapter('tp-declared', {
factory: () => null,
defaults: {
dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' },
group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'public' },
mentions: 'platform',
},
});
const mention = (channelType: string, platformId: string, isGroup: boolean): InboundEvent => ({
channelType,
platformId,
threadId: null,
message: {
id: `msg-${platformId}`,
kind: 'chat',
content: JSON.stringify({ sender: 'U', text: '@bot hi' }),
timestamp: now(),
isMention: true,
isGroup,
},
});
// Declared adapter: group context reads the group declaration...
await routeInbound(mention('tp-declared', 'tp:G1', true));
expect(getMessagingGroupByPlatform('tp-declared', 'tp:G1')!.unknown_sender_policy).toBe('public');
// ...and DM context reads the dm declaration.
await routeInbound(mention('tp-declared', 'tp:D1', false));
expect(getMessagingGroupByPlatform('tp-declared', 'tp:D1')!.unknown_sender_policy).toBe('strict');
// Undeclared channel: the behavior-faithful fallback reproduces the
// historical hardcoded 'request_approval'.
await routeInbound(mention('tp-undeclared', 'tp:U1', true));
expect(getMessagingGroupByPlatform('tp-undeclared', 'tp:U1')!.unknown_sender_policy).toBe('request_approval');
});
});
describe('routing metadata preservation', () => {
beforeEach(async () => {
createAgentGroup({
id: 'ag-1',
name: 'Test Agent',
@@ -810,6 +1000,34 @@ describe('routing metadata preservation', () => {
priority: 0,
created_at: now(),
});
// A live threaded adapter, matching real Discord routing — inbound
// platform events always have their receiving adapter live, and the
// per-wiring thread policy hard-ANDs the live capability.
const { registerChannelAdapter, initChannelAdapters } = await import('./channels/channel-registry.js');
registerChannelAdapter('discord', {
factory: () => ({
name: 'discord',
channelType: 'discord',
supportsThreads: true,
async setup() {},
async teardown() {},
isConnected: () => true,
async deliver() {
return undefined;
},
}),
});
await initChannelAdapters(() => ({
onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {},
onAction: () => {},
}));
});
afterEach(async () => {
const { teardownChannelAdapters } = await import('./channels/channel-registry.js');
await teardownChannelAdapters();
});
it('routed message carries platformId, channelType, threadId on the messages_in row', async () => {
@@ -822,7 +1040,8 @@ describe('routing metadata preservation', () => {
message: { id: 'msg-r1', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'hi' }), timestamp: now() },
});
const session = findSession('mg-1', null);
// Threaded adapter in a group chat forces a per-thread session.
const session = findSession('mg-1', 'thread-42');
const db = new Database(inboundDbPath('ag-1', session!.id));
const row = db
.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?')
@@ -19,9 +19,23 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createMessagingGroup, getMessagingGroupByPlatform } from '../../db/messaging-groups.js';
import { registerChannelAdapter } from '../../channels/channel-registry.js';
import type { ChannelDefaults } from '../../channels/adapter.js';
import { upsertUser } from './db/users.js';
import { grantRole } from './db/user-roles.js';
// Registration-tier declaration for the fixture channel — a threaded platform
// whose declared defaults match the historical card-flow behavior
// (mention-sticky groups, pattern '.' DMs). Without it, the no-live-adapter
// fallback resolves threads=false and coerces sticky → mention.
// Registry maps are module-global; keep channel names unique per test file.
const telegramDefaults: ChannelDefaults = {
dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' },
group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' },
mentions: 'platform',
};
registerChannelAdapter('telegram', { factory: () => null, defaults: telegramDefaults });
// Mock container runner — prevent actual docker spawn.
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
@@ -113,13 +127,14 @@ function groupMention(platformId: string, text = '@bot hello') {
return {
channelType: 'telegram',
platformId,
threadId: 'thread-1', // non-null → is_group=true per channel-approval default-picker logic
threadId: 'thread-1',
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text }),
timestamp: now(),
isMention: true,
isGroup: true, // group context comes from the adapter flag, never threadId
},
};
}
@@ -153,6 +168,8 @@ describe('unknown-channel registration flow', () => {
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
// Card tells the approver the resolved engage rule.
expect(payload.question).toContain('will respond to @-mentions in this group');
// Single-agent card offers a direct "Connect to <name>" button.
const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
expect(connectOption).toBeDefined();
@@ -171,6 +188,8 @@ describe('unknown-channel registration flow', () => {
await new Promise((r) => setTimeout(r, 10));
expect(deliverMock).toHaveBeenCalledTimes(1);
const payload = JSON.parse(deliverMock.mock.calls[0][4] as string) as { question: string };
expect(payload.question).toContain('will respond to all messages');
const { getDb } = await import('../../db/connection.js');
const count = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }).c;
expect(count).toBe(1);
@@ -228,7 +247,7 @@ describe('unknown-channel registration flow', () => {
agent_group_id: string;
};
expect(mga).toBeDefined();
expect(mga.engage_mode).toBe('mention-sticky'); // group (threadId != null)
expect(mga.engage_mode).toBe('mention-sticky'); // declared group default (threads:true keeps sticky)
expect(mga.engage_pattern).toBeNull();
expect(mga.sender_scope).toBe('known');
expect(mga.ignored_message_policy).toBe('accumulate');
@@ -279,6 +298,135 @@ describe('unknown-channel registration flow', () => {
expect(mga.engage_pattern).toBe('.');
});
// WhatsApp-like platform: groups exist but thread ids don't (threadId is
// always null), and the adapter is undeclared (stale skill-installed copy)
// so resolution goes through the behavior-faithful fallback. This is the
// one deliberate behavior change of the defaults work: the old
// `threadId !== null` heuristic misread these groups as DMs and wired
// pattern '.'.
function waGroupMention(platformId: string) {
return {
channelType: 'wamock',
platformId,
threadId: null,
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'caller', senderName: 'Caller', text: '@bot hi' }),
timestamp: now(),
isMention: true,
isGroup: true,
},
};
}
async function approvePending(agentGroupId = 'ag-1') {
const { getResponseHandlers } = await import('../../response-registry.js');
const { getDb } = await import('../../db/connection.js');
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
messaging_group_id: string;
};
expect(pending).toBeDefined();
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: `connect:${agentGroupId}`,
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
return pending.messaging_group_id;
}
it('non-threaded group (isGroup flag, null threadId) wires the GROUP default, sticky coerced to mention', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound(waGroupMention('wa-group-1'));
await new Promise((r) => setTimeout(r, 10));
const mgId = await approvePending();
const { getDb } = await import('../../db/connection.js');
const mga = getDb()
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(mgId) as { engage_mode: string; engage_pattern: string | null };
// Faithful fallback group default is mention-sticky, but with no live
// adapter threads resolve false → coerced to plain mention. NOT the old
// pattern '.' DM misclassification.
expect(mga.engage_mode).toBe('mention');
expect(mga.engage_pattern).toBeNull();
});
it('DM on an undeclared channel stays pattern "." through the faithful fallback', async () => {
const { routeInbound } = await import('../../router.js');
await routeInbound({
...waGroupMention('wa-dm-1'),
message: { ...waGroupMention('wa-dm-1').message, isGroup: false },
});
await new Promise((r) => setTimeout(r, 10));
const mgId = await approvePending();
const { getDb } = await import('../../db/connection.js');
const mga = getDb()
.prepare('SELECT engage_mode, engage_pattern FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(mgId) as { engage_mode: string; engage_pattern: string | null };
expect(mga.engage_mode).toBe('pattern');
expect(mga.engage_pattern).toBe('.');
});
it('connect-existing and new-agent approve paths produce identical wirings', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
const { getDb } = await import('../../db/connection.js');
// Path 1: connect to existing agent.
await routeInbound(groupMention('chat-path-connect'));
await new Promise((r) => setTimeout(r, 10));
const mgIdConnect = await approvePending();
// Path 2: new agent via free-text name reply.
await routeInbound(groupMention('chat-path-newagent'));
await new Promise((r) => setTimeout(r, 10));
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
messaging_group_id: string;
};
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'new_agent',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
if (claimed) break;
}
// Owner replies with the agent name in their DM — interceptor wires.
await routeInbound({
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
message: {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat' as const,
content: JSON.stringify({ senderId: 'owner', text: 'Bravo' }),
timestamp: now(),
},
});
await new Promise((r) => setTimeout(r, 10));
const select =
'SELECT engage_mode, engage_pattern, sender_scope, ignored_message_policy, session_mode, priority ' +
'FROM messaging_group_agents WHERE messaging_group_id = ?';
const viaConnect = getDb().prepare(select).get(mgIdConnect);
const viaNewAgent = getDb().prepare(select).get(pending.messaging_group_id);
expect(viaNewAgent).toBeDefined();
expect(viaNewAgent).toEqual(viaConnect);
});
it('deny → sets denied_at; future mentions drop silently without a second card', async () => {
const { routeInbound } = await import('../../router.js');
const { getResponseHandlers } = await import('../../response-registry.js');
+44 -7
View File
@@ -18,8 +18,8 @@
* so it can be re-routed on connect/create.
*
* On connect (handler in index.ts):
* - Create `messaging_group_agents` with defaults
* (mention-sticky for groups / pattern='.' for DMs,
* - Create `messaging_group_agents` with the channel's declared engage
* defaults (resolveWiringDefaults, DM vs group context;
* sender_scope='known', ignored_message_policy='accumulate')
* - Add the triggering sender to `agent_group_members` so sender_scope
* doesn't bounce the replayed message into a sender-approval cascade
@@ -45,6 +45,7 @@
* - Delivery adapter missing.
*/
import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
import { resolveWiringDefaults } from '../../channels/channel-defaults.js';
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
import { getChannelAdapter } from '../../channels/channel-registry.js';
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
@@ -121,13 +122,39 @@ function buildQuestionText(
senderName: string | undefined,
channelName: string | null,
channelType: string,
ruleNote: string | null,
): string {
const who = senderName ?? 'Someone';
const note = ruleNote ? ` If connected, the agent ${ruleNote}.` : '';
if (isGroup) {
const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`;
return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`;
return `${who} mentioned your bot in ${where}.${note} How would you like to handle this channel?`;
}
return `${who} sent your bot a DM on ${channelType}.${note} How would you like to handle it?`;
}
/**
* Human summary of the engage rule an approval would create, from the same
* resolution the wire step uses. Null when the declaration is unresolvable
* (mis-declared pattern mode) the card still works without the preview.
*/
function describeResolvedRule(
channelKey: string,
isGroup: boolean,
agentGroupName: string,
channelType: string,
): string | null {
try {
const engage = resolveWiringDefaults(channelKey, isGroup, agentGroupName, channelType);
if (engage.engage_mode !== 'pattern') {
return isGroup ? 'will respond to @-mentions in this group' : 'will respond to @-mentions';
}
return engage.engage_pattern === '.'
? 'will respond to all messages'
: `will respond to messages matching ${engage.engage_pattern}`;
} catch {
return null;
}
return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`;
}
// ── Main flow ──
@@ -168,9 +195,10 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? '';
// Resolve channel name if not yet persisted.
// Resolve channel name if not yet persisted. Key by instance so a named
// instance's own adapter (and bot identity) does the lookup.
if (originMg && !originMg.name) {
const channelAdapter = getChannelAdapter(originChannelType);
const channelAdapter = getChannelAdapter(originMg.instance ?? originMg.channel_type);
if (channelAdapter?.resolveChannelName) {
try {
const name = await channelAdapter.resolveChannelName(originMg.platform_id);
@@ -205,7 +233,16 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
const channelName = originMg?.name ?? null;
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
// Preview the engage rule an approval would create. The reference group's
// name only feeds {name} pattern substitution — a best-effort preview when
// the approver ends up picking a different agent.
const ruleNote = describeResolvedRule(
originMg?.instance ?? originChannelType,
isGroup,
referenceGroup.name,
originChannelType,
);
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType, ruleNote);
const options = normalizeOptions(buildApprovalOptions(agentGroups, delivery.userId));
createPendingChannelApproval({
+108 -116
View File
@@ -17,7 +17,8 @@
*/
import { recordDroppedMessage } from '../../db/dropped-messages.js';
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import { createMessagingGroupAgent, getMessagingGroup, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import { resolveWiringDefaults } from '../../channels/channel-defaults.js';
import {
routeInbound,
setAccessGate,
@@ -47,6 +48,7 @@ import {
deletePendingChannelApproval,
getPendingChannelApproval,
updatePendingChannelApprovalCard,
type PendingChannelApproval,
} from './db/pending-channel-approvals.js';
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js';
@@ -293,6 +295,104 @@ setChannelRequestGate(async (mg, event) => {
await requestChannelApproval({ messagingGroupId: mg.id, event });
});
/**
* Wire an approved channel to an agent group and replay the stored event.
* Shared by both approve paths (connect-existing button, free-text new-agent
* name reply) so they produce identical wirings. Returns true when the wiring
* was created callers must not confirm success to the approver otherwise.
*
* Engage defaults come from the channel's declared defaults (DM vs group
* context). isGroup uses the adapter's own flag with the persisted row as
* fallback never `threadId !== null` (DM sub-threads exist on Slack/Discord;
* non-threaded group platforms like WhatsApp have null threadIds in groups).
*/
async function wireApprovedChannel(
row: PendingChannelApproval,
agentGroupId: string,
approverId: string,
): Promise<boolean> {
let event: InboundEvent;
try {
event = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return false;
}
const mg = getMessagingGroup(row.messaging_group_id);
const isGroup = event.message.isGroup ?? mg?.is_group === 1;
const agentGroupName = getAgentGroup(agentGroupId)?.name ?? '';
let engage: { engage_mode: MessagingGroupAgent['engage_mode']; engage_pattern: string | null };
try {
engage = resolveWiringDefaults(
mg?.instance ?? mg?.channel_type ?? event.channelType,
isGroup,
agentGroupName,
mg?.channel_type ?? event.channelType,
);
} catch (err) {
// Mis-declared adapter (pattern mode without a pattern). Drop the pending
// row so a future mention can retry once the declaration is fixed.
log.error('Channel registration: channel defaults unresolvable', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return false;
}
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: agentGroupId,
engage_mode: engage.engage_mode,
engage_pattern: engage.engage_pattern,
// Deliberate card-flow choices, not channel defaults: the triggering
// sender is auto-admitted below, so 'known' keeps other strangers gated;
// 'accumulate' / 'shared' / priority 0 are the flow's fixed semantics.
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId,
mgaId,
engageMode: engage.engage_mode,
approverId,
});
const senderUserId = extractAndUpsertUser(event);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: agentGroupId,
added_by: approverId,
added_at: new Date().toISOString(),
});
}
deletePendingChannelApproval(row.messaging_group_id);
try {
await routeInbound(event);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
return true;
}
/**
* Response handler for the unknown-channel registration card.
*
@@ -455,63 +555,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
}
// ── Wire + replay (shared path for connect and create) ──
let event: InboundEvent;
try {
event = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
const isGroup = event.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: targetAgentGroupId,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: targetAgentGroupId,
mgaId,
engageMode,
approverId,
});
const senderUserId = extractAndUpsertUser(event);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: targetAgentGroupId,
added_by: approverId,
added_at: new Date().toISOString(),
});
}
deletePendingChannelApproval(row.messaging_group_id);
try {
await routeInbound(event);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
await wireApprovedChannel(row, targetAgentGroupId, approverId);
return true;
}
@@ -555,63 +599,7 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
folder: ag.folder,
});
let originalEvent: InboundEvent;
try {
originalEvent = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
const isGroup = originalEvent.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: ag.id,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: ag.id,
mgaId,
engageMode,
approverId: userId,
});
const senderUserId = extractAndUpsertUser(originalEvent);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: ag.id,
added_by: userId,
added_at: new Date().toISOString(),
});
}
deletePendingChannelApproval(row.messaging_group_id);
try {
await routeInbound(originalEvent);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
const wired = await wireApprovedChannel(row, ag.id, userId);
const adapter = getDeliveryAdapter();
if (adapter) {
@@ -623,7 +611,11 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
dm.platform_id,
null,
'chat-sdk',
JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
JSON.stringify({
text: wired
? `✅ Agent "${ag.name}" created and connected.`
: `⚠️ Agent "${ag.name}" was created but the channel couldn't be connected — check the host logs.`,
}),
)
.catch(() => {});
}
+4
View File
@@ -90,6 +90,10 @@ export async function ensureUserDm(userId: string): Promise<MessagingGroup | nul
platform_id: dmPlatformId,
name: user.display_name,
is_group: 0,
// Deliberately 'strict', NOT the channel's declared DM policy: this row
// backs a host-initiated DM to a known privileged user (approver,
// admin). Consulting the declaration would let a 'public' DM
// declaration open the approval-delivery channel to strangers.
unknown_sender_policy: 'strict',
created_at: now,
};
+61 -23
View File
@@ -17,7 +17,8 @@
* drops (no agent wired, no trigger match); the access gate writes rows
* for policy refusals.
*/
import { getChannelAdapter } from './channels/channel-registry.js';
import { getChannelAdapter, getChannelDefaults } from './channels/channel-registry.js';
import { resolveThreadPolicy, resolveUnknownSenderPolicy } from './channels/channel-defaults.js';
import { gateCommand } from './command-gate.js';
import { getAgentGroup } from './db/agent-groups.js';
import { recordDroppedMessage } from './db/dropped-messages.js';
@@ -208,7 +209,15 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
instance: event.instance ?? event.channelType,
name: null,
is_group: event.message.isGroup ? 1 : 0,
unknown_sender_policy: 'request_approval',
// Policy from the receiving channel's declared defaults (DM vs group
// context); undeclared adapters resolve through the behavior-faithful
// fallback, which is 'request_approval' in both contexts — identical
// to the historical hardcode.
unknown_sender_policy: resolveUnknownSenderPolicy(
event.instance ?? event.channelType,
event.message.isGroup === true,
event.channelType,
),
denied_at: null,
created_at: new Date().toISOString(),
};
@@ -289,6 +298,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
const parsed = safeParseContent(event.message.content);
const messageText = parsed.text ?? '';
// Per-wiring thread policy inputs, resolved once per event. Each wiring's
// threads override (NULL = inherit) resolves against the channel's declared
// defaults, hard-bounded by the live adapter's raw capability. Undeclared
// adapters resolve through the behavior-faithful fallback, so a NULL-threads
// wiring reproduces the historical supportsThreads-derived routing exactly.
const channelDefaults = getChannelDefaults(mg.instance ?? mg.channel_type, mg.channel_type);
const supportsThreads = adapter?.supportsThreads === true;
let engagedCount = 0;
let accumulatedCount = 0;
let subscribed = false;
@@ -297,33 +314,49 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
const agentGroup = getAgentGroup(agent.agent_group_id);
if (!agentGroup) continue;
const engages = evaluateEngage(agent, messageText, isMention, mg, event.threadId);
// Effective thread id for THIS wiring: the event-derived address is
// policy-stripped when the wiring (or its channel declaration) opts out
// of threads. event.replyTo is operator intent from the CLI admin
// transport and is never nulled. Guard: platform thread ids must never
// collide with the reserved 'system:%' session namespace
// (src/db/sessions.ts) — they are platform-native identifiers, and this
// is the only place an inbound thread id enters session resolution.
const threadsEnabled = resolveThreadPolicy(
agent.threads ?? null,
channelDefaults,
mg.is_group === 1,
supportsThreads,
);
const effectiveThreadId = threadsEnabled ? event.threadId : null;
const engages = evaluateEngage(agent, messageText, isMention, mg, effectiveThreadId);
const accessOk = engages && (!accessGate || accessGate(event, userId, mg, agent.agent_group_id).allowed);
const scopeOk = engages && (!senderScopeGate || senderScopeGate(event, userId, mg, agent).allowed);
if (engages && accessOk && scopeOk) {
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, true);
await deliverToAgent(agent, agentGroup, mg, event, userId, threadsEnabled, effectiveThreadId, true);
engagedCount++;
// Mention-sticky: ask the adapter to subscribe the thread so the
// platform's subscribed-message path carries follow-ups without
// requiring another @mention. Threaded-adapter only; DMs and
// non-threaded platforms skip.
// requiring another @mention. Uses this wiring's OWN effective thread
// id — a non-null value already implies the adapter supports threads
// (resolveThreadPolicy hard-ANDs the capability). DMs, non-threaded
// platforms, and thread-opted-out wirings skip.
if (
!subscribed &&
agent.engage_mode === 'mention-sticky' &&
adapter?.supportsThreads &&
adapter.subscribe &&
event.threadId !== null &&
adapter?.subscribe &&
effectiveThreadId !== null &&
mg.is_group !== 0
) {
subscribed = true;
// Fire-and-forget — subscribe is platform-side bookkeeping and
// shouldn't block message routing. Errors are logged inside the
// adapter (or by the promise rejection handler below).
void adapter.subscribe(event.platformId, event.threadId).catch((err) => {
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err });
void adapter.subscribe(event.platformId, effectiveThreadId).catch((err) => {
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: effectiveThreadId, err });
});
}
} else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) {
@@ -334,7 +367,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
// message (which also stages their attachments to disk via
// writeSessionMessage → extractAttachmentFiles) is exactly what the
// gate is meant to prevent.
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
await deliverToAgent(agent, agentGroup, mg, event, userId, threadsEnabled, effectiveThreadId, false);
accumulatedCount++;
} else {
log.debug('Message not engaged for agent (drop policy)', {
@@ -419,28 +452,33 @@ async function deliverToAgent(
mg: MessagingGroup,
event: InboundEvent,
userId: string | null,
adapterSupportsThreads: boolean,
threadsEnabled: boolean,
effectiveThreadId: string | null,
wake: boolean,
): Promise<void> {
// Apply the adapter thread policy: threaded adapter in a group chat →
// per-thread session regardless of wiring. agent-shared preserved (it's
// a cross-channel directive the adapter doesn't know about). DMs collapse
// sub-threads to one session (is_group=0 short-circuit).
// Apply the resolved thread policy (wiring override AND channel declaration
// AND adapter capability — resolveThreadPolicy at fanout): thread-enabled
// wiring in a group chat → per-thread session regardless of wiring
// session_mode. agent-shared preserved (it's a cross-channel directive the
// adapter doesn't know about). DMs collapse sub-threads to one session
// (is_group=0 short-circuit).
let effectiveSessionMode = agent.session_mode;
if (adapterSupportsThreads && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) {
if (threadsEnabled && effectiveSessionMode !== 'agent-shared' && mg.is_group !== 0) {
effectiveSessionMode = 'per-thread';
}
const { session, created } = resolveSession(agent.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
const { session, created } = resolveSession(agent.agent_group_id, mg.id, effectiveThreadId, effectiveSessionMode);
// The inbound row's (channel_type, platform_id, thread_id) is the address
// the agent's reply will be delivered to. Normally it mirrors the source
// (stamped from the event). When the caller supplied `replyTo` (CLI admin
// transport acting on operator intent), the reply is redirected there.
// (stamped from the event, with the wiring's thread policy applied). When
// the caller supplied `replyTo` (CLI admin transport acting on operator
// intent), the reply is redirected there — replyTo is exempt from
// thread-policy stripping.
const deliveryAddr = event.replyTo ?? {
channelType: event.channelType,
platformId: event.platformId,
threadId: event.threadId,
threadId: effectiveThreadId,
};
// Command gate: classify slash commands before they reach the container.
@@ -497,7 +535,7 @@ async function deliverToAgent(
session.agent_group_id,
event.channelType,
event.platformId,
event.threadId,
effectiveThreadId,
mg.instance,
);
const freshSession = getSession(session.id);
+9
View File
@@ -124,6 +124,15 @@ export interface MessagingGroupAgent {
ignored_message_policy: IgnoredMessagePolicy;
session_mode: 'shared' | 'per-thread' | 'agent-shared';
priority: number;
/**
* Per-wiring thread-policy override (migration 019). NULL = inherit the
* channel adapter's declared default for the wiring's context (DM vs
* group); 1/0 = explicit override, hard-ANDed with the adapter's raw
* capability at router fanout (resolveThreadPolicy). Optional on the TS
* type per the denied_at convention so pre-migration fixtures don't need
* updating.
*/
threads?: number | null;
created_at: string;
}