Compare commits

..

44 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
gavrielc e3bd22f96f style: prettier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:11:38 +03:00
gavrielc c2e5b14bf8 fix: ISO storage + local-time display for all timestamps
Storage: every JS-side datetime('now') write becomes new Date().toISOString()
(messages_out all writers, processing_ack all stamps, delivered, retry
backoff, roles/members/destinations, skill SQL via strftime ISO-Z). Dormant
deliver_after raw string compares wrapped in datetime().

Display: ncl human output renders ISO instants as local "YYYY-MM-DD HH:mm"
(host + container renderers; --json stays ISO; the stamp shape round-trips
through parseZonedToUtc). Task run logs, conversation archive filenames,
clidash, the dashboard pusher, and the uninstall backup stamp all render
local; instruction texts updated in lockstep. CLAUDE.md documents the
convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:06:33 +03:00
github-actions[bot] 1ccef326dc chore: bump version to 2.1.43 2026-07-10 17:17:24 +00:00
gavrielc 39e7604526 Merge pull request #3005 from nanocoai/fix/task-timestamp-iso
fix: stamp task rows with ISO timestamps
2026-07-10 20:17:10 +03:00
gavrielc c0e591f2fb fix: stamp task rows with ISO timestamps
insertTaskRow used SQL datetime('now') — a naive-UTC stamp the container
formatter parses as local time, rendering <task time> hours off from the
ISO-Z chat timestamps beside it. Stamp ISO like every other messages_in
writer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 19:54:35 +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
github-actions[bot] 14f528c895 docs: update token count to 224k tokens · 112% of context window 2026-07-10 06:42:50 +00:00
github-actions[bot] 366c5f06f1 chore: bump version to 2.1.42 2026-07-10 06:42:46 +00:00
glifocat 077ce6fd9d Merge pull request #2416 from glifocat/fix/ncl-create-handlers
fix(cli): provision companion rows on `ncl groups create` and `ncl wirings create`
2026-07-10 08:42:32 +02:00
github-actions[bot] 0c0f4c2592 docs: update token count to 223k tokens · 111% of context window 2026-07-09 08:24:27 +00:00
github-actions[bot] 92635f1934 chore: bump version to 2.1.41 2026-07-09 08:24:22 +00:00
gavrielc f7a43ef881 Merge pull request #2981 from nanocoai/tasks/02-ncl-tasks-core
Scheduled tasks: ncl tasks control plane, isolated sessions, script gate
2026-07-09 11:24:07 +03:00
omri-maya 5459925c65 Apply suggestions from code review
Co-authored-by: omri-maya <omri@nanoco.ai>
2026-07-09 11:23:08 +03:00
Omri Maya b0c76ce4c5 feat(tasks): ncl tasks control plane — isolated per-series sessions, strict verb contract, script gate
Scheduled tasks move from MCP tools to a first-class ncl resource:
list / get / create / update / cancel / pause / resume / run / append-log,
with args declared on every verb (strict validation, fix-carrying errors)
and deep per-verb help with executable examples.

A series is CronJob-like: the live (pending/paused) row is the next
occurrence, completed rows are its run history. tasks list reads as a
compact run-history table, server-rendered so the container agent prints
the same aligned table. Short named ids (<slug>-<hex>) are
filesystem/thread-safe and copy-pasteable. Agents keep a per-series work
journal: the run log at tasks/<series>.md (append-log + auto-logged
final text).

Each series runs in its own isolated system session
(system:tasks:<series>); the live-task cap is dropped — isolation
replaces throttling. Spent task sessions are GC'd by the sweep.

--script on a task runs a bash gate BEFORE the agent wakes. It prints a
JSON verdict as its last stdout line: {"wakeAgent": bool, "data": {...}}.
wakeAgent:false handles the occurrence without waking the agent; data is
threaded into the fire's prompt, so a gate can fetch/inspect and hand the
agent exactly what changed. Failing scripts back off exponentially
(2·2^(n-1) min, cap 60) and auto-pause the series after 8 consecutive
failures with a host-written run-log note; manual runs are excluded from
the streak.

BREAKING: the schedule_task / list_tasks / update_task / cancel_task /
pause_task / resume_task MCP tools are removed. Agents schedule via
ncl tasks (available under cli_scope=group). Existing task rows keep
firing — storage (messages_in kind='task') is unchanged; only the
management surface moved. Migration: docs/ncl-tasks-migration.md.
Review round (gavrielc):
- Recurrence frequency guard: create/update refuse a recurrence more
  frequent than 4 fires/day with a warning steering to gate scripts;
  --dangerously-override-recurrence-limit bypasses after explicit user
  confirmation. Counted over the next 24h in the instance TZ. Guarded on
  update too, so create-slow-then-update cannot sneak past.
- ncl tasks help scripts: resource help topics (new helpTopics seam on
  registerResource) carry the full gate-script guide — contract, examples,
  state, failure/backoff semantics, testing directions.
- Agent-facing scheduling instructions slimmed: no recurring-frequency
  prose in the fragment; one pointer to the help topic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:04:45 +03:00
glifocat 1985fd6a5e fix(cli): live-refresh destinations on ncl wirings create
Closes the restart-required behavior: wirings-create's postCreate wrote
the central agent_destinations row but never projected it into running
sessions' inbound.db, so an agent with a live container dropped every
reply to the newly-wired chat as "unknown destination" until a group
restart (observed on a live instance). `ncl destinations add` already
projects for exactly this reason; wirings create now has parity.

postCreate is sync and runs inside the central-DB transaction, so it's
the wrong place for an async, cross-DB (session inbound.db) side effect.

- crud.ts: add a postCommit hook that runs after the create transaction
  commits; correct the "pure DB writes" comment.
- resources/destinations.ts: export projectDestinationsToSessions so
  wirings.ts reuses the exact same projection as destinations add/remove.
- resources/wirings.ts: project the new destination to live sessions in
  postCommit.
- crud.test.ts: cover projection with and without a running session.
2026-07-04 01:44:12 +00:00
glifocat 0820d39424 fix(cli): wrap INSERT + postCreate in db.transaction()
genericCreate previously ran the parent INSERT and the postCreate hook
as two independent statements. If postCreate throws, the parent row is
left behind without its companion — the exact partial-state class this
PR exists to close (#2415, #2389).

Wrap both in a single better-sqlite3 db.transaction() so a postCreate
throw rolls the whole thing back. postCreate is typed `(row) => void`
and the two registered hooks (container_configs insert on groups,
agent_destinations insert on wirings) are pure sync DB writes, so the
sync transaction wrapper is the right shape.
2026-07-04 01:42:29 +00:00
glifocat c752f9c065 test(cli): cover postCreate hook for groups/wirings create
Locks down both regressions: `groups-create` must result in a
`container_configs` row, `wirings-create` must result in an
`agent_destinations` row. Without these, a future refactor of
`genericCreate` that drops the `postCreate` call would silently reopen
issues #2415 and #2389.

Also drops the speculative `Promise<void>` return type on `postCreate`.
The current side effects are sync, and keeping it sync means a future
better-sqlite3 transaction wrapper around INSERT + postCreate stays
viable. If we ever need async, narrow → wide is a cheap change.
2026-07-04 01:42:29 +00:00
glifocat 0e73e4a782 fix(cli): provision companion rows on ncl groups create and ncl wirings create
`ncl groups create` only inserted into `agent_groups`, leaving no
`container_configs` row — so the first spawn for that group threw
"Container config not found" until the next host restart kicked in the
backfill.

`ncl wirings create` only inserted into `messaging_group_agents`, leaving
no companion `agent_destinations` row — so the agent could receive
messages on the wired channel but its replies got silently dropped by the
delivery ACL.

Both bugs share the same shape: the resources are declared in
`src/cli/resources/*.ts` with `create: 'approval'` and no custom
handler, so creation falls through to `genericCreate` in
`src/cli/crud.ts`, which is a raw INSERT. The domain functions
(`initGroupFilesystem`, `createMessagingGroupAgent`) wire the companion
rows, but the generic path skips them.

This adds a `postCreate` hook to `ResourceDef`. `genericCreate` calls it
after the INSERT with the values that were written, so generated fields
(`id`, `created_at`) are populated. `groups.ts` uses it to call
`initGroupFilesystem`; `wirings.ts` uses it to create the destination row.

The destination logic was extracted from `createMessagingGroupAgent`
into a new exported `ensureAgentDestinationForWiring`, so the wirings
hook doesn't have to re-INSERT the wiring row to get the destination
side effect.

Closes #2415
Closes #2389
2026-07-04 01:42:29 +00:00
140 changed files with 6019 additions and 2426 deletions
@@ -18,6 +18,12 @@ function normTs(ts) {
return `${ts.replace(' ', 'T')}Z`;
}
// Local calendar day "YYYY-MM-DD" — chart labels are read by a human, so
// bucket by the server's local day, not the UTC date prefix.
function localDay(date) {
return date.toLocaleDateString('sv-SE');
}
function readTable(dbPath, table) {
let db;
try {
@@ -28,7 +34,7 @@ function readTable(dbPath, table) {
for (const r of rows) {
const ts = normTs(r.timestamp);
if (!ts) continue;
const day = ts.slice(0, 10); // ISO date prefix
const day = localDay(new Date(ts));
byDay.set(day, (byDay.get(day) ?? 0) + 1);
if (last === null || ts > last) last = ts;
}
@@ -52,12 +58,12 @@ function listDirs(path) {
* Aggregate message activity across all session DBs under `sessionsRoot`.
* @returns {{ sessions: Array, series: Array<{date,in,out}> }}
* sessions — per session: { agent_group_id, session_id, in, out, lastActivity }
* series — one bucket per day for the last `days` days (UTC, newest last)
* series — one bucket per day for the last `days` days (local time, newest last)
*/
export function collectActivity(sessionsRoot, days, now) {
const dates = [];
for (let i = days - 1; i >= 0; i--) {
dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10));
dates.push(localDay(new Date(now.getTime() - i * 86_400_000)));
}
const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }]));
const sessions = [];
@@ -71,6 +71,14 @@ function icon(name) {
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
// Local wall time "YYYY-MM-DD HH:mm" — the raw ISO string is UTC; slicing it
// would display UTC wall time with no marker, masquerading as local.
function absTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
}
function relTime(iso) {
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return iso;
@@ -127,7 +135,7 @@ function cellFor(value) {
if (f.cls === 'null') return el('td', { class: 'null' }, 'null');
if (f.iso) {
return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [
relTime(f.iso), el('span', { class: 'abs' }, f.iso.slice(0, 16).replace('T', ' ')),
relTime(f.iso), el('span', { class: 'abs' }, absTime(f.iso)),
]));
}
if (f.text.length > 42) {
@@ -142,7 +150,7 @@ function kvRows(obj) {
return Object.entries(obj ?? {}).map(([k, v]) => {
let valEl;
if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2));
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${v.slice(0, 16).replace('T', ' ')})`);
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${absTime(v)})`);
else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null');
else valEl = el('span', {}, String(v));
return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]);
@@ -11,9 +11,14 @@ before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-'));
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); };
const today = new Date().toISOString().slice(0, 10);
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]);
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]);
// Seed with instants moments ago: they land in the LOCAL-today bucket
// (series.at(-1)) regardless of the machine's timezone.
const now = Date.now();
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [
new Date(now - 120_000).toISOString(),
new Date(now - 60_000).toISOString(),
]);
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [new Date(now - 90_000).toISOString()]);
});
after(() => rmSync(root, { recursive: true, force: true }));
@@ -45,20 +45,30 @@ test('collectActivity: per-session in/out totals + last activity', () => {
assert.equal(s2.out, 0);
});
// Buckets are LOCAL calendar days — derive expectations with the same
// mapping so the assertions hold in any machine timezone.
const day = (t) => new Date(t).toLocaleDateString('sv-SE');
test('collectActivity: series has one bucket per day for `days`, newest last', () => {
const { series } = collectActivity(root, 14, NOW);
assert.equal(series.length, 14);
assert.equal(series[0].date, '2026-06-01');
assert.equal(series[13].date, '2026-06-14');
assert.equal(series[0].date, day(new Date(NOW.getTime() - 13 * 86_400_000)));
assert.equal(series[13].date, day(NOW));
});
test('collectActivity: counts land in the right day buckets', () => {
const { series } = collectActivity(root, 14, NOW);
const byDate = Object.fromEntries(series.map((d) => [d.date, d]));
assert.equal(byDate['2026-06-14'].in, 2);
assert.equal(byDate['2026-06-14'].out, 2);
assert.equal(byDate['2026-06-13'].in, 1);
assert.equal(byDate['2026-06-13'].out, 0);
const expIn = {};
const expOut = {};
for (const t of ['2026-06-14T09:01:23Z', '2026-06-14T10:00:00Z', '2026-06-13T08:00:00Z']) {
expIn[day(t)] = (expIn[day(t)] ?? 0) + 1;
}
for (const t of ['2026-06-14T09:05:00Z', '2026-06-14T10:05:00Z']) {
expOut[day(t)] = (expOut[day(t)] ?? 0) + 1;
}
for (const [d, n] of Object.entries(expIn)) assert.equal(byDate[d].in, n);
for (const [d, n] of Object.entries(expOut)) assert.equal(byDate[d].out, n);
});
test('collectActivity: messages outside the window are counted in totals but not the series', () => {
@@ -427,12 +427,20 @@ function collectContextWindows() {
return results;
}
// "YYYY-MM-DDTHH" in the host's local time — the chart's hour labels are read
// by a human, so bucket by local hour, not UTC. sv-SE renders "YYYY-MM-DD HH".
function localHourKey(d: Date): string {
return d
.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false })
.replace(' ', 'T');
}
function collectActivity() {
const now = Date.now();
const buckets: Record<string, { inbound: number; outbound: number }> = {};
for (let i = 0; i < 24; i++) {
const key = new Date(now - i * 3600000).toISOString().slice(0, 13);
const key = localHourKey(new Date(now - i * 3600000));
buckets[key] = { inbound: 0, outbound: 0 };
}
@@ -453,7 +461,7 @@ function collectActivity() {
const table = direction === 'outbound' ? 'messages_out' : 'messages_in';
const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string }[];
for (const row of rows) {
const key = row.timestamp.slice(0, 13);
const key = localHourKey(new Date(row.timestamp));
if (buckets[key]) buckets[key][direction]++;
}
db.close();
+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.
+1 -1
View File
@@ -18,7 +18,7 @@ There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https:
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = datetime('now') \
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '<group-id>';"
```
+2 -2
View File
@@ -168,11 +168,11 @@ HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
+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>', datetime('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, datetime('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>', datetime('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`
+1 -1
View File
@@ -26,7 +26,7 @@ GROUP_ID='<group-id>'
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '$GROUP_ID';"
```
+2 -2
View File
@@ -189,11 +189,11 @@ HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
+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', datetime('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, datetime('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, datetime('now'))
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode);
});
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)
+3 -1
View File
@@ -4,7 +4,9 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **One approval contract across every hold.** All approval holds now share one hold-record shape on `pending_approvals` (approver rule, approver blast-radius scope, dedup key — migration 019, with backfills) and ONE click-authorization rule (`mayResolve` in `src/modules/approvals/approver-rule.ts`), replacing three divergent click-auth copies. Unknown-sender admission folds onto the approvals primitive (sessionless hold, action `sender_admit`) — the `pending_sender_approvals` table is dropped by migration; an in-flight sender card does not survive the upgrade (a new message from the same sender re-triggers one). Channel registration and OneCLI keep their flows and adopt the shared rule. Observers now see the full hold lifecycle with zero touch points inside the flows: `registerApprovalRequestedHandler` (new, the creation-side sibling of `registerApprovalResolvedHandler`) fires once whenever a hold record comes into existence, whichever stack created it — `requestApproval`, the OneCLI credential bridge, channel registration (as a synthesized hold view) — and every resolution announces through the approval-resolved observer (outcome `approve` | `reject` | `expire` | `sweep`, session nullable). Two absorbed security fixes intentionally change decision outcomes: **(D1)** a hold with global blast radius (e.g. `roles grant`) can only be approved by an owner or global admin — a scoped admin's click is now rejected; **(D4, approver half)** channel-registration approvers are owners/global admins, no longer "admin of whichever agent group sorts first".
- [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.
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
+15 -5
View File
@@ -67,8 +67,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry, hold-lifecycle observers (`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` — every stack announces creation + resolution through these) |
| `src/modules/approvals/approver-rule.ts` | `mayResolve` — the one click-authorization rule (approver rule `exclusive` \| `admins-of-scope` + blast-radius scope) for every hold |
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
@@ -78,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). |
@@ -106,6 +106,7 @@ ncl help
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| sessions | list, get | Active sessions (read-only) |
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
@@ -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:
@@ -138,7 +141,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
@@ -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 |
|-------|-------------|
@@ -252,6 +255,13 @@ Check these first when something goes wrong:
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
## Timestamps
Two rules, no exceptions:
- **Storage**: every timestamp written from JS is `new Date().toISOString()` (ISO-8601 UTC with `Z`). Never `datetime('now')` — its naive `YYYY-MM-DD HH:MM:SS` shape is misparsed as local time by `new Date()` and breaks string comparisons against ISO values. In pure-SQL contexts (skill snippets) use `strftime('%Y-%m-%dT%H:%M:%fZ','now')`. SQL-side *comparisons* wrap both sides in `datetime()`.
- **Display**: anything shown to an agent or a user renders in the install timezone — `formatLocalTime` (prose) or `formatLocalStamp` (log lines) from `src/timezone.ts` / `container/agent-runner/src/timezone.ts`. `--json` output, DB values, and operator logs stay ISO.
## Supply Chain Security (pnpm)
This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them.
+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.
+39 -4
View File
@@ -65,10 +65,11 @@ function writeRequest(req: RequestFrame): void {
db.prepare(
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
VALUES ($id, $seq, $timestamp, 'system', $content)`,
).run({
$id: req.id,
$seq: nextSeq,
$timestamp: new Date().toISOString(),
$content: JSON.stringify({
action: 'cli_request',
requestId: req.id,
@@ -110,9 +111,9 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul
outDb.exec('PRAGMA busy_timeout = 5000');
outDb
.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)",
)
.run(row.id);
.run(row.id, new Date().toISOString());
outDb.close();
const parsed = JSON.parse(row.content);
@@ -184,12 +185,46 @@ function printUsage(): void {
// Formatting (mirrors src/cli/format.ts on the host)
// ---------------------------------------------------------------------------
// Mirrors localizeIsoTimestamps in src/cli/format.ts (self-contained — no
// imports from agent-runner). Human display shows local time (container TZ
// env = install timezone); --json keeps the ISO machine contract. Only whole
// ISO-instant strings convert — embedded occurrences may be machine payloads.
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
// "YYYY-MM-DD HH:mm" — round-trips through parseZonedToUtc (naive = local
// wall-clock), so a value copied from human output into --process-after
// means what it shows.
function localTime(iso: string): string {
return new Date(iso).toLocaleString('sv-SE', {
timeZone: process.env.TZ || 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function localizeIsoTimestamps(value: unknown): unknown {
if (typeof value === 'string') {
return ISO_UTC_RE.test(value) ? localTime(value) : value;
}
if (Array.isArray(value)) return value.map(localizeIsoTimestamps);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, localizeIsoTimestamps(v)]),
);
}
return value;
}
function formatHuman(resp: ResponseFrame): string {
if (!resp.ok) {
return `error (${resp.error.code}): ${resp.error.message}\n`;
}
const data = resp.data;
const data = localizeIsoTimestamps(resp.data);
if (!Array.isArray(data) || data.length === 0) {
return JSON.stringify(data, null, 2) + '\n';
}
+24 -6
View File
@@ -101,10 +101,10 @@ export function markProcessing(ids: string[]): void {
if (ids.length === 0) return;
const db = getOutboundDb();
const stmt = db.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))",
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', ?)",
);
db.transaction(() => {
for (const id of ids) stmt.run(id);
for (const id of ids) stmt.run(id, new Date().toISOString());
})();
}
@@ -113,10 +113,28 @@ export function markCompleted(ids: string[]): void {
if (ids.length === 0) return;
const db = getOutboundDb();
const stmt = db.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)",
);
db.transaction(() => {
for (const id of ids) stmt.run(id);
for (const id of ids) stmt.run(id, new Date().toISOString());
})();
}
/**
* Ack task messages whose pre-task script gated the run. The reason decides
* the ack: `gated` (wakeAgent=false) is the monitor working as designed a
* plain `completed`; `error` (broken script) `script-skip:error`, which the
* host's ack sync records as a FAILED run so recurrence can read the trailing
* failed streak off the occurrence rows and back the series off.
*/
export function markScriptSkipped(skips: Array<{ id: string; reason: string }>): void {
if (skips.length === 0) return;
const db = getOutboundDb();
const stmt = db.prepare(
'INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, ?)',
);
db.transaction(() => {
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed', new Date().toISOString());
})();
}
@@ -124,9 +142,9 @@ export function markCompleted(ids: string[]): void {
export function markFailed(id: string): void {
getOutboundDb()
.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))",
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', ?)",
)
.run(id);
.run(id, new Date().toISOString());
}
/** Get a message by ID (read from inbound.db). */
+22 -2
View File
@@ -58,11 +58,12 @@ export function writeMessageOut(msg: WriteMessageOut): number {
outbound
.prepare(
`INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
VALUES ($id, $seq, $in_reply_to, $timestamp, $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`,
)
.run({
$id: msg.id,
$seq: nextSeq,
$timestamp: new Date().toISOString(),
$in_reply_to: msg.in_reply_to ?? null,
$deliver_after: msg.deliver_after ?? null,
$recurrence: msg.recurrence ?? null,
@@ -136,8 +137,27 @@ export function getUndeliveredMessages(): MessageOutRow[] {
return getOutboundDb()
.prepare(
`SELECT * FROM messages_out
WHERE (deliver_after IS NULL OR deliver_after <= datetime('now'))
WHERE (deliver_after IS NULL OR datetime(deliver_after) <= datetime('now'))
ORDER BY timestamp ASC`,
)
.all() as MessageOutRow[];
}
/**
* True if a deliberate send with this exact destination + text already exists
* (an MCP send_message row from the current turn). Used by the task-fire
* final-text dispatcher to drop the turn-final <message> echo of a send the
* agent already made the dedup happens where the duplication originates.
*/
export function hasIdenticalSend(platformId: string, channelType: string, text: string): boolean {
const row = getOutboundDb()
.prepare(
`SELECT 1 FROM messages_out
WHERE platform_id = $platform_id AND channel_type = $channel_type
AND (in_reply_to IS NULL OR in_reply_to = '')
AND json_extract(content, '$.text') = $text
LIMIT 1`,
)
.get({ $platform_id: platformId, $channel_type: channelType, $text: text });
return row != null;
}
+9 -4
View File
@@ -105,13 +105,11 @@ function buildDestinationsSection(): string {
const lines = ['## Sending messages', ''];
if (all.length === 1) {
const d = all[0];
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`Your destination is \`${d.name}\`${label}.`);
lines.push(`Your destination is \`${d.name}\`${destinationLabel(d)}.`);
} else {
lines.push('You can send messages to the following destinations:', '');
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
lines.push(`- \`${d.name}\`${destinationLabel(d)}`);
}
}
lines.push('');
@@ -128,3 +126,10 @@ function buildDestinationsSection(): string {
);
return lines.join('\n');
}
function destinationLabel(d: DestinationEntry): string {
const parts: string[] = [];
if (d.channelType) parts.push(d.channelType);
if (d.displayName && d.displayName !== d.name) parts.push(d.displayName);
return parts.length > 0 ? ` (${parts.join(' · ')})` : '';
}
+9 -1
View File
@@ -14,7 +14,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
import { getPendingMessages } from './db/messages-in.js';
import { formatMessages, stripInternalTags } from './formatter.js';
import { TIMEZONE } from './timezone.js';
import { TIMEZONE, formatLocalTime } from './timezone.js';
beforeEach(() => {
initTestSessionDb();
@@ -109,6 +109,14 @@ describe('timestamp formatting', () => {
});
});
describe('task timestamps', () => {
it('renders task time in the user TZ, same as chat rows', () => {
insertMessage('t1', 'task', { prompt: 'do the thing' }, { timestamp: '2026-01-05T12:00:00.000Z' });
const result = formatMessages(getPendingMessages());
expect(result).toContain(`time="${formatLocalTime('2026-01-05T12:00:00.000Z', TIMEZONE)}"`);
});
});
describe('reply_to + quoted_message rendering', () => {
it('renders reply_to attribute and quoted_message when all fields present', () => {
insertMessage('m1', 'chat', {
+6
View File
@@ -98,6 +98,11 @@ export interface RoutingContext {
channelType: string | null;
threadId: string | null;
inReplyTo: string | null;
/** Batch is a task fire explicit `<message to>` sends must NOT inherit
* inReplyTo (the series id), or the host's task-fire suppression drops
* them as turn-final echoes: zero delivery. Deliberate sends are
* in_reply_to-null, same as the out-of-process MCP send_message path. */
taskFire: boolean;
}
/**
@@ -111,6 +116,7 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
channelType: first?.channel_type ?? null,
threadId: first?.thread_id ?? null,
inReplyTo: first?.id ?? null,
taskFire: messages.length > 0 && messages.every((m) => m.kind === 'task'),
};
}
@@ -18,12 +18,13 @@ Your CLI access may be scoped. Run `ncl help` to see which resources are availab
Run `ncl help` for the full list. Common resources:
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
| Resource | Verbs | What it is |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
| tasks | list, get, create, update, cancel, pause, resume, delete, append-log | Scheduled tasks for your agent group |
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
@@ -33,11 +34,12 @@ Additional resources (available under `global` scope only): messaging-groups, wi
- **Restarting your container**`ncl groups restart` (with optional `--rebuild` and `--message`).
- **Checking who's in your group**`ncl members list`.
- **Seeing your destinations**`ncl destinations list`.
- **Scheduling work**`ncl tasks create`, then `ncl tasks list/get/update/cancel/pause/resume/delete`; `ncl tasks run <id>` fires one extra run now (testing) without changing the schedule. At the end of each task run, `ncl tasks append-log --msg "…"` to record what happened (host-timestamped, not a message).
- **Answering questions about the system** — query `ncl` rather than guessing.
### Access rules
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
Read commands (list, get) are open. Most write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. `ncl tasks` is the exception: an agent can manage its own group tasks without approval.
### Approval flow
@@ -61,6 +63,13 @@ ncl groups config get
ncl sessions list
ncl destinations list
ncl members list
ncl tasks list
# Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid).
# For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots.
ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *"
# At the END of every task run, record one line of history. The host stamps the local time — you supply only the summary.
# This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task fire --id is auto-derived from your session.
ncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"
# Write commands (approval required)
ncl groups restart
@@ -6,7 +6,6 @@
* at module scope, and append the import here. No central list.
*/
import './core.js';
import './scheduling.js';
import './interactive.js';
import './agents.js';
import './self-mod.js';
@@ -1,40 +1,25 @@
## Task scheduling (`schedule_task`)
## Task scheduling (`ncl tasks`)
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below.
Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session, so when it fires you must choose a destination explicitly with `<message to="name">...</message>` or `send_message({ to: "name", ... })`.
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule.
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"` `sales-briefing-a25c`); without it ids are `t-<hex>`.
Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes.
### How it works
1. Provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first
3. Script returns: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — claude receives the script's data + prompt and handles
### Always test your script first
Before scheduling, run the script directly to verify it works:
Common commands:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
ncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"
ncl tasks list
ncl tasks get ping-a25c # includes run count, failures, and recent run-log lines
ncl tasks run ping-a25c # fire once now without changing the schedule (testing)
ncl tasks update ping-a25c --prompt "New instructions"
ncl tasks pause ping-a25c
ncl tasks resume ping-a25c
ncl tasks cancel ping-a25c # or --all as a kill switch
ncl tasks delete ping-a25c
```
### When NOT to use scripts
Use good judgement on whether it's appropriate to check in with the user about the task prompt before task creation, and if so, whether to share verbatim or a description of it.
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts.
`--process-after` accepts UTC timestamps or naive local timestamps interpreted in the instance timezone (shown in the `<context timezone="..."/>` header).
### Frequent task guidance
If a user wants a task to run more than a few times a day and a script can't be used:
- Explain that each time the task fires it uses API credits and risks rate limits
- Suggest adjusting the task requirements in a way that will allow you to use a script
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency
Run `ncl tasks create --help` for schedules, options, and pre-task gate scripts (checks that run before you wake).
@@ -1,302 +0,0 @@
/**
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
*
* With the two-DB split, the container cannot write to inbound.db (host-owned).
* Scheduling operations are sent as system actions via messages_out the host
* reads them during delivery and applies the changes to inbound.db.
*/
import { getInboundDb } from '../db/connection.js';
import { writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function routing() {
return getSessionRouting();
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
export const scheduleTask: McpToolDefinition = {
tool: {
name: 'schedule_task',
description:
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
inputSchema: {
type: 'object' as const,
properties: {
prompt: { type: 'string', description: 'Task instructions/prompt' },
processAfter: {
type: 'string',
description:
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
},
recurrence: {
type: 'string',
description:
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
},
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
},
required: ['prompt', 'processAfter'],
},
},
async handler(args) {
const prompt = args.prompt as string;
const processAfterIn = args.processAfter as string;
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
let processAfter: string;
try {
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
processAfter = d.toISOString();
} catch {
return err(`invalid processAfter: ${processAfterIn}`);
}
const id = generateId();
const r = routing();
const recurrence = (args.recurrence as string) || null;
const script = (args.script as string) || null;
// Write as a system action — host will insert into inbound.db
writeMessageOut({
id,
kind: 'system',
platform_id: r.platform_id,
channel_type: r.channel_type,
thread_id: r.thread_id,
content: JSON.stringify({
action: 'schedule_task',
taskId: id,
prompt,
script,
processAfter,
recurrence,
platformId: r.platform_id,
channelType: r.channel_type,
threadId: r.thread_id,
}),
});
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
},
};
export const listTasks: McpToolDefinition = {
tool: {
name: 'list_tasks',
description:
'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.',
inputSchema: {
type: 'object' as const,
properties: {
status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' },
},
},
},
async handler(args) {
const status = args.status as string | undefined;
const db = getInboundDb();
// One row per series — the live (pending or paused) occurrence. Recurring
// tasks accumulate one completed row per firing plus one live follow-up;
// exposing the whole pile to the agent is noisy and confuses task identity
// ("which id do I cancel?"). The series_id is the stable handle.
//
// SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY
// query, the bare columns take values from the row that contains that max
// — that's how we pick "the latest live row per series" in one pass.
let rows;
if (status) {
rows = db
.prepare(
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
FROM messages_in
WHERE kind = 'task' AND status = ?
GROUP BY series_id
ORDER BY process_after ASC`,
)
.all(status);
} else {
rows = db
.prepare(
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
FROM messages_in
WHERE kind = 'task' AND status IN ('pending', 'paused')
GROUP BY series_id
ORDER BY process_after ASC`,
)
.all();
}
if ((rows as unknown[]).length === 0) return ok('No tasks found.');
const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => {
const content = JSON.parse(r.content);
const prompt = (content.prompt as string || '').slice(0, 80);
return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}${prompt}`;
});
return ok(lines.join('\n'));
},
};
export const cancelTask: McpToolDefinition = {
tool: {
name: 'cancel_task',
description: 'Cancel a scheduled task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to cancel' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
// Write as a system action — host will update inbound.db
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'cancel_task', taskId }),
});
log(`cancel_task: ${taskId}`);
return ok(`Task cancellation requested: ${taskId}`);
},
};
export const pauseTask: McpToolDefinition = {
tool: {
name: 'pause_task',
description: 'Pause a scheduled task. It will not run until resumed.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to pause' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'pause_task', taskId }),
});
log(`pause_task: ${taskId}`);
return ok(`Task pause requested: ${taskId}`);
},
};
export const resumeTask: McpToolDefinition = {
tool: {
name: 'resume_task',
description: 'Resume a paused task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Task ID to resume' },
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'resume_task', taskId }),
});
log(`resume_task: ${taskId}`);
return ok(`Task resume requested: ${taskId}`);
},
};
export const updateTask: McpToolDefinition = {
tool: {
name: 'update_task',
description:
'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' },
prompt: { type: 'string', description: 'New task prompt (optional)' },
recurrence: {
type: 'string',
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
},
processAfter: {
type: 'string',
description:
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
},
script: {
type: 'string',
description: 'New pre-agent script (optional). Pass empty string to clear.',
},
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const update: Record<string, unknown> = { taskId };
if (typeof args.prompt === 'string') update.prompt = args.prompt;
if (typeof args.processAfter === 'string') {
try {
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
update.processAfter = d.toISOString();
} catch {
return err(`invalid processAfter: ${args.processAfter}`);
}
}
// Empty string clears recurrence/script; undefined leaves them as-is.
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
if (Object.keys(update).length === 1) return err('at least one field to update is required');
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'update_task', ...update }),
});
log(`update_task: ${taskId}`);
return ok(`Task update requested: ${taskId}`);
},
};
registerTools([scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask]);
+19 -10
View File
@@ -1,6 +1,6 @@
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { getPendingMessages, markProcessing, markCompleted, markScriptSkipped, type MessageInRow } from './db/messages-in.js';
import { hasIdenticalSend, writeMessageOut } from './db/messages-out.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import {
clearContinuation,
@@ -207,15 +207,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Without the scheduling module, the marker block is empty, `keep`
// falls back to `normalMessages`, and no gating happens.
let keep: MessageInRow[] = normalMessages;
let skipped: string[] = [];
let skipped: Array<{ id: string; reason: string }> = [];
// MODULE-HOOK:scheduling-pre-task:start
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
const preTask = await applyPreTaskScripts(normalMessages);
keep = preTask.keep;
skipped = preTask.skipped;
if (skipped.length > 0) {
markCompleted(skipped);
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
markScriptSkipped(skipped);
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.map((s) => s.id).join(', ')}`);
}
// MODULE-HOOK:scheduling-pre-task:end
@@ -238,7 +238,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
});
// Process the query while concurrently polling for new messages
const skippedSet = new Set(skipped);
const skippedSet = new Set(skipped.map((s) => s.id));
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
// can stamp it on outbound rows — needed for a2a return-path routing.
@@ -401,15 +401,15 @@ export async function processQuery(
// its script gate and always wakes the agent, defeating the gate.
// Mirrors the initial-batch hook above.
let keep = newMessages;
let skipped: string[] = [];
let skipped: Array<{ id: string; reason: string }> = [];
// MODULE-HOOK:scheduling-pre-task-followup:start
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
const preTask = await applyPreTaskScripts(newMessages);
keep = preTask.keep;
skipped = preTask.skipped;
if (skipped.length > 0) {
markCompleted(skipped);
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
markScriptSkipped(skipped);
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.map((s) => s.id).join(', ')}`);
}
// MODULE-HOOK:scheduling-pre-task-followup:end
@@ -650,6 +650,15 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
// Task fires: an explicitly-addressed final-text block is either the echo of
// an MCP send the agent already made this turn (drop it HERE, where the
// duplication originates) or the agent's only deliberate send (write it
// in_reply_to-null like the MCP path, or the host's task-fire suppression
// would discard it — zero delivery).
if (routing.taskFire && hasIdenticalSend(platformId, channelType, body)) {
log(`Dropping turn-final echo of an already-sent task message to ${dest.name}`);
return;
}
// Resolve thread_id per-destination from the most recent inbound message
// that came from this same channel+platform. In agent-shared sessions,
// different destinations have different thread contexts — using a single
@@ -657,7 +666,7 @@ function sendToDestination(dest: DestinationEntry, body: string, routing: Routin
const destRouting = resolveDestinationThread(channelType, platformId);
writeMessageOut({
id: generateId(),
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
in_reply_to: destRouting?.inReplyTo ?? (routing.taskFire ? null : routing.inReplyTo),
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
@@ -5,6 +5,7 @@ import path from 'path';
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js';
import { TIMEZONE, formatLocalStamp } from '../timezone.js';
import { registerProvider } from './provider-registry.js';
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
@@ -17,7 +18,7 @@ function log(msg: string): void {
// Code's interactive UI and would hang here).
//
// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable
// scheduling via mcp__nanoclaw__schedule_task.
// scheduling via `ncl tasks`.
// - AskUserQuestion: SDK returns a placeholder instead of blocking on a
// real answer — we have mcp__nanoclaw__ask_user_question that persists
// the question and blocks on the real reply.
@@ -223,7 +224,9 @@ function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: st
const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations';
fs.mkdirSync(conversationsDir, { recursive: true });
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
// Local calendar date — the fallback `name` above already uses local
// hours, and the agent navigates conversations/ by these date prefixes.
const filename = `${formatLocalStamp(new Date(), TIMEZONE).slice(0, 10)}-${name}.md`;
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
log(`Archived conversation to ${filename}`);
return true;
@@ -0,0 +1,72 @@
/**
* Container leg of the script-failure backoff chain, tested at unit level so
* the e2e suite doesn't need a live multi-sweep scenario for it:
*
* script error applyPreTaskScripts skips with reason 'error'
* markScriptSkipped acks `script-skip:error` in outbound.db
* (gated plain 'completed': the monitor working as designed).
*
* The host leg (ack FAILED run streak backoff) is pinned in
* src/db/session-db.test.ts and src/modules/scheduling/recurrence.test.ts
* both sides pin the literal 'script-skip:error'; if either renames it, its
* own test goes red.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
import { getPendingMessages, markScriptSkipped } from '../db/messages-in.js';
import { applyPreTaskScripts } from './task-script.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function insertTask(id: string, script: string) {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, trigger, content)
VALUES (?, 'task', datetime('now'), 'pending', 1, ?)`,
)
.run(id, JSON.stringify({ prompt: 'monitor', script }));
}
const ackStatus = (id: string): string | undefined =>
(getOutboundDb().prepare('SELECT status FROM processing_ack WHERE message_id = ?').get(id) as { status: string } | undefined)
?.status;
describe('script-skip ack chain (container leg)', () => {
it('an erroring script skips with reason "error" and acks script-skip:error', async () => {
insertTask('t-err', 'echo boom >&2; exit 1');
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
expect(keep).toHaveLength(0);
expect(skipped).toEqual([{ id: 't-err', reason: 'error' }]);
markScriptSkipped(skipped);
expect(ackStatus('t-err')).toBe('script-skip:error');
});
it('a deliberate wakeAgent=false gate acks plain completed — never backs off', async () => {
insertTask('t-gated', 'echo \'{"wakeAgent": false}\'');
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
expect(keep).toHaveLength(0);
expect(skipped).toEqual([{ id: 't-gated', reason: 'gated' }]);
markScriptSkipped(skipped);
expect(ackStatus('t-gated')).toBe('completed');
});
it('wakeAgent=true keeps the task and enriches the prompt with script data', async () => {
insertTask('t-wake', 'echo \'{"wakeAgent": true, "data": {"alerts": 2}}\'');
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
expect(skipped).toHaveLength(0);
expect(keep).toHaveLength(1);
expect(JSON.parse(keep[0].content).scriptOutput).toEqual({ alerts: 2 });
});
});
@@ -64,21 +64,26 @@ export async function runScript(script: string, taskId: string): Promise<ScriptR
});
}
/** Why a script gated its task: deliberate wakeAgent=false vs a broken script. */
export type ScriptSkipReason = 'gated' | 'error';
export interface TaskScriptOutcome {
keep: MessageInRow[];
skipped: string[];
skipped: Array<{ id: string; reason: ScriptSkipReason }>;
}
/**
* Run pre-task scripts for any task messages that carry one, serially.
* - Errors / missing output / wakeAgent=false task id added to `skipped`.
* - Errors / missing output / wakeAgent=false task id added to `skipped`,
* with the reason. The caller acks these as script-skips (not plain
* completions) so the host can count consecutive failures and back off.
* - wakeAgent=true content JSON is mutated to carry `scriptOutput`, so the
* formatter renders it into the prompt.
* Non-task messages and tasks without scripts pass through unchanged.
*/
export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<TaskScriptOutcome> {
const keep: MessageInRow[] = [];
const skipped: string[] = [];
const skipped: Array<{ id: string; reason: ScriptSkipReason }> = [];
for (const msg of messages) {
if (msg.kind !== 'task') {
@@ -106,9 +111,9 @@ export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<Tas
touchHeartbeat();
if (!result || !result.wakeAgent) {
const reason = result ? 'wakeAgent=false' : 'script error/no output';
log(`task ${msg.id} skipped: ${reason}`);
skipped.push(msg.id);
const reason: ScriptSkipReason = result ? 'gated' : 'error';
log(`task ${msg.id} skipped: ${reason === 'gated' ? 'wakeAgent=false' : 'script error/no output'}`);
skipped.push({ id: msg.id, reason });
continue;
}
+16
View File
@@ -48,6 +48,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
});
}
/**
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
* (sv-SE is the one locale whose default rendering is this exact shape.)
*/
export function formatLocalStamp(date: Date, timezone: string): string {
return date.toLocaleString('sv-SE', {
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function resolveContainerTimezone(): string {
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
for (const tz of candidates) {
+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.
+3 -3
View File
@@ -134,11 +134,11 @@ A personal AI assistant accessible via messaging, with minimal custom code.
### Scheduler
- Built-in scheduler runs on the host, spawns containers for task execution
- Custom `nanoclaw` MCP server (inside container) provides scheduling tools
- Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message`
- `ncl tasks` provides scheduling commands
- Commands: `list`, `get`, `create`, `update`, `cancel`, `pause`, `resume`, `delete`, `run`, `append-log`
- Tasks stored in SQLite with run history
- Scheduler loop checks for due tasks every minute
- Tasks execute Claude Agent SDK in containerized group context
- Tasks execute in the agent group's system session
### Web Access
- Built-in WebSearch and WebFetch tools
+3 -20
View File
@@ -579,12 +579,7 @@ NanoClaw has a built-in scheduler that runs tasks as full agents in their group'
```
User: @Andy remind me every Monday at 9am to review the weekly metrics
Claude: [calls mcp__nanoclaw__schedule_task]
{
"prompt": "Send a reminder to review weekly metrics. Be encouraging!",
"schedule_type": "cron",
"schedule_value": "0 9 * * 1"
}
Claude: [runs ncl tasks create --prompt "Send a reminder to review weekly metrics. Be encouraging!" --process-after "2024-02-05T09:00:00" --recurrence "0 9 * * 1"]
Claude: Done! I'll remind you every Monday at 9am.
```
@@ -594,12 +589,7 @@ Claude: Done! I'll remind you every Monday at 9am.
```
User: @Andy at 5pm today, send me a summary of today's emails
Claude: [calls mcp__nanoclaw__schedule_task]
{
"prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.",
"schedule_type": "once",
"schedule_value": "2024-01-31T17:00:00Z"
}
Claude: [runs ncl tasks create --prompt "Search for today's emails, summarize the important ones, and send the summary to the group." --process-after "2024-01-31T17:00:00"]
```
### Managing Tasks
@@ -620,18 +610,11 @@ From main channel:
### NanoClaw MCP (built-in)
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context.
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. Scheduled task management lives in `ncl tasks`, not MCP.
**Available Tools:**
| Tool | Purpose |
|------|---------|
| `schedule_task` | Schedule a recurring or one-time task |
| `list_tasks` | Show tasks (group's tasks, or all if main) |
| `get_task` | Get task details and run history |
| `update_task` | Modify task prompt or schedule |
| `pause_task` | Pause a task |
| `resume_task` | Resume a paused task |
| `cancel_task` | Delete a task |
| `send_message` | Send a message to the group via its channel |
---
+8 -42
View File
@@ -634,52 +634,18 @@ destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row
`channel_type: 'agent'` and `platform_id` set to the target agent group id; the host
validates the send and routes it into the target session's `inbound.db`.
#### schedule_task
#### ncl tasks
Schedule a one-shot or recurring task.
Schedule, inspect, and modify one-shot or recurring tasks.
```typescript
{
name: 'schedule_task',
params: {
prompt: string, // task prompt
processAfter: string, // ISO timestamp for first run
recurrence?: string, // cron expression (optional)
script?: string, // pre-agent script (optional)
}
}
```bash
ncl tasks create --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
ncl tasks list
ncl tasks update <series_id> --prompt "..."
ncl tasks cancel <series_id>
```
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts``insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
#### list_tasks
List active scheduled/recurring tasks.
```typescript
{
name: 'list_tasks',
params: {}
}
```
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
#### cancel_task / pause_task / resume_task / update_task
Modify a scheduled task.
```typescript
{
name: 'cancel_task',
params: { taskId: string }
}
// pause_task: set status = 'paused' (new status value for recurring tasks)
// resume_task: set status = 'pending'
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
```
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
Implementation: the host writes `messages_in` task rows into the agent group's system session (`thread_id = system:tasks`). The host sweep wakes that system-session container when a task is due. The task agent chooses its destination at fire time by emitting `<message to="name">...</message>` or using `send_message`.
#### create_agent
+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.
+1 -1
View File
@@ -32,7 +32,7 @@ flowchart TB
direction TB
PollLoop["Poll Loop<br/>(container/agent-runner)"]
Provider["Agent providers<br/>(claude — the only one registered in trunk;<br/>opencode ships via the /add-opencode skill)"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>create_agent, install_packages, add_mcp_server<br/>CLI: ncl tasks"]
Skills["Container Skills<br/>(container/skills/)"]
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>delivered<br/>destinations<br/>session_routing")]
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>processing_ack<br/>session_state<br/>container_state<br/>heartbeat file")]
+4 -7
View File
@@ -904,13 +904,10 @@ MCP tools write to the container's own `outbound.db`. Anything that needs a chan
(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)
**Scheduling** (all emit `kind: 'system'` actions except the read-only `list_tasks`):
| Tool | What it does |
|------|-------------|
| `schedule_task` | `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
| `list_tasks` | Read `inbound.db` (read-only) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
| `pause_task` / `resume_task` / `cancel_task` / `update_task` | matching `action`; host updates the live `messages_in` row(s) |
**Scheduling**: scheduled-task management is not an MCP surface — it lives on
`ncl tasks` (create/list/get/update/cancel/pause/resume/run/append-log). Due
task rows live in the agent group's system session and are woken by the host
sweep.
**Central-DB / self-modification** (`kind: 'system'` actions; host authorizes, often via admin approval):
+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
+52
View File
@@ -0,0 +1,52 @@
# `ncl tasks` migration
## Detect
If an agent mentions `schedule_task`, `list_tasks`, `update_task`, `cancel_task`, `pause_task`, or `resume_task`, it is using the old scheduling MCP surface.
A subtler symptom of a stale container image: the agent reports a task as scheduled, but `ncl tasks list` shows nothing and the host log has `Unknown system action` — the old image's `schedule_task` call is acknowledged in-container and then dropped by the new host. The fix below (rebuild + restart) resolves it.
## Why
Scheduling moved to `ncl tasks`. New tasks are stored in a per-agent-group system session and run there, so a scheduled task does not wake an existing chat session. When it fires, the agent must choose the delivery destination explicitly.
## Fix
Rebuild and restart agent containers so they load the updated MCP tool list and instructions:
```bash
./container/build.sh
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
On Linux, restart with `systemctl --user restart nanoclaw`.
Use:
```bash
ncl tasks list
ncl tasks create --group <agent_group_id> --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
ncl tasks update --id <series_id> --prompt "..."
ncl tasks cancel --id <series_id>
```
## Verify
Run `ncl tasks list`. New task rows should show a system `session_id`, not the chat session that requested the task.
## Legacy tasks (scheduled before this update)
Tasks created through the old MCP tools live in the **chat session** that created them, not in a per-series system session. They are unaffected by this update: they keep firing and delivering exactly as before. Two things to know:
- An agent's own `ncl tasks list` (group scope) shows only its group's task rows; from the **host**, unscoped `ncl tasks list` enumerates everything, and `--session <id>` narrows to one session — that is how you find and manage legacy rows (`ncl tasks cancel --session <chat_session_id> --all` to clear a chat session's tasks).
- The `messages_in` status enum now includes `cancelled` (cancel marks the row and clears its recurrence rather than deleting it). Custom code that exhaustively switches on task status needs the new arm.
## Rollback
Order matters:
1. Remove tasks created through `ncl tasks` (`ncl tasks list` / `delete`) — they live in per-series system sessions the old code doesn't know about.
2. **Wait one sweep (≤60s)** so the host closes the now-empty task sessions.
3. Then revert the update and rebuild the container image.
Reverting before the task sessions are collected leaves system sessions behind that the old `findSessionByAgentGroup` (which has no system-session exclusion) can resolve as the group's session — mis-routing agent-to-agent messages into a dead task thread.
+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.40",
"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="213k tokens, 106% of context window">
<title>213k tokens, 106% 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">213k</text>
<text x="71" y="14">213k</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',
+2 -1
View File
@@ -34,9 +34,10 @@ db.exec(`
// Insert test message
db.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', ?, 'pending', ?)`,
).run(
'test-1',
new Date().toISOString(),
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
);
console.log('✓ Session DB created with test message');
+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',
+4 -2
View File
@@ -46,10 +46,12 @@ export function backupEnv(envPath: string): string {
const dir = path.dirname(envPath);
let backup = path.join(dir, '.env.bak');
if (fs.existsSync(backup)) {
// Local time (system TZ) — the stamp is read by the human running the
// uninstall. sv-SE renders "YYYY-MM-DD HH:mm:ss".
const stamp = new Date()
.toISOString()
.toLocaleString('sv-SE', { hour12: false })
.replace(/[-:]/g, '')
.replace('T', '-')
.replace(' ', '-')
.slice(0, 15);
backup = path.join(dir, `.env.bak.${stamp}`);
}
+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 });
+26 -1
View File
@@ -15,7 +15,7 @@ vi.mock('./log.js', () => ({
}));
import { composeGroupClaudeMd } from './claude-md-compose.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { ensureContainerConfig, updateContainerConfigScalars } from './db/container-configs.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { PERSONA_PREPEND_FILE } from './group-persona.js';
import type { AgentGroup } from './types.js';
@@ -91,3 +91,28 @@ describe('composeGroupClaudeMd persona prepend', () => {
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
});
});
describe('composeGroupClaudeMd scheduling instructions (ncl tasks reach-in)', () => {
// Red-on-delete guard for the `scheduling`/`cli` exclusion at the
// module-fragment loop: the agent is taught `ncl tasks` iff it has ncl.
it('imports module-scheduling.md at the default cli_scope', () => {
const ag = group('ag-sched', 'sched-group');
seed(ag);
composeGroupClaudeMd(ag);
expect(importsOf(ag.folder)).toContain('@./.claude-fragments/module-scheduling.md');
});
it('excludes module-scheduling.md (and module-cli.md) when cli_scope is disabled', () => {
const ag = group('ag-sched-off', 'sched-group-off');
seed(ag);
updateContainerConfigScalars(ag.id, { cli_scope: 'disabled' });
composeGroupClaudeMd(ag);
const imports = importsOf(ag.folder);
expect(imports).not.toContain('@./.claude-fragments/module-scheduling.md');
expect(imports).not.toContain('@./.claude-fragments/module-cli.md');
});
});
+6 -4
View File
@@ -80,10 +80,12 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
}
}
// Built-in module fragments — every MCP tool source file that ships a
// Built-in module fragments — every MCP/CLI module that ships a
// sibling `<name>.instructions.md`. These describe how the agent should
// use that module's MCP tools (schedule_task, install_packages, etc.).
// Skip cli.instructions.md when cli_scope is disabled.
// use that module's tools (`ncl tasks`, install_packages, etc.).
// Skip ncl-dependent instructions when cli_scope is disabled. `scheduling`
// teaches `ncl tasks`, so it is just as dead as `cli` itself when the agent
// has no ncl — dispatch rejects every cli_request and ncl is excluded.
const cliDisabled = configRow?.cli_scope === 'disabled';
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
if (fs.existsSync(mcpToolsHostDir)) {
@@ -91,7 +93,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
const match = entry.match(/^(.+)\.instructions\.md$/);
if (!match) continue;
const moduleName = match[1];
if (moduleName === 'cli' && cliDisabled) continue;
if ((moduleName === 'cli' || moduleName === 'scheduling') && cliDisabled) continue;
desired.set(`module-${moduleName}.md`, {
type: 'symlink',
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
+238
View File
@@ -0,0 +1,238 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// `groups.ts`'s postCreate calls `initGroupFilesystem`, which touches the
// real filesystem (groups/<folder>, data/v2-sessions/<id>/.claude-shared).
// We're not testing the filesystem layout here — we're testing that the
// hook fires with the inserted row — so mock the FS-touching helper and
// keep only the DB side effect (`ensureContainerConfig`) as the observable.
const ensureContainerConfigSpy = vi.fn();
vi.mock('../group-init.js', async () => {
const { ensureContainerConfig } = await import('../db/container-configs.js');
return {
initGroupFilesystem: vi.fn((group: { id: string }) => {
ensureContainerConfigSpy(group.id);
ensureContainerConfig(group.id);
}),
};
});
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// `wirings.ts`'s postCommit projects the new destination into every running
// session's `inbound.db` via the agent-to-agent module's `writeDestinations`.
// That helper opens on-disk session DB files we don't have in a unit test, so
// mock it and observe that the projection is invoked per live session.
const writeDestinationsSpy = vi.fn();
vi.mock('../modules/agent-to-agent/write-destinations.js', () => ({
writeDestinations: (...args: unknown[]) => writeDestinationsSpy(...args),
}));
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
// module top-level, which wires up the `groups-create` / `wirings-create`
// handlers we exercise below.
import '../cli/resources/groups.js';
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);
ensureContainerConfigSpy.mockClear();
writeDestinationsSpy.mockClear();
});
afterEach(() => {
closeDb();
});
describe('genericCreate postCreate hook', () => {
it('groups-create writes the companion container_configs row', async () => {
const cmd = lookup('groups-create');
expect(cmd, 'groups-create command must be registered').toBeDefined();
const result = (await cmd!.handler({ name: 'Test', folder: 'test' }, hostCtx)) as { id: string };
// Hook fired with the just-inserted row (incl. generated `id`).
expect(ensureContainerConfigSpy).toHaveBeenCalledWith(result.id);
// Visible side effect: container_configs row exists for the new group.
// Without postCreate, this was empty and the first spawn threw
// "Container config not found" — issue #2415.
const config = getContainerConfig(result.id);
expect(config).toBeDefined();
expect(config!.agent_group_id).toBe(result.id);
});
it('wirings-create writes the companion agent_destinations row', async () => {
// Seed the FKs that the wiring references.
createAgentGroup({
id: 'ag-1',
name: 'Agent One',
folder: 'agent-one',
agent_provider: null,
created_at: new Date().toISOString(),
});
createMessagingGroup({
id: 'mg-1',
channel_type: 'discord',
platform_id: 'channel-123',
name: 'general',
is_group: 1,
unknown_sender_policy: 'strict',
created_at: new Date().toISOString(),
});
const cmd = lookup('wirings-create');
expect(cmd, 'wirings-create command must be registered').toBeDefined();
await cmd!.handler({ messaging_group_id: 'mg-1', agent_group_id: 'ag-1' }, hostCtx);
// Visible side effect: a destination row was created so the agent can
// address this chat as a delivery target. Without postCreate, this was
// empty and the agent's replies were silently dropped by the delivery
// ACL — issue #2389.
const destinations = getDestinations('ag-1');
expect(destinations).toHaveLength(1);
expect(destinations[0].target_type).toBe('channel');
expect(destinations[0].target_id).toBe('mg-1');
});
});
describe('genericCreate postCommit hook', () => {
it('wirings-create projects the new destination into live sessions', async () => {
createAgentGroup({
id: 'ag-1',
name: 'Agent One',
folder: 'agent-one',
agent_provider: null,
created_at: new Date().toISOString(),
});
createMessagingGroup({
id: 'mg-1',
channel_type: 'discord',
platform_id: 'channel-123',
name: 'general',
is_group: 1,
unknown_sender_policy: 'strict',
created_at: new Date().toISOString(),
});
// A container is already running for this agent — its session inbound.db
// holds a stale destination projection.
createSession({
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'running',
last_active: new Date().toISOString(),
created_at: new Date().toISOString(),
});
const cmd = lookup('wirings-create');
await cmd!.handler({ messaging_group_id: 'mg-1', agent_group_id: 'ag-1' }, hostCtx);
// Live-refresh parity with `ncl destinations add`: without postCommit the
// running container keeps serving the stale projection and drops replies
// to this chat as "unknown destination" until a restart — issue #2389.
expect(writeDestinationsSpy).toHaveBeenCalledWith('ag-1', 'sess-1');
});
it('wirings-create projection is a no-op when no sessions are running', async () => {
createAgentGroup({
id: 'ag-2',
name: 'Agent Two',
folder: 'agent-two',
agent_provider: null,
created_at: new Date().toISOString(),
});
createMessagingGroup({
id: 'mg-2',
channel_type: 'discord',
platform_id: 'channel-456',
name: 'ops',
is_group: 1,
unknown_sender_policy: 'strict',
created_at: new Date().toISOString(),
});
const cmd = lookup('wirings-create');
await cmd!.handler({ messaging_group_id: 'mg-2', agent_group_id: 'ag-2' }, hostCtx);
// No live session for ag-2 → nothing to project, and the central
// destination row was still written (covered above).
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);
});
});
+80 -5
View File
@@ -83,6 +83,51 @@ 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
* doesn't trigger e.g. creating a `container_configs` row when a new
* agent group is added, or the companion `agent_destinations` row when a
* wiring is added. The hook receives the same `values` object that was
* inserted, so generated fields like `id` and `created_at` are populated.
*/
postCreate?: (row: Record<string, unknown>) => void;
/**
* Runs AFTER the create transaction has committed, with the row that was
* written. Use this not `postCreate` for side effects that live
* OUTSIDE the central DB (filesystem writes, projecting rows into a
* running agent's session `inbound.db`) or that are async: those must not
* sit inside the better-sqlite3 transaction, which only covers central-DB
* statements and is synchronous.
*
* The canonical case is live-refresh parity with `ncl destinations add`:
* after `ncl wirings create` writes the companion `agent_destinations`
* row, the change has to be projected into any running container's session
* DB or the agent won't see the new delivery target until its next spawn
* (#2389). Runs only if the transaction succeeds, so it never observes a
* rolled-back row.
*/
postCommit?: (row: Record<string, unknown>) => void | Promise<void>;
}
// ---------------------------------------------------------------------------
@@ -147,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) {
@@ -165,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];
@@ -174,15 +232,25 @@ function genericCreate(def: ResourceDef) {
const colNames = Object.keys(values);
const placeholders = colNames.map((c) => `@${c}`);
getDb()
.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`)
.run(values);
// Single transaction so a postCreate throw rolls back the parent INSERT —
// closes the partial-state class this PR exists to fix (#2415, #2389).
// better-sqlite3 .transaction() is sync, so `postCreate` is sync too and
// must only touch the central DB (it's the atomic companion-row write).
// Anything async or outside the central DB — filesystem, session-DB
// projection — belongs in `postCommit`, which runs after commit below.
const db = getDb();
db.transaction(() => {
db.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`).run(values);
if (def.postCreate) def.postCreate(values);
})();
if (def.postCommit) await def.postCommit(values);
return values;
};
}
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`);
@@ -203,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(', ');
@@ -211,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);
};
}
+23 -68
View File
@@ -115,6 +115,15 @@ register({
handler: async (args) => ({ echo: args }),
});
register({
name: 'tasks-list',
description: 'test command (tasks resource)',
resource: 'tasks',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'wirings-list',
description: 'test command (wirings resource — not allowed)',
@@ -136,33 +145,6 @@ register({
},
});
register({
name: 'roles-grant',
description: 'approval command on a non-scoped resource (global blast radius)',
resource: 'roles',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => ({}),
});
register({
name: 'members-add-gated',
description: 'approval command on a scoped resource',
resource: 'members',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => ({}),
});
register({
name: 'groups-update',
description: 'approval command on the groups resource (id = agent group)',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => ({}),
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
@@ -245,6 +227,7 @@ beforeEach(() => {
sessions: 'agent_group_id',
destinations: 'agent_group_id',
members: 'agent_group_id',
tasks: 'agent_group_id',
};
mockGetResource.mockImplementation((plural: string) =>
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
@@ -391,6 +374,19 @@ describe('CLI scope enforcement', () => {
}
});
it('group: allows tasks, auto-fills --group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'tasks-list', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.group).toBe('g1');
expect(data.echo.id).toBeUndefined();
}
});
it('group: blocks non-whitelisted resources (wirings)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
@@ -511,47 +507,6 @@ describe('CLI scope enforcement', () => {
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
});
// --- Approver blast radius (D1) ---
it('holds on non-scoped resources carry approverScope global (roles grant)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
const resp = await dispatch(
{ id: '1', command: 'roles-grant', args: { user: 'telegram:mallory', role: 'owner' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
expect(approvalState.requestApproval.mock.calls[0][0]).toMatchObject({ approverScope: 'global' });
});
it('holds on scoped resources pinned to the caller stay approverScope group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
const resp = await dispatch({ id: '1', command: 'members-add-gated', args: { user: 'telegram:new' } }, agentCtx());
expect(resp.ok).toBe(false);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
expect(approvalState.requestApproval.mock.calls[0][0]).toMatchObject({ approverScope: 'group' });
});
it('holds on scoped resources targeting another group escalate to approverScope global', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
const resp = await dispatch({ id: '1', command: 'groups-update', args: { id: 'g2' } }, agentCtx());
expect(resp.ok).toBe(false);
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
expect(approvalState.requestApproval.mock.calls[0][0]).toMatchObject({ approverScope: 'global' });
});
// --- Post-handler filtering ---
it('group: groups list filters out other groups', async () => {
+4 -22
View File
@@ -11,34 +11,16 @@ import { getAgentGroup } from '../db/agent-groups.js';
import { getSession } from '../db/sessions.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { localizeIsoTimestamps } from './format.js';
import { getResource } from './crud.js';
import { listVerbs, renderVerbHelp } from './help-render.js';
import { GROUP_SCOPE_RESOURCES, listCommands, lookup, type CommandDef } from './registry.js';
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
type DispatchOptions = {
/** True when a command is being replayed after approval. */
approved?: boolean;
};
/**
* Blast radius of a held command, for the hold's approver rule (D1): a
* mutation of a non-group-scoped resource (roles, users, wirings,
* messaging-groups, policies) or one explicitly targeting another agent
* group needs an owner or global admin to approve; a scoped admin's click
* is rejected. GROUP_SCOPE_RESOURCES anchors rows to one agent group, so its
* held mutations default to group-local blast radius.
*/
function approverScopeFor(
cmd: CommandDef,
args: Record<string, unknown>,
callerAgentGroupId: string,
): 'group' | 'global' {
if (!cmd.resource || !GROUP_SCOPE_RESOURCES.has(cmd.resource)) return 'global';
const groupRefs = [args.agent_group_id, args.group];
if (cmd.resource === 'groups' || cmd.resource === 'destinations') groupRefs.push(args.id);
return groupRefs.some((v) => v !== undefined && v !== callerAgentGroupId) ? 'global' : 'group';
}
export async function dispatch(
req: RequestFrame,
ctx: CallerContext,
@@ -165,7 +147,6 @@ export async function dispatch(
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
title: `CLI: ${req.command}`,
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
approverScope: approverScopeFor(cmd, req.args, ctx.agentGroupId),
});
return err(req.id, 'approval-pending', 'Approval request sent to admin. You will be notified of the result.');
@@ -241,7 +222,8 @@ registerApprovalHandler('cli_command', async ({ payload, notify }) => {
const response = await dispatch(frame, callerContext, { approved: true });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
const localized = localizeIsoTimestamps(response.data);
const data = typeof localized === 'string' ? localized : JSON.stringify(localized, null, 2);
notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`);
} else {
notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`);
+84
View File
@@ -0,0 +1,84 @@
/**
* Compact aligned-table renderer for `ncl tasks list` (human mode only).
*
* A task series is a recurring job; each fire is a run. This surfaces the run history
* the raw row hides run count, last/next fire, schedule as an aligned table.
* Driven by the enriched rows from listTasks (tasks.ts); the --json path is
* untouched. `now` is injectable so the relative times are testable.
*/
interface TaskListRow {
series_id: string;
schedule?: string | null;
runs?: number;
failed_runs?: number;
last_run?: string | null;
next_run?: string | null;
status?: string;
log?: string | null;
created_at?: string | null;
prompt?: string | null;
}
const COLS = ['SERIES', 'SCHEDULE', 'RUNS', 'FAILED', 'LAST RUN', 'NEXT RUN', 'STATUS', 'AGE', 'PROMPT'] as const;
function parseMs(iso: string): number {
return Date.parse(/[Z+]|[+-]\d\d:\d\d$/.test(iso) ? iso : iso + 'Z');
}
/** "1m ago" / "in 30s" — coarse, human relative time. */
function duration(ms: number): string {
const s = Math.abs(ms) / 1000;
if (s < 60) return `${Math.round(s)}s`;
if (s < 3600) return `${Math.round(s / 60)}m`;
if (s < 86400) return `${Math.round(s / 3600)}h`;
return `${Math.round(s / 86400)}d`;
}
function lastRun(iso: string | null | undefined, now: number): string {
if (!iso) return '-';
const t = parseMs(iso);
if (Number.isNaN(t)) return iso;
return `${duration(now - t)} ago`;
}
function nextRun(iso: string | null | undefined, now: number): string {
if (!iso) return '-';
const t = parseMs(iso);
if (Number.isNaN(t)) return iso;
return t <= now ? 'due' : `in ${duration(t - now)}`;
}
/** AGE — how long since the series was created. */
function age(iso: string | null | undefined, now: number): string {
if (!iso) return '-';
const t = parseMs(iso);
return Number.isNaN(t) ? '-' : duration(now - t);
}
function clip(s: string | null | undefined, n: number): string {
const v = (s ?? '').replace(/\s+/g, ' ').trim();
return v.length > n ? v.slice(0, n - 1) + '…' : v;
}
export function formatTasksTable(rows: TaskListRow[], now: number = Date.now()): string {
if (!rows.length) return 'No tasks.';
const body = rows.map((r) => [
r.series_id, // full id, copy-pasteable into `ncl tasks get --id <…>`
r.schedule || 'once',
String(r.runs ?? 0),
String(r.failed_runs ?? 0),
lastRun(r.last_run, now),
nextRun(r.next_run, now),
r.status ?? '-',
age(r.created_at, now),
clip(r.prompt, 40),
]);
const widths = COLS.map((c, i) => Math.max(c.length, ...body.map((row) => row[i].length)));
const line = (cells: string[]) =>
cells
.map((c, i) => c.padEnd(widths[i]))
.join(' ')
.trimEnd();
return [line([...COLS]), ...body.map(line)].join('\n');
}
+29 -1
View File
@@ -8,10 +8,37 @@
* itself. The DB transport (when it lands) skips this layer entirely
* the agent sees frames directly.
*/
import { TIMEZONE } from '../config.js';
import { formatLocalStamp } from '../timezone.js';
import type { ResponseFrame } from './frame.js';
export type FormatMode = 'human' | 'json';
// A string is treated as a display timestamp only when the WHOLE value is a
// UTC ISO instant; embedded occurrences inside longer strings may be machine
// payloads and stay raw. Mirrored in container/agent-runner/src/cli/ncl.ts
// (the two runtimes share no modules).
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/;
/**
* Human display shows local time; --json keeps the ISO machine contract.
* The "YYYY-MM-DD HH:mm" stamp shape round-trips: parseZonedToUtc reads a
* naive string as local wall-clock time, so a value copied from `ncl tasks
* get` output into `--process-after` means what it shows.
*/
export function localizeIsoTimestamps(value: unknown): unknown {
if (typeof value === 'string') {
return ISO_UTC_RE.test(value) ? formatLocalStamp(new Date(value), TIMEZONE) : value;
}
if (Array.isArray(value)) return value.map(localizeIsoTimestamps);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, localizeIsoTimestamps(v)]),
);
}
return value;
}
export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
if (mode === 'json') return JSON.stringify(res, null, 2) + '\n';
@@ -21,7 +48,8 @@ export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
return formatHuman(res.data) + '\n';
}
function formatHuman(data: unknown): string {
function formatHuman(rawData: unknown): string {
const data = localizeIsoTimestamps(rawData);
if (data === null || data === undefined) return '';
if (typeof data === 'string') return data;
if (Array.isArray(data) && data.every(isFlatRecord)) {
+1 -1
View File
@@ -15,7 +15,7 @@ import type { CallerContext } from './frame.js';
* consumed by both dispatch enforcement and `ncl help` filtering, so the
* agent is never shown a resource the gate would reject (or vice versa).
*/
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members', 'tasks']);
export type Access = 'open' | 'approval' | 'hidden';
-18
View File
@@ -48,24 +48,6 @@ registerResource({
},
{ name: 'title', type: 'string', description: 'Card title shown to the admin.' },
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
{
name: 'approver_user_id',
type: 'string',
description: 'Named approver (exclusive) or the admin the card was delivered to (admins-of-scope).',
},
{
name: 'approver_rule',
type: 'string',
description: 'Who may resolve: only the named approver, or the admin chain of the anchoring group.',
enum: ['exclusive', 'admins-of-scope'],
},
{
name: 'approver_scope',
type: 'string',
description: "Blast radius: 'global' holds require an owner or global admin to resolve.",
enum: ['group', 'global'],
},
{ name: 'dedup_key', type: 'string', description: 'In-flight dedup key (e.g. sender admission per chat+sender).' },
],
operations: { list: 'open', get: 'open' },
});
+38 -7
View File
@@ -9,13 +9,15 @@ import { registerResource } from '../crud.js';
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily
* same pattern as container-runner.ts on container wake.
*
* Called from both `add` and `remove` so the live container picks up the
* change without waiting for the next spawn. Without this, send_message to
* the new local_name silently drops with "unknown destination" until restart.
* Called from every destination-mutating ncl command `add` and `remove`
* here, plus `wirings create` (which writes a companion destination row in
* its postCreate hook) so the live container picks up the change without
* waiting for the next spawn. Without this, send_message to the new
* local_name silently drops with "unknown destination" until restart.
* See the destination-projection invariant in
* src/modules/agent-to-agent/db/agent-destinations.ts.
*/
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
export async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
if (!hasTable(getDb(), 'agent_destinations')) return;
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
for (const session of getSessionsByAgentGroup(agentGroupId)) {
@@ -58,10 +60,39 @@ registerResource({
type: 'string',
description: "The target's ID — messaging_groups.id for channels, agent_groups.id for agents.",
},
{ name: 'channel_type', type: 'string', description: 'Resolved channel type for channel destinations.' },
{ name: 'display_name', type: 'string', description: 'Resolved chat title or agent name.' },
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
],
operations: { list: 'open' },
operations: {},
customOperations: {
list: {
access: 'open',
description: 'List destinations with resolved channel/title labels.',
handler: async (args) => {
const agentGroupId = (args.agent_group_id as string | undefined) ?? (args.id as string | undefined);
const params: unknown[] = [];
const where = agentGroupId ? 'WHERE ad.agent_group_id = ?' : '';
if (agentGroupId) params.push(agentGroupId);
return getDb()
.prepare(
`SELECT
ad.agent_group_id,
ad.local_name,
ad.target_type,
ad.target_id,
CASE WHEN ad.target_type = 'channel' THEN mg.channel_type ELSE NULL END AS channel_type,
CASE WHEN ad.target_type = 'channel' THEN mg.name ELSE ag.name END AS display_name,
ad.created_at
FROM agent_destinations ad
LEFT JOIN messaging_groups mg ON ad.target_type = 'channel' AND ad.target_id = mg.id
LEFT JOIN agent_groups ag ON ad.target_type = 'agent' AND ad.target_id = ag.id
${where}
ORDER BY ad.agent_group_id, ad.local_name`,
)
.all(...params);
},
},
add: {
access: 'approval',
description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.',
@@ -79,9 +110,9 @@ registerResource({
getDb()
.prepare(
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
VALUES (?, ?, ?, ?, ?)`,
)
.run(agentGroupId, localName, targetType, targetId);
.run(agentGroupId, localName, targetType, targetId, new Date().toISOString());
await projectDestinationsToSessions(agentGroupId);
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
},
+6 -7
View File
@@ -109,13 +109,10 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
VALUES (?, ?, 'req-1', 'cli_command', '{}', ?, ?, 'pending', '', '[]')`,
).run('pa-1', SID, now(), GID);
// Sessionless sender-admission hold anchored to the group (the folded
// pending_sender_approvals shape) — covered by the agent_group_id leg of
// the pending_approvals cascade.
db.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, status, title, options_json, dedup_key)
VALUES (?, NULL, 'req-2', 'sender_admit', '{}', ?, ?, 'pending', '', '[]', 'sender_admit:mg:tg:99')`,
).run('pa-2', now(), GID);
`INSERT INTO pending_sender_approvals (id, messaging_group_id, agent_group_id, sender_identity, sender_name, original_message, approver_user_id, created_at)
VALUES ('psa-1', ?, ?, 'tg:99', 'them', '{}', ?, ?)`,
).run(MGID, GID, UID, now());
db.prepare(
`INSERT INTO pending_channel_approvals (messaging_group_id, agent_group_id, original_message, approver_user_id, created_at)
@@ -151,9 +148,10 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
expect(data.removed).toMatchObject({
sessions: 1,
pending_questions: 1,
pending_approvals: 2,
pending_approvals: 1,
agent_destinations_owned: 1,
agent_destinations_pointing: 0,
pending_sender_approvals: 1,
pending_channel_approvals: 1,
messaging_group_agents: 1,
agent_group_members: 1,
@@ -169,6 +167,7 @@ describe('groups CLI delete cascades dependent rows (#2525)', () => {
count('SELECT COUNT(*) AS c FROM pending_approvals WHERE agent_group_id = ? OR session_id = ?', GID, SID),
).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM pending_sender_approvals WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM pending_channel_approvals WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE agent_group_id = ?', GID)).toBe(0);
expect(count('SELECT COUNT(*) AS c FROM agent_group_members WHERE agent_group_id = ?', GID)).toBe(0);
+14
View File
@@ -12,6 +12,7 @@ import {
updateContainerConfigScalars,
updateContainerConfigJson,
} from '../../db/container-configs.js';
import { initGroupFilesystem } from '../../group-init.js';
import { createAgentFromTemplate } from '../../templates/create-agent.js';
import type { AgentGroup, ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
@@ -90,6 +91,15 @@ registerResource({
created_at: new Date().toISOString(),
};
createAgentGroup(group);
// Provision the workspace folder and the `container_configs` row that
// `getContainerConfig` and the spawn path require. Without this, a
// group created via `ncl groups create` would throw "Container config
// not found" on first spawn and stay broken until the host restart
// backfill ran (#2415). The template branch above provisions its own
// config + folder in `createAgentFromTemplate`; this covers the bare
// path. Mirrors what `setup/register.ts` does after creating an agent
// group via the setup flow.
initGroupFilesystem(group);
return group;
},
},
@@ -124,6 +134,7 @@ registerResource({
pending_approvals: 0,
agent_destinations_owned: 0,
agent_destinations_pointing: 0,
pending_sender_approvals: 0,
pending_channel_approvals: 0,
messaging_group_agents: 0,
agent_group_members: 0,
@@ -152,6 +163,9 @@ registerResource({
.run(groupId, groupId).changes;
}
counts.sessions = db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(groupId).changes;
counts.pending_sender_approvals = db
.prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?')
.run(groupId).changes;
counts.pending_channel_approvals = db
.prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?')
.run(groupId).changes;
+1
View File
@@ -14,3 +14,4 @@ import './user-dms.js';
import './dropped-messages.js';
import './approvals.js';
import './sessions.js';
import './tasks.js';
+2 -2
View File
@@ -41,9 +41,9 @@ registerResource({
getDb()
.prepare(
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES (?, ?, ?, datetime('now'))`,
VALUES (?, ?, ?, ?)`,
)
.run(userId, groupId, addedBy);
.run(userId, groupId, addedBy, new Date().toISOString());
return { user_id: userId, agent_group_id: groupId };
},
},
@@ -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);
},
});
+2 -2
View File
@@ -41,9 +41,9 @@ registerResource({
getDb()
.prepare(
`INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
VALUES (?, ?, ?, ?, ?)`,
)
.run(userId, role, groupId, grantedBy);
.run(userId, role, groupId, grantedBy, new Date().toISOString());
return { user_id: userId, role, agent_group_id: groupId };
},
},
+576
View File
@@ -0,0 +1,576 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return {
...actual,
DATA_DIR: '/tmp/nanoclaw-test-cli-tasks',
GROUPS_DIR: '/tmp/nanoclaw-test-cli-tasks/groups',
TIMEZONE: 'UTC',
};
});
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
const TEST_DIR = '/tmp/nanoclaw-test-cli-tasks';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { createSession, findSessionByAgentGroup, getSessionsByAgentGroup, taskThreadId } from '../../db/sessions.js';
import { countDueMessages } from '../../db/session-db.js';
import { inboundDbPath, initSessionFolder } from '../../session-manager.js';
import { dispatch } from '../dispatch.js';
import { formatTasksTable } from '../format-tasks.js';
import type { CallerContext } from '../frame.js';
import './tasks.js';
import '../commands/index.js'; // registers tasks-help for the help-topic test
function now(): string {
return new Date().toISOString();
}
function createGroup(id: string): void {
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: now() });
}
function createChatSession(group: string, id: string): void {
createSession({
id,
agent_group_id: group,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: now(),
});
initSessionFolder(group, id);
}
function agentCtx(group = 'ag-1', session = 'chat-1'): CallerContext {
return { caller: 'agent', agentGroupId: group, sessionId: session, messagingGroupId: 'mg-1' };
}
describe('tasks CLI resource', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createGroup('ag-1');
createGroup('ag-2');
createChatSession('ag-1', 'chat-1');
createChatSession('ag-2', 'chat-2');
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('create writes the task into the group system session, not the caller chat session', async () => {
const resp = await dispatch(
{
id: 'req-1',
command: 'tasks-create',
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
},
agentCtx(),
);
expect(resp.ok).toBe(true);
if (!resp.ok) return;
const created = resp.data as { series_id: string; session_id: string };
expect(created.session_id).not.toBe('chat-1');
// The task lands in its own isolated per-series session, not the chat session.
const sessions = getSessionsByAgentGroup('ag-1');
const taskSession = sessions.find((s) => s.id === created.session_id);
expect(taskSession?.thread_id).toBe(taskThreadId(created.series_id));
const chatDb = new Database(inboundDbPath('ag-1', 'chat-1'), { readonly: true });
expect(chatDb.prepare("SELECT COUNT(*) AS count FROM messages_in WHERE kind = 'task'").get()).toEqual({
count: 0,
});
chatDb.close();
const systemDb = new Database(inboundDbPath('ag-1', created.session_id), { readonly: true });
const row = systemDb.prepare("SELECT content FROM messages_in WHERE kind = 'task'").get() as { content: string };
const content = JSON.parse(row.content);
expect(content).toMatchObject({ originSessionId: 'chat-1' });
expect(content.prompt).toContain('send a briefing');
expect(content.prompt).toContain(`tasks/${created.series_id}.md`); // log-path hint injected
systemDb.close();
});
it('tasks-list attaches a server-rendered human table (so the container agent gets it too)', async () => {
await dispatch(
{
id: 'c',
command: 'tasks-create',
args: { prompt: 'x', name: 'briefing', process_after: '2999-01-01T00:00:00Z' },
},
agentCtx(),
);
const resp = await dispatch({ id: 'l', command: 'tasks-list', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (!resp.ok) return;
// Red-on-delete guard for the dispatch wiring: the host renders format-tasks
// once and ships it as `human`, so the Bun container prints the aligned
// table instead of a raw column dump (it cannot import the host formatter).
expect(resp.human).toBeDefined();
expect(resp.human).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN/);
expect(resp.human).toContain('briefing-');
});
it('recurrence more frequent than 4x/day is refused with the quota warning', async () => {
const resp = await dispatch(
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'spam', recurrence: '*/2 * * * *' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.message).toContain('this task has not been scheduled');
expect(resp.error.message).toContain('ncl tasks create --help');
expect(resp.error.message).toContain('--dangerously-override-recurrence-limit');
}
});
it('exactly 4 fires/day passes; the override flag bypasses the limit', async () => {
const four = await dispatch(
{ id: 'c4', command: 'tasks-create', args: { prompt: 'x', name: 'four', recurrence: '0 0,6,12,18 * * *' } },
agentCtx(),
);
expect(four.ok).toBe(true);
const overridden = await dispatch(
{
id: 'co',
command: 'tasks-create',
args: { prompt: 'x', name: 'fast', recurrence: '*/30 * * * *', dangerously_override_recurrence_limit: true },
},
agentCtx(),
);
expect(overridden.ok).toBe(true);
});
it('a --script gate exempts frequent recurrence — the sanctioned monitor pattern', async () => {
const scripted = await dispatch(
{
id: 'cs',
command: 'tasks-create',
args: {
prompt: 'triage queue',
name: 'watch',
recurrence: '*/10 * * * *',
script: 'echo {"wakeAgent": false}',
},
},
agentCtx(),
);
expect(scripted.ok).toBe(true);
// update --recurrence on a task that already has a script: also exempt.
if (!scripted.ok) return;
const seriesId = (scripted.data as { series_id: string }).series_id;
const upd = await dispatch(
{ id: 'us', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *' } },
agentCtx(),
);
expect(upd.ok).toBe(true);
// …but clearing the script in the same update re-arms the guard.
const cleared = await dispatch(
{ id: 'uc', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *', script: 'none' } },
agentCtx(),
);
expect(cleared.ok).toBe(false);
});
it('the limit also guards update --recurrence (no create-slow-then-update bypass)', async () => {
const created = await dispatch(
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'sneak', recurrence: '0 9 * * *' } },
agentCtx(),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
const seriesId = (created.data as { series_id: string }).series_id;
const upd = await dispatch(
{ id: 'u', command: 'tasks-update', args: { id: seriesId, recurrence: '* * * * *' } },
agentCtx(),
);
expect(upd.ok).toBe(false);
if (!upd.ok) expect(upd.error.message).toContain('this task has not been scheduled');
});
it('tasks create --help carries the script contract and the frequency-limit caveat', async () => {
// --help and `tasks help create` render the same deep verb help.
const resp = await dispatch({ id: 'h', command: 'tasks-create', args: { help: true } }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const text = resp.data as string;
expect(text).toContain('wakeAgent');
expect(text).toContain('Frequency limit');
}
});
it('agent-shared lookup skips the task system session', async () => {
await dispatch(
{
id: 'req-1',
command: 'tasks-create',
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
},
agentCtx(),
);
expect(findSessionByAgentGroup('ag-1')?.id).toBe('chat-1');
});
it('group-scoped agents cannot list tasks from another group session', async () => {
const resp = await dispatch(
{ id: 'req-1', command: 'tasks-list', args: { session: 'chat-2' } },
agentCtx('ag-1', 'chat-1'),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('handler-error');
expect(resp.error.message).toContain('session not found');
}
});
it('--name yields a short, readable, fs/thread-safe id', async () => {
const r = await dispatch(
{
id: 'rn',
command: 'tasks-create',
args: { prompt: 'x', name: 'Morning Joke!!', process_after: '2999-01-01T00:00:00Z' },
},
agentCtx('ag-1', 'chat-1'),
);
expect(r.ok).toBe(true);
if (!r.ok) return;
const id = (r.data as { series_id: string }).series_id;
expect(id).toMatch(/^morning-joke-[0-9a-f]{4}$/);
expect(id).toMatch(/^[a-z0-9-]+$/); // safe as thread suffix / filename / --id
});
it('no name yields a t-<hex> id', async () => {
const r = await dispatch(
{ id: 'rnn', command: 'tasks-create', args: { prompt: 'x', process_after: '2999-01-01T00:00:00Z' } },
agentCtx('ag-1', 'chat-1'),
);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect((r.data as { series_id: string }).series_id).toMatch(/^t-[0-9a-f]{6}$/);
});
it('recurring create derives the first run from the cron grid when --process-after is omitted', async () => {
const r = await dispatch(
{ id: 'rec', command: 'tasks-create', args: { prompt: 'x', name: 'nightly', recurrence: '0 9 * * 1-5' } },
agentCtx('ag-1', 'chat-1'),
);
expect(r.ok).toBe(true);
if (!r.ok) return;
const task = r.data as { process_after: string; recurrence: string };
expect(task.recurrence).toBe('0 9 * * 1-5');
// First fire snapped onto the cron grid (TIMEZONE=UTC in this suite).
const firstRun = new Date(task.process_after);
expect(Number.isNaN(firstRun.getTime())).toBe(false);
expect(firstRun.getUTCHours()).toBe(9);
expect(firstRun.getTime()).toBeGreaterThan(Date.now());
});
it('one-shot create still requires --process-after (nothing to derive it from)', async () => {
const r = await dispatch(
{ id: 'os', command: 'tasks-create', args: { prompt: 'x', name: 'once' } },
agentCtx('ag-1', 'chat-1'),
);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error.message).toContain('--process-after is required');
});
it('run queues an extra immediate occurrence without consuming the scheduled one', async () => {
const created = await dispatch(
{
id: 'c',
command: 'tasks-create',
args: { prompt: 'x', name: 'pingable', process_after: '2999-01-01T00:00:00Z' },
},
agentCtx('ag-1', 'chat-1'),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
const run = await dispatch({ id: 'r', command: 'tasks-run', args: { id: series_id } }, agentCtx('ag-1', 'chat-1'));
expect(run.ok).toBe(true);
if (!run.ok) return;
const fired = run.data as { series_id: string; row_id: string; status: string };
expect(fired.series_id).toBe(series_id);
expect(fired.row_id).not.toBe(series_id);
expect(fired.status).toBe('pending');
const db = new Database(inboundDbPath('ag-1', session_id), { readonly: true });
const pending = db
.prepare(
"SELECT id, recurrence, process_after FROM messages_in WHERE kind = 'task' AND status = 'pending' AND series_id = ?",
)
.all(series_id) as Array<{ id: string; recurrence: string | null; process_after: string }>;
db.close();
// Original scheduled row + the new run-now occurrence both still pending.
expect(pending).toHaveLength(2);
const runRow = pending.find((p) => p.id === fired.row_id);
expect(runRow?.recurrence).toBeNull(); // never re-armed into a phantom series
expect(new Date(runRow!.process_after).getTime()).toBeLessThanOrEqual(Date.now());
});
it('task object exposes origin_session_id and created_at', async () => {
const r = await dispatch(
{
id: 'ro',
command: 'tasks-create',
args: { prompt: 'x', name: 'o', process_after: '2999-01-01T00:00:00Z' },
},
agentCtx('ag-1', 'chat-1'),
);
expect(r.ok).toBe(true);
if (!r.ok) return;
const d = r.data as { origin_session_id: string | null; created_at: string };
expect(d.origin_session_id).toBe('chat-1'); // the session that created it
expect(d.created_at).toBeTruthy();
});
it('each task gets its own isolated session, and list fans out across them', async () => {
const a = await dispatch(
{ id: 'r-a', command: 'tasks-create', args: { prompt: 'task A', process_after: '2026-01-15T09:00:00Z' } },
agentCtx('ag-1', 'chat-1'),
);
const b = await dispatch(
{ id: 'r-b', command: 'tasks-create', args: { prompt: 'task B', process_after: '2026-01-15T09:00:00Z' } },
agentCtx('ag-1', 'chat-1'),
);
expect(a.ok && b.ok).toBe(true);
if (!a.ok || !b.ok) return;
const ta = a.data as { session_id: string; series_id: string };
const tb = b.data as { session_id: string; series_id: string };
// Distinct per-series sessions — not one shared system:tasks session.
expect(ta.session_id).not.toBe(tb.session_id);
// list (no --session) fans out across every task session in the group.
const list = await dispatch({ id: 'r-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
expect(list.ok).toBe(true);
if (!list.ok) return;
const ids = (list.data as Array<{ series_id: string }>).map((t) => t.series_id);
expect(ids).toContain(ta.series_id);
expect(ids).toContain(tb.series_id);
});
it('list enriches each series with run history (CronJob view)', async () => {
const created = await dispatch(
{
id: 'r-agg',
command: 'tasks-create',
args: { prompt: 'brain digest', recurrence: '0 9 * * *', 'process-after': '2026-01-15T09:05:00Z' },
},
agentCtx('ag-1', 'chat-1'),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
const { session_id, series_id } = created.data as { session_id: string; series_id: string };
// Seed three completed fires for this series into its own session inbound.db.
const db = new Database(inboundDbPath('ag-1', session_id));
const ins = db.prepare(
'INSERT INTO messages_in (id, seq, timestamp, status, tries, kind, content, series_id, process_after) ' +
"VALUES (?, ?, datetime('now'), 'completed', 0, 'task', '{}', ?, ?)",
);
ins.run('run-1', 100, series_id, '2026-01-15T09:02:00Z');
ins.run('run-2', 102, series_id, '2026-01-15T09:03:00Z');
ins.run('run-3', 104, series_id, '2026-01-15T09:04:00Z');
db.close();
const list = await dispatch({ id: 'r-agg-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
expect(list.ok).toBe(true);
if (!list.ok) return;
const row = (list.data as Array<Record<string, unknown>>).find((t) => t.series_id === series_id);
expect(row).toBeDefined();
expect(row?.runs).toBe(3);
expect(row?.last_run).toBe('2026-01-15T09:04:00Z'); // max completed process_after
expect(String(row?.next_run)).toMatch(/^2026-01-15T09:05:00/); // the live pending occurrence
expect(row?.schedule).toBe('0 9 * * *');
expect(row?.log).toBe(`tasks/${series_id}.md`);
});
// The schedule→wake primitive without a container: a task created through the
// real `ncl tasks create` path must land in the agent group's system session
// AND be counted by the same due-message query the host sweep uses to decide a
// wake. Goes red if trigger defaulting, system-session routing, or the due
// predicate ever drift apart.
describe('a due task makes the system session wakeable', () => {
it('countDueMessages sees a past task and ignores a future one', async () => {
const created = await dispatch(
{ id: 'r-due', command: 'tasks-create', args: { prompt: 'run me', 'process-after': '2020-01-01T00:00:00Z' } },
agentCtx('ag-1', 'chat-1'),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
const systemId = (created.data as { session_id: string }).session_id;
const dueDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
expect(countDueMessages(dueDb)).toBe(1); // host sweep would wake this session
dueDb.close();
// A far-future task in the same system session is not yet due.
const future = await dispatch(
{ id: 'r-fut', command: 'tasks-create', args: { prompt: 'later', 'process-after': '2999-01-01T00:00:00Z' } },
agentCtx('ag-1', 'chat-1'),
);
expect(future.ok).toBe(true);
const stillDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
expect(countDueMessages(stillDb)).toBe(1); // still just the one past task
stillDb.close();
});
});
describe('append-log', () => {
const logFile = (folder: string, series: string) => `${TEST_DIR}/groups/${folder}/tasks/${series}.md`;
it('writes a host-timestamped line to the run log and creates the file (explicit --id)', async () => {
const resp = await dispatch(
{ id: 'al-1', command: 'tasks-append-log', args: { id: 'my-task-1', msg: 'did the thing; it worked' } },
agentCtx('ag-1', 'chat-1'),
);
expect(resp.ok).toBe(true);
if (!resp.ok) return;
const content = fs.readFileSync(logFile('ag-1', 'my-task-1'), 'utf8').trim();
// Local-time stamp (formatLocalStamp): "YYYY-MM-DD HH:mm".
expect(content).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} — did the thing; it worked$/);
});
it('derives the series from the caller task session when --id is omitted', async () => {
const created = await dispatch(
{
id: 'al-c',
command: 'tasks-create',
args: { name: 'derive-me', prompt: 'x', 'process-after': '2999-01-01T00:00:00Z' },
},
agentCtx('ag-1', 'chat-1'),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
// The fire runs INSIDE that task session, so no --id is needed.
const resp = await dispatch(
{ id: 'al-2', command: 'tasks-append-log', args: { msg: 'auto-derived run' } },
agentCtx('ag-1', session_id),
);
expect(resp.ok).toBe(true);
if (!resp.ok) return;
expect((resp.data as { series: string }).series).toBe(series_id);
expect(fs.readFileSync(logFile('ag-1', series_id), 'utf8')).toContain('auto-derived run');
});
it('requires --msg', async () => {
const resp = await dispatch(
{ id: 'al-3', command: 'tasks-append-log', args: { id: 'my-task-1' } },
agentCtx('ag-1', 'chat-1'),
);
expect(resp.ok).toBe(false);
if (!resp.ok) expect(resp.error.message).toContain('--msg is required');
});
it('errors when there is no --id and the caller is not in a task session', async () => {
// chat-1 is a normal chat session, not system:tasks:* → nothing to derive.
const resp = await dispatch(
{ id: 'al-4', command: 'tasks-append-log', args: { msg: 'orphan' } },
agentCtx('ag-1', 'chat-1'),
);
expect(resp.ok).toBe(false);
if (!resp.ok) expect(resp.error.message).toMatch(/--id is required/);
});
});
});
describe('formatTasksTable', () => {
const now = Date.parse('2026-01-15T09:05:30Z');
const rows = [
{
series_id: 'task-5bbe082a-6298-4699',
schedule: '* * * * *',
runs: 7,
failed_runs: 2,
last_run: '2026-01-15T09:04:30Z',
next_run: '2026-01-15T09:06:00Z',
status: 'pending',
log: 'tasks/task-5bbe082a.md',
created_at: '2026-01-15T08:05:30Z', // 1h before now
prompt: 'You are NanoClaw, wired into the company brain, your job this run is to read it',
},
];
it('renders an aligned table with run history', () => {
const lines = formatTasksTable(rows, now).split('\n');
expect(lines[0]).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN\s+STATUS\s+AGE\s+PROMPT/);
expect(lines[1]).toContain('1h'); // AGE column — created 1h ago
expect(lines[1]).toContain('task-5bbe082a-6298-4699'); // FULL series id — copy-pasteable into `tasks get --id`
expect(lines[1]).toContain('* * * * *');
expect(lines[1]).toContain('1m ago'); // 09:04:30 vs 09:05:30
expect(lines[1]).toContain('in 30s'); // 09:06:00 vs 09:05:30
expect(lines[1]).toContain('…'); // prompt truncated
});
it('handles a never-fired series and an empty list', () => {
expect(formatTasksTable([], now)).toBe('No tasks.');
const oneShot = formatTasksTable(
[
{
series_id: 'task-x',
schedule: 'once',
runs: 0,
last_run: null,
next_run: '2026-01-15T09:00:00Z',
status: 'pending',
},
],
now,
).split('\n')[1];
expect(oneShot).toContain('once');
expect(oneShot).toMatch(/\bdue\b/); // next_run in the past → due
expect(oneShot).toContain('-'); // last_run '-' (never fired)
});
});
describe('deep verb help (ncl tasks help create)', () => {
it('resolves through the dispatcher fallback and renders the full contract + examples', async () => {
// Side-effect import mirrors the CLI server boot: registers <plural>-help.
await import('../commands/index.js');
const resp = await dispatch({ id: 'h1', command: 'tasks-help-create', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(true);
if (resp.ok) {
const text = resp.data as string;
expect(text).toContain('ncl tasks create');
expect(text).toContain('wakeAgent'); // full multi-line script contract present
expect(text).toContain('Examples:'); // examples block rendered
}
});
it('rejects an unknown verb with a pointer back to resource help', async () => {
await import('../commands/index.js');
const resp = await dispatch({ id: 'h2', command: 'tasks-help-frobnicate', args: {} }, { caller: 'host' });
expect(resp.ok).toBe(false);
});
});
+784
View File
@@ -0,0 +1,784 @@
import { randomUUID } from 'crypto';
import fs from 'fs';
import type Database from 'better-sqlite3';
import { CronExpressionParser } from 'cron-parser';
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import {
findTaskSessions,
getActiveSessions,
getSession,
isTaskThread,
TASKS_SYSTEM_THREAD_ID,
} from '../../db/sessions.js';
import {
cancelAllTasks,
cancelTask,
deleteTask,
insertTaskRow,
pauseTask,
resumeTask,
updateTask,
type TaskUpdate,
} from '../../modules/scheduling/db.js';
import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js';
import { formatLocalStamp, parseZonedToUtc } from '../../timezone.js';
import { registerResource } from '../crud.js';
import { formatTasksTable } from '../format-tasks.js';
import type { CallerContext } from '../frame.js';
type TaskStatus = 'pending' | 'paused';
interface TaskRow {
row_id: string;
series_id: string | null;
status: string;
process_after: string | null;
recurrence: string | null;
content: string;
timestamp: string;
tries: number;
seq: number;
}
interface ScopedSession {
id: string;
agent_group_id: string;
}
function str(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function bool(value: unknown): boolean {
return value === true || value === 'true' || value === '1';
}
/**
* Short, readable, filesystem/thread-safe task id. With a name `<slug>-<4hex>`
* (e.g. "Morning joke" `morning-joke-a25c`); without `t-<6hex>`. Always
* matches /^[a-z0-9-]+$/ so it is safe as a thread suffix (`system:tasks:<id>`),
* a filename (`tasks/<id>.md`), and a copy-pasteable --id.
*/
function makeTaskId(name: unknown): string {
const hex = (n: number): string => randomUUID().replace(/-/g, '').slice(0, n);
const slug =
typeof name === 'string'
? name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 24)
.replace(/-+$/g, '')
: '';
return slug ? `${slug}-${hex(4)}` : `t-${hex(6)}`;
}
function parseProcessAfter(value: unknown): string {
const raw = str(value);
if (!raw) throw new Error('--process-after is required');
const date = parseZonedToUtc(raw, TIMEZONE);
if (Number.isNaN(date.getTime())) throw new Error(`invalid --process-after: ${raw}`);
return date.toISOString();
}
/**
* First-run timestamp for a new task. When a recurrence is given but no
* --process-after, derive the first fire from the cron grid (in TIMEZONE) so the
* common recurring case is a single flag `--recurrence "0 9 * * 1-5"` with no
* redundant, easily-stale hand-picked instant. --process-after is still required
* for one-shots (no recurrence to derive from) and still wins when supplied.
*/
function firstRunIso(value: unknown, recurrence: string | null): string {
if (str(value) === undefined && recurrence) {
const next = CronExpressionParser.parse(recurrence, { tz: TIMEZONE }).next().toISOString();
if (!next) throw new Error(`--recurrence has no upcoming run: ${recurrence}`);
return next;
}
return parseProcessAfter(value);
}
function normalizeNullableString(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== 'string') return String(value);
const trimmed = value.trim();
if (trimmed === '' || trimmed === 'null' || trimmed === 'none') return null;
return value;
}
function validateRecurrence(value: string | null | undefined): void {
if (!value) return;
try {
CronExpressionParser.parse(value, { tz: TIMEZONE });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`invalid --recurrence: ${msg}`, { cause: err });
}
}
/**
* Frequency guard: refuse recurrences more frequent than 4 fires/day unless
* the agent explicitly overrides. Frequent tasks burn the user's quota (or
* get their account banned) the sanctioned pattern is a slower cron plus a
* pre-task gate script that checks an external condition and only wakes the
* agent when something changed (`ncl tasks create --help`). Counted over the
* next 24h from now in the instance timezone, so uneven crons are judged by
* what they would actually do.
*/
const MAX_DAILY_FIRES = 4;
const RECURRENCE_LIMIT_WARNING =
'Warning: this task has not been scheduled. Frequent running tasks consume the ' +
"user's subscription quota or unnecessarily use tokens and can cause the user's " +
'account to be banned. Instead, use a pre-task run script that you write that can ' +
'check some kind of external condition, usually via one or more API calls. The ' +
'script returns a decision programmatically whether the task needs to be run now ' +
'or not. For example, an API call to GitHub to check if there are open PRs, and ' +
'only run when there are new open PRs.\n' +
'Run `ncl tasks create --help` to get full directions on how to write a script and test it.\n\n' +
'Note: if and only if you explicitly need to schedule a task more frequently and ' +
"you've verified with the user that they understand and that this is what they " +
'want and based on your judgment you agree that this is the right thing to do in ' +
'this situation, you can override this with --dangerously-override-recurrence-limit';
function enforceRecurrenceLimit(recurrence: string | null, override: boolean, hasScript: boolean): void {
// A gate script IS the sanctioned mitigation the warning steers toward — a
// script-gated fire that finds nothing never wakes the agent, so scripted
// tasks may run at any cadence without the override.
if (!recurrence || override || hasScript) return;
const horizon = Date.now() + 24 * 60 * 60 * 1000;
const interval = CronExpressionParser.parse(recurrence, { tz: TIMEZONE });
let fires = 0;
while (fires <= MAX_DAILY_FIRES) {
const next = interval.next();
if (next.getTime() > horizon) break;
fires++;
}
if (fires > MAX_DAILY_FIRES) throw new Error(RECURRENCE_LIMIT_WARNING);
}
function statusFilter(args: Record<string, unknown>): TaskStatus | undefined {
const status = str(args.status);
if (!status) return undefined;
if (status !== 'pending' && status !== 'paused') {
throw new Error('--status must be pending or paused');
}
return status;
}
function groupArg(args: Record<string, unknown>, ctx: CallerContext): string | undefined {
if (ctx.caller === 'agent') return ctx.agentGroupId;
return str(args.group) ?? str(args.agent_group_id);
}
function ownSession(sessionId: string, ctx: CallerContext): ScopedSession {
const session = getSession(sessionId);
if (!session) throw new Error(`session not found: ${sessionId}`);
if (ctx.caller === 'agent' && session.agent_group_id !== ctx.agentGroupId) {
throw new Error(`session not found: ${sessionId}`);
}
return { id: session.id, agent_group_id: session.agent_group_id };
}
function selectedSessions(args: Record<string, unknown>, ctx: CallerContext): ScopedSession[] {
const sessionId = str(args.session);
if (sessionId) return [ownSession(sessionId, ctx)];
const group = groupArg(args, ctx);
if (group) {
// One session per live task series — the loops below already fan out across them.
return findTaskSessions(group).map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
}
if (ctx.caller === 'agent') return [];
return getActiveSessions().map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
}
function withInbound<T>(session: ScopedSession, fn: (db: Database.Database) => T): T | undefined {
if (!fs.existsSync(inboundDbPath(session.agent_group_id, session.id))) return undefined;
return withInboundDb(session.agent_group_id, session.id, fn);
}
function parseContent(raw: string): { prompt: string; script: string | null; originSessionId: string | null } {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
script: typeof parsed.script === 'string' ? parsed.script : null,
originSessionId: typeof parsed.originSessionId === 'string' ? parsed.originSessionId : null,
};
} catch {
// LEGACY-COMPAT(v1-tasks): plain-string content from rows that predate the
// JSON envelope. Removable once no pre-v2 session DBs remain in the wild.
return { prompt: raw, script: null, originSessionId: null };
}
}
function toOutput(session: ScopedSession, row: TaskRow) {
const content = parseContent(row.content);
return {
agent_group_id: session.agent_group_id,
session_id: session.id,
series_id: row.series_id ?? row.row_id,
row_id: row.row_id,
status: row.status,
process_after: row.process_after,
recurrence: row.recurrence,
prompt: content.prompt.length > 120 ? content.prompt.slice(0, 117) + '...' : content.prompt,
has_script: content.script ? 1 : 0,
origin_session_id: content.originSessionId, // which session created the task (null for CLI-created)
created_at: row.timestamp,
tries: row.tries,
};
}
function selectLiveTasks(db: Database.Database, status?: TaskStatus): TaskRow[] {
const statusSql = status ? 'status = ?' : "status IN ('pending', 'paused')";
return db
.prepare(
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, MAX(seq) AS seq
FROM messages_in
WHERE kind = 'task'
AND ${statusSql}
GROUP BY series_id
ORDER BY datetime(process_after) ASC, seq ASC`,
)
.all(...(status ? [status] : [])) as TaskRow[];
}
function selectTask(db: Database.Database, id: string): TaskRow | undefined {
return db
.prepare(
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, seq
FROM messages_in
WHERE kind = 'task'
AND (id = ? OR series_id = ?)
ORDER BY CASE WHEN status IN ('pending', 'paused') THEN 0 ELSE 1 END, seq DESC
LIMIT 1`,
)
.get(id, id) as TaskRow | undefined;
}
function taskId(args: Record<string, unknown>): string {
const id = str(args.id);
if (!id) throw new Error('task series id is required');
return id;
}
function createTask(args: Record<string, unknown>, ctx: CallerContext) {
const group = groupArg(args, ctx);
if (!group) throw new Error('--group is required');
const prompt = str(args.prompt);
if (!prompt) throw new Error('--prompt is required');
const recurrence = normalizeNullableString(args.recurrence) ?? null;
validateRecurrence(recurrence);
const script = normalizeNullableString(args.script) ?? null;
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), script != null);
const processAfter = firstRunIso(args.process_after, recurrence);
const id = makeTaskId(args.name);
const originSessionId = ctx.caller === 'agent' ? ctx.sessionId : null;
// Each series runs in its own isolated session; point the fire at its own log.
const { session } = resolveTaskSession(group, id);
const promptWithLog =
`${prompt}\n\n` +
`[A task serves the user two separate ways — do whichever the task above asks for, and ALWAYS the run log:\n` +
`• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — <message to="name">…</message> or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` +
`• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` +
` ncl tasks append-log --msg "<what you did, and why it mattered>"\n` +
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the local time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
`Need context from past runs? Read tasks/${id}.md first.]`;
const created = withInbound(session, (db) => {
insertTaskRow(db, {
id,
seriesId: id,
processAfter,
recurrence,
content: JSON.stringify({ prompt: promptWithLog, script, originSessionId }),
});
return selectTask(db, id);
});
if (!created) throw new Error('task system session inbound.db not found');
return toOutput(session, created);
}
/**
* Append one host-timestamped line to a task's run log
* (`<GROUPS_DIR>/<folder>/tasks/<series>.md`). This is NOT a delivery it writes
* nothing to messages_out; it just records what happened so the agent (and human)
* can see when and why each fire ran. Inside a task fire the series is derived from
* the caller's own task session, so the agent supplies only --msg.
*/
function appendTaskLog(
args: Record<string, unknown>,
ctx: CallerContext,
): { series: string; timestamp: string; path: string; ok: true } {
const msg = str(args.msg);
if (!msg) throw new Error('--msg is required');
let series = str(args.id);
let group = groupArg(args, ctx);
if (!series && ctx.caller === 'agent' && ctx.sessionId) {
const sess = getSession(ctx.sessionId);
if (sess && sess.thread_id && isTaskThread(sess.thread_id)) {
series = sess.thread_id.slice(`${TASKS_SYSTEM_THREAD_ID}:`.length);
group ??= sess.agent_group_id;
}
}
if (!series) throw new Error('--id is required (no task session to derive it from)');
// Charset guard is the security boundary here: blocks path traversal and keeps
// the id safe as a filename / thread suffix. Group scope is already enforced by
// groupArg (a cli_scope=group caller can only ever resolve its own folder), so a
// foreign id at worst writes a stray log under the caller's OWN folder — no leak.
if (!/^[a-z0-9-]+$/.test(series)) throw new Error(`invalid task id: ${series}`);
if (!group) throw new Error('could not resolve the agent group');
const ag = getAgentGroup(group);
if (!ag) throw new Error(`agent group not found: ${group}`);
const timestamp = formatLocalStamp(new Date(), TIMEZONE);
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
const file = `${dir}/${series}.md`;
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(file, `${timestamp}${msg}\n`);
return { series, timestamp, path: file, ok: true };
}
/**
* Run history for one task series, aggregated over its occurrence rows: number
* of successful fires, the last fire time, and failed fires (a row reaches
* `failed` after MAX_TRIES on a stuck claim). Cancelled occurrences are
* `cancelled`, not `completed`, so they never inflate the run count.
*/
function seriesStats(
db: Database.Database,
seriesKey: string,
): { runs: number; last_run: string | null; failed_runs: number } {
return db
.prepare(
`SELECT
COUNT(*) FILTER (WHERE status = 'completed') AS runs,
MAX(process_after) FILTER (WHERE status = 'completed') AS last_run,
COUNT(*) FILTER (WHERE status = 'failed') AS failed_runs
FROM messages_in
WHERE kind = 'task' AND (id = ? OR series_id = ?)`,
)
.get(seriesKey, seriesKey) as { runs: number; last_run: string | null; failed_runs: number };
}
/** Last ~10 lines of a series' run log (`tasks/<series>.md`), newest last. */
function tailRunLog(agentGroupId: string, seriesKey: string, lines = 10): string[] {
const ag = getAgentGroup(agentGroupId);
if (!ag) return [];
const file = `${GROUPS_DIR}/${ag.folder}/tasks/${seriesKey}.md`;
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, 'utf8').trimEnd().split('\n').filter(Boolean).slice(-lines);
}
/**
* A task series is CronJob-like: the live (pending/paused) row is the next run,
* and the `completed` rows are its run history. Enrich each listed series with
* that history run count, failures, last fire, next fire, schedule, and a
* pointer to the agent's own run log so `tasks list` reads as a compact
* run-history table.
*/
function enrichListRow(db: Database.Database, base: ReturnType<typeof toOutput>) {
const seriesKey = base.series_id;
const stats = seriesStats(db, seriesKey);
return {
...base,
schedule: base.recurrence ?? 'once',
runs: stats.runs,
failed_runs: stats.failed_runs,
last_run: stats.last_run,
next_run: base.process_after,
log: `tasks/${seriesKey}.md`,
};
}
function listTasks(args: Record<string, unknown>, ctx: CallerContext) {
const status = statusFilter(args);
const rows = [];
for (const session of selectedSessions(args, ctx)) {
const sessionRows = withInbound(session, (db) =>
selectLiveTasks(db, status).map((row) => enrichListRow(db, toOutput(session, row))),
);
if (sessionRows) rows.push(...sessionRows);
}
return rows;
}
function getTask(args: Record<string, unknown>, ctx: CallerContext) {
const id = taskId(args);
for (const session of selectedSessions(args, ctx)) {
const found = withInbound(session, (db) => {
const row = selectTask(db, id);
if (!row) return undefined;
const seriesKey = row.series_id ?? row.row_id;
const stats = seriesStats(db, seriesKey);
const content = parseContent(row.content);
return {
...toOutput(session, row),
prompt: content.prompt,
script: content.script,
origin_session_id: content.originSessionId,
completed_runs: stats.runs,
failed_runs: stats.failed_runs,
recent_log: tailRunLog(session.agent_group_id, seriesKey),
};
});
if (found) return found;
}
throw new Error(`task not found: ${id}`);
}
function mutateTask(
args: Record<string, unknown>,
ctx: CallerContext,
fn: (db: Database.Database, id: string) => number,
) {
const id = taskId(args);
let touched = 0;
for (const session of selectedSessions(args, ctx)) {
touched += withInbound(session, (db) => fn(db, id)) ?? 0;
}
if (touched === 0) throw new Error(`no live task matched: ${id}`);
return { series_id: id, touched };
}
function updateTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
const id = taskId(args);
const update: TaskUpdate = {};
if (typeof args.prompt === 'string') update.prompt = args.prompt;
if (args.process_after !== undefined) update.processAfter = parseProcessAfter(args.process_after);
const recurrence = normalizeNullableString(args.recurrence);
const script = normalizeNullableString(args.script);
if (recurrence !== undefined) {
validateRecurrence(recurrence);
// Effective script AFTER this update: the new value when provided
// (including an explicit clear), else whatever the task already has.
let scriptAfter: string | null = script !== undefined ? script : null;
if (script === undefined) {
for (const session of selectedSessions(args, ctx)) {
const row = withInbound(session, (db) => selectTask(db, id));
if (row) {
scriptAfter = parseContent(row.content).script;
break;
}
}
}
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), scriptAfter != null);
update.recurrence = recurrence;
}
if (script !== undefined) update.script = script;
const fields = Object.keys(update);
if (fields.length === 0) throw new Error('nothing to update');
let touched = 0;
for (const session of selectedSessions(args, ctx)) {
touched += withInbound(session, (db) => updateTask(db, id, update)) ?? 0;
}
if (touched === 0) throw new Error(`no live task matched: ${id}`);
return { series_id: id, touched, fields };
}
function cancelTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
if (!bool(args.all)) {
return mutateTask(args, ctx, cancelTask);
}
let touched = 0;
for (const session of selectedSessions(args, ctx)) {
touched += withInbound(session, cancelAllTasks) ?? 0;
}
return { cancelled: touched };
}
/**
* `ncl tasks run <id>` fire a task on demand without disturbing its schedule.
* Inserts a fresh pending occurrence (same series, content, no recurrence) due
* now, which the next sweep delivers through the normal fire path. Unlike
* `update --process-after now`, it neither consumes a one-shot nor force-advances
* a recurring series' armed occurrence, so it is safe for testing a task.
*/
function runTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
const id = taskId(args);
for (const session of selectedSessions(args, ctx)) {
const fired = withInbound(session, (db) => {
const row = selectTask(db, id);
if (!row) return undefined;
const seriesKey = row.series_id ?? row.row_id;
const rowId = makeTaskId(`${seriesKey}-run`);
// recurrence=NULL is load-bearing: a run-now row must not be re-armed by
// handleRecurrence into a phantom series.
insertTaskRow(db, {
id: rowId,
seriesId: seriesKey,
processAfter: new Date().toISOString(),
recurrence: null,
content: row.content,
});
return { series_id: seriesKey, row_id: rowId, status: 'pending' };
});
if (fired) return fired;
}
throw new Error(`task not found: ${id}`);
}
registerResource({
name: 'task',
plural: 'tasks',
table: 'messages_in',
description:
'Scheduled task — prompt plus run time. Tasks run from the agent group system session and the agent chooses delivery destination at fire time.',
idColumn: 'series_id',
scopeField: 'agent_group_id',
columns: [
{ name: 'series_id', type: 'string', description: 'Stable task handle.', generated: true },
{ name: 'agent_group_id', type: 'string', description: 'Agent group that owns the task.' },
{ name: 'session_id', type: 'string', description: 'System session that runs the task.' },
{ name: 'status', type: 'string', description: 'Live state.', enum: ['pending', 'paused'] },
{
name: 'process_after',
type: 'string',
// Not flagged required: with --recurrence the first run is derived from the
// cron grid (firstRunIso). Required only for one-shots, enforced in the
// create handler — so the generic col.required validator must stay off here.
description:
'Next run time (ISO 8601 or naive local). Required for one-shots; with --recurrence the first run is derived from the cron grid.',
updatable: true,
},
{ name: 'recurrence', type: 'string', description: 'Optional cron expression.', updatable: true },
{ name: 'prompt', type: 'string', description: 'Task prompt.', required: true, updatable: true },
{ name: 'script', type: 'string', description: 'Optional pre-task bash script.', updatable: true },
],
operations: {},
customOperations: {
list: {
access: 'open',
description: 'List live tasks with per-series run history (schedule, runs, failures, next fire).',
args: [
{ name: 'status', type: 'string', description: 'Filter by live state.', enum: ['pending', 'paused'] },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
{
name: 'all',
type: 'boolean',
description: 'List across all groups (host default when no --group; accepted for explicitness).',
},
],
handler: async (args, ctx) => listTasks(args, ctx),
// Server-rendered run-history table (frame `human` field) — the container
// agent gets the same legible view as the host CLI without a Bun-side
// formatter copy.
formatHuman: (rows) => formatTasksTable(rows as Parameters<typeof formatTasksTable>[0]),
},
get: {
access: 'open',
description: 'Get a task by series id.',
args: [
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
],
handler: async (args, ctx) => getTask(args, ctx),
},
create: {
access: 'open',
description:
`Create a scheduled task (recurring or one-shot) in the agent group system session.\n\n` +
`Requires --prompt plus EITHER --recurrence (recurring; first run derived from the cron grid) OR --process-after (one-shot, ISO 8601 or naive local). Always pass --name for a readable id.\n\n` +
`--script contract (pre-task gate, runs BEFORE the agent wakes):\n` +
` bash, 30s timeout, 1MB output cap. Its LAST stdout line must be JSON:\n` +
` {"wakeAgent": <bool>, "data": {...}}\n` +
` wakeAgent=false marks the run handled without waking the agent (zero tokens);\n` +
` wakeAgent=true wakes the agent with data attached to the prompt.\n` +
` DO: print the JSON as the very last line, exit 0, keep data small (a summary, not a dump).\n` +
` DON'T: print anything after the JSON, prompt for input, or rely on state from previous runs.\n` +
` Always test with bash -c '<script>' before scheduling.\n` +
` Persist state between fires under the group workspace (e.g. a last-seen id file).\n` +
` Use good judgement on whether to share with the user the script (only if they are technical), a description of the script condition, or whether there's no need.\n\n` +
`Frequency limit: recurrences more frequent than ${MAX_DAILY_FIRES} fires/day are refused unless the task\n` +
`carries a --script gate (the script decides whether each fire needs you — a gated fire that\n` +
`finds nothing costs zero tokens) or you pass --dangerously-override-recurrence-limit after\n` +
`the user explicitly confirmed they want an ungated frequent task.\n\n` +
`Failure backoff: a script that ERRORS repeatedly backs the series off (2,4,8,…60 min between fires; each errored fire counts as a failed run); after 8 consecutive failures the series is auto-paused with a note in its run log — fix the script, then \`ncl tasks resume <id>\`. A deliberate wakeAgent=false is a normal run and never backs off. \`ncl tasks get <id>\` shows failed_runs and the run log.`,
args: [
{
name: 'name',
type: 'string',
description: 'Short descriptive name → readable task id (<slug>-<hex>). Without it, ids are t-<hex>.',
},
{ name: 'prompt', type: 'string', description: 'Task prompt the agent wakes to.', required: true },
{
name: 'recurrence',
type: 'string',
description:
'Cron expression (instance TZ). First run derives from the cron grid when --process-after is omitted.',
},
{
name: 'dangerously_override_recurrence_limit',
type: 'boolean',
description:
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
},
{
name: 'process_after',
type: 'string',
description: 'First/next run time (ISO 8601 or naive local). Required for one-shots.',
},
{
name: 'script',
type: 'string',
description: 'Pre-task gate script (bash) — see the --script contract above.',
},
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
],
examples: [
`# Recurring — --recurrence alone is enough; the first run comes off the cron grid:\nncl tasks create --name "sales briefing" --prompt "Send the weekday sales briefing" --recurrence "0 9 * * 1-5"`,
`# One-shot — --process-after required (UTC, offset, or naive-local in the instance TZ):\nncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"`,
`# Monitor — script gates the run; the agent wakes only when something matters:\nncl tasks create --name "alert watch" --recurrence "*/15 * * * *" \\\n --prompt "Investigate the alerts in the script data and notify me if serious" \\\n --script 'c=$(curl -sf https://example.com/api/alerts | jq length) || exit 0\necho "{\\"wakeAgent\\": $([ "$c" -gt 0 ] && echo true || echo false), \\"data\\": {\\"alerts\\": $c}}"'`,
],
handler: async (args, ctx) => createTask(args, ctx),
},
'append-log': {
access: 'open',
description:
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the local timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
examples: [
`# Inside a task fire (--id auto-derived) — the run's work-log line:\nncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"`,
],
args: [
{
name: 'msg',
type: 'string',
description:
'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the local timestamp; this is logged, never sent to the user.',
required: true,
},
{
name: 'id',
type: 'string',
description: 'Task series id. Auto-derived when called from inside a task fire; required otherwise.',
},
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
],
handler: async (args, ctx) => appendTaskLog(args, ctx),
},
update: {
access: 'open',
description: 'Update a live task by series id.',
args: [
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
{ name: 'prompt', type: 'string', description: 'Replace the task prompt.' },
{ name: 'process_after', type: 'string', description: 'New next-run time (ISO 8601 or naive local).' },
{ name: 'recurrence', type: 'string', description: 'New cron expression; "null"/"none" clears it (one-shot).' },
{
name: 'dangerously_override_recurrence_limit',
type: 'boolean',
description:
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
},
{ name: 'script', type: 'string', description: 'New pre-task script; "null"/"none" removes it.' },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
],
handler: async (args, ctx) => updateTaskCommand(args, ctx),
},
cancel: {
access: 'open',
description: 'Cancel a live task by series id, or use --all as a kill switch.',
args: [
{ name: 'id', type: 'string', description: 'Task series id (omit with --all).' },
{ name: 'all', type: 'boolean', description: 'Cancel every live task in scope — kill switch.' },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
],
handler: async (args, ctx) => cancelTaskCommand(args, ctx),
},
run: {
access: 'open',
description:
'Fire a task now without changing its schedule (queues an extra run due immediately). Safe for testing — unlike update --process-after now, it neither consumes a one-shot nor advances a recurring series.',
args: [
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
],
handler: async (args, ctx) => runTaskCommand(args, ctx),
},
pause: {
access: 'open',
description: 'Pause a pending task by series id.',
args: [
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
],
handler: async (args, ctx) => mutateTask(args, ctx, pauseTask),
},
resume: {
access: 'open',
description: 'Resume a paused task by series id.',
args: [
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
],
handler: async (args, ctx) => mutateTask(args, ctx, resumeTask),
},
delete: {
access: 'open',
description: 'Hard-delete a task series and its history.',
args: [
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
{
name: 'group',
type: 'string',
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
},
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
],
handler: async (args, ctx) => mutateTask(args, ctx, deleteTask),
},
},
});
+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/);
});
});
+108 -1
View File
@@ -1,4 +1,29 @@
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',
@@ -25,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,
@@ -64,7 +89,89 @@ 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
// generates a response, but delivery's ACL drops the outbound message
// (no destination matches the target) and the reply is silently lost.
// `createMessagingGroupAgent` does this automatically; the generic
// CRUD path doesn't, hence this hook. See issue #2389.
ensureAgentDestinationForWiring(row as unknown as MessagingGroupAgent);
},
postCommit: async (row) => {
// Live-refresh parity with `ncl destinations add`: `postCreate` above
// only wrote the central `agent_destinations` row. Any container already
// running for this agent keeps serving its stale session projection, so
// it would drop replies to this chat as "unknown destination" until the
// next spawn (the exact symptom operators hit running `ncl wirings
// create` against a live instance — it needed a group restart). Project
// the new destination into live sessions now so the fix takes effect
// without a restart. Runs after commit because it writes to session
// `inbound.db` files (outside the central-DB transaction) and is async.
await projectDestinationsToSessions((row as unknown as MessagingGroupAgent).agent_group_id);
},
});
+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 ──
@@ -1,80 +0,0 @@
/**
* Upgrade-path test for migration 019 (holds-approver-rule): in-flight
* pending_approvals rows created by the pre-contract code must come out with
* the approver rule the old click-auth gave them, and the sender table must be
* gone.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDb, initTestDb, runMigrations } from './index.js';
import { migrations } from './migrations/index.js';
import type Database from 'better-sqlite3';
let db: Database.Database;
beforeEach(() => {
db = initTestDb();
// Everything up to — but not including — the holds-approver-rule migration.
runMigrations(
db,
migrations.filter((m) => m.name !== 'holds-approver-rule'),
);
});
afterEach(() => {
closeDb();
});
function hasTable(name: string): boolean {
return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) !== undefined;
}
describe('migration 019 — holds-approver-rule', () => {
it('backfills approver_rule and agent_group_id on in-flight rows and drops the sender table', () => {
const now = new Date().toISOString();
db.prepare("INSERT INTO agent_groups (id, name, folder, created_at) VALUES ('ag-1', 'One', 'one', ?)").run(now);
db.prepare(
"INSERT INTO sessions (id, agent_group_id, messaging_group_id, thread_id, created_at) VALUES ('sess-1', 'ag-1', NULL, NULL, ?)",
).run(now);
// Legacy a2a hold: named approver ⇒ was exclusive.
db.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, approver_user_id, title, options_json)
VALUES ('appr-a2a', 'sess-1', 'appr-a2a', 'a2a_message_gate', '{}', ?, 'tg:dana', '', '[]')`,
).run(now);
// Legacy cli hold: no approver, no agent_group_id — click-auth fell back
// to the session's group; the backfill makes that anchoring explicit.
db.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, title, options_json)
VALUES ('appr-cli', 'sess-1', 'appr-cli', 'cli_command', '{}', ?, '', '[]')`,
).run(now);
// Legacy OneCLI hold: sessionless, agent_group_id already stamped.
db.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, title, options_json)
VALUES ('oa-1', NULL, 'req-uuid', 'onecli_credential', '{}', ?, 'ag-1', '', '[]')`,
).run(now);
expect(hasTable('pending_sender_approvals')).toBe(true);
runMigrations(db); // applies only holds-approver-rule
const rows = db
.prepare('SELECT approval_id, approver_rule, approver_scope, agent_group_id FROM pending_approvals')
.all() as Array<{ approval_id: string; approver_rule: string; approver_scope: string; agent_group_id: string }>;
const byId = Object.fromEntries(rows.map((r) => [r.approval_id, r]));
expect(byId['appr-a2a']).toMatchObject({
approver_rule: 'exclusive',
approver_scope: 'group',
agent_group_id: 'ag-1',
});
expect(byId['appr-cli']).toMatchObject({
approver_rule: 'admins-of-scope',
approver_scope: 'group',
agent_group_id: 'ag-1',
});
expect(byId['oa-1']).toMatchObject({ approver_rule: 'admins-of-scope', agent_group_id: 'ag-1' });
expect(hasTable('pending_sender_approvals')).toBe(false);
});
});
+31 -20
View File
@@ -183,26 +183,37 @@ export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
)
.run(mga);
// Auto-create an agent_destinations row so delivery's ACL doesn't block
// outbound messages that target this chat. Guarded: when the agent-to-agent
// module isn't installed the table doesn't exist — skip silently. Without
// the module, the ACL check in delivery is also skipped (same guard), so
// channel sends still work.
//
// ⚠️ DESTINATION PROJECTION NOTE: this function only writes the central
// `agent_destinations` row. It does NOT project into any running
// agent's session inbound.db (see top-of-file invariant in
// src/modules/agent-to-agent/db/agent-destinations.ts). In practice this
// is fine because the only real callers are one-shot setup scripts
// (setup/register.ts, scripts/init-first-agent.ts, /manage-channels
// skill) that run in a separate process from the host. Any already-
// running container for `mga.agent_group_id` will keep serving the
// stale projection until its next wake (idle timeout or next inbound
// message) at which point spawnContainer's writeDestinations call
// refreshes from central. If you call this from code that runs INSIDE
// the host process and need the refresh to happen immediately,
// explicitly call the module's `writeDestinations(mga.agent_group_id,
// <sessionId>)` afterwards.
ensureAgentDestinationForWiring(mga);
}
/**
* Create the `agent_destinations` row that lets the agent address this chat
* as a delivery target. Idempotent no-op when the destination already
* exists, the agent-to-agent module isn't installed, or the messaging group
* has been deleted out from under the wiring.
*
* Split out from `createMessagingGroupAgent` so callers that already wrote
* the `messaging_group_agents` row directly (e.g. the generic ncl CRUD path)
* can still get the companion destination without re-inserting the wiring.
*
* DESTINATION PROJECTION NOTE: this function only writes the central
* `agent_destinations` row. It does NOT project into any running agent's
* session inbound.db (see top-of-file invariant in
* src/modules/agent-to-agent/db/agent-destinations.ts). In practice this is
* fine because the only real callers are one-shot setup paths
* (setup/register.ts, scripts/init-first-agent.ts, /manage-channels skill,
* ncl wirings create) that run in a separate process from the host. Any
* already-running container for `mga.agent_group_id` will keep serving the
* stale projection until its next wake (idle timeout or next inbound
* message) at which point spawnContainer's writeDestinations call refreshes
* from central. If you call this from code that runs INSIDE the host
* process and need the refresh to happen immediately, explicitly call the
* module's `writeDestinations(mga.agent_group_id, <sessionId>)` afterwards.
*/
export function ensureAgentDestinationForWiring(mga: MessagingGroupAgent): void {
// Guarded: when the agent-to-agent module isn't installed the table
// doesn't exist — skip silently. Without the module, the ACL check in
// delivery is also skipped (same guard), so channel sends still work.
if (!hasTable(getDb(), 'agent_destinations')) return;
const existing = getDestinationByTarget(mga.agent_group_id, 'channel', mga.messaging_group_id);

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