Compare commits

...

173 Commits

Author SHA1 Message Date
Moshe Krupper c1445876a6 docs(guard): the name reply is not re-authorized — stop claiming it is
The channelsRegister docstring and the CLAUDE.md guard row still described
the withdrawn reply-time re-check (behavior delta (c), removed in fe235c7):
the docstring claimed the decision is consulted by the name-capture
interceptor "so a privilege revoked mid-flow is re-checked at each step",
and the CLAUDE.md row said message interceptors consult the guard inline.
Neither is true — channels.register is consulted only by the card-click
handler, and the free-text reply deliberately authorizes off the click
(main's behavior). Both docs now state that explicitly, including the
consequence: a privilege revoked between click and reply still completes
the flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 10:15:44 +03:00
Moshe Krupper 5a114b5427 Merge remote-tracking branch 'origin/main' into feat/guard-seam
# Conflicts:
#	CHANGELOG.md
2026-07-12 00:17:37 +03:00
Moshe Krupper 790a7a6dfd docs: drop internal design-process references from the guard change
The public tree shouldn't point at internal artifacts: phase numbering
("guarded-actions phase 2/3") and the team-hub decisions-doc pointer are
gone from the CHANGELOG bullet, the CLAUDE.md guard row, and the guard
file headers. The facts stay ("policy-as-data is deliberately deferred —
a generalized rules table can arrive later"); only the internal roadmap
vocabulary goes. Scope: lines this branch introduced only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:58:45 +03:00
Moshe Krupper f24edc2c3b refactor(guard): drop boot conformance, rename the spec vocabulary, extract runGuarded
Three trims to the seam, no behavior change (713/713 green):

- Boot-time conformance check deleted (src/guard-conformance.ts + its
  main() call). The invariant — every holding action has a registered
  approve continuation — is already covered in-tree by the conformance
  test, and at runtime response-handler.ts handles a missing continuation
  loudly (warn + "approved, but no handler is installed" notify + row
  cleanup). Refusing to boot bought no safety over that and could brick
  the host on a mis-installed skill.

- GuardedActionSpec vocabulary renamed to read as what each field does:
  baseline → decide (the decision function — allow | hold | deny),
  approvalAction → grantActionName (the pending_approvals.action string a
  grant's row must carry; "Name" because at consult sites `action` means
  the branded GuardedAction value), grantMatches → grantCoversRequest.
  Comment prose follows ("structural baseline" → the checks / the
  decision).

- runGuarded + DeliveryGuardSpec extracted to src/delivery-guard.ts.
  delivery.ts is a high-traffic file for forks: the registry itself
  (tagged entries, spec-or-unguarded registration overloads, disarm-throw,
  one-door getDeliveryAction, grant-carrying reenter) stays there, evolved
  in place — only the consult pipeline (precheck → guard → deny/hold/allow)
  and the spec type move out; runGuarded takes spec + handler explicitly so
  the new file has no import back into delivery.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:54:23 +03:00
Moshe Krupper fe235c7ca0 refactor(guard): unwrap the broadcast registries — response handlers and interceptors consult inline
registerResponseHandler and registerMessageInterceptor return to main's
single-argument shape: the guard-spec/unguarded declaration machinery
(claims, onDeny, the registry wrappers) is deleted. The declaration
requirement stays where registration is keyed — every ncl command derives
its guard inside register(), and registerDeliveryAction demands a spec or
unguarded(<reason>) — while broadcast hooks gate inline at the point of
privilege, like the a2a route and the unknown-sender gate:

- handleChannelApprovalResponse consults guard(channelsRegister) inline
  where main had the click-auth if — same decision, same timing.
- The free-text name capture returns to main verbatim: the click arms it,
  the reply is not re-authorized (the body's own pending-row re-fetch
  still refuses a vanished registration). Behavior delta (c) is withdrawn
  — the deliberate deltas are back to (a) approve-then-revoke and
  (b) grant refusals.

Also gone with the wrappers: the claims/body predicate duplication (the
same early-exits existed twice and could drift apart) and the repeated
extractAndUpsertUser upsert per captured event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:16:17 +03:00
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
Moshe Krupper a9e5231039 refactor(guard): one runtime discriminator for Unguarded — isUnguarded()
The unguarded brand becomes a real (module-private) symbol and
isUnguarded() its exported type guard, replacing three hand-rolled
`'reason' in guardDecl` checks (delivery, router, response-registry)
plus the one inside isUnguardedEntry. Only unguarded() can mint the
brand now, so a look-alike { reason } object — or a guard spec that
someday grows a `reason` field — can't pass as an unguarded
declaration at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:23:58 +03:00
Moshe Krupper f87f82c528 refactor(guard): consults carry the defined action value — delete the registry walk
The guard's weak point was the wiring: catalog entries registered by
side-effect import, consult sites naming them by string, a map miss
resolving to ALLOW, and a boot walk that could only see 2 of the 4
registries (response handlers and interceptors erased their specs into
closures). Dropping one unreferenced import silently disarmed
channel-registration click-auth.

Make the broken wiring unconstructible instead of detected:

- defineGuardedAction returns a branded GuardedAction value; guard(action,
  input) takes the value, not a name. A dropped module-edge import or a
  typo'd action is now a compile error; there is no lookup and no
  fail-open branch. A forged value (outside TS) is denied at runtime.
- Every registry (delivery actions, response handlers, interceptors)
  requires a guard spec or an explicit unguarded(<reason>) declaration at
  the registration site — unguarded-by-omission is not representable, and
  the justification travels with the registration (grep "unguarded(" for
  the complete inventory). CLI commands derive their guard inside
  register(), so a command cannot exist without one.
- guardConformanceViolations() and EXEMPT_DELIVERY_ACTIONS are gone. The
  boot check shrinks to the one cross-registry invariant the compiler
  can't see: every holding action has a registered approve continuation
  (grantContinuationGaps), same fail-closed refuse-to-start posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:23:58 +03:00
Moshe Krupper 35ca4e14d2 refactor: rename src/guard/catalog.ts → guard-actions.ts
File + internal map (catalog → guardedActions). The concept stays "the
action catalog" in prose (the term the requirements/decisions docs and
the conformance banner use); exported symbols (registerGuardedAction,
GuardedActionSpec, …) unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:22:29 +03:00
Moshe Krupper 69e3b57d9d feat: guard seam — decision function, registration wrapping, grant-carrying replay, boot conformance
Every privileged action crossing the container or channel boundary now
passes one decision function — guard() in the new src/guard/ leaf —
before it executes: allow | hold | deny (hub:
engineering/requirements/guarded-actions +
engineering/discovery/guarded-actions-decisions, phase 2).

- Registration-derived catalog: registry.register() derives one entry
  per ncl command (dotted action names stamped by registerResource);
  module-edge guard.ts adapters register the domain baselines
  (agents.create, a2a.send incl. the agent_message_policies hold,
  self_mod.*, senders.admit, channels.register). The baseline is the
  decision — policy-as-data (tighten-only rules) is deferred to
  phase 3.
- All four handler registries wrap at registration: dispatch consults
  the guard; guarded delivery actions (create_agent, install_packages,
  add_mcp_server) store only the precheck → guard → handler wrapper
  (the raw handler is never stored; spec-less re-registration throws);
  response handlers + message interceptors take guard specs (channel
  registration's click + free-text name capture).
- Grant-carrying replay: approved continuations re-enter their entry
  point with the verified approval row as the grant
  (ApprovalHandlerContext.approval). A grant satisfies a hold, never a
  deny — live-row + approvalAction + grantMatches checks; the forgeable
  approved:true boolean is deleted.
- Boot conformance: the registry walk (src/guard-conformance.ts) runs
  in CI and at boot — an unmapped privileged registration stops the
  host with a banner (skill-installed code never runs this repo's CI).

Baselines are main's behavior verbatim (host trusted-caller, cli_scope
allowlist, create_agent scope branch, a2a ACL order, unconditional
self-mod hold, unknown_sender_policy, channel click auth incl. the
anchor-group approver). Sender/channel holds stay on their own tables;
guard holds map onto the existing requestApproval options
(approverUserId).

Deliberate outcome changes inherent to the replay semantics (called out
in CHANGELOG too): (a) a2a approve-then-revoke no longer delivers — the
structural baseline re-runs live on replay; (b) forged, already-consumed,
or mismatched grants refuse instead of executing; (c) the channel-name
free-text reply re-checks approver eligibility at reply time. The
D1/D2/D4 click-auth fixes are deliberately NOT here — they belong to the
approval-contract PR (D2's host fallback at dispatch replay is preserved
verbatim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:22:28 +03: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
Gabi Simons 87a2d3af8d Merge pull request #2978 from nanocoai/feat/core-team-pr-label
ci: auto-label PRs from core team members
2026-07-08 14:49:23 +03:00
Gabi Simons 68ebc02114 Update CONTRIBUTING.md 2026-07-08 14:48:25 +03:00
Gabi Simons d4e73c5523 Merge branch 'main' into feat/core-team-pr-label 2026-07-08 14:35:23 +03:00
github-actions[bot] 9a103f4f93 docs: update token count to 213k tokens · 106% of context window 2026-07-08 11:29:45 +00:00
github-actions[bot] a8554c6248 chore: bump version to 2.1.40 2026-07-08 11:29:40 +00:00
gavrielc 2480ae7cb8 Merge pull request #2980 from nanocoai/tasks/01-cli-verb-args-human-view
ncl CLI: verb-level args, deep help, server-rendered human view
2026-07-08 14:29:24 +03:00
Omri Maya 763a3f757b feat(cli): verb-level args, deep help, server-rendered human view
Every ncl verb now declares its args: strict validation with fix-carrying
error messages, generated per-verb --help, and multi-word custom-op key
resolution (spaces vs dashes). Longest-prefix command fallback keeps dashed
positional ids intact.

Responses gain a server-rendered 'human' frame field (formatHuman hook): the
host renders tables once and every client prints them verbatim — container
agents cannot import host formatters, so this is how they get aligned output
instead of a raw column dump. --help responses carry 'human' for clean
multi-line rendering.

Robustness fixes riding along:
- generic list returns newest rows first, so the LIMIT no longer hides
  recent rows
- stdout flushed before exit — >64KB responses were silently truncated

Additive only: new response-frame fields, no schema changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:39:10 +03:00
Gabi Simons 8943c1cbf4 ci: auto-label PRs from core team members
Extends the existing label-pr workflow with an author allowlist that
applies a core-team label. The label is auto-provisioned on first use
so no manual repo setup is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:11:43 +03:00
github-actions[bot] 559bb5ca1a chore: bump version to 2.1.39 2026-07-07 20:00:50 +00:00
gavrielc 04b364e828 Merge pull request #2965 from nanocoai/fix/rate-limit-event-shape
fix(agent-runner): match rate_limit_event as a top-level SDK message type
2026-07-07 23:00:36 +03:00
gavrielc 627ab23bad Merge branch 'main' into fix/rate-limit-event-shape 2026-07-07 23:00:24 +03:00
gavrielc 8986fef586 Apply suggestion from @gavrielc 2026-07-07 23:00:07 +03:00
gavrielc 7f49450c0c Merge pull request #2964 from nanocoai/docs-sdk-deep-dive
docs: update SDK deep-dive from 0.2.x to 0.3.197
2026-07-07 22:58:40 +03:00
gavrielc cdc519e1f4 Merge pull request #2963 from nanocoai/docs-rewrite-core
docs: rewrite architecture.md and agent-runner-details.md to match current code
2026-07-07 22:56:12 +03:00
gavrielc 1276645c3b Merge pull request #2962 from nanocoai/docs-fix-schema
docs: sync DB schema and entity docs with migrations 010-018
2026-07-07 22:55:40 +03:00
github-actions[bot] d3499b7d70 docs: update token count to 209k tokens · 104% of context window 2026-07-07 19:55:04 +00:00
gavrielc c2cf19ec28 Merge pull request #2961 from nanocoai/docs-fix-mechanical
docs: fix stale claims across README, CONTRIBUTING, CLAUDE.md and operational docs
2026-07-07 22:54:48 +03:00
glifocat 44f351349a docs: Output Delivery — messages_out comes from <message> envelope parsing, not raw results
The section still described the pre-envelope model where every provider
result was written to messages_out verbatim. Since the envelope parser
landed, the agent-runner parses the final text for <message to="name">
blocks (dispatchResultText, poll-loop.ts): one messages_out row per
block, bare/<internal> text is scratchpad (logged, never sent), unknown
destinations are dropped, unwrapped output gets a one-time re-wrap
nudge, and non-retryable error results are delivered as error notices
instead of being dropped.
2026-07-07 00:26:12 +00:00
glifocat e8a32207d8 fix(agent-runner): match rate_limit_event as a top-level SDK message type
The claude provider mapped rate-limit events with
`message.type === 'system' && subtype === 'rate_limit_event'`, but
`@anthropic-ai/claude-agent-sdk` 0.3.x ships rate limits as a top-level
`SDKRateLimitEvent` (`{ type: 'rate_limit_event', ... }`) with no `system`
subtype. The old condition never matched, so the quota-classified error
event was never emitted and rate-limit signals were silently dropped.

Match the top-level `type` instead.
2026-07-06 15:05:01 +00:00
glifocat 1dda751a48 docs: update SDK deep-dive from 0.2.x to 0.3.x 2026-07-06 14:52:03 +00:00
glifocat 4f1b17c737 docs: sync DB schema + entity docs with migrations 010-018 2026-07-06 13:04:14 +00:00
glifocat 967aee2c27 docs: fix stale claims vs v2.1.38 (skills lists, runtime claims, file refs) 2026-07-06 13:04:14 +00:00
glifocat 5aac750aa5 docs: rewrite architecture + agent-runner internals to match current code 2026-07-06 13:04:14 +00:00
glifocat b6cb53e21c Merge pull request #2953 from nanocoai/docs-staleness-fixes
docs: correct stale mount topology row + removed env var
2026-07-04 23:06:27 +02:00
gavrielc 5a7e75f854 Apply suggestion from @gavrielc 2026-07-04 23:36:05 +03:00
glifocat d770e56596 docs: correct stale mount topology + removed env var vs code 2026-07-04 19:39:13 +00:00
github-actions[bot] 08a1ac9753 chore: bump version to 2.1.38 2026-07-04 16:58:45 +00:00
gavrielc 273489badf Merge pull request #2931 from nanocoai/cleanup/async-image-build
Build agent images asynchronously instead of blocking the host
2026-07-04 19:58:34 +03:00
gavrielc 504651633f Merge branch 'main' into cleanup/async-image-build 2026-07-04 19:58:25 +03:00
github-actions[bot] 694ab74aa1 chore: bump version to 2.1.37 2026-07-04 16:55:14 +00:00
gavrielc aae81321e9 Merge pull request #2948 from nanocoai/cleanup/arch-scheduling-provider-docs
Fix stale architecture, scheduling, provider-config, and overlay docs
2026-07-04 19:55:02 +03:00
gavrielc 6c46b1e43d Merge branch 'main' into cleanup/arch-scheduling-provider-docs 2026-07-04 19:54:49 +03:00
gavrielc 71453707ba Apply suggestion from @gavrielc 2026-07-04 19:54:15 +03:00
gavrielc 0aa9e668f6 Apply suggestion from @gavrielc 2026-07-04 19:53:24 +03:00
gavrielc 023128def5 Merge pull request #2946 from nanocoai/cleanup/remove-env-secrets-mirror
Remove the dead data/env/env secrets mirror
2026-07-04 19:52:00 +03:00
gavrielc c4a1679666 Merge branch 'main' into cleanup/remove-env-secrets-mirror 2026-07-04 19:51:30 +03:00
gavrielc 2e40e17155 Merge pull request #2945 from nanocoai/cleanup/security-docs-v2
Rewrite the security docs to match the v2 perimeter
2026-07-04 19:51:10 +03:00
gavrielc f896caefa0 Merge branch 'main' into cleanup/security-docs-v2 2026-07-04 19:50:59 +03:00
gavrielc 55c003c5f4 Apply suggestion from @gavrielc 2026-07-04 19:50:43 +03:00
gavrielc 20f2bae5cd Apply suggestion from @gavrielc 2026-07-04 19:50:10 +03:00
gavrielc d6b82e6473 Apply suggestion from @gavrielc 2026-07-04 19:47:01 +03:00
gavrielc fea5ac5f2c Apply suggestion from @gavrielc 2026-07-04 19:45:37 +03:00
gavrielc 2b86bbef19 Apply suggestion from @gavrielc 2026-07-04 19:45:04 +03:00
gavrielc 9dc0a7e62f Apply suggestion from @gavrielc 2026-07-04 19:44:33 +03:00
gavrielc 2035033397 Delete docs/docker-sandboxes.md 2026-07-04 19:44:00 +03:00
gavrielc 1c294ff9a5 Delete docs/APPLE-CONTAINER-NETWORKING.md 2026-07-04 19:43:27 +03:00
github-actions[bot] 43198310e1 chore: bump version to 2.1.36 2026-07-04 16:41:48 +00:00
gavrielc b7d6eebf4d Merge pull request #2943 from nanocoai/cleanup/mount-allowlist-readonly-and-cache
Mount allowlist: honor the readOnly key and stop caching parse errors
2026-07-04 19:41:35 +03:00
gavrielc 803f3413ec Merge branch 'main' into cleanup/mount-allowlist-readonly-and-cache 2026-07-04 19:41:26 +03:00
github-actions[bot] a8b7da7bcf docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:40:39 +00:00
github-actions[bot] 0b6ad5550d chore: bump version to 2.1.35 2026-07-04 16:40:37 +00:00
gavrielc c1965cfcaf Merge pull request #2942 from nanocoai/cleanup/a2a-batch-stamp-crossprocess
Fix the agent-to-agent in_reply_to stamp (cross-process no-op)
2026-07-04 19:40:23 +03:00
gavrielc 3cefbfccf4 Merge branch 'main' into cleanup/a2a-batch-stamp-crossprocess 2026-07-04 19:38:09 +03:00
github-actions[bot] 6f22c73aac docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:38:00 +00:00
gavrielc 31dd37b3a8 Merge pull request #2940 from nanocoai/cleanup/remove-onedb-shims
Delete one-DB-era @deprecated shims and dead exports
2026-07-04 19:37:47 +03:00
gavrielc 0dfde3aa5b Merge branch 'main' into cleanup/remove-onedb-shims 2026-07-04 19:37:36 +03:00
github-actions[bot] 33d2366252 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:35:58 +00:00
github-actions[bot] 9bf1514f26 chore: bump version to 2.1.34 2026-07-04 16:35:56 +00:00
gavrielc be3502c23b Merge pull request #2937 from nanocoai/cleanup/resolve-session-reprovision
Re-provision a missing session folder so the documented reset works
2026-07-04 19:35:44 +03:00
gavrielc e3f38dbed9 Merge branch 'main' into cleanup/resolve-session-reprovision 2026-07-04 19:35:26 +03:00
github-actions[bot] d8a6b99f0e chore: bump version to 2.1.33 2026-07-04 16:35:06 +00:00
gavrielc 8ca7564fbc Merge pull request #2936 from nanocoai/cleanup/cli-protocol-vocab
Clean up dead ncl CLI protocol vocabulary
2026-07-04 19:34:51 +03:00
gavrielc afa07f0566 Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:42 +03:00
github-actions[bot] 8059ee4eec docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:34:08 +00:00
gavrielc 41c486cacc Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:02 +03:00
gavrielc 190a7d4f43 Merge pull request #2935 from nanocoai/cleanup/remove-dead-v1-config
Delete dead v1 config knobs and the broken pnpm auth script
2026-07-04 19:33:53 +03:00
gavrielc e3d2d43b0e Merge branch 'main' into cleanup/remove-dead-v1-config 2026-07-04 19:33:11 +03:00
github-actions[bot] afde23bbe6 chore: bump version to 2.1.32 2026-07-04 16:32:51 +00:00
gavrielc 776bc14ca0 Merge pull request #2934 from nanocoai/cleanup/reachable-perimeter-env
Make the security-perimeter env vars reachable under the shipped service
2026-07-04 19:32:37 +03:00
gavrielc a9461afef1 Merge branch 'main' into cleanup/reachable-perimeter-env 2026-07-04 19:32:28 +03:00
github-actions[bot] ca81558ec8 chore: bump version to 2.1.31 2026-07-04 16:30:56 +00:00
github-actions[bot] 2f88e4fa15 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:30:54 +00:00
gavrielc 9b0d1dd044 Merge pull request #2933 from nanocoai/feat/approval-button-styles
feat(approvals): colored buttons on approval cards (Slack primary/danger)
2026-07-04 19:30:42 +03:00
gavrielc 79e490dfcf Merge branch 'main' into feat/approval-button-styles 2026-07-04 19:29:58 +03:00
gavrielc 01b07d6652 Merge pull request #2795 from leetwito/feat/add-clidash-skill
feat: add /add-clidash — read-only CLI-derived dashboard skill
2026-07-04 16:53:44 +03:00
gavrielc be20e9f723 Merge branch 'main' into feat/add-clidash-skill 2026-07-04 16:53:25 +03:00
gavrielc e3d156f800 test(channels): cover ask_question option style normalization
Reviewers on #2933 asked for tests on the button-style plumbing. Two layers:

- src/channels/ask-question.test.ts (new): normalizeOption/normalizeOptions
  style whitelist — primary/danger/default pass through; unknown strings,
  case variants, and non-string values drop to undefined; string-shorthand
  options carry no style; style coexists with the label/selectedLabel/value
  defaulting. This is the load-bearing gate: an invalid style reaching
  Slack Block Kit fails the whole card with invalid_blocks, which in the
  approval flow is an effective auto-deny.

- src/channels/chat-sdk-bridge.test.ts: ask_question delivery passes each
  normalized option style into Button() and omits it when unset; an invalid
  style in the raw payload is stripped before the card is built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:04 +03:00
github-actions[bot] b4da018d8c chore: bump version to 2.1.30 2026-07-04 13:33:48 +00:00
gavrielc 4b11079007 Merge pull request #2932 from nanocoai/cleanup/dispatch-longest-prefix
Fix ncl positional IDs for generated (dashed) identifiers
2026-07-04 16:33:36 +03:00
gavrielc 31cc35ea32 Merge branch 'main' into cleanup/dispatch-longest-prefix 2026-07-04 16:33:27 +03:00
github-actions[bot] bf0a3d2612 chore: bump version to 2.1.29 2026-07-04 13:31:46 +00:00
gavrielc 6dc25a9b8a Merge pull request #2930 from nanocoai/cleanup/command-gate-start-and-failopen
command-gate: restore the /start filter and remove the fail-open admin check
2026-07-04 16:31:32 +03:00
gavrielc cafba7fb18 Merge branch 'main' into cleanup/command-gate-start-and-failopen 2026-07-04 16:31:05 +03:00
github-actions[bot] 10d400ec64 chore: bump version to 2.1.28 2026-07-04 13:29:49 +00:00
gavrielc 1a9643cfa3 Merge pull request #2929 from nanocoai/feat/onecli-approval-card-summary
feat(approvals): render OneCLI approval requests from the gateway's structured summary
2026-07-04 16:29:38 +03:00
gavrielc 2ba2555032 Merge branch 'main' into feat/onecli-approval-card-summary 2026-07-04 16:28:01 +03:00
github-actions[bot] 687d7d13ac chore: bump version to 2.1.27 2026-07-04 13:26:56 +00:00
gavrielc 2938bbf94a Merge pull request #2928 from nanocoai/cleanup/remove-dead-global-mount
Remove the dead /workspace/global mount and untrack v1 group seed files
2026-07-04 16:26:42 +03:00
gavrielc d1a2b04d32 Merge branch 'main' into cleanup/remove-dead-global-mount 2026-07-04 16:26:10 +03:00
github-actions[bot] 455014e7b9 chore: bump version to 2.1.26 2026-07-04 13:25:56 +00:00
gavrielc 5032c431ae Merge pull request #2927 from nanocoai/cleanup/unregister-mock-provider
Unregister the mock provider from the production container barrel
2026-07-04 16:25:40 +03:00
gavrielc ac8273c698 Merge branch 'main' into cleanup/unregister-mock-provider 2026-07-04 16:24:37 +03:00
gavrielc 0835089a51 Fix stale architecture, scheduling, provider-config, and overlay docs
Correct docs (and one code comment) that describe systems that no longer
exist in v2: mark docs/SPEC.md as the historical v1 spec; replace the
impossible "write messages_in (to self)" / stale list_tasks scheduling
model with the real messages_out system-action path in
agent-runner-details.md and architecture.md; drop the false
MAX_CONCURRENT_CONTAINERS cap claim; remove the per-group agent-runner-src
overlay from the live DB map (source is a shared read-only mount) and fix
the insertTask attribution to src/modules/scheduling/db.ts; correct the
container-skills count to 8; repoint the deleted-doc comment in
src/claude-md-compose.ts; and replace the AGENT_PROVIDER / hand-edit
container.json provider-config instructions in add-opencode and add-mnemon
with the real `ncl groups config update --provider` flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:18:08 +03:00
gavrielc c82f062d57 Remove the dead data/env/env secrets mirror from setup
Nothing has read data/env/env since commit 1a07869 removed the container
mount, yet setup still wrote the full .env (live tokens included) there.
Drop every writer and correct the stale comments/docs that described it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:11:09 +03:00
gavrielc 3906104960 docs: rewrite security docs to match the v2 perimeter
Rewrite docs/SECURITY.md against the real v2 codepaths: the actual
buildMounts mount table, the allowReadWrite allowlist schema the mount
validator enforces, and the true defaults (egress open, no CPU/mem
limits, additional mounts blocked until an allowlist exists). Replace the
deleted v1 perimeter (main/non-main groups, ephemeral containers, IPC
authorization, /dev/null .env shadow, data/sessions path) with a v2 Trust
Model built on user_roles and long-lived per-session containers. Drop the
dangling /add-golden-registry reference. Mark docs/docker-sandboxes.md and
docs/APPLE-CONTAINER-NETWORKING.md as v1-historical and update the
docs/README.md portal rows. Remove the Apple Container native-runtime
claims from README.md (no runtime seam exists; container-runtime.ts
hardcodes docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:10:46 +03:00
gavrielc ed9a3e330d Mount allowlist: honor the readOnly key and stop caching parse errors
Translate the per-root `readOnly` key (and tolerate the top-level
`nonMainReadOnly` key) that /manage-mounts and setup actually write, so
read-write grants are no longer silently forced read-only. Read+validate
the allowlist per call (mtime-keyed cache) instead of caching it — and its
parse errors — for the whole process lifetime. Add tests and fix the
skill docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:08:46 +03:00
gavrielc 102ce80fda Fix a2a in_reply_to stamp: publish through outbound.db, not module state
The active batch's inReplyTo lived in module-level state in current-batch.ts,
but the nanoclaw MCP server runs as a separate stdio subprocess from the poll
loop, so getCurrentInReplyTo() always read null there. The a2a reply stamp was
therefore dead — only the host peer-affinity fallback kept replies routing.

Publish the stamp through session_state in outbound.db (both processes already
open it): the poll loop writes it at batch start and clears it in the existing
finally; the MCP tools read it with an updated_at staleness guard so a stamp
left behind by a killed container isn't reused. Delete current-batch.ts and
reseed core.test.ts via the DB so it reflects the real process boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:49 +03:00
gavrielc 0626dcec92 Delete one-DB-era @deprecated shims and dead exports
These are leftovers from the April-2026 inbound/outbound session-DB
split, marked @deprecated "kept temporarily for test compatibility":
sessionDbPath, openSessionDb, writeSystemResponse (session-manager),
getStuckProcessingIds (session-db, superseded by getProcessingClaims),
getSessionDb (container connection + barrel re-export), and the dead
registerResponseHandler/onShutdown re-exports from index.ts. The only
real callers are two dev scripts, repointed to inboundDbPath /
getInboundDb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:02 +03:00
gavrielc b42329551d Re-provision a missing session folder so the documented reset works
The /debug skill tells operators to rm -rf a session folder to reset a
stuck session, but the sessions row survives. The next message then takes
the existing-session path and opens inbound.db in a directory that no
longer exists — better-sqlite3 throws and the message is dropped forever.

writeSessionMessage now calls the idempotent initSessionFolder when the
inbound.db path is missing, so the documented reset actually re-provisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:55 +03:00
gavrielc 855f204d8a Clean up dead ncl CLI protocol vocabulary
Remove scaffold-era vocabulary in the ncl protocol that no code produces:
the duplicate `Access` union in crud.ts (now imported from registry.ts),
the unused `'hidden'` access level, and the never-emitted `'permission-denied'`
and `'not-found'` error codes. Also refresh three stale docstrings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:43 +03:00
gavrielc d4ca8a4ea6 chore(config): remove dead v1 config knobs and broken pnpm auth script
These config exports in src/config.ts have zero readers and advertise
controls that v2 removed:

- CONTAINER_TIMEOUT / CONTAINER_MAX_OUTPUT_SIZE / IDLE_TIMEOUT /
  MAX_CONCURRENT_CONTAINERS / MAX_MESSAGES_PER_PROMPT
- the trigger block (escapeRegex, buildTriggerPattern, DEFAULT_TRIGGER,
  getTriggerPattern, TRIGGER_PATTERN)

Also remove the `auth` script from package.json — it pointed at the
deleted src/whatsapp-auth.ts, so `pnpm run auth` only errored — and fix
the stale MAX_MESSAGES_PER_PROMPT reference in the container's
messages-in.ts comment (the value comes from container.json's
maxMessagesPerPrompt).

Grep confirms no importers in src/, container/, scripts/, setup/, or
tests. Precedent: 0283391.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:34 +03:00
gavrielc 7615d7d846 Make security-perimeter env vars reachable under the shipped service
Egress-lockdown and CPU/memory limits were read from process.env only, but
the shipped launchd/systemd service sets just PATH+HOME and the host never
loads .env into process.env — so these knobs could not be turned on the
supported way. Route them through the same readEnvFile path as the other
config keys, keeping process.env precedence so dev-mode/nohup installs are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:27 +03:00
gavrielc f094f56bf6 Fix ncl positional IDs for generated (dashed) identifiers
The dispatcher trimmed exactly one trailing dash-segment to recover the
target id, so any generated id containing dashes (UUIDs, sess-*, appr-*)
never matched a command and failed unknown-command; only --id worked.
Resolve by longest registered prefix instead: split the dash-joined
command, take the longest prefix that lookup() resolves as the command,
and treat the re-joined remainder as args.id. Server-side only, no wire
or client change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:02 +03:00
gavrielc fcee39ea14 command-gate: restore the /start filter and remove the fail-open admin check
Add '/start' back to the host FILTERED set (a Telegram fix from 866b791
was silently undone when host gating was added) so it is dropped instead
of reaching the agent as a normal message. Replace the inline isAdmin
query and its hasTable('user_roles') fail-open guard with a call to
hasAdminPrivilege; user_roles always exists (core migration 001-initial),
so the guard only ever masked a missing check. Add a focused command-gate
test covering both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:58 +03:00
gavrielc e3b2ffce36 Build agent images asynchronously instead of blocking the host
Replace the execSync docker build in buildAgentGroupImage with an awaited
promisified exec so the single-threaded host stays responsive during the
image build (up to 15 minutes) that a package-install approval or
`ncl groups restart --rebuild` triggers. Timeout, buffered stdio, and
non-zero-exit error propagation are preserved; both callers already await.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:56 +03:00
gavrielc 2bce84781b feat(approvals): colored buttons on approval cards (Slack primary/danger)
Approval cards render every button with the same neutral style, so Approve
and Reject read identically at a glance. The chat SDK's Button already
accepts style ('primary' | 'danger' | 'default') and @chat-adapter/slack
maps primary→green and danger→red Block Kit styles (Telegram ignores it),
but the ask_question pipeline dropped the field.

- ask-question.ts: add OptionStyle and an optional style field to
  OptionInput/NormalizedOption; normalizeOption whitelists the value.
  Bare-string options stay unstyled.
- chat-sdk-bridge.ts: forward opt.style into Button() on ask_question
  cards. Persisted options_json tolerates the extra key
  (getAskQuestionRender only reads label/selectedLabel/value).
- Producers: module approvals (Approve/Reject), sender approvals
  (Allow/Deny), channel approvals (Connect/Reject), and OneCLI credential
  cards (Approve/Reject) annotate primary/danger. Multi-choice picker
  options stay unstyled — a list of equals.

Purely additive: options without style render exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:24 +03:00
gavrielc f4be00e6ed Remove dead /workspace/global mount and untrack v1 group seed files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:14 +03:00
gavrielc c9aa69dea9 Unregister the mock provider from the production container barrel
The container self-registration barrel imported mock.js, so every container
registered a 'mock' provider returning canned text. A typo'd --provider mock
would silently 'work' instead of failing loudly. Drop the import (mock.ts stays
for direct test use) and tidy the now-stale host comments and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:09 +03:00
gavrielc 3731e26769 feat(approvals): render OneCLI approval requests from the gateway's structured summary
The hosted OneCLI gateway (api.onecli.sh) sends a structured summary field
on ApprovalRequest — { action, details: [{label, value}] } — that the SDK's
TypeScript type doesn't declare yet. When present, render it as the approval
card body (*Action:* plus labeled fields, fencing multi-line values) instead
of the raw METHOD host/path + bodyPreview, so approvers see what the agent
is doing (e.g. To / Subject / Body of an email send) rather than an HTTP
trace.

Rendering is defensive: non-string values are coerced via JSON.stringify (a
render throw would turn into a deny via handleRequest's catch), each value is
capped at 900 chars, and a 2600-char running budget keeps the card under
Slack's 3000-char section block limit — overflow is reported with an
explicit "…N field(s) omitted for length" line instead of a failed
delivery. Without a summary, the old bodyPreview fallback remains, now
capped and with the method/host/path as a footnote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:08 +03:00
leetwito d472d9d32b Merge remote-tracking branch 'upstream/main' into feat/add-clidash-skill 2026-07-04 15:38:41 +03:00
leetwito 6c2d26836f fix(add-clidash): mkdir -p tools in install step; require Node >=22.5
Address review feedback on PR #2795:
- SKILL.md Step 1 now creates tools/ before cp -R (cp does not create
  intermediate parents; tools/ is not a standard NanoClaw dir, so the copy
  failed on a fresh checkout).
- package.json engines bumped to >=22.5 — server.js statically imports
  activity.js -> node:sqlite (DatabaseSync), which lands in Node 22.5, so
  the server crashed at module load on Node 20/21 LTS.
- Doc references (SKILL.md, README.md) updated to Node >=22.5 for consistency.

Verified 87/87 tests pass on Node 22.14.
2026-07-04 15:38:38 +03:00
github-actions[bot] b28c917997 docs: update token count to 207k tokens · 104% of context window 2026-07-04 08:08:53 +00:00
github-actions[bot] a00a5610bd chore: bump version to 2.1.25 2026-07-04 08:08:50 +00:00
gavrielc 05dc1b0a3c Merge pull request #2611 from Hinotoi-agent/fix/approval-cli-caller-context
[security] fix(cli): preserve caller context after approval
2026-07-04 11:08:39 +03:00
glifocat a7b34bc872 Merge branch 'main' into fix/approval-cli-caller-context 2026-07-04 09:11:27 +02: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
Lee Twito 8ee7915418 Merge branch 'main' into feat/add-clidash-skill 2026-06-21 16:19:48 +03:00
gavrielc 9a142302df Merge branch 'main' into feat/add-clidash-skill 2026-06-18 09:18:10 +03:00
leetwito e856e924a5 feat: add /add-clidash — read-only CLI-derived dashboard skill
clidash is a zero-dependency, read-only web dashboard that derives its tabs
and tables at runtime from any CLI that lists resources as JSON. Ships
pre-wired for NanoClaw's ncl CLI plus docker, with message-activity charts, a
log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.

Packaged as a utility skill per CONTRIBUTING.md: code under the skill dir,
copied into tools/clidash on install. No edits to NanoClaw src, no new deps.
2026-06-17 15:40:39 +03:00
Hinotobi 0516bea638 Merge branch 'main' into fix/approval-cli-caller-context 2026-06-11 11:29:11 +08:00
hinotoi-agent 32f067f5bb fix(cli): preserve caller context after approval 2026-05-25 19:51:54 +08:00
239 changed files with 13846 additions and 3789 deletions
+22
View File
@@ -0,0 +1,22 @@
# Remove /add-clidash
clidash is fully self-contained, so removal is a single directory delete. It
made no edits to NanoClaw `src/`, added no dependency, and wired into nothing.
```bash
# Stop the service first if you set one up:
systemctl --user disable --now clidash 2>/dev/null || true
rm -f ~/.config/systemd/user/clidash.service
# Remove the tool:
rm -rf tools/clidash
```
If you added the config to `.gitignore` in step 2 of the install, remove that
line too:
```
tools/clidash/clidash.config.json
```
Nothing else needs reverting.
+168
View File
@@ -0,0 +1,168 @@
---
name: add-clidash
description: Add clidash — a zero-dependency, read-only web dashboard that derives its tabs and tables at runtime from any CLI that lists resources as JSON. Ships pre-wired for NanoClaw's ncl CLI (agent groups, sessions, channels, users, roles), plus message-activity charts, a log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.
---
# /add-clidash — CLI-derived read-only dashboard
clidash is a small, read-only web dashboard. You point it at any CLI that can
list resources as JSON (NanoClaw's `ncl`, `docker`, `kubectl`, …) and it builds
the dashboard at runtime: one tab per resource, a generic table over whatever
columns the rows have. A new `ncl` resource becomes a new tab and a new column
becomes a new table column with **zero code changes**.
It ships pre-wired for NanoClaw's `ncl` CLI and adds three NanoClaw-aware
panels driven entirely by config:
- **Agents overview** — status cards joining groups + sessions + messaging
groups + wirings (green <15m / amber <2h / red older).
- **Activity** — per-session inbound/outbound message totals and a daily series,
read directly from the session DBs (`ncl` has no messages resource).
- **Logs** — last N lines of allowlisted host log files.
- **Files** — a read-only viewer for group skills, `CLAUDE.md`, and profiles.
## Why it's safe
clidash is **read-only by construction**: the server can only `execFile` the
argv templates in its config. `{resource}` is the sole substitution and is
allowlist-validated against the discovered/static resource set before exec —
never a shell, no free-form input reaches argv. There is no auth; **the network
is the auth boundary** — it binds `127.0.0.1` by default. Only ever bind a
private interface (e.g. a tailnet IP), never a public one.
It's distinct from `/add-dashboard` (which pushes JSON snapshots to a separate
`@nanoco/nanoclaw-dashboard` npm package): clidash has **zero dependencies**, no
build step, no push pipeline, and no edits to NanoClaw source — it just reads
`ncl` and the session DBs.
## Steps
### 1. Copy the tool into place
clidash is fully self-contained — copy the whole directory in:
`tools/` is not a standard NanoClaw directory and `cp -R` won't create it, so
make it first:
```bash
mkdir -p tools
cp -R .claude/skills/add-clidash/add/tools/clidash tools/clidash
```
That is the only file change this skill makes. Nothing in NanoClaw `src/` is
touched, no dependency is added.
### 2. Create the config
The example config is pre-wired for NanoClaw with paths relative to the repo
root, so it works as-is when you run clidash from `tools/clidash/`:
```bash
cd tools/clidash
cp clidash.config.example.json clidash.config.json
```
`clidash.config.json` is your local config — add it to `.gitignore` if you
don't want to commit install-specific paths:
```bash
echo 'tools/clidash/clidash.config.json' >> ../../.gitignore
```
The example assumes `ncl` is built at `bin/ncl`. If `bin/ncl` doesn't exist,
build it first (`pnpm run build`) or point `clis.ncl.bin` at the right path.
### 3. Test
Tests use a stub CLI — no real `ncl` or `docker` needed:
```bash
npm test
```
All tests should pass (Node ≥ 22.5, `node:test`, zero dependencies).
### 4. Run and verify
```bash
node server.js # serves http://127.0.0.1:4690
```
In another shell, confirm it's live and that `ncl` discovery worked:
```bash
curl -s http://127.0.0.1:4690/api/clis | head -c 400 # CLIs + discovered resources
curl -s http://127.0.0.1:4690/api/r/ncl/groups | head -c 400 # a real resource table
```
Then open `http://127.0.0.1:4690/` in a browser. You should see the Agents
overview plus a tab per `ncl` resource.
### 5. (Optional) Run as a service
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
private (e.g. tailnet) IP via the `BIND` env var or `bind` in config — never a
public interface.
```ini
# ~/.config/systemd/user/clidash.service (Linux)
[Unit]
Description=clidash read-only CLI dashboard
[Service]
WorkingDirectory=%h/nanoclaw/tools/clidash
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
Environment=BIND=127.0.0.1
Restart=on-failure
[Install]
WantedBy=default.target
```
```bash
systemctl --user enable --now clidash
```
On macOS, wrap `node server.js` (with `WorkingDirectory` = `tools/clidash`) in a
launchd plist the same way the main NanoClaw service is configured.
## Configuration reference
`clidash.config.json` keys (see `tools/clidash/README.md` and
`clidash.config.example.json` for the full shape):
| Key | Purpose |
|-----|---------|
| `port`, `bind`, `refreshSeconds` | server bind + UI auto-refresh cadence |
| `clis.<name>.bin` / `cwd` / `env` | how to invoke the CLI (`bin` is relative to `cwd`) |
| `clis.<name>.discover` or `resources` | runtime discovery (`ncl help`) vs a static resource list |
| `clis.<name>.list` | argv template; `{resource}` is the only substitution |
| `clis.<name>.output` | `json` or `jsonlines` (docker/kubectl style) |
| `clis.<name>.unwrap` | dot-path into a response envelope (e.g. `data`) |
| `clis.<name>.enrich`/`badges`/`summary` | table decorations (ID→name joins, status colors, summary cards) |
| `activity` | `sessionsRoot` + `days` for the message-activity charts |
| `logs` | `dir`, `tailLines`, and an allowlist of `files` to tail |
| `docs` | file viewer: `root`, a `deny` glob list, and `collections` of glob patterns |
Adding a second CLI is config-only — e.g. `docker` is included as a `jsonlines`
example. View plugins (`views/<cli>-<view>.js`) are the only per-CLI code and
are optional.
## Troubleshooting
- **`ENOENT` / config not found** — run from `tools/clidash/` and make sure you
copied `clidash.config.example.json` to `clidash.config.json` (step 2), or set
`CLIDASH_CONFIG=/abs/path.json`.
- **No `ncl` resources / discovery empty** — `bin/ncl` isn't built or the path
is wrong. Build it (`pnpm run build`) or fix `clis.ncl.bin`.
- **docker tab errors** — the docker daemon isn't running, or remove the
`docker` CLI from config if you don't need it.
- **Can't reach it from another device** — it binds `127.0.0.1`; set
`BIND=<private-ip>` (tailnet), never a public interface.
- **Empty Activity/Logs/Files** — check that `activity.sessionsRoot`,
`logs.dir`, and `docs.root` resolve to your NanoClaw root (relative to where
you launch `node server.js`).
## Removal
See [REMOVE.md](REMOVE.md).
@@ -0,0 +1,109 @@
# clidash
CLI-agnostic **read-only** web dashboard. Point it at any CLI that can list
resources as JSON and it derives the dashboard at runtime: one tab per
resource, a generic table over whatever columns the rows have. New resource →
new tab; new column → new table column; **zero code changes**.
It ships pre-wired for NanoClaw's `ncl` CLI (agent groups, sessions, messaging
groups, wirings, users, roles, …) plus `docker`, but the same config shape
works for any list-as-JSON CLI.
- **Zero dependencies** — Node built-ins only (Node ≥ 22.5, for `node:sqlite`),
no build step,
vanilla-JS frontend.
- **Read-only by construction** — the server can only `execFile` the configured
argv templates; `{resource}` is the sole substitution and is validated
against the discovered/static resource allowlist. Never a shell.
- **Standalone** — no imports from NanoClaw source; the core is extractable to
its own repo. The NanoClaw-specific knowledge lives entirely in the config
and in the `views/ncl-overview.js` view plugin.
## Run
```bash
cp clidash.config.example.json clidash.config.json # then edit paths if needed
node server.js # uses ./clidash.config.json
CLIDASH_CONFIG=/path/to.json node server.js
PORT=4690 BIND=127.0.0.1 node server.js # env overrides
```
Run it from `tools/clidash/`; the example config uses paths relative to the
NanoClaw root two levels up, so it works out of the box once `ncl` is built.
## Configure (`clidash.config.json`)
```jsonc
{
"port": 4690,
"bind": "127.0.0.1", // never a public interface; a tailnet IP at most
"refreshSeconds": 60,
"clis": {
"ncl": {
"bin": "bin/ncl", // relative to cwd below
"cwd": "../..", // the NanoClaw root
"discover": { "args": ["help"], "parser": "ncl-help" }, // runtime resource discovery
"list": ["{resource}", "list", "--json"], // argv template
"output": "json", // or "jsonlines" (docker/kubectl style)
"unwrap": "data" // dot-path into a response envelope
},
"docker": {
"bin": "docker",
"resources": ["ps", "images"], // static alternative to discover
"list": ["{resource}", "--format", "{{json .}}"],
"output": "jsonlines"
}
}
}
```
`{resource}` may appear as a whole argv element or inside one — e.g. a remote
CLI via ssh: `"list": ["-i", "key.pem", "user@host", "ncl {resource} list --json"]`.
Per-CLI `env` (merged over the server's env) and `cwd` are supported. See
`clidash.config.example.json` for the full NanoClaw config, including the
`enrich`/`badges`/`summary` table decorations and the `activity`/`logs`/`docs`
sections.
## API
| Route | Returns |
|---|---|
| `GET /api/clis` | configured CLIs + discovered/static resources (discovery cached 60s) |
| `GET /api/r/<cli>/<resource>` | `{ok, rows, fetchedAt}` — coalesced, 10s exec timeout |
| `GET /api/view/<cli>/<view>` | curated view plugin from `views/<cli>-<view>.js` |
View plugins are the only per-CLI *code*, and optional: a default-exported
async function receiving `{ fetch }` (bound to that CLI) returning JSON.
`views/ncl-overview.js` joins groups + sessions + messaging-groups + wirings
into per-agent status cards (green <15m / amber <2h / red older).
## Test
```bash
npm test # unit + integration (node:test, stub CLI — no real CLI needed)
./test/smoke.sh # against a running instance
```
## Deploy as a service
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
private (e.g. tailnet) IP — **never a public interface**; the network is the
auth boundary. Example systemd user service:
```ini
# ~/.config/systemd/user/clidash.service
[Unit]
Description=clidash read-only CLI dashboard
[Service]
WorkingDirectory=%h/nanoclaw/tools/clidash
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
Environment=BIND=127.0.0.1
Restart=on-failure
[Install]
WantedBy=default.target
```
Then `systemctl --user enable --now clidash`.
@@ -0,0 +1,86 @@
// Message-activity reader for clidash.
//
// ncl has no `messages` resource — message data lives in the per-session SQLite
// DBs (`data/v2-sessions/<group>/<session>/{inbound,outbound}.db`). We read them
// read-only with Node's built-in `node:sqlite` (no new dependency) and aggregate
// per-session in/out totals + a daily time-series for charting.
import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
// Timestamps come in two shapes across tables: SQLite "YYYY-MM-DD HH:MM:SS" (UTC)
// and already-ISO "YYYY-MM-DDTHH:MM:SS.sssZ". Normalize to a comparable ISO form
// so date-bucketing and max("last") work regardless of which a row used.
function normTs(ts) {
if (typeof ts !== 'string' || ts.length < 10) return null;
if (ts.includes('T')) return ts; // already ISO
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 {
db = new DatabaseSync(dbPath, { readOnly: true });
const rows = db.prepare(`SELECT timestamp FROM ${table}`).all();
const byDay = new Map();
let last = null;
for (const r of rows) {
const ts = normTs(r.timestamp);
if (!ts) continue;
const day = localDay(new Date(ts));
byDay.set(day, (byDay.get(day) ?? 0) + 1);
if (last === null || ts > last) last = ts;
}
return { total: rows.length, byDay, last };
} catch {
return { total: 0, byDay: new Map(), last: null }; // missing/locked/corrupt → skip
} finally {
try { db?.close(); } catch { /* already closed */ }
}
}
function listDirs(path) {
try {
return readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return [];
}
}
/**
* 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 (local time, newest last)
*/
export function collectActivity(sessionsRoot, days, now) {
const dates = [];
for (let i = days - 1; i >= 0; i--) {
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 = [];
for (const group of listDirs(sessionsRoot)) {
for (const session of listDirs(join(sessionsRoot, group))) {
const base = join(sessionsRoot, group, session);
// a real session dir has at least one of the two message DBs; skip shared
// scaffolding dirs like `.claude-shared` that don't.
if (!existsSync(join(base, 'inbound.db')) && !existsSync(join(base, 'outbound.db'))) continue;
const inb = readTable(join(base, 'inbound.db'), 'messages_in');
const out = readTable(join(base, 'outbound.db'), 'messages_out');
const lastActivity = [inb.last, out.last].filter(Boolean).sort().at(-1) ?? null;
sessions.push({ agent_group_id: group, session_id: session, in: inb.total, out: out.total, lastActivity });
for (const [day, n] of inb.byDay) series.get(day)?.in !== undefined && (series.get(day).in += n);
for (const [day, n] of out.byDay) series.get(day)?.out !== undefined && (series.get(day).out += n);
}
}
return { sessions, series: dates.map((d) => series.get(d)) };
}
@@ -0,0 +1,106 @@
{
"port": 4690,
"bind": "127.0.0.1",
"refreshSeconds": 60,
"clis": {
"ncl": {
"bin": "bin/ncl",
"cwd": "../..",
"discover": { "args": ["help"], "parser": "ncl-help" },
"list": ["{resource}", "list", "--json"],
"output": "json",
"unwrap": "data",
"commands": {
"get": ["{resource}", "get", "--id", "{id}", "--json"],
"config-get": ["groups", "config", "get", "--id", "{id}", "--json"]
},
"help": ["{resource}", "help"],
"enrich": {
"sessions": {
"agent_group_id": { "ref": "groups", "label": "name" },
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
},
"wirings": {
"agent_group_id": { "ref": "groups", "label": "name" },
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
},
"roles": {
"agent_group_id": { "ref": "groups", "label": "name" },
"user_id": { "ref": "users", "label": "display_name" },
"granted_by": { "ref": "users", "label": "display_name" }
},
"members": {
"agent_group_id": { "ref": "groups", "label": "name" },
"user_id": { "ref": "users", "label": "display_name" }
},
"destinations": {
"agent_group_id": { "ref": "groups", "label": "name" }
},
"user-dms": {
"user_id": { "ref": "users", "label": "display_name" },
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
}
},
"badges": {
"container_status": { "running": "green", "idle": "green", "starting": "amber", "stopped": "gray", "error": "red" },
"status": { "active": "green", "stopped": "gray", "error": "red", "pending": "amber" }
},
"summary": {
"sessions": "container_status",
"messaging-groups": "channel_type",
"roles": "role",
"users": "kind",
"destinations": "target_type",
"dropped-messages": "reason"
}
},
"docker": {
"bin": "docker",
"resources": ["ps", "images"],
"list": ["{resource}", "--format", "{{json .}}"],
"output": "jsonlines"
}
},
"activity": {
"sessionsRoot": "../../data/v2-sessions",
"days": 14
},
"logs": {
"dir": "../../logs",
"tailLines": 500,
"files": [
{ "name": "nanoclaw.log", "label": "host log" },
{ "name": "nanoclaw.error.log", "label": "errors" }
]
},
"docs": {
"root": "../..",
"deny": ["node_modules", ".env", "*token*", "*secret*", "*.pem", "*.key", "*.lock", "pnpm-lock.yaml"],
"collections": [
{
"name": "skills",
"label": "Skills",
"lang": "markdown",
"patterns": ["groups/*/skills/*/SKILL.md", "container/skills/*/SKILL.md"]
},
{
"name": "claude-md",
"label": "CLAUDE.md",
"lang": "markdown",
"patterns": ["groups/*/CLAUDE.md", "groups/*/CLAUDE.local.md"]
},
{
"name": "profiles",
"label": "Profiles",
"lang": "json",
"patterns": ["groups/*/profile.json"]
},
{
"name": "conversations",
"label": "Conversations",
"lang": "markdown",
"patterns": ["groups/*/conversations/*.md"]
}
]
}
}
@@ -0,0 +1,102 @@
// Read-only file viewer for clidash.
//
// Surfaces on-disk documents (skills, CLAUDE.md, profile.json, conversations)
// that are NOT ncl resources. Same security posture as the rest of clidash:
// only files matching a configured collection's glob patterns are listable or
// readable; a deny-list blocks secrets; path traversal is impossible because a
// requested path must be a member of the freshly-globbed allow-set.
import { readdirSync, realpathSync } from 'node:fs';
import { join, resolve, sep } from 'node:path';
// Convert one glob segment to an anchored regex. `*` matches any run of
// non-slash chars (so it works both as a whole segment and inside a filename,
// e.g. `CLAUDE*.md`). All other regex metacharacters are escaped.
function segToRegExp(seg) {
const esc = seg.replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '[^/]*');
return new RegExp('^' + esc + '$');
}
// A path is denied if any of its segments matches any deny glob.
function isDenied(relPath, deny) {
const segs = relPath.split('/');
return deny.some((d) => {
const re = segToRegExp(d);
return segs.some((s) => re.test(s));
});
}
// Directed walk: descend only entries matching each successive pattern segment.
function walk(root, rel, segs, depth, out, deny) {
if (depth >= segs.length) return;
let entries;
try {
entries = readdirSync(join(root, rel), { withFileTypes: true });
} catch {
return;
}
const re = segToRegExp(segs[depth]);
const last = depth === segs.length - 1;
for (const e of entries) {
if (e.name === '.' || e.name === '..') continue;
if (!re.test(e.name)) continue;
const childRel = rel ? `${rel}/${e.name}` : e.name;
if (isDenied(childRel, deny)) continue;
if (last) {
if (e.isFile()) out.add(childRel);
} else if (e.isDirectory()) {
walk(root, childRel, segs, depth + 1, out, deny);
}
}
}
/**
* Relative paths under `root` matching any of `patterns`, minus `deny` matches.
* Sorted, de-duplicated. Patterns use `*` per the segment rules above; no `**`.
*/
export function globFiles(root, patterns, deny = []) {
const out = new Set();
for (const pattern of patterns) {
walk(root, '', pattern.split('/'), 0, out, deny);
}
return [...out].sort();
}
/**
* Human-friendly grouping/label for a relative path.
* `groups/<g>/...` → group `<g>`; `container/...` → group `shared`.
*/
const CONTAINER_SEGS = new Set(['skills', 'conversations']); // redundant grouping dirs
export function describeFile(relPath) {
const parts = relPath.split('/');
if (parts[0] === 'groups' && parts.length > 2) {
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
return { group: parts[1], label: `${parts[1]} / ${rest}` };
}
if (parts[0] === 'container') {
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
return { group: 'shared', label: `shared / ${rest}` };
}
return { group: '', label: relPath };
}
/**
* Validate a requested doc path against a collection and return its absolute
* path, or throw. A path is allowed only if it is a member of the collection's
* freshly-globbed allow-set — this single check enforces the patterns, the
* deny-list, and traversal safety at once.
*/
export function resolveDoc(root, collection, relPath, deny = []) {
const allowed = new Set(globFiles(root, collection.patterns, deny));
if (!allowed.has(relPath)) {
throw new Error(`Path not allowed: ${relPath}`);
}
// Defence in depth: the resolved real path must still live under root.
const abs = resolve(root, relPath);
const rootReal = realpathSync(root);
const absReal = realpathSync(abs);
if (absReal !== rootReal && !absReal.startsWith(rootReal + sep)) {
throw new Error(`Path not allowed: ${relPath}`);
}
return abs;
}
@@ -0,0 +1,18 @@
// Log tailing for clidash — reads the last N lines of an allowlisted log file
// and strips ANSI color codes (the host logger writes colored output).
import { readFile } from 'node:fs/promises';
const ANSI_RE = /\x1b\[[0-9;]*m/g;
/**
* Last `maxLines` lines of a log file, ANSI-stripped.
* @returns {{ lines: string[], text: string }}
*/
export async function tailFile(path, maxLines) {
const raw = (await readFile(path, 'utf8')).replace(ANSI_RE, '');
const all = raw.split('\n');
if (all.length && all.at(-1) === '') all.pop(); // drop trailing newline's empty field
const lines = all.slice(-maxLines);
return { lines, text: lines.join('\n') };
}
@@ -0,0 +1,14 @@
{
"name": "clidash",
"version": "0.1.0",
"description": "CLI-agnostic read-only web dashboard — derives tabs and tables from any CLI that lists resources as JSON",
"type": "module",
"private": true,
"scripts": {
"start": "node server.js",
"test": "node --test 'test/*.test.js'"
},
"engines": {
"node": ">=22.5"
}
}
@@ -0,0 +1,101 @@
// Pluggable parsers for clidash.
//
// discoveryParsers — turn a CLI's "help"-style output into a resource list.
// parseOutput / unwrapPath — turn a CLI's list output into rows.
// All per-CLI knowledge beyond these small functions lives in clidash.config.json.
/**
* Discovery parsers, keyed by the `discover.parser` name in config.
* Each receives the raw discovery output and returns
* [{ name, description, verbs }] for resources that support `list`.
* They must throw loudly on unrecognized formats — silent empty results
* would render as silently-stale tabs.
*/
export const discoveryParsers = {
/**
* Parses ncl's two-column help format:
*
* Resources:
* sessions Session — the runtime unit. ...
* verbs: list, get
* Commands:
* help ...
*/
'ncl-help'(text) {
const lines = String(text).split('\n');
const start = lines.findIndex((l) => l.trim() === 'Resources:');
if (start === -1) {
throw new Error('ncl-help parser: no "Resources:" section in output — format may have changed');
}
const resources = [];
let current = null;
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') continue;
if (/^\S/.test(line)) break; // next top-level section, e.g. "Commands:"
const verbsMatch = line.match(/^\s+verbs:\s*(.+)$/);
if (verbsMatch && current) {
current.verbs = verbsMatch[1].split(',').map((v) => v.trim()).filter(Boolean);
continue;
}
const resMatch = line.match(/^ (\S+)\s{2,}(\S.*)$/);
if (resMatch) {
current = { name: resMatch[1], description: resMatch[2].trim(), verbs: [] };
resources.push(current);
}
}
return resources.filter((r) => r.verbs.includes('list'));
},
};
/**
* Parses a CLI's list output per the config's `output` field.
* - 'json' — one JSON document.
* - 'jsonlines' — one JSON object per line (docker/kubectl style).
* Thrown errors carry the raw output on `err.raw` so the UI can show it.
*/
export function parseOutput(text, format) {
if (format === 'json') {
try {
return JSON.parse(text);
} catch (e) {
const err = new Error(`Invalid JSON output: ${e.message}`);
err.raw = text;
throw err;
}
}
if (format === 'jsonlines') {
const rows = [];
const lines = String(text).split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
try {
rows.push(JSON.parse(line));
} catch (e) {
const err = new Error(`Invalid JSON on line ${i + 1}: ${e.message}`);
err.raw = text;
throw err;
}
}
return rows;
}
throw new Error(`Unknown output format: ${format}`);
}
/**
* Follows a dot-path into a response envelope (e.g. 'data' for ncl's
* {id, ok, data} frame). No path → value passes through unchanged.
* Missing path throws — a changed envelope must fail loudly.
*/
export function unwrapPath(value, path) {
if (!path) return value;
let cur = value;
for (const key of path.split('.')) {
if (cur === null || typeof cur !== 'object' || !(key in cur)) {
throw new Error(`Unwrap path "${path}" not found in CLI output (missing "${key}")`);
}
cur = cur[key];
}
return cur;
}
@@ -0,0 +1,723 @@
// clidash frontend — vanilla JS, no build step.
//
// Layout: a left sidebar with top-level items (Overview, Activity) and grouped
// sections (one per CLI — ncl, docker — and a Files section for on-disk docs).
// Each page shows the exact command that produced it. Tables auto-derive from
// `ncl <resource> list --json`; rows drill into their `get` detail.
//
// Refresh UX: on first load every resource of every CLI is prefetched so nav is
// instant. 60s auto-refresh + a manual button. Background refreshes diff-and-
// inject (the data DOM rebuilds only when the data signature changes).
import { mdToHtml } from './md.js';
const $ = (id) => document.getElementById(id);
const state = {
clis: [],
docCollections: [],
activeView: 'overview', // 'overview' | 'activity' | 'r:<cli>:<resource>' | 'doc:<collection>'
paused: false,
refreshSeconds: 60,
lastUpdated: null,
refreshing: false,
snapshots: new Map(), // "cli/resource" -> { rows, fetchedAt, command }
errors: new Map(),
activity: null, // { sessions, series }
activityConfigured: false,
activityCommand: null,
logs: [], // [{ name, label }]
logCache: new Map(), // name -> { text, command }
activeDocPath: null,
openDocGroups: new Set(), // which doc groups (e.g. agents) are expanded
docCache: new Map(),
configCache: new Map(), // groupId -> container config (for the overview page)
helpCache: new Map(), // "cli/resource" -> help text | null (prefetched each cycle)
detail: null,
sidebarOpen: false,
renderedSig: null,
};
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
for (const c of [].concat(children)) if (c != null) node.append(c);
return node;
}
// Lucide-style inline icons (static trusted markup) — crisp, themeable via currentColor.
const ICONS = {
overview: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
activity: '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3"/><path d="M13 15h4"/>',
box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
logs: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>',
};
function icon(name) {
const s = document.createElementNS(SVG_NS, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('fill', 'none');
s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', '1.8');
s.setAttribute('stroke-linecap', 'round');
s.setAttribute('stroke-linejoin', 'round');
s.innerHTML = ICONS[name] ?? '';
return s;
}
// ---------------------------------------------------------------- helpers
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;
const s = Math.round(ms / 1000);
if (s < 0) return new Date(iso).toLocaleString();
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 48) return `${h}h ago`;
return `${Math.round(h / 24)}d ago`;
}
function coarseAgo(date) {
const s = (Date.now() - date.getTime()) / 1000;
if (s < 60) return 'less than a minute ago';
const m = Math.floor(s / 60);
if (m < 60) return m === 1 ? '1 minute ago' : `${m} minutes ago`;
const h = Math.floor(m / 60);
if (h < 24) return h === 1 ? '1 hour ago' : `${h} hours ago`;
const d = Math.floor(h / 24);
return d === 1 ? '1 day ago' : `${d} days ago`;
}
function staleness(lastActive) {
if (!lastActive) return 'gray';
const min = (Date.now() - new Date(lastActive).getTime()) / 60000;
if (Number.isNaN(min)) return 'gray';
return min < 15 ? 'green' : min < 120 ? 'amber' : 'red';
}
function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
}
for (const child of [].concat(children)) {
if (child == null) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
function fmtValue(value) {
if (value === null || value === undefined) return { text: 'null', cls: 'null' };
if (typeof value === 'string' && ISO_RE.test(value)) return { iso: value };
return { text: typeof value === 'object' ? JSON.stringify(value) : String(value) };
}
function cellFor(value) {
const f = fmtValue(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' }, absTime(f.iso)),
]));
}
if (f.text.length > 42) {
const span = el('span', { class: 'trunc', title: f.text }, f.text.slice(0, 39) + '…');
span.addEventListener('click', (e) => { e.stopPropagation(); span.textContent = f.text; span.classList.remove('trunc'); });
return el('td', {}, span);
}
return el('td', {}, f.text);
}
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)} (${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]);
});
}
function resolveRef(cliName, ref, id) {
const snap = state.snapshots.get(`${cliName}/${ref.ref}`);
const row = snap?.rows?.find((r) => String(r.id) === String(id));
return row ? (row[ref.label] ?? null) : null;
}
function badgeChip(value, colorMap) {
const color = colorMap[String(value).toLowerCase()] ?? 'gray';
return el('span', { class: `badge-status ${color}` }, [el('span', { class: `dot ${color}` }), String(value)]);
}
function buildCell(value, column, ctx) {
if (ctx.badges?.[column] && value != null && typeof value !== 'object') {
return el('td', {}, badgeChip(value, ctx.badges[column]));
}
if (ctx.enrich?.[column] && value != null) {
const name = resolveRef(ctx.cliName, ctx.enrich[column], value);
if (name != null) {
return el('td', { class: 'enriched', title: String(value) }, [
el('span', {}, String(name)), el('span', { class: 'raw-id' }, String(value)),
]);
}
}
return cellFor(value);
}
function summaryBar(resource, rows, col, cli) {
let label = resource.replace(/-/g, ' ');
if (rows.length === 1 && label.endsWith('s')) label = label.slice(0, -1);
const bits = [el('span', { class: 'sum-count' }, `${rows.length} ${label}`)];
if (col && rows.some((r) => col in r)) {
const counts = new Map();
for (const r of rows) { const v = r[col] ?? '—'; counts.set(v, (counts.get(v) ?? 0) + 1); }
const colorMap = cli.badges?.[col];
for (const [v, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
bits.push(el('span', { class: 'sum-sep' }, '·'));
const c = colorMap?.[String(v).toLowerCase()] ?? null;
bits.push(c
? el('span', { class: `badge-status ${c}` }, [el('span', { class: `dot ${c}` }), `${v} ×${n}`])
: el('span', { class: 'sum-chip' }, `${v} ×${n}`));
}
}
return el('div', { class: 'summary-bar' }, bits);
}
// ---------------------------------------------------------------- views
const nclCli = () => state.clis.find((c) => c.name === 'ncl') ?? state.clis[0];
function currentView() {
const v = state.activeView;
if (v === 'overview' || v === 'activity') return { type: v };
const m = v.match(/^r:([^:]+):(.+)$/);
if (m) return { type: 'resource', cli: m[1], resource: m[2] };
if (v.startsWith('doc:')) return { type: 'doc', collection: v.slice(4) };
if (v.startsWith('log:')) return { type: 'log', name: v.slice(4) };
return { type: 'overview' };
}
const activeCollection = () => {
const v = currentView();
return v.type === 'doc' ? state.docCollections.find((c) => c.name === v.collection) : null;
};
// ---------------------------------------------------------------- fetching
async function fetchJson(url) {
const res = await fetch(url);
return res.json().catch(() => ({ ok: false, error: `Bad response from ${url}` }));
}
async function refresh(force = false) {
state.refreshing = true;
if (force) renderControls();
const [cliList, docList, logList] = await Promise.all([
fetchJson('/api/clis').catch(() => null),
fetchJson('/api/docs').catch(() => null),
fetchJson('/api/logs').catch(() => null),
]);
if (cliList?.clis) {
state.clis = cliList.clis;
state.refreshSeconds = cliList.clis[0]?.refreshSeconds ?? state.refreshSeconds;
}
if (docList?.collections) state.docCollections = docList.collections;
if (logList?.files) state.logs = logList.files;
render(); // paint sidebar + active view's loading state immediately
const jobs = [];
jobs.push(fetchJson('/api/activity').then((body) => {
if (body.ok && body.configured) {
state.activity = { sessions: body.sessions, series: body.series };
state.activityConfigured = true; state.activityCommand = body.command ?? null;
} else state.activityConfigured = false;
render();
}));
for (const lg of state.logs) {
jobs.push(fetchJson(`/api/log/${encodeURIComponent(lg.name)}`).then((body) => {
if (body.ok) state.logCache.set(lg.name, { text: body.text, command: body.command });
render();
}));
}
for (const c of state.clis) {
for (const r of c.resources ?? []) {
const key = `${c.name}/${r.name}`;
jobs.push(fetchJson(`/api/r/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
if (body.ok) { state.snapshots.set(key, { rows: body.rows, fetchedAt: body.fetchedAt, command: body.command }); state.errors.set(key, null); }
else state.errors.set(key, body.raw ? `${body.error}\n\n${body.raw}` : body.error);
render();
}));
if (c.help) {
jobs.push(fetchJson(`/api/help/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
state.helpCache.set(key, body.ok ? body.text : null);
render();
}));
}
}
}
await Promise.all(jobs);
// per-group container config (for the Overview page) — small, refetched each cycle
const groups = state.snapshots.get('ncl/groups')?.rows ?? [];
await Promise.all(groups.map(async (g) => {
const c = await fetchJson(`/api/cmd/ncl/config-get?id=${encodeURIComponent(g.id)}`);
if (c.ok) state.configCache.set(g.id, c.data);
}));
state.lastUpdated = new Date();
state.refreshing = false;
render();
}
async function openDoc(collectionName, path) {
state.activeDocPath = path;
const key = `${collectionName}\0${path}`;
if (!state.docCache.has(key)) {
const body = await fetchJson(`/api/doc?c=${encodeURIComponent(collectionName)}&p=${encodeURIComponent(path)}`);
state.docCache.set(key, body.ok ? { lang: body.lang, content: body.content } : { lang: 'error', content: body.error || 'Failed to load' });
}
state.renderedSig = null;
render();
}
async function openDetail(cliName, resource, id) {
state.detail = { cli: cliName, resource, id, loading: true };
state.renderedSig = null;
render();
const rec = await fetchJson(`/api/cmd/${cliName}/get?resource=${encodeURIComponent(resource)}&id=${encodeURIComponent(id)}`);
let config = null;
if (resource === 'groups') {
const cg = await fetchJson(`/api/cmd/${cliName}/config-get?id=${encodeURIComponent(id)}`);
if (cg.ok) config = cg.data;
}
if (!state.detail || state.detail.id !== id) return;
state.detail = { cli: cliName, resource, id, record: rec.ok ? rec.data : null, error: rec.ok ? null : rec.error, config };
state.renderedSig = null;
render();
}
function closeDetail() { state.detail = null; state.renderedSig = null; render(); }
// Help panel: the description (first paragraph) is always visible; the verbs +
// fields (everything after the first blank line) sit behind a collapse.
function helpPanel(text) {
if (text === null) return null; // explicitly no help
if (text === undefined) return el('div', { class: 'help-panel' }, el('div', { class: 'help-head dim' }, 'loading help…'));
const idx = text.indexOf('\n\n');
const head = (idx >= 0 ? text.slice(0, idx) : text).trim();
const body = idx >= 0 ? text.slice(idx + 2).trim() : '';
return el('div', { class: 'help-panel' }, [
el('div', { class: 'help-head' }, head),
body ? el('details', { class: 'help-more' }, [
el('summary', {}, 'verbs & fields'),
el('pre', { class: 'help-text' }, body),
]) : null,
]);
}
function go(view) {
state.activeView = view;
state.detail = null;
state.sidebarOpen = false;
state.renderedSig = null;
const v = currentView();
if (v.type === 'doc') {
const coll = state.docCollections.find((c) => c.name === v.collection);
const first = coll && (coll.name === 'conversations' ? coll.files.at(-1) : coll.files[0]); // newest conversation
state.activeDocPath = state.activeDocPath && coll?.files.some((f) => f.path === state.activeDocPath)
? state.activeDocPath : (first?.path ?? null);
// expand only the group holding the active doc; the user picks the rest
const activeFile = coll?.files.find((f) => f.path === state.activeDocPath);
state.openDocGroups = new Set(activeFile ? [activeFile.group] : []);
render();
if (state.activeDocPath) openDoc(coll.name, state.activeDocPath);
return;
}
render();
}
// ---------------------------------------------------------------- rendering
function dataSignature() {
const v = currentView();
const key = v.type === 'resource' ? `${v.cli}/${v.resource}` : null;
const coll = activeCollection();
return JSON.stringify({
view: state.activeView, clis: state.clis.map((c) => `${c.name}:${(c.resources || []).length}`),
activityConfigured: state.activityConfigured,
rows: key ? state.snapshots.get(key)?.rows ?? null : null,
rowsError: key ? state.errors.get(key) ?? null : null,
command: key ? state.snapshots.get(key)?.command ?? null : null,
help: key ? state.helpCache.get(key) ?? null : null,
overview: v.type === 'overview' ? {
groups: state.snapshots.get('ncl/groups')?.rows ?? null,
sessions: state.snapshots.get('ncl/sessions')?.rows ?? null,
configs: [...state.configCache.entries()],
activity: state.activity?.sessions ?? null,
} : null,
activity: v.type === 'activity' ? state.activity : null,
log: v.type === 'log' ? state.logCache.get(v.name)?.text ?? null : null,
docFiles: coll ? coll.files.map((f) => f.path) : null,
docPath: state.activeDocPath,
docGroupsOpen: coll ? [...state.openDocGroups] : null,
docContent: coll ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`)?.content ?? null : null,
detail: state.detail, paused: state.paused, sidebarOpen: state.sidebarOpen,
});
}
function renderControls() {
$('updated').textContent = state.lastUpdated
? `updated ${coarseAgo(state.lastUpdated)}${state.paused ? ' · paused' : ''}` : '';
$('refresh').classList.toggle('spinning', state.refreshing);
}
function render() {
renderControls();
const sig = dataSignature();
if (sig === state.renderedSig) return;
state.renderedSig = sig;
$('sidebar').classList.toggle('open', state.sidebarOpen);
$('scrim').hidden = !state.sidebarOpen;
renderNav();
const v = currentView();
const banner = $('banner');
const tabError = v.type === 'resource' ? state.errors.get(`${v.cli}/${v.resource}`) : null;
const cli = v.type === 'resource' ? state.clis.find((c) => c.name === v.cli) : null;
const bannerMsg = cli?.error ? `Discovery failed for ${v.cli}: ${cli.error}`
: (tabError ? `CLI unreachable — showing last good snapshot. ${tabError.split('\n')[0]}` : null);
banner.hidden = !bannerMsg;
banner.textContent = bannerMsg ?? '';
renderCmdline(v);
if (v.type === 'overview') renderOverviewPage();
else if (v.type === 'activity') renderActivity();
else if (v.type === 'doc') renderDocs();
else if (v.type === 'log') renderLogPage(v.name);
else renderTable(v.cli, v.resource);
renderDetail();
}
function navItem(label, view, cls = '', iconName = null) {
return el('button', {
class: `nav-item ${cls}` + (state.activeView === view ? ' active' : ''),
onclick: () => go(view),
}, [iconName ? icon(iconName) : null, el('span', {}, label)]);
}
function renderNav() {
const nav = $('nav');
const items = [navItem('Overview', 'overview', '', 'overview')];
if (state.activityConfigured) items.push(navItem('Activity', 'activity', '', 'activity'));
for (const cli of state.clis) {
items.push(el('div', { class: 'nav-section' }, [icon(cli.name === 'docker' ? 'box' : 'terminal'), el('span', {}, cli.name)]));
for (const r of cli.resources ?? []) {
items.push(navItem(r.name, `r:${cli.name}:${r.name}`, 'nav-sub'));
}
}
if (state.docCollections.length) {
items.push(el('div', { class: 'nav-section' }, [icon('folder'), el('span', {}, 'Files')]));
for (const coll of state.docCollections) {
items.push(navItem(coll.label, `doc:${coll.name}`, 'nav-sub'));
}
}
if (state.logs.length) {
items.push(el('div', { class: 'nav-section' }, [icon('logs'), el('span', {}, 'Logs')]));
for (const lg of state.logs) {
items.push(navItem(lg.label, `log:${lg.name}`, 'nav-sub'));
}
}
nav.replaceChildren(...items);
}
function renderCmdline(v) {
const bar = $('cmdline');
let cmd = null;
if (v.type === 'resource') cmd = state.snapshots.get(`${v.cli}/${v.resource}`)?.command;
else if (v.type === 'activity') cmd = state.activityCommand;
else if (v.type === 'doc') cmd = state.activeDocPath ? `file · ${state.activeDocPath}` : null;
else if (v.type === 'log') cmd = state.logCache.get(v.name)?.command ?? null;
else if (v.type === 'overview') cmd = 'derived · ncl groups/sessions/messaging-groups/wirings + config-get + activity';
bar.hidden = !cmd;
bar.textContent = cmd ? `$ ${cmd}` : '';
}
// ---- Overview page (rich agent cards) ----
function renderOverviewPage() {
const content = $('content');
const groups = state.snapshots.get('ncl/groups')?.rows;
if (!groups) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
const sessions = state.snapshots.get('ncl/sessions')?.rows ?? [];
const wirings = state.snapshots.get('ncl/wirings')?.rows ?? [];
const mgs = state.snapshots.get('ncl/messaging-groups')?.rows ?? [];
const act = state.activity?.sessions ?? [];
const mgName = (id) => mgs.find((m) => m.id === id)?.name ?? mgs.find((m) => m.id === id)?.platform_id ?? id;
const field = (k, v, cls = '') => el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, k), el('span', { class: `v ${cls}` }, v)]);
const cards = groups.map((g) => {
const gs = sessions.filter((s) => s.agent_group_id === g.id);
const lastActive = gs.map((s) => s.last_active).filter(Boolean).sort().at(-1) ?? null;
const container = gs.some((s) => s.container_status === 'running') ? 'running' : (gs[0]?.container_status ?? 'none');
const ga = act.filter((a) => a.agent_group_id === g.id);
const msgIn = ga.reduce((a, s) => a + s.in, 0), msgOut = ga.reduce((a, s) => a + s.out, 0);
const cfg = state.configCache.get(g.id);
const chans = wirings.filter((w) => w.agent_group_id === g.id).map((w) => `${mgs.find((m) => m.id === w.messaging_group_id)?.channel_type ?? '?'}: ${mgName(w.messaging_group_id)}`);
const status = staleness(lastActive);
const containerColor = container === 'running' ? 'green' : container === 'idle' ? 'green' : container === 'none' ? 'gray' : 'gray';
const fields = [
el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'container'), badgeChip(container, { running: 'green', idle: 'green', stopped: 'gray', none: 'gray' })]),
field('sessions', String(gs.length)),
field('messages', `${msgIn} in · ${msgOut} out`),
field('last active', lastActive ? relTime(lastActive) : '—', lastActive ? '' : 'dim'),
];
if (cfg) {
fields.push(field('provider / model', `${cfg.provider ?? 'claude'} / ${cfg.model ?? 'default'}`));
fields.push(el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'cli scope'), badgeChip(cfg.cli_scope ?? 'group', { global: 'amber', group: 'green', disabled: 'gray' })]));
const pkgs = (cfg.packages_apt?.length ?? 0) + (cfg.packages_npm?.length ?? 0);
const mcp = Object.keys(cfg.mcp_servers ?? {}).length;
if (pkgs || mcp) fields.push(field('extras', `${pkgs} pkgs · ${mcp} mcp`));
}
return el('div', { class: 'ov-card' }, [
el('div', { class: 'ov-head' }, [
el('span', { class: `dot ${status}` }),
el('span', { class: 'ov-name' }, g.name),
el('span', { class: 'ov-folder' }, g.folder),
]),
el('div', { class: 'ov-fields' }, fields),
el('div', { class: 'ov-chans' }, chans.map((c) => el('span', { class: 'badge' }, c))),
]);
});
content.replaceChildren(
el('h2', { class: 'page-title' }, 'Agents overview'),
el('div', { class: 'ov-cards' }, cards),
);
}
// ---- Activity ----
function renderActivity() {
const content = $('content');
const data = state.activity;
if (!data) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
const { series, sessions } = data;
const totalIn = series.reduce((a, d) => a + d.in, 0);
const totalOut = series.reduce((a, d) => a + d.out, 0);
const W = 720, H = 220, padL = 34, padB = 28, padT = 10;
const max = Math.max(1, ...series.map((d) => Math.max(d.in, d.out)));
const slot = (W - padL) / series.length;
const bw = Math.max(3, slot / 2 - 2);
const yOf = (vv) => padT + (H - padT - padB) * (1 - vv / max);
const chart = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'activity-chart', preserveAspectRatio: 'none' });
for (const frac of [0, 0.5, 1]) {
const y = yOf(max * frac);
chart.append(svg('line', { x1: padL, y1: y, x2: W, y2: y, class: 'grid' }));
chart.append(svg('text', { x: padL - 6, y: y + 3, class: 'axis', 'text-anchor': 'end' }, String(Math.round(max * frac))));
}
series.forEach((d, i) => {
const x = padL + i * slot;
chart.append(svg('rect', { x: x + 1, y: yOf(d.in), width: bw, height: yOf(0) - yOf(d.in), class: 'bar-in' }, [svg('title', {}, `${d.date}: ${d.in} in`)]));
chart.append(svg('rect', { x: x + 1 + bw, y: yOf(d.out), width: bw, height: yOf(0) - yOf(d.out), class: 'bar-out' }, [svg('title', {}, `${d.date}: ${d.out} out`)]));
if (i % 2 === 0) chart.append(svg('text', { x: x + bw, y: H - 8, class: 'axis', 'text-anchor': 'middle' }, d.date.slice(5)));
});
const legend = el('div', { class: 'activity-legend' }, [
el('span', {}, [el('span', { class: 'lg in' }), `inbound (${totalIn})`]),
el('span', {}, [el('span', { class: 'lg out' }), `outbound (${totalOut})`]),
el('span', { class: 'dim' }, `last ${series.length} days`),
]);
const sessRows = [...sessions].sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || '')).map((s) => {
const groupName = resolveRef('ncl', { ref: 'groups', label: 'name' }, s.agent_group_id) ?? s.agent_group_id;
return el('tr', {}, [
el('td', {}, groupName),
el('td', {}, el('span', { class: 'trunc', title: s.session_id }, s.session_id.slice(0, 22) + '…')),
el('td', { class: 'num' }, String(s.in)),
el('td', { class: 'num' }, String(s.out)),
el('td', {}, s.lastActivity ? el('span', { class: 'reltime', title: s.lastActivity }, relTime(s.lastActivity)) : el('span', { class: 'null' }, '—')),
]);
});
content.replaceChildren(
el('h2', { class: 'page-title' }, 'Message activity'),
el('div', { class: 'activity-wrap' }, [
legend,
el('div', { class: 'chart-box' }, chart),
el('div', { class: 'table-wrap' }, el('table', { class: 'activity-table' }, [
el('thead', {}, el('tr', {}, ['agent', 'session', 'in', 'out', 'last activity'].map((h) => el('th', {}, h)))),
el('tbody', {}, sessRows),
])),
]),
);
}
// ---- Logs (tail of a log file) ----
function renderLogPage(name) {
const content = $('content');
const label = state.logs.find((l) => l.name === name)?.label ?? name;
const cached = state.logCache.get(name);
if (!cached) { content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'empty' }, 'Loading…')); return; }
const view = el('div', { class: 'log-view' });
for (const line of cached.text.split('\n')) {
const lvl = /\bERROR\b/i.test(line) ? 'err' : /\bWARN(ING)?\b/i.test(line) ? 'warn' : '';
view.append(el('div', { class: `log-line ${lvl}` }, line || ' '));
}
content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'log-box' }, view));
// follow the tail — scroll to the newest line
requestAnimationFrame(() => { const b = content.querySelector('.log-box'); if (b) b.scrollTop = b.scrollHeight; });
}
// ---- Files (doc viewer) ----
function renderDocs() {
const coll = activeCollection();
const content = $('content');
if (!coll) { content.replaceChildren(el('div', { class: 'empty' }, 'No documents.')); return; }
if (!coll.files.length) { content.replaceChildren(el('div', { class: 'empty' }, `No ${coll.label.toLowerCase()}.`)); return; }
// display name: drop the group prefix, the `/SKILL.md` tail (show the skill
// dir), and the .md extension — leaving e.g. "meeting-tagger" or "2026-06-13-…"
const itemName = (label) => {
let n = label.includes('/') ? label.split('/').slice(1).join('/').trim() : label;
return n.replace(/\/SKILL\.md$/, '').replace(/\.md$/, '') || label;
};
const newestFirst = coll.name === 'conversations';
const groups = new Map();
for (const f of coll.files) { if (!groups.has(f.group)) groups.set(f.group, []); groups.get(f.group).push(f); }
const toggleGroup = (g) => {
state.openDocGroups.has(g) ? state.openDocGroups.delete(g) : state.openDocGroups.add(g);
state.renderedSig = null; render();
};
const list = el('div', { class: 'doc-list' });
for (const [group, files] of groups) {
const open = state.openDocGroups.has(group);
list.append(el('button', { class: 'doc-group-toggle' + (open ? ' open' : ''), onclick: () => toggleGroup(group) }, [
el('span', { class: 'chev' }, open ? '▾' : '▸'),
el('span', { class: 'g-name' }, group || '—'),
el('span', { class: 'g-count' }, String(files.length)),
]));
if (open) {
const ordered = newestFirst ? [...files].reverse() : files;
for (const f of ordered) {
list.append(el('button', { class: 'doc-item' + (f.path === state.activeDocPath ? ' active' : ''), title: f.path, onclick: () => openDoc(coll.name, f.path) }, itemName(f.label) || f.path));
}
}
}
const pane = el('div', { class: 'doc-content' });
const cached = state.activeDocPath ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`) : null;
if (!state.activeDocPath) pane.append(el('div', { class: 'empty' }, 'Select a document.'));
else if (!cached) pane.append(el('div', { class: 'empty' }, 'Loading…'));
else if (cached.lang === 'error') pane.append(el('div', { class: 'tab-error' }, cached.content));
else if (cached.lang === 'json') {
let pretty = cached.content;
try { pretty = JSON.stringify(JSON.parse(cached.content), null, 2); } catch { /* keep raw */ }
pane.append(el('pre', { class: 'code json' }, pretty));
} else if (cached.lang === 'markdown') {
const md = el('div', { class: 'markdown' }); md.innerHTML = mdToHtml(cached.content); pane.append(md);
} else pane.append(el('pre', { class: 'code' }, cached.content));
content.replaceChildren(el('h2', { class: 'page-title' }, coll.label), el('div', { class: 'doc-viewer' }, [list, pane]));
}
// ---- resource table ----
function renderTable(cliName, resource) {
const content = $('content');
const cli = state.clis.find((c) => c.name === cliName);
if (!cli) { content.replaceChildren(el('div', { class: 'empty' }, 'No such CLI.')); return; }
const key = `${cliName}/${resource}`;
const snapshot = state.snapshots.get(key);
const error = state.errors.get(key);
const canDrill = (cli.commands || []).includes('get');
const parts = [el('h2', { class: 'page-title' }, resource)];
if (cli.help) parts.push(helpPanel(state.helpCache.get(key)));
if (error && snapshot) parts.push(el('div', { class: 'stale-note' }, `⚠ live fetch failing — snapshot from ${new Date(snapshot.fetchedAt).toLocaleTimeString()}`));
if (!snapshot) {
parts.push(error ? el('div', { class: 'tab-error' }, [`Failed to load ${resource}.`, el('pre', {}, error)]) : el('div', { class: 'empty' }, 'Loading…'));
content.replaceChildren(...parts); return;
}
const rows = snapshot.rows;
parts.push(summaryBar(resource, rows, cli.summary?.[resource], cli));
if (rows.length === 0) { parts.push(el('div', { class: 'empty' }, `No ${resource}.`)); content.replaceChildren(...parts); return; }
const columns = [];
for (const row of rows) for (const k of Object.keys(row)) if (!columns.includes(k)) columns.push(k);
const ctx = { cliName, enrich: cli.enrich?.[resource], badges: cli.badges };
const body = rows.map((row) => {
const id = row.id; const canRow = canDrill && id != null;
return el('tr', { class: canRow ? 'drillable' : '', ...(canRow ? { onclick: () => openDetail(cliName, resource, String(id)) } : {}) },
columns.map((c) => buildCell(row[c], c, ctx)));
});
parts.push(el('div', { class: 'table-wrap' }, el('table', {}, [
el('thead', {}, el('tr', {}, columns.map((c) => el('th', {}, c)))),
el('tbody', {}, body),
])));
content.replaceChildren(...parts);
}
// ---- drill-down detail overlay ----
function renderDetail() {
const overlay = $('detail');
if (!state.detail) { overlay.hidden = true; overlay.replaceChildren(); return; }
overlay.hidden = false;
const d = state.detail;
const panel = el('div', { class: 'detail-panel' });
panel.append(el('div', { class: 'detail-head' }, [
el('div', {}, [el('span', { class: 'detail-res' }, d.resource), ' ', el('span', { class: 'detail-id' }, d.id)]),
el('button', { class: 'detail-close', onclick: closeDetail, title: 'Close' }, '✕'),
]));
const sub = el('div', { class: 'detail-body' });
if (d.loading) sub.append(el('div', { class: 'empty' }, 'Loading…'));
else if (d.error) sub.append(el('div', { class: 'tab-error' }, d.error));
else if (d.record) sub.append(el('div', { class: 'kv' }, kvRows(d.record)));
if (d.config) {
sub.append(el('div', { class: 'detail-section' }, 'Container config'));
sub.append(el('div', { class: 'kv' }, kvRows(d.config)));
}
panel.append(sub);
overlay.replaceChildren(panel);
}
// ---------------------------------------------------------------- boot
$('pause').addEventListener('click', () => {
state.paused = !state.paused;
$('pause').textContent = state.paused ? '▶ resume' : '⏸ pause';
$('pause').classList.toggle('paused', state.paused);
state.renderedSig = null; render();
});
$('refresh').addEventListener('click', () => { if (!state.refreshing) refresh(true); });
$('hamburger').addEventListener('click', () => { state.sidebarOpen = !state.sidebarOpen; state.renderedSig = null; render(); });
$('scrim').addEventListener('click', () => { state.sidebarOpen = false; state.renderedSig = null; render(); });
$('detail').addEventListener('click', (e) => { if (e.target === $('detail')) closeDetail(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (state.detail) closeDetail(); else if (state.sidebarOpen) { state.sidebarOpen = false; state.renderedSig = null; render(); } } });
async function tick() {
if (!state.paused) { try { await refresh(); } catch { /* keep snapshots; retry next tick */ } }
else renderControls();
setTimeout(tick, state.refreshSeconds * 1000);
}
tick();
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#141a24"/>
<stop offset="1" stop-color="#0b0e14"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7.5" fill="url(#bg)"/>
<rect x="0.5" y="0.5" width="31" height="31" rx="7" fill="none" stroke="#2b3342" stroke-width="1"/>
<text x="16.2" y="20.8" font-family="monospace" font-size="15" font-weight="700" letter-spacing="-0.5" text-anchor="middle" fill="#5b9dff">ncl</text>
</svg>

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>clidash</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#0a0c11">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<button id="hamburger" class="hamburger" title="Menu" aria-label="Toggle menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</button>
<div id="scrim" class="scrim" hidden></div>
<div class="app">
<aside id="sidebar" class="sidebar">
<div class="brand"><h1>clidash</h1></div>
<div class="controls">
<button id="refresh" class="refresh" title="Refresh now">↻ refresh</button>
<button id="pause" class="pause" title="Pause auto-refresh">⏸ pause</button>
</div>
<div id="updated" class="updated"></div>
<nav id="nav" class="nav"></nav>
</aside>
<main class="main">
<div id="banner" class="banner" hidden></div>
<div id="cmdline" class="cmdline" hidden></div>
<section id="content" class="content"></section>
</main>
</div>
<div id="detail" class="detail-overlay" hidden></div>
<script type="module" src="app.js"></script>
</body>
</html>
@@ -0,0 +1,68 @@
// Minimal, dependency-free, XSS-safe markdown → HTML for clidash's file viewer
// (SKILL.md / CLAUDE.md). Pure string functions, no DOM — importable in both the
// browser (app.js) and node tests.
//
// Safety model: the ENTIRE source is HTML-escaped first, so no raw markup from a
// file can reach innerHTML. Markdown transforms then emit only tags this module
// generates. Link hrefs are taken from the URL capture group and gated to an
// http(s) scheme, so a `javascript:`/`data:` URL (or one smuggled via link text)
// can never become an executable href.
export function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
export function mdToHtml(src) {
const lines = escapeHtml(src).split('\n');
const out = [];
let i = 0;
const inline = (t) => t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, (m, text, url) =>
/^https?:\/\//i.test(url) ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>` : m);
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) {
const buf = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(`<pre class="code"><code>${buf.join('\n')}</code></pre>`);
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); i++; continue; }
if (/^\s*([-*])\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*([-*])\s+/.test(lines[i])) {
items.push(`<li>${inline(lines[i].replace(/^\s*([-*])\s+/, ''))}</li>`);
i++;
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
i++;
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
if (/^\s*>\s?/.test(line)) { out.push(`<blockquote>${inline(line.replace(/^\s*>\s?/, ''))}</blockquote>`); i++; continue; }
if (line.trim() === '') { i++; continue; }
const para = [line];
i++;
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>)/.test(lines[i])) {
para.push(lines[i++]);
}
out.push(`<p>${inline(para.join(' '))}</p>`);
}
return out.join('\n');
}
@@ -0,0 +1,11 @@
{
"name": "clidash",
"short_name": "ncl",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#0a0c11",
"background_color": "#0a0c11",
"display": "standalone"
}
@@ -0,0 +1,328 @@
/* clidash — refined terminal-ops console.
IBM Plex superfamily (Sans for UI, Mono for the CLI identity), a deep layered
dark palette, real depth, and precise micro-interactions. */
:root {
/* layered surfaces */
--bg: #0a0c11;
--bg-grad: #10141d;
--panel: #13171f;
--panel-2: #1a1f2a;
--border: #222834;
--border-strong: #2e3644;
/* text */
--text: #e8edf5;
--dim: #98a2b3;
--faint: #5c6675;
/* accent + semantics */
--accent: #5b9dff;
--accent-soft: rgba(91, 157, 255, 0.14);
--green: #4cc97a;
--amber: #e0a93a;
--red: #f76d6d;
--purple: #c08cff;
--gray: #6b7585;
/* shape + motion */
--radius: 11px;
--radius-sm: 8px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow: 0 6px 22px -8px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 18px 44px -12px rgba(0, 0, 0, 0.6);
--ease: 160ms cubic-bezier(0.2, 0.6, 0.2, 1);
--font-sans: "IBM Plex Sans", -apple-system, system-ui, Segoe UI, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
html { scrollbar-color: var(--border-strong) transparent; }
body {
background:
radial-gradient(120% 80% at 50% -10%, var(--bg-grad) 0%, transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--accent-soft); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
h1 {
font-family: var(--font-mono); font-size: 17px; font-weight: 600;
letter-spacing: -0.3px; color: var(--text);
}
h1::before { content: "▍"; color: var(--accent); margin-right: 4px; }
/* ---- layout ---- */
.app { display: flex; align-items: stretch; min-height: 100vh; min-height: 100dvh; }
.sidebar {
width: 236px; flex-shrink: 0; height: 100vh; height: 100dvh;
position: sticky; top: 0; align-self: flex-start; overflow-y: auto;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 200px), var(--bg);
border-right: 1px solid var(--border);
padding: 18px 12px 40px; display: flex; flex-direction: column; gap: 10px;
}
.sidebar .brand { padding: 2px 8px 6px; }
.sidebar .controls { display: flex; gap: 8px; padding: 0 4px; }
.sidebar .updated { color: var(--faint); font-size: 11.5px; padding: 0 8px; min-height: 16px; font-family: var(--font-mono); }
.pause, .refresh {
flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--dim); padding: 6px 10px; font-size: 12.5px; font-family: var(--font-mono);
cursor: pointer; transition: color var(--ease), border-color var(--ease), background var(--ease);
}
.pause:hover, .refresh:hover { color: var(--text); border-color: var(--border-strong); background: var(--panel-2); }
.pause.paused { color: var(--amber); border-color: var(--amber); }
.refresh.spinning { color: var(--accent); border-color: var(--accent); animation: pulse 0.85s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.5; } }
.nav { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; }
.nav-section {
color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px;
font-weight: 600; margin: 16px 10px 5px; font-family: var(--font-mono);
display: flex; align-items: center; gap: 7px;
}
.nav-section svg { width: 13px; height: 13px; opacity: 0.7; }
.nav-item {
display: flex; align-items: center; gap: 9px; text-align: left;
background: none; border: none; border-radius: var(--radius-sm);
color: var(--dim); padding: 7px 10px; font-size: 13.5px; cursor: pointer; width: 100%;
font-family: var(--font-sans); position: relative;
transition: color var(--ease), background var(--ease);
}
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; }
.nav-item:hover { background: var(--panel); color: var(--text); }
.nav-item.active { background: var(--accent-soft); color: var(--text); }
.nav-item.active::before {
content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px; background: var(--accent); border-radius: 0 3px 3px 0;
}
.nav-item.nav-sub { padding-left: 16px; font-size: 13px; }
.nav-item.nav-sub.active { color: var(--accent); }
.hamburger {
display: none; position: fixed; top: 12px; left: 12px; z-index: 60;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text); width: 40px; height: 40px; cursor: pointer; align-items: center; justify-content: center;
}
.hamburger svg { width: 20px; height: 20px; }
.scrim { display: none; }
.main { flex: 1; min-width: 0; padding: 26px 28px 64px; max-width: 1500px; }
.page-title {
font-size: 20px; font-weight: 600; letter-spacing: -0.4px; margin-bottom: 16px;
animation: fadeUp 0.3s var(--ease) both;
}
.banner {
background: rgba(247, 109, 109, 0.1); border: 1px solid rgba(247, 109, 109, 0.4);
border-radius: var(--radius-sm); color: var(--red); padding: 9px 14px; font-size: 13.5px; margin-bottom: 16px;
}
.cmdline {
font-family: var(--font-mono); font-size: 12px; color: var(--dim);
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 9px 14px; margin-bottom: 18px; overflow-x: auto; white-space: nowrap; box-shadow: var(--shadow-sm);
}
.cmdline::first-letter { color: var(--green); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(7px); } }
/* ---- overview cards ---- */
.ov-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; }
.ov-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 40%), var(--panel);
border: 1px solid var(--border); border-radius: var(--radius); padding: 17px 19px;
box-shadow: var(--shadow); transition: border-color var(--ease), transform var(--ease);
animation: fadeUp 0.4s var(--ease) both;
}
.ov-card:nth-child(2) { animation-delay: 0.05s; }
.ov-card:nth-child(3) { animation-delay: 0.1s; }
.ov-card:nth-child(4) { animation-delay: 0.15s; }
.ov-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
.ov-head { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; }
.ov-head .ov-name { font-weight: 600; font-size: 15px; }
.ov-head .ov-folder { color: var(--faint); font-size: 12px; font-family: var(--font-mono); }
.ov-fields { display: flex; flex-direction: column; gap: 7px; }
.ov-field { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
.ov-field .k { color: var(--dim); }
.ov-field .v { color: var(--text); font-variant-numeric: tabular-nums; } .ov-field .v.dim { color: var(--faint); }
.ov-chans { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.dot, .ov-head .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.dot.green { background: var(--green); box-shadow: 0 0 8px -1px var(--green); }
.dot.amber { background: var(--amber); box-shadow: 0 0 8px -1px var(--amber); }
.dot.red { background: var(--red); box-shadow: 0 0 8px -1px var(--red); }
.dot.gray { background: var(--gray); }
.badge {
font-size: 11.5px; border: 1px solid var(--border-strong); border-radius: 99px;
padding: 2px 10px; color: var(--dim); background: var(--panel-2); font-family: var(--font-mono);
}
/* ---- status badges + enriched cells ---- */
.badge-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; }
.badge-status .dot { width: 7px; height: 7px; }
.badge-status.green { color: var(--green); } .badge-status.amber { color: var(--amber); }
.badge-status.red { color: var(--red); } .badge-status.gray { color: var(--gray); }
td.enriched span:first-child { color: var(--text); }
td.enriched .raw-id { display: block; color: var(--faint); font-size: 11px; font-family: var(--font-mono); }
/* ---- summary bar ---- */
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 2px 0 16px; font-size: 13px; }
.summary-bar .sum-count { color: var(--text); font-weight: 600; }
.summary-bar .sum-sep { color: var(--border-strong); }
.summary-bar .sum-chip { color: var(--dim); font-family: var(--font-mono); font-size: 12px; }
/* ---- per-resource help panel ---- */
.help-panel { margin-bottom: 18px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; box-shadow: var(--shadow-sm); }
.help-head { color: var(--dim); font-size: 13px; line-height: 1.6; }
.help-head.dim { color: var(--faint); }
.help-more { margin-top: 9px; }
.help-more > summary { cursor: pointer; color: var(--accent); font-size: 12px; list-style: none; user-select: none; font-family: var(--font-mono); }
.help-more > summary::-webkit-details-marker { display: none; }
.help-more > summary::before { content: "▸ "; }
.help-more[open] > summary::before { content: "▾ "; }
.help-text {
margin: 9px 0 0; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 13px 15px; font-size: 12px; line-height: 1.6; color: var(--dim);
white-space: pre-wrap; overflow-x: auto; font-family: var(--font-mono);
}
/* ---- document viewer ---- */
.doc-viewer { display: grid; grid-template-columns: 264px 1fr; gap: 20px; align-items: start; }
.doc-list { display: flex; flex-direction: column; gap: 2px; max-height: 76vh; overflow-y: auto; padding-right: 4px; }
.doc-group-toggle {
flex-shrink: 0; display: flex; align-items: center; gap: 7px; width: 100%;
background: none; border: none; cursor: pointer; text-align: left;
color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
font-weight: 600; font-family: var(--font-mono); margin: 12px 0 4px; padding: 5px 8px;
border-radius: var(--radius-sm); transition: color var(--ease), background var(--ease);
}
.doc-group-toggle:first-child { margin-top: 0; }
.doc-group-toggle:hover { color: var(--text); background: var(--panel); }
.doc-group-toggle.open { color: var(--text); }
.doc-group-toggle .chev { color: var(--accent); width: 10px; }
.doc-group-toggle .g-name { flex: 1; }
.doc-group-toggle .g-count {
color: var(--faint); background: var(--panel-2); border-radius: 99px;
padding: 1px 8px; font-size: 10.5px; letter-spacing: 0;
}
.doc-item {
flex-shrink: 0; text-align: left; background: none; border: none; border-radius: var(--radius-sm);
color: var(--dim); padding: 6px 11px; font-size: 13px; line-height: 1.5; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--ease), background var(--ease);
}
.doc-item:hover { background: var(--panel); color: var(--text); }
.doc-item.active { background: var(--accent-soft); color: var(--accent); }
.doc-content {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 22px 26px; min-height: 220px; max-height: 78vh; overflow-y: auto; box-shadow: var(--shadow);
}
.markdown { font-size: 14px; line-height: 1.7; color: var(--text); }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { margin: 20px 0 8px; line-height: 1.3; font-family: var(--font-mono); }
.markdown h1 { font-size: 21px; } .markdown h2 { font-size: 17px; color: var(--accent); } .markdown h3 { font-size: 15px; }
.markdown h1:first-child, .markdown h2:first-child { margin-top: 0; }
.markdown p { color: var(--text); margin: 8px 0; }
.markdown ul, .markdown ol { margin: 8px 0 8px 22px; }
.markdown li { margin: 3px 0; }
.markdown a { color: var(--accent); }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
.markdown blockquote { border-left: 3px solid var(--border-strong); padding-left: 12px; color: var(--dim); margin: 8px 0; }
.markdown code { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; font-size: 12.5px; font-family: var(--font-mono); }
.markdown pre.code, .doc-content pre.code {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 14px; overflow-x: auto; font-size: 12.5px; line-height: 1.6; margin: 12px 0; font-family: var(--font-mono);
}
.markdown pre.code code { background: none; border: none; padding: 0; }
.doc-content pre.json { color: var(--text); white-space: pre; font-family: var(--font-mono); }
/* ---- activity ---- */
.activity-wrap { max-width: 780px; }
.activity-legend { display: flex; gap: 18px; align-items: center; margin-bottom: 12px; font-size: 13px; color: var(--dim); }
.activity-legend .lg { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
.activity-legend .lg.in { background: var(--accent); } .activity-legend .lg.out { background: var(--purple); }
.chart-box { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; box-shadow: var(--shadow); }
.activity-chart { width: 100%; height: 220px; display: block; }
.activity-chart .bar-in { fill: var(--accent); } .activity-chart .bar-out { fill: var(--purple); }
.activity-chart .grid { stroke: var(--border); stroke-width: 1; }
.activity-chart .axis { fill: var(--faint); font-size: 9px; font-family: var(--font-mono); }
.activity-table td.num, .activity-table th:nth-child(3), .activity-table th:nth-child(4) { text-align: right; }
.activity-table td.num { font-variant-numeric: tabular-nums; }
/* ---- log viewer ---- */
.log-box {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
max-height: 78vh; overflow: auto; padding: 12px 0; box-shadow: var(--shadow);
}
.log-view { font-family: var(--font-mono); font-size: 12px; line-height: 1.65; min-width: max-content; }
.log-line { padding: 0 16px; white-space: pre; color: var(--dim); }
.log-line:hover { background: var(--panel); }
.log-line.err { color: var(--red); background: rgba(247, 109, 109, 0.06); }
.log-line.warn { color: var(--amber); }
/* ---- tables ---- */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }
tbody tr:last-child td { border-bottom: none; }
th { color: var(--dim); font-weight: 600; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.6px; position: sticky; top: 0; background: var(--panel-2); font-family: var(--font-mono); }
td { font-variant-numeric: tabular-nums; }
td.null { color: var(--faint); font-style: italic; }
tr.drillable { cursor: pointer; transition: background var(--ease); }
tr.drillable:hover td { background: var(--panel); }
.reltime .abs { color: var(--faint); font-size: 11.5px; margin-left: 6px; font-family: var(--font-mono); }
td .trunc { cursor: pointer; border-bottom: 1px dotted var(--faint); }
.stale-note { color: var(--amber); font-size: 12.5px; margin-bottom: 8px; }
.empty, .tab-error { color: var(--dim); padding: 26px 2px; font-size: 14px; }
.tab-error { color: var(--red); }
.tab-error pre { margin-top: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; color: var(--dim); font-size: 12px; overflow-x: auto; white-space: pre-wrap; font-family: var(--font-mono); }
/* ---- drill-down detail overlay ---- */
.detail-overlay { position: fixed; inset: 0; background: rgba(2, 4, 8, 0.62); display: flex; justify-content: flex-end; z-index: 50; backdrop-filter: blur(2px); animation: fade 0.18s ease; }
@keyframes fade { from { opacity: 0; } }
.detail-panel { width: min(580px, 100%); height: 100%; background: var(--bg); border-left: 1px solid var(--border-strong); overflow-y: auto; box-shadow: var(--shadow-lg); animation: slideIn 0.22s var(--ease); }
@keyframes slideIn { from { transform: translateX(24px); opacity: 0.6; } }
.detail-head { display: flex; justify-content: space-between; align-items: center; padding: 17px 22px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); }
.detail-res { font-weight: 600; font-size: 15px; }
.detail-id { color: var(--dim); font-family: var(--font-mono); font-size: 13px; }
.detail-close { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--dim); width: 30px; height: 30px; cursor: pointer; font-size: 14px; transition: color var(--ease), border-color var(--ease); }
.detail-close:hover { color: var(--text); border-color: var(--accent); }
.detail-body { padding: 17px 22px 44px; }
.detail-section { margin: 24px 0 8px; color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; border-top: 1px solid var(--border); padding-top: 17px; font-family: var(--font-mono); }
.kv { display: flex; flex-direction: column; gap: 2px; }
.kv-row { display: grid; grid-template-columns: 168px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.kv-key { color: var(--dim); font-family: var(--font-mono); font-size: 12.5px; }
.kv-json { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; font-size: 12px; overflow-x: auto; margin: 0; font-family: var(--font-mono); }
/* ---- mobile ---- */
@media (max-width: 640px) {
.hamburger { display: flex; }
.sidebar {
position: fixed; top: 0; left: 0; z-index: 70; transform: translateX(-100%);
transition: transform 0.22s var(--ease); box-shadow: var(--shadow-lg);
}
.sidebar.open { transform: translateX(0); }
.scrim { display: block; position: fixed; inset: 0; background: rgba(2, 4, 8, 0.55); z-index: 65; backdrop-filter: blur(1px); }
.scrim[hidden] { display: none; }
.main { padding: 58px 16px 48px; }
.ov-cards { grid-template-columns: 1fr; }
.doc-viewer { grid-template-columns: 1fr; gap: 12px; }
.doc-list { max-height: 220px; flex-direction: row; flex-wrap: wrap; }
.doc-content { max-height: none; padding: 18px; }
.detail-panel { width: 100%; }
.kv-row { grid-template-columns: 1fr; gap: 2px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
@@ -0,0 +1,437 @@
// clidash — CLI-agnostic read-only web dashboard.
// Node built-ins only. All per-CLI knowledge lives in clidash.config.json;
// the only per-CLI code is optional view plugins (views/) and discovery
// parsers (parsers.js).
//
// Security model: the server can only exec the configured argv templates.
// `{resource}` is the sole substitution and is validated against the
// discovered/static resource set before exec. execFile, never a shell.
import { createServer } from 'node:http';
import { execFile } from 'node:child_process';
import { readFile, readdir } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { dirname, join, resolve, sep, basename } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { discoveryParsers, parseOutput, unwrapPath } from './parsers.js';
import { globFiles, describeFile, resolveDoc } from './docs.js';
import { collectActivity } from './activity.js';
import { tailFile } from './logs.js';
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
const MAX_DOC_BYTES = 2 * 1024 * 1024; // cap a single served document at 2 MB
const DEFAULTS = {
bind: '127.0.0.1',
port: 4690,
refreshSeconds: 60,
execTimeoutMs: 10_000,
discoveryTtlMs: 60_000,
};
const CONTENT_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
};
export function createApp(userConfig) {
const config = { ...DEFAULTS, ...userConfig };
const publicDir = resolve(config.publicDir ?? join(MODULE_DIR, 'public'));
const viewsDir = resolve(config.viewsDir ?? join(MODULE_DIR, 'views'));
// Human-readable form of a command, for display in the UI ("the command run").
const displayCmd = (bin, args) => `${basename(bin)} ${args.join(' ')}`;
// ---- exec --------------------------------------------------------------
function execCli(cliCfg, args, label) {
return new Promise((resolvePromise, rejectPromise) => {
execFile(cliCfg.bin, args, {
cwd: cliCfg.cwd,
timeout: config.execTimeoutMs,
maxBuffer: 32 * 1024 * 1024,
env: { ...process.env, ...cliCfg.env },
}, (error, stdout, stderr) => {
if (error) {
const timedOut = error.killed || error.signal === 'SIGTERM';
const detail = stderr.trim() || error.message;
const msg = timedOut
? `${label} timed out after ${config.execTimeoutMs}ms`
: `${label} failed: ${detail}`;
rejectPromise(new Error(msg));
return;
}
resolvePromise(stdout);
});
});
}
// ---- resource discovery (cached, coalesced, keeps last good) -----------
const discoveryCache = new Map(); // cli -> { at, resources }
const discoveryInflight = new Map(); // cli -> Promise
async function discoverResources(cliName) {
const cliCfg = config.clis[cliName];
if (cliCfg.resources) {
return cliCfg.resources.map((name) =>
typeof name === 'string' ? { name, description: '' } : name,
);
}
const cached = discoveryCache.get(cliName);
if (cached && Date.now() - cached.at < config.discoveryTtlMs) return cached.resources;
if (discoveryInflight.has(cliName)) return discoveryInflight.get(cliName);
const parser = discoveryParsers[cliCfg.discover.parser];
if (!parser) throw new Error(`Unknown discovery parser: ${cliCfg.discover.parser}`);
const promise = execCli(cliCfg, cliCfg.discover.args, `${cliName} discovery`)
.then((stdout) => {
const resources = parser(stdout);
discoveryCache.set(cliName, { at: Date.now(), resources });
return resources;
})
.finally(() => discoveryInflight.delete(cliName));
discoveryInflight.set(cliName, promise);
return promise;
}
// ---- row fetching (coalesced per cli+resource) --------------------------
const listInflight = new Map(); // "cli\0resource" -> Promise
async function fetchRows(cliName, resourceName) {
const cliCfg = config.clis[cliName];
const resources = await discoverResources(cliName);
if (!resources.some((r) => r.name === resourceName)) {
const err = new Error(`Unknown resource "${resourceName}" for CLI "${cliName}"`);
err.statusCode = 404;
throw err;
}
const key = `${cliName}\0${resourceName}`;
if (listInflight.has(key)) return listInflight.get(key);
// {resource} may appear as a whole arg or inside one (e.g. an ssh remote
// command). Safe either way — the value is allowlist-validated above.
const args = cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName));
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} list`)
.then((stdout) => {
const parsed = parseOutput(stdout, cliCfg.output ?? 'json');
const rows = unwrapPath(parsed, cliCfg.unwrap);
if (!Array.isArray(rows)) {
const err = new Error(`${cliName} ${resourceName}: expected an array of rows`);
err.raw = stdout;
throw err;
}
return rows;
})
.finally(() => listInflight.delete(key));
listInflight.set(key, promise);
return promise;
}
// ---- detail commands (drill-down: get, config-get, …) -------------------
const cmdInflight = new Map();
const ID_RE = /^[A-Za-z0-9:_.-]+$/; // ncl ids / uuids; no shell metas (and execFile never shells)
async function runCommand(cliName, cmdName, resourceName, id) {
const cliCfg = config.clis[cliName];
const template = cliCfg.commands?.[cmdName];
if (!template) {
const err = new Error(`Unknown command "${cmdName}"`);
err.statusCode = 404;
throw err;
}
const needsResource = template.includes('{resource}');
if (needsResource) {
const resources = await discoverResources(cliName);
if (!resources.some((r) => r.name === resourceName)) {
const err = new Error(`Unknown resource "${resourceName}"`);
err.statusCode = 404;
throw err;
}
}
if (template.includes('{id}') && !ID_RE.test(id ?? '')) {
const err = new Error('Invalid id');
err.statusCode = 400;
throw err;
}
const key = `${cliName}\0${cmdName}\0${resourceName}\0${id}`;
if (cmdInflight.has(key)) return cmdInflight.get(key);
const args = template.map((a) => a.replaceAll('{resource}', resourceName ?? '').replaceAll('{id}', id ?? ''));
const promise = execCli(cliCfg, args, `${cliName} ${cmdName}`)
.then((stdout) => unwrapPath(parseOutput(stdout, cliCfg.output ?? 'json'), cliCfg.unwrap))
.finally(() => cmdInflight.delete(key));
cmdInflight.set(key, promise);
return promise;
}
// ---- per-resource help (raw text from `<cli> <resource> help`) -----------
const helpInflight = new Map();
async function runHelp(cliName, resourceName) {
const cliCfg = config.clis[cliName];
if (!cliCfg.help) { const e = new Error(`No help for "${cliName}"`); e.statusCode = 404; throw e; }
const resources = await discoverResources(cliName);
if (!resources.some((r) => r.name === resourceName)) {
const e = new Error(`Unknown resource "${resourceName}"`); e.statusCode = 404; throw e;
}
const key = `${cliName}\0${resourceName}`;
if (helpInflight.has(key)) return helpInflight.get(key);
const args = cliCfg.help.map((a) => a.replaceAll('{resource}', resourceName));
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} help`).finally(() => helpInflight.delete(key));
helpInflight.set(key, promise);
return promise;
}
// ---- view plugins --------------------------------------------------------
async function listViews(cliName) {
try {
const files = await readdir(viewsDir);
return files
.filter((f) => f.startsWith(`${cliName}-`) && f.endsWith('.js'))
.map((f) => f.slice(cliName.length + 1, -3));
} catch {
return [];
}
}
async function runView(cliName, viewName) {
if (!/^[a-zA-Z0-9_-]+$/.test(viewName)) {
const err = new Error(`Invalid view name`);
err.statusCode = 404;
throw err;
}
const file = join(viewsDir, `${cliName}-${viewName}.js`);
let mod;
try {
mod = await import(pathToFileURL(file).href);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
const err = new Error(`No view "${viewName}" for CLI "${cliName}"`);
err.statusCode = 404;
throw err;
}
throw e;
}
return mod.default({ fetch: (resource) => fetchRows(cliName, resource) });
}
// ---- http ----------------------------------------------------------------
function sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(payload);
}
function sendError(res, err) {
const status = err.statusCode ?? 502;
const body = { ok: false, error: err.message };
if (err.raw !== undefined) body.raw = String(err.raw).slice(0, 64 * 1024);
sendJson(res, status, body);
}
async function serveStatic(res, urlPath) {
const relative = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath.slice(1));
const file = resolve(publicDir, relative);
if (file !== publicDir && !file.startsWith(publicDir + sep)) {
sendJson(res, 403, { ok: false, error: 'Forbidden' });
return;
}
try {
const content = await readFile(file);
const ext = file.slice(file.lastIndexOf('.'));
// always revalidate so a redeploy is picked up immediately (no stale JS/CSS)
res.writeHead(200, { 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
res.end(content);
} catch {
sendJson(res, 404, { ok: false, error: 'Not found' });
}
}
return createServer(async (req, res) => {
try {
if (req.method !== 'GET') {
sendJson(res, 405, { ok: false, error: 'Read-only dashboard: GET only' });
return;
}
const urlPath = req.url.split('?')[0];
const segments = urlPath.split('/').map((s) => decodeURIComponent(s));
if (urlPath === '/api/clis') {
const clis = await Promise.all(Object.keys(config.clis).map(async (name) => {
const entry = {
name,
refreshSeconds: config.refreshSeconds,
views: await listViews(name),
commands: Object.keys(config.clis[name].commands ?? {}),
enrich: config.clis[name].enrich ?? null,
badges: config.clis[name].badges ?? null,
summary: config.clis[name].summary ?? null,
help: !!config.clis[name].help,
};
try {
entry.resources = await discoverResources(name);
} catch (e) {
// keep last good discovery (≤TTL old) if we have one; always surface the error
entry.resources = discoveryCache.get(name)?.resources ?? [];
entry.error = e.message;
}
return entry;
}));
sendJson(res, 200, { clis });
return;
}
if (segments[1] === 'api' && segments[2] === 'r' && segments.length === 5) {
const [, , , cliName, resourceName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const rows = await fetchRows(cliName, resourceName);
const cliCfg = config.clis[cliName];
const command = displayCmd(cliCfg.bin, cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName)));
sendJson(res, 200, { ok: true, rows, command, fetchedAt: new Date().toISOString() });
return;
}
if (segments[1] === 'api' && segments[2] === 'cmd' && segments.length === 5) {
const [, , , cliName, cmdName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const q = new URL(req.url, 'http://localhost').searchParams;
const data = await runCommand(cliName, cmdName, q.get('resource'), q.get('id'));
const tmpl = config.clis[cliName].commands?.[cmdName] ?? [];
const command = displayCmd(config.clis[cliName].bin,
tmpl.map((a) => a.replaceAll('{resource}', q.get('resource') ?? '').replaceAll('{id}', q.get('id') ?? '')));
sendJson(res, 200, { ok: true, data, command, fetchedAt: new Date().toISOString() });
return;
}
if (segments[1] === 'api' && segments[2] === 'help' && segments.length === 5) {
const [, , , cliName, resourceName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const text = await runHelp(cliName, resourceName);
sendJson(res, 200, { ok: true, text });
return;
}
if (segments[1] === 'api' && segments[2] === 'view' && segments.length === 5) {
const [, , , cliName, viewName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const result = await runView(cliName, viewName);
sendJson(res, 200, { ok: true, result, fetchedAt: new Date().toISOString() });
return;
}
// Log tails (allowlisted files under logs.dir).
if (urlPath === '/api/logs') {
sendJson(res, 200, { files: (config.logs?.files ?? []).map((f) => ({ name: f.name, label: f.label ?? f.name })) });
return;
}
if (segments[1] === 'api' && segments[2] === 'log' && segments.length === 4) {
const name = segments[3];
const file = config.logs?.files?.find((f) => f.name === name);
if (!file) { sendJson(res, 404, { ok: false, error: `Unknown log "${name}"` }); return; }
const lines = config.logs.tailLines ?? 400;
const { text } = await tailFile(join(config.logs.dir, name), lines);
sendJson(res, 200, { ok: true, text, command: `tail -n ${lines} ${join(config.logs.dir, name)}`, fetchedAt: new Date().toISOString() });
return;
}
// Message activity (read per-session DBs; ncl has no messages resource).
if (urlPath === '/api/activity') {
if (!config.activity) { sendJson(res, 200, { ok: true, configured: false }); return; }
const days = config.activity.days ?? 14;
const { sessions, series } = collectActivity(config.activity.sessionsRoot, days, new Date());
const command = `node:sqlite · ${config.activity.sessionsRoot}/*/*/{inbound,outbound}.db (last ${days}d)`;
sendJson(res, 200, { ok: true, configured: true, sessions, series, command, fetchedAt: new Date().toISOString() });
return;
}
// Read-only file viewer (skills, CLAUDE.md, profiles, conversations).
if (urlPath === '/api/docs') {
const docs = config.docs;
const collections = (docs?.collections ?? []).map((coll) => ({
name: coll.name,
label: coll.label ?? coll.name,
lang: coll.lang ?? 'text',
files: globFiles(docs.root, coll.patterns, docs.deny ?? []).map((path) => ({
path,
...describeFile(path),
})),
}));
sendJson(res, 200, { collections });
return;
}
if (urlPath === '/api/doc') {
const docs = config.docs;
const query = new URL(req.url, 'http://localhost').searchParams;
const collName = query.get('c');
const relPath = query.get('p') ?? '';
const collection = docs?.collections?.find((c) => c.name === collName);
if (!collection) {
sendJson(res, 404, { ok: false, error: `Unknown collection "${collName}"` });
return;
}
let abs;
try {
abs = resolveDoc(docs.root, collection, relPath, docs.deny ?? []);
} catch {
sendJson(res, 404, { ok: false, error: 'Not found' });
return;
}
const content = await readFile(abs, 'utf8');
sendJson(res, 200, {
ok: true,
path: relPath,
lang: collection.lang ?? 'text',
content: content.length > MAX_DOC_BYTES ? content.slice(0, MAX_DOC_BYTES) : content,
});
return;
}
if (urlPath.startsWith('/api/')) {
sendJson(res, 404, { ok: false, error: 'Not found' });
return;
}
await serveStatic(res, urlPath);
} catch (err) {
sendError(res, err);
}
});
}
// ---- standalone entry point ------------------------------------------------
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (isMain) {
const configPath = process.env.CLIDASH_CONFIG ?? join(MODULE_DIR, 'clidash.config.json');
const config = JSON.parse(readFileSync(configPath, 'utf8'));
if (process.env.PORT) config.port = Number(process.env.PORT);
if (process.env.BIND) config.bind = process.env.BIND;
const finalConfig = { ...DEFAULTS, ...config };
const server = createApp(finalConfig);
server.listen(finalConfig.port, finalConfig.bind, () => {
console.log(`clidash listening on http://${finalConfig.bind}:${finalConfig.port}`);
});
}
@@ -0,0 +1,51 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { createApp } from '../server.js';
let root;
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(); };
// 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 }));
async function withServer(config, fn) {
const server = createApp({ port: 0, bind: '127.0.0.1', clis: {}, ...config });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/activity: returns per-session totals + a daily series', async () => {
await withServer({ activity: { sessionsRoot: root, days: 14 } }, async (base) => {
const body = await (await fetch(`${base}/api/activity`)).json();
assert.equal(body.ok, true);
assert.equal(body.configured, true);
assert.equal(body.series.length, 14);
assert.equal(body.sessions[0].in, 2);
assert.equal(body.sessions[0].out, 1);
assert.equal(body.series.at(-1).in, 2); // today
assert.equal(body.series.at(-1).out, 1);
});
});
test('/api/activity: not configured → configured:false, no crash', async () => {
await withServer({}, async (base) => {
const body = await (await fetch(`${base}/api/activity`)).json();
assert.equal(body.ok, true);
assert.equal(body.configured, false);
});
});
@@ -0,0 +1,85 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { collectActivity } from '../activity.js';
let root;
const NOW = new Date('2026-06-14T12:00:00Z');
function makeDb(path, table, timestamps) {
const db = new DatabaseSync(path);
db.exec(`CREATE TABLE ${table} (id TEXT, timestamp TEXT)`);
const ins = db.prepare(`INSERT INTO ${table} (id, timestamp) VALUES (?, ?)`);
timestamps.forEach((t, i) => ins.run(String(i), t));
db.close();
}
before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-act-'));
// session 1 (group ag-1): 3 inbound across 2 days, 2 outbound today
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
makeDb(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in',
['2026-06-14 09:01:23', '2026-06-14 10:00:00', '2026-06-13 08:00:00']);
makeDb(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out',
['2026-06-14 09:05:00', '2026-06-14 10:05:00']);
// session 2 (group ag-2): 1 inbound 20 days ago (outside 14d window), 0 outbound
mkdirSync(join(root, 'ag-2', 'sess-2'), { recursive: true });
makeDb(join(root, 'ag-2', 'sess-2', 'inbound.db'), 'messages_in', ['2026-05-25 08:00:00']);
makeDb(join(root, 'ag-2', 'sess-2', 'outbound.db'), 'messages_out', []);
});
after(() => rmSync(root, { recursive: true, force: true }));
test('collectActivity: per-session in/out totals + last activity', () => {
const { sessions } = collectActivity(root, 14, NOW);
const s1 = sessions.find((s) => s.session_id === 'sess-1');
assert.equal(s1.agent_group_id, 'ag-1');
assert.equal(s1.in, 3);
assert.equal(s1.out, 2);
assert.equal(s1.lastActivity, '2026-06-14T10:05:00Z'); // normalized to ISO
const s2 = sessions.find((s) => s.session_id === 'sess-2');
assert.equal(s2.in, 1);
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, 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]));
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', () => {
const { series, sessions } = collectActivity(root, 14, NOW);
const total = series.reduce((a, d) => a + d.in + d.out, 0);
assert.equal(total, 5); // the 20-day-old message is excluded from series
assert.equal(sessions.find((s) => s.session_id === 'sess-2').in, 1); // but still in the total count
});
test('collectActivity: a dir with no message DBs is not a session (skipped)', () => {
mkdirSync(join(root, 'ag-1', '.claude-shared'), { recursive: true }); // scaffolding, no db files
const { sessions } = collectActivity(root, 14, NOW);
assert.ok(!sessions.some((s) => s.session_id === '.claude-shared'));
});
@@ -0,0 +1,91 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createApp } from '../server.js';
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
const tmp = mkdtempSync(join(tmpdir(), 'clidash-cmd-'));
after(() => rmSync(tmp, { recursive: true, force: true }));
function cli(extra = {}) {
return {
bin: process.execPath,
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
list: [STUB, '{resource}', 'list', '--json'],
output: 'json',
unwrap: 'data',
commands: {
get: [STUB, '{resource}', 'get', '{id}', '--json'],
'config-get': [STUB, 'groups', 'config', 'get', '--id', '{id}', '--json'],
},
...extra,
};
}
async function withServer(clis, fn, extra = {}) {
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis, ...extra });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/cmd: runs an allowlisted command with {resource} + {id}', async () => {
await withServer({ ncl: cli() }, async (base) => {
const body = await (await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=sess-123`)).json();
assert.equal(body.ok, true);
assert.equal(body.data.id, 'sessions-detail');
assert.match(body.data.args, /sessions get sess-123/);
});
});
test('/api/cmd: config-get needs no resource', async () => {
await withServer({ ncl: cli() }, async (base) => {
const body = await (await fetch(`${base}/api/cmd/ncl/config-get?id=ag-1`)).json();
assert.equal(body.ok, true);
assert.match(body.data.args, /groups config get --id ag-1/);
});
});
test('/api/cmd: unknown command name → 404 (allowlist)', async () => {
await withServer({ ncl: cli() }, async (base) => {
const res = await fetch(`${base}/api/cmd/ncl/delete?resource=groups&id=ag-1`);
assert.equal(res.status, 404);
});
});
test('/api/cmd: a {resource} not in the discovered set is rejected without exec', async () => {
const countFile = join(tmp, 'cmd-count.txt');
const c = cli();
c.env = { STUB_COUNT_FILE: countFile };
await withServer({ ncl: c }, async (base) => {
const res = await fetch(`${base}/api/cmd/ncl/get?resource=evil&id=x`);
assert.equal(res.status, 404);
// only discovery ran, never a get for the bogus resource
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
assert.deepEqual(calls, ['help']);
});
});
test('/api/cmd: an id with illegal characters is rejected', async () => {
await withServer({ ncl: cli() }, async (base) => {
const res = await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=${encodeURIComponent('a b;rm -rf')}`);
assert.equal(res.status, 400);
});
});
test('/api/cmd: unknown cli → 404', async () => {
await withServer({ ncl: cli() }, async (base) => {
assert.equal((await fetch(`${base}/api/cmd/nope/get?resource=sessions&id=x`)).status, 404);
});
});
test('/api/cmd: a cli without a commands map → 404', async () => {
const c = cli();
delete c.commands;
await withServer({ ncl: c }, async (base) => {
assert.equal((await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=x`)).status, 404);
});
});
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const css = readFileSync(fileURLToPath(new URL('../public/style.css', import.meta.url)), 'utf8');
// Regression: the `hidden` attribute must override author `display` rules.
// `.detail-overlay` and `.cli-switcher` set `display:flex`, which beats the
// browser's default `[hidden]{display:none}` — without this reset a hidden
// overlay stays on top of the page and silently eats every click.
test('style.css forces [hidden] to display:none with !important', () => {
assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important;?\s*\}/);
});
// Guard the premise: if these stop using display:flex the reset is less load-
// bearing, but this documents WHY the reset exists.
test('the overlays that motivated the reset still use display:flex', () => {
assert.match(css, /\.detail-overlay\s*\{[^}]*display:\s*flex/);
});
@@ -0,0 +1,111 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createApp } from '../server.js';
let root;
before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-docsrv-'));
const w = (rel, body) => {
const abs = join(root, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
};
w('groups/alpha/skills/tagger/SKILL.md', '# tagger\nhello');
w('container/skills/welcome/SKILL.md', '# welcome');
w('groups/alpha/profile.json', '{"name":"Alpha"}');
w('groups/alpha/.env', 'SECRET=nope');
});
after(() => rmSync(root, { recursive: true, force: true }));
function docsConfig() {
return {
port: 0,
bind: '127.0.0.1',
clis: {},
docs: {
root,
deny: ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'],
collections: [
{ name: 'skills', label: 'Skills', lang: 'markdown', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] },
{ name: 'profiles', label: 'Profiles', lang: 'json', patterns: ['groups/*/profile.json'] },
],
},
};
}
async function withServer(config, fn) {
const server = createApp(config);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try {
return await fn(base);
} finally {
await new Promise((r) => server.close(r));
}
}
test('/api/docs: lists collections with their files', async () => {
await withServer(docsConfig(), async (base) => {
const body = await (await fetch(`${base}/api/docs`)).json();
const skills = body.collections.find((c) => c.name === 'skills');
assert.equal(skills.label, 'Skills');
assert.equal(skills.lang, 'markdown');
const paths = skills.files.map((f) => f.path);
assert.ok(paths.includes('groups/alpha/skills/tagger/SKILL.md'));
assert.ok(paths.includes('container/skills/welcome/SKILL.md'));
// each file carries a readable label + group
const f = skills.files.find((x) => x.path.includes('tagger'));
assert.equal(f.group, 'alpha');
assert.match(f.label, /tagger/);
});
});
test('/api/doc: returns file content + lang', async () => {
await withServer(docsConfig(), async (base) => {
const url = `${base}/api/doc?c=skills&p=${encodeURIComponent('groups/alpha/skills/tagger/SKILL.md')}`;
const body = await (await fetch(url)).json();
assert.equal(body.ok, true);
assert.equal(body.lang, 'markdown');
assert.match(body.content, /# tagger/);
});
});
test('/api/doc: a denied file is not readable even though it sits under root', async () => {
await withServer(docsConfig(), async (base) => {
// .env is excluded by the deny-list and not in any collection pattern
const coll = docsConfig();
coll.docs.collections.push({ name: 'all', label: 'All', lang: 'text', patterns: ['groups/*/*'] });
await withServer(coll, async (base2) => {
const res = await fetch(`${base2}/api/doc?c=all&p=${encodeURIComponent('groups/alpha/.env')}`);
assert.equal(res.status, 404);
assert.equal((await res.json()).ok, false);
});
});
});
test('/api/doc: path traversal is rejected', async () => {
await withServer(docsConfig(), async (base) => {
const res = await fetch(`${base}/api/doc?c=skills&p=${encodeURIComponent('../../../../etc/passwd')}`);
assert.equal(res.status, 404);
assert.equal((await res.json()).ok, false);
});
});
test('/api/doc: unknown collection → 404', async () => {
await withServer(docsConfig(), async (base) => {
const res = await fetch(`${base}/api/doc?c=nope&p=x`);
assert.equal(res.status, 404);
});
});
test('/api/docs: absent docs config → empty collections, no crash', async () => {
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
const body = await (await fetch(`${base}/api/docs`)).json();
assert.deepEqual(body.collections, []);
});
});
@@ -0,0 +1,111 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { globFiles, describeFile, resolveDoc } from '../docs.js';
let root;
before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-docs-'));
const w = (rel, body = 'x') => {
const abs = join(root, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
};
w('groups/alpha/skills/example-skill/SKILL.md', '# example-skill\nbody');
w('groups/alpha/skills/tagger/SKILL.md');
w('groups/alpha/CLAUDE.md', '# Alpha');
w('groups/alpha/CLAUDE.local.md');
w('groups/alpha/profile.json', '{"name":"Alpha"}');
w('groups/alpha/conversations/2026-06-01.md');
w('groups/bravo/skills/tagger/SKILL.md');
w('groups/bravo/profile.json');
w('container/skills/agent-browser/SKILL.md');
w('container/skills/welcome/SKILL.md');
// things that must NEVER be served
w('groups/alpha/.env', 'SECRET=1');
w('groups/alpha/skills/example-skill/node_modules/dep/SKILL.md');
w('groups/alpha/notion-token.txt', 'ntn_xxx');
});
after(() => rmSync(root, { recursive: true, force: true }));
const DENY = ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'];
// --------------------------------------------------------------- globFiles
test('globFiles: matches a nested *-segment pattern', () => {
const files = globFiles(root, ['groups/*/skills/*/SKILL.md'], DENY);
assert.deepEqual(files, [
'groups/alpha/skills/example-skill/SKILL.md',
'groups/alpha/skills/tagger/SKILL.md',
'groups/bravo/skills/tagger/SKILL.md',
]);
});
test('globFiles: multiple patterns union, sorted', () => {
const files = globFiles(root, ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'], DENY);
assert.ok(files.includes('container/skills/agent-browser/SKILL.md'));
assert.ok(files.includes('groups/alpha/skills/example-skill/SKILL.md'));
});
test('globFiles: wildcard inside a filename segment', () => {
const files = globFiles(root, ['groups/*/CLAUDE*.md'], DENY);
assert.deepEqual(files, ['groups/alpha/CLAUDE.local.md', 'groups/alpha/CLAUDE.md']);
});
test('globFiles: deny list excludes node_modules and secret-ish files', () => {
const files = globFiles(root, ['groups/*/skills/*/**', 'groups/*/*'], DENY);
assert.ok(!files.some((f) => f.includes('node_modules')));
assert.ok(!files.some((f) => f.endsWith('.env')));
assert.ok(!files.some((f) => f.includes('token')));
});
test('globFiles: no match returns empty array', () => {
assert.deepEqual(globFiles(root, ['nope/*/x.md'], DENY), []);
});
// ------------------------------------------------------------- describeFile
test('describeFile: per-group skill → group + readable label', () => {
const d = describeFile('groups/alpha/skills/tagger/SKILL.md');
assert.equal(d.group, 'alpha');
assert.match(d.label, /alpha/);
assert.match(d.label, /tagger/);
});
test('describeFile: container skill → shared', () => {
const d = describeFile('container/skills/agent-browser/SKILL.md');
assert.equal(d.group, 'shared');
assert.match(d.label, /agent-browser/);
});
// --------------------------------------------------------------- resolveDoc
const SKILLS = { name: 'skills', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] };
test('resolveDoc: returns an absolute path for an allowed file', () => {
const abs = resolveDoc(root, SKILLS, 'groups/alpha/skills/example-skill/SKILL.md', DENY);
assert.ok(abs.endsWith('/groups/alpha/skills/example-skill/SKILL.md'));
assert.ok(abs.startsWith(root));
});
test('resolveDoc: rejects a path not matching the collection patterns', () => {
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/profile.json', DENY), /not allowed/i);
});
test('resolveDoc: rejects path traversal', () => {
assert.throws(() => resolveDoc(root, SKILLS, '../../etc/passwd', DENY), /not allowed/i);
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/skills/../../../.env', DENY), /not allowed/i);
});
test('resolveDoc: rejects an absolute path', () => {
assert.throws(() => resolveDoc(root, SKILLS, '/etc/passwd', DENY), /not allowed/i);
});
test('resolveDoc: a denied file is not resolvable even if pattern-shaped', () => {
const coll = { name: 'all', patterns: ['groups/*/*'] };
assert.throws(() => resolveDoc(root, coll, 'groups/alpha/.env', DENY), /not allowed/i);
});
@@ -0,0 +1,28 @@
Resources:
approvals Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.
verbs: list, get
destinations Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.
verbs: list, add, remove
dropped-messages Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn't fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).
verbs: list
groups Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.
verbs: list, get, create, update, delete, restart, config get, config update, config add-mcp-server, config remove-mcp-server, config add-package, config remove-package
members Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".
verbs: list, add, remove
messaging-groups Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.
verbs: list, get, create, update, delete
roles User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).
verbs: list, grant, revoke
sessions Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.
verbs: list, get
user-dms User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.
verbs: list
users User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).
verbs: list, get, create, update
wirings Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.
verbs: list, get, create, update, delete
Commands:
help List available resources and commands.
Run `ncl <resource> help` for detailed field information.
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Stub CLI for clidash tests. Impersonates ncl (envelope json) or a
// jsonlines CLI, with failure/slowness/garbage modes driven by env vars.
import { readFileSync, appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const args = process.argv.slice(2);
if (process.env.STUB_COUNT_FILE) {
appendFileSync(process.env.STUB_COUNT_FILE, args.join(' ') + '\n');
}
const sleepMs = Number(process.env.STUB_SLEEP_MS || 0);
setTimeout(() => {
if (process.env.STUB_FAIL) {
process.stderr.write('boom: socket down\n');
process.exit(2);
}
if (args[0] === 'help') {
process.stdout.write(
readFileSync(fileURLToPath(new URL('./ncl-help.txt', import.meta.url)), 'utf8'),
);
process.exit(0);
}
if (args[1] === 'help') { // `<resource> help` → raw per-resource help text
process.stdout.write(`${args[0]}: help for ${args[0]}\n\nVerbs:\n list\n get <id>\n`);
process.exit(0);
}
if (process.env.STUB_RAW) {
process.stdout.write(process.env.STUB_RAW + '\n');
process.exit(0);
}
const resource = args[0];
// `get`/detail commands → single-object envelope
if (args.includes('get') || args.includes('config')) {
process.stdout.write(JSON.stringify({
id: 'req-1', ok: true,
data: { id: `${resource}-detail`, args: args.join(' '), extra: 'field' },
}) + '\n');
process.exit(0);
}
if (process.env.STUB_JSONLINES) {
process.stdout.write(JSON.stringify({ id: `${resource}-1`, name: 'row one' }) + '\n');
process.stdout.write(JSON.stringify({ id: `${resource}-2`, name: 'row two' }) + '\n');
process.exit(0);
}
process.stdout.write(JSON.stringify({
id: 'req-1',
ok: true,
data: [
{ id: `${resource}-1`, name: 'row one' },
{ id: `${resource}-2`, name: 'row two' },
],
}) + '\n');
process.exit(0);
}, sleepMs);
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { createApp } from '../server.js';
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
function cli(extra = {}) {
return {
bin: process.execPath,
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
list: [STUB, '{resource}', 'list', '--json'],
output: 'json', unwrap: 'data',
help: [STUB, '{resource}', 'help'],
...extra,
};
}
async function withServer(clis, fn) {
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/help: returns raw per-resource help text', async () => {
await withServer({ ncl: cli() }, async (base) => {
const body = await (await fetch(`${base}/api/help/ncl/sessions`)).json();
assert.equal(body.ok, true);
assert.match(body.text, /sessions: help for sessions/);
assert.match(body.text, /Verbs:/);
});
});
test('/api/help: undiscovered resource → 404', async () => {
await withServer({ ncl: cli() }, async (base) => {
assert.equal((await fetch(`${base}/api/help/ncl/evil`)).status, 404);
});
});
test('/api/help: a cli without a help template → 404', async () => {
const c = cli(); delete c.help;
await withServer({ ncl: c }, async (base) => {
assert.equal((await fetch(`${base}/api/help/ncl/sessions`)).status, 404);
});
});
test('/api/help: unknown cli → 404', async () => {
await withServer({ ncl: cli() }, async (base) => {
assert.equal((await fetch(`${base}/api/help/nope/sessions`)).status, 404);
});
});
test('/api/clis: reports help availability per cli', async () => {
const noHelp = cli(); delete noHelp.help;
await withServer({ ncl: cli(), docker: noHelp }, async (base) => {
const body = await (await fetch(`${base}/api/clis`)).json();
assert.equal(body.clis.find((c) => c.name === 'ncl').help, true);
assert.equal(body.clis.find((c) => c.name === 'docker').help, false);
});
});
@@ -0,0 +1,75 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { tailFile } from '../logs.js';
import { createApp } from '../server.js';
let dir;
before(() => {
dir = mkdtempSync(join(tmpdir(), 'clidash-logs-'));
// 10 lines, some with ANSI color codes
const lines = Array.from({ length: 10 }, (_, i) =>
`[12:00:0${i}] \x1b[32mINFO\x1b[39m line ${i}`);
writeFileSync(join(dir, 'app.log'), lines.join('\n') + '\n');
writeFileSync(join(dir, 'error.log'), 'boom\n');
});
after(() => rmSync(dir, { recursive: true, force: true }));
test('tailFile: returns the last N lines, ANSI stripped, no trailing blank', async () => {
const { lines, text } = await tailFile(join(dir, 'app.log'), 3);
assert.equal(lines.length, 3);
assert.deepEqual(lines, ['[12:00:07] INFO line 7', '[12:00:08] INFO line 8', '[12:00:09] INFO line 9']);
assert.ok(!text.includes('\x1b'));
});
test('tailFile: maxLines larger than file returns all lines', async () => {
const { lines } = await tailFile(join(dir, 'app.log'), 100);
assert.equal(lines.length, 10);
});
// ---- server endpoints ----
function cfg() {
return {
port: 0, bind: '127.0.0.1', clis: {},
logs: { dir, tailLines: 5, files: [{ name: 'app.log', label: 'app' }, { name: 'error.log', label: 'errors' }] },
};
}
async function withServer(config, fn) {
const server = createApp(config);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/logs: lists the configured log files', async () => {
await withServer(cfg(), async (base) => {
const body = await (await fetch(`${base}/api/logs`)).json();
assert.deepEqual(body.files.map((f) => f.name), ['app.log', 'error.log']);
});
});
test('/api/logs: absent logs config → empty list', async () => {
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
assert.deepEqual((await (await fetch(`${base}/api/logs`)).json()).files, []);
});
});
test('/api/log: returns the tail text + a tail command', async () => {
await withServer(cfg(), async (base) => {
const body = await (await fetch(`${base}/api/log/app.log`)).json();
assert.equal(body.ok, true);
assert.match(body.text, /line 9$/);
assert.equal(body.text.split('\n').length, 5); // tailLines
assert.match(body.command, /tail -n 5 .*app\.log/);
});
});
test('/api/log: a name not in the allowlist is rejected (no traversal)', async () => {
await withServer(cfg(), async (base) => {
assert.equal((await fetch(`${base}/api/log/${encodeURIComponent('../../etc/passwd')}`)).status, 404);
assert.equal((await fetch(`${base}/api/log/secrets.log`)).status, 404);
});
});
@@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { escapeHtml, mdToHtml } from '../public/md.js';
// ---- escaping -------------------------------------------------------------
test('escapeHtml: neutralizes all HTML metacharacters', () => {
assert.equal(escapeHtml(`<script>"&'`), '&lt;script&gt;&quot;&amp;&#39;');
});
test('mdToHtml: raw HTML in source is escaped, never passed through', () => {
const html = mdToHtml('a <script>alert(1)</script> b');
assert.ok(!html.includes('<script>'));
assert.ok(html.includes('&lt;script&gt;'));
});
// ---- the security-sensitive part: links -----------------------------------
test('mdToHtml: link href comes from the URL, label from the text', () => {
const html = mdToHtml('see [the docs](https://example.com/x)');
assert.match(html, /<a href="https:\/\/example\.com\/x" target="_blank" rel="noopener noreferrer">the docs<\/a>/);
});
test('mdToHtml: javascript: smuggled in link TEXT stays inert (never an href)', () => {
const html = mdToHtml('[javascript:alert(1)](https://safe.com)');
// href is the safe URL; the js string is only visible label text
assert.match(html, /href="https:\/\/safe\.com"/);
assert.ok(!/href="javascript:/i.test(html));
});
test('mdToHtml: a non-http(s) URL is not turned into a link', () => {
// javascript:/data: never match the (https?:...) capture, so the literal
// (escaped) markdown is left as-is — no anchor, no executable href.
const html = mdToHtml('[click](javascript:alert(1))');
assert.ok(!/<a /.test(html));
assert.ok(!/href="javascript:/i.test(html));
});
test('mdToHtml: an attribute-breakout attempt in the URL cannot escape the href', () => {
// The double-quote is escaped to &quot; before the regex runs, so it can never
// close an attribute. (Here the URL also has a space, so no anchor even forms.)
// The security property: no REAL attribute (with a literal quote) is injected.
const html = mdToHtml('[x](https://a" onmouseover="alert(1))');
assert.ok(!/<a/.test(html), 'malformed link must not produce an anchor');
assert.ok(!/onmouseover="/.test(html), 'no real (unescaped-quote) attribute injected');
});
test('mdToHtml: an escaped quote inside a matched URL stays inside the href, inert', () => {
// Even when a URL matches, any " in it is already &quot; (an entity), which
// does not terminate an HTML attribute value — so no breakout.
const html = mdToHtml('[x](https://a"onmouseover=alert)');
assert.ok(!/onmouseover="/.test(html));
if (/<a/.test(html)) assert.match(html, /href="https:\/\/a&quot;onmouseover=alert"/);
});
// ---- basic rendering sanity ----------------------------------------------
test('mdToHtml: headings, code fences, lists render', () => {
const html = mdToHtml('# Title\n\n```\ncode\n```\n\n- a\n- b');
assert.match(html, /<h1>Title<\/h1>/);
assert.match(html, /<pre class="code"><code>code<\/code><\/pre>/);
assert.match(html, /<ul><li>a<\/li><li>b<\/li><\/ul>/);
});
@@ -0,0 +1,70 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import overview from '../views/ncl-overview.js';
const minutesAgo = (m) => new Date(Date.now() - m * 60_000).toISOString();
// Shapes mirror real `ncl <resource> list --json` output.
function makeFixtures({ alphaLastActive, bravoLastActive }) {
return {
groups: [
{ id: 'ag-1', name: 'Alpha', folder: 'alpha', created_at: '2026-05-31T11:14:48.793Z' },
{ id: 'ag-2', name: 'Bravo Team', folder: 'bravo', created_at: '2026-05-31T11:14:48.796Z' },
{ id: 'ag-3', name: 'Orphan', folder: 'orphan', created_at: '2026-05-31T11:14:48.799Z' },
],
sessions: [
{ id: 'sess-1', agent_group_id: 'ag-1', messaging_group_id: 'mg-1', thread_id: null, status: 'active', container_status: 'stopped', last_active: alphaLastActive, created_at: '2026-05-31T11:14:51.911Z' },
{ id: 'sess-2', agent_group_id: 'ag-2', messaging_group_id: 'mg-2', thread_id: null, status: 'active', container_status: 'running', last_active: bravoLastActive, created_at: '2026-05-31T11:14:51.973Z' },
],
'messaging-groups': [
{ id: 'mg-1', channel_type: 'telegram', platform_id: 'telegram:1', name: 'Alpha', is_group: 0 },
{ id: 'mg-2', channel_type: 'telegram', platform_id: 'telegram:2', name: 'Bravo Team', is_group: 0 },
],
wirings: [
{ id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', session_mode: 'shared' },
{ id: 'mga-2', messaging_group_id: 'mg-2', agent_group_id: 'ag-2', session_mode: 'shared' },
],
};
}
function fetchFrom(fixtures) {
return async (resource) => {
if (!(resource in fixtures)) throw new Error(`unexpected fetch: ${resource}`);
return fixtures[resource];
};
}
test('overview: one card per agent group with joined session + wiring data', async () => {
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
const result = await overview({ fetch: fetchFrom(fixtures) });
assert.equal(result.cards.length, 3);
const alpha = result.cards.find((c) => c.title === 'Alpha');
assert.equal(alpha.subtitle, 'alpha');
assert.equal(alpha.fields.container, 'stopped');
assert.equal(alpha.fields.sessions, 1);
assert.deepEqual(alpha.badges, ['telegram: Alpha']);
const bravo = result.cards.find((c) => c.title === 'Bravo Team');
assert.equal(bravo.fields.container, 'running');
assert.deepEqual(bravo.badges, ['telegram: Bravo Team']);
});
test('overview: staleness thresholds — green <15m, amber <2h, red older, gray never', async () => {
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
const result = await overview({ fetch: fetchFrom(fixtures) });
assert.equal(result.cards.find((c) => c.title === 'Alpha').status, 'green');
assert.equal(result.cards.find((c) => c.title === 'Bravo Team').status, 'amber');
assert.equal(result.cards.find((c) => c.title === 'Orphan').status, 'gray');
const stale = makeFixtures({ alphaLastActive: minutesAgo(300), bravoLastActive: minutesAgo(30) });
const result2 = await overview({ fetch: fetchFrom(stale) });
assert.equal(result2.cards.find((c) => c.title === 'Alpha').status, 'red');
});
test('overview: last_active is exposed for relative-time rendering', async () => {
const ts = minutesAgo(5);
const fixtures = makeFixtures({ alphaLastActive: ts, bravoLastActive: minutesAgo(30) });
const result = await overview({ fetch: fetchFrom(fixtures) });
assert.equal(result.cards.find((c) => c.title === 'Alpha').fields['last active'], ts);
});
@@ -0,0 +1,111 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { discoveryParsers, parseOutput, unwrapPath } from '../parsers.js';
const fixture = readFileSync(
fileURLToPath(new URL('./fixtures/ncl-help.txt', import.meta.url)),
'utf8',
);
// ---------------------------------------------------------------- ncl-help
test('ncl-help: parses all listable resources from real captured output', () => {
const resources = discoveryParsers['ncl-help'](fixture);
assert.deepEqual(
resources.map((r) => r.name),
[
'approvals', 'destinations', 'dropped-messages', 'groups', 'members',
'messaging-groups', 'roles', 'sessions', 'user-dms', 'users', 'wirings',
],
);
});
test('ncl-help: every parsed resource has a non-empty description and a list verb', () => {
const resources = discoveryParsers['ncl-help'](fixture);
for (const r of resources) {
assert.ok(r.description.length > 0, `${r.name} has empty description`);
assert.ok(r.verbs.includes('list'), `${r.name} missing list verb`);
}
});
test('ncl-help: parses verbs correctly, including multi-word verbs', () => {
const resources = discoveryParsers['ncl-help'](fixture);
const groups = resources.find((r) => r.name === 'groups');
assert.deepEqual(groups.verbs, [
'list', 'get', 'create', 'update', 'delete', 'restart',
'config get', 'config update', 'config add-mcp-server',
'config remove-mcp-server', 'config add-package', 'config remove-package',
]);
});
test('ncl-help: excludes resources without a list verb', () => {
const input = [
'Resources:',
' alpha Has list.',
' verbs: list, get',
' beta No list here.',
' verbs: grant, revoke',
'',
].join('\n');
const resources = discoveryParsers['ncl-help'](input);
assert.deepEqual(resources.map((r) => r.name), ['alpha']);
});
test('ncl-help: ignores the Commands section (help is not a resource)', () => {
const resources = discoveryParsers['ncl-help'](fixture);
assert.ok(!resources.some((r) => r.name === 'help'));
});
test('ncl-help: throws loudly on unrecognized format', () => {
assert.throws(() => discoveryParsers['ncl-help']('totally not help output'), /Resources/);
assert.throws(() => discoveryParsers['ncl-help'](''), /Resources/);
});
// ------------------------------------------------------------- parseOutput
test('parseOutput json: parses a single document', () => {
assert.deepEqual(parseOutput('{"a": 1}', 'json'), { a: 1 });
});
test('parseOutput json: throws on malformed input with raw output preserved', () => {
assert.throws(() => parseOutput('not json', 'json'), (err) => {
assert.match(err.message, /JSON/i);
assert.equal(err.raw, 'not json');
return true;
});
});
test('parseOutput jsonlines: one object per line, blank lines skipped', () => {
const text = '{"id":1}\n\n{"id":2}\n{"id":3}\n';
assert.deepEqual(parseOutput(text, 'jsonlines'), [{ id: 1 }, { id: 2 }, { id: 3 }]);
});
test('parseOutput jsonlines: throws on a malformed line', () => {
assert.throws(() => parseOutput('{"ok":1}\ngarbage\n', 'jsonlines'), /line 2/i);
});
test('parseOutput: rejects unknown format', () => {
assert.throws(() => parseOutput('{}', 'xml'), /format/i);
});
// -------------------------------------------------------------- unwrapPath
test('unwrapPath: extracts the ncl {id, ok, data} envelope', () => {
const doc = { id: 'x', ok: true, data: [{ id: 'sess-1' }] };
assert.deepEqual(unwrapPath(doc, 'data'), [{ id: 'sess-1' }]);
});
test('unwrapPath: supports nested dot paths', () => {
assert.deepEqual(unwrapPath({ a: { b: [1, 2] } }, 'a.b'), [1, 2]);
});
test('unwrapPath: throws when the path is missing', () => {
assert.throws(() => unwrapPath({ ok: true }, 'data'), /data/);
});
test('unwrapPath: no path returns the value unchanged', () => {
const rows = [{ id: 1 }];
assert.equal(unwrapPath(rows, undefined), rows);
});
@@ -0,0 +1,240 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createApp } from '../server.js';
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
const tmp = mkdtempSync(join(tmpdir(), 'clidash-test-'));
function stubCli(extra = {}) {
return {
bin: process.execPath,
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
list: [STUB, '{resource}', 'list', '--json'],
output: 'json',
unwrap: 'data',
...extra,
};
}
function makeConfig(clis, extra = {}) {
return { port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, refreshSeconds: 10, clis, ...extra };
}
async function withServer(config, fn) {
const server = createApp(config);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
try {
return await fn(base);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
after(() => rmSync(tmp, { recursive: true, force: true }));
// ----------------------------------------------------------------- /api/clis
test('/api/clis: lists configured CLIs with discovered resources', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/api/clis`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.clis.length, 1);
assert.equal(body.clis[0].name, 'stub');
assert.equal(body.clis[0].refreshSeconds, 10);
const names = body.clis[0].resources.map((r) => r.name);
assert.ok(names.includes('sessions'));
assert.ok(names.includes('groups'));
assert.equal(names.length, 11);
});
});
test('/api/clis: static resource list needs no discovery', async () => {
const cli = stubCli({ resources: ['alpha', 'beta'] });
delete cli.discover;
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/clis`)).json();
assert.deepEqual(body.clis[0].resources.map((r) => r.name), ['alpha', 'beta']);
});
});
test('/api/clis: discovery failure reports a loud error', async () => {
const cli = stubCli();
cli.env = { STUB_FAIL: '1' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/clis`)).json();
assert.equal(body.clis[0].resources.length, 0);
assert.match(body.clis[0].error, /boom/);
});
});
// ------------------------------------------------------------ /api/r/cli/res
test('/api/r: returns unwrapped rows with fetchedAt', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/api/r/stub/sessions`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.deepEqual(body.rows.map((r) => r.id), ['sessions-1', 'sessions-2']);
assert.ok(body.fetchedAt);
});
});
test('/api/r: rejects a resource not in the discovered set without exec', async () => {
const countFile = join(tmp, 'count-reject.txt');
const cli = stubCli();
cli.env = { STUB_COUNT_FILE: countFile };
await withServer(makeConfig({ stub: cli }), async (base) => {
const res = await fetch(`${base}/api/r/stub/evil%20--rm`);
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.ok, false);
// only the discovery exec ran — never a list exec for the bogus resource
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
assert.deepEqual(calls, ['help']);
});
});
test('/api/r: unknown cli → 404', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/api/r/nope/sessions`);
assert.equal(res.status, 404);
});
});
test('/api/r: jsonlines CLI with static resources works', async () => {
const cli = {
bin: process.execPath,
resources: ['ps'],
list: [STUB, '{resource}'],
output: 'jsonlines',
env: { STUB_JSONLINES: '1' },
};
await withServer(makeConfig({ docker: cli }), async (base) => {
const body = await (await fetch(`${base}/api/r/docker/ps`)).json();
assert.equal(body.ok, true);
assert.deepEqual(body.rows.map((r) => r.id), ['ps-1', 'ps-2']);
});
});
test('/api/r: exec failure returns ok:false with stderr', async () => {
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_FAIL: '1' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const res = await fetch(`${base}/api/r/stub/sessions`);
assert.equal(res.status, 502);
const body = await res.json();
assert.equal(body.ok, false);
assert.match(body.error, /boom: socket down/);
});
});
test('/api/r: exec timeout returns ok:false naming the resource', async () => {
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_SLEEP_MS: '5000' };
await withServer(makeConfig({ stub: cli }, { execTimeoutMs: 200 }), async (base) => {
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
assert.equal(body.ok, false);
assert.match(body.error, /sessions/);
assert.match(body.error, /timed out/i);
});
});
test('/api/r: malformed CLI output returns the raw output', async () => {
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_RAW: 'this is not json' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
assert.equal(body.ok, false);
assert.match(body.raw, /this is not json/);
});
});
test('/api/r: concurrent requests for the same resource coalesce into one exec', async () => {
const countFile = join(tmp, 'count-coalesce.txt');
const cli = stubCli({ resources: ['sessions'] });
delete cli.discover;
cli.env = { STUB_COUNT_FILE: countFile, STUB_SLEEP_MS: '150' };
await withServer(makeConfig({ stub: cli }), async (base) => {
const bodies = await Promise.all(
Array.from({ length: 5 }, () => fetch(`${base}/api/r/stub/sessions`).then((r) => r.json())),
);
for (const body of bodies) assert.equal(body.ok, true);
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
assert.equal(calls.length, 1);
});
});
// ------------------------------------------------------------- /api/view
test('/api/view: runs a view plugin with a bound fetch helper', async () => {
const viewsDir = join(tmp, 'views');
writeFileSync(join(viewsDir, '..', 'placeholder'), ''); // ensure tmp exists
const { mkdirSync } = await import('node:fs');
mkdirSync(viewsDir, { recursive: true });
writeFileSync(
join(viewsDir, 'stub-overview.js'),
'export default async function ({ fetch }) {\n' +
' const rows = await fetch("sessions");\n' +
' return { count: rows.length, first: rows[0].id };\n' +
'}\n',
);
await withServer(makeConfig({ stub: stubCli() }, { viewsDir }), async (base) => {
const res = await fetch(`${base}/api/view/stub/overview`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.deepEqual(body.result, { count: 2, first: 'sessions-1' });
});
});
test('/api/view: missing view → 404; bad view name → 404', async () => {
await withServer(makeConfig({ stub: stubCli() }, { viewsDir: join(tmp, 'views') }), async (base) => {
assert.equal((await fetch(`${base}/api/view/stub/nope`)).status, 404);
assert.equal((await fetch(`${base}/api/view/stub/..%2F..%2Fserver`)).status, 404);
});
});
// ------------------------------------------------------------- static files
test('GET /: serves the dashboard index.html', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/`);
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type'), /text\/html/);
assert.match(await res.text(), /clidash/i);
});
});
test('static: path traversal outside public/ is rejected', async () => {
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
const res = await fetch(`${base}/..%2Fserver.js`);
assert.notEqual(res.status, 200);
});
});
test('/api/r: {resource} substitutes inside a larger argv string (ssh-remote pattern)', async () => {
const cli = {
bin: process.execPath,
resources: ['sessions'],
list: [STUB, 'wrapped-{resource}-arg', 'list'],
output: 'json',
unwrap: 'data',
env: { STUB_COUNT_FILE: join(tmp, 'count-embed.txt') },
};
await withServer(makeConfig({ stub: cli }), async (base) => {
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
assert.equal(body.ok, true);
const calls = readFileSync(join(tmp, 'count-embed.txt'), 'utf8').trim();
assert.equal(calls, 'wrapped-sessions-arg list');
});
});
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Smoke test against a running clidash instance (run on the VM after deploy).
# Usage: ./test/smoke.sh [base-url] (default http://127.0.0.1:4690)
set -euo pipefail
BASE="${1:-http://127.0.0.1:4690}"
check() {
local label="$1" url="$2" pattern="$3"
if curl -fsS --max-time 15 "$url" | grep -q "$pattern"; then
echo "OK $label"
else
echo "FAIL $label ($url did not match $pattern)"
exit 1
fi
}
check "/api/clis" "$BASE/api/clis" '"resources"'
check "/api/r/ncl/sessions" "$BASE/api/r/ncl/sessions" '"ok":true'
check "/api/view/ncl/overview" "$BASE/api/view/ncl/overview" '"ok":true'
check "GET / (static UI)" "$BASE/" 'clidash'
echo "smoke: all good"
@@ -0,0 +1,60 @@
// Curated "Agents overview" view for ncl: joins groups + sessions +
// messaging-groups + wirings into per-agent cards. Returns the generic
// card shape the frontend renders, so the UI itself stays CLI-agnostic:
// { title, cards: [{ title, subtitle, status, fields, badges }] }
// status: green <15m since last_active, amber <2h, red older, gray never.
const GREEN_MAX_MIN = 15;
const AMBER_MAX_MIN = 120;
function staleness(lastActive) {
if (!lastActive) return 'gray';
const ageMin = (Date.now() - new Date(lastActive).getTime()) / 60_000;
if (ageMin < GREEN_MAX_MIN) return 'green';
if (ageMin < AMBER_MAX_MIN) return 'amber';
return 'red';
}
export default async function overview({ fetch }) {
const [groups, sessions, messagingGroups, wirings] = await Promise.all([
fetch('groups'),
fetch('sessions'),
fetch('messaging-groups'),
fetch('wirings'),
]);
const mgById = new Map(messagingGroups.map((mg) => [mg.id, mg]));
const cards = groups.map((group) => {
const groupSessions = sessions.filter((s) => s.agent_group_id === group.id);
const lastActive = groupSessions
.map((s) => s.last_active)
.filter(Boolean)
.sort()
.at(-1) ?? null;
const container = groupSessions.some((s) => s.container_status === 'running')
? 'running'
: groupSessions[0]?.container_status ?? 'none';
const badges = wirings
.filter((w) => w.agent_group_id === group.id)
.map((w) => {
const mg = mgById.get(w.messaging_group_id);
return mg ? `${mg.channel_type}: ${mg.name ?? mg.platform_id}` : w.messaging_group_id;
});
return {
title: group.name,
subtitle: group.folder,
status: staleness(lastActive),
fields: {
container,
sessions: groupSessions.length,
'last active': lastActive,
},
badges,
};
});
return { title: 'Agents overview', cards };
}
@@ -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
+3 -3
View File
@@ -9,13 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
## Provider Compatibility
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider. The provider is the materialized `provider` key in each group's `container.json` (absent or `claude` = default Claude provider). Confirm it before applying:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
grep -H '"provider"' groups/*/container.json 2>/dev/null # no match, or "provider": "claude" = Claude
```
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
If a group sets a different provider (e.g. `"provider": "opencode"`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
## Phase 1: Pre-flight
+14 -7
View File
@@ -1,11 +1,11 @@
---
name: add-opencode
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
description: Use OpenCode as an agent provider. OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per group via `ncl groups config update --provider opencode`; host passes OPENCODE_* and XDG mount when spawning containers.
---
# OpenCode agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected per agent group by the **`provider`** key in that group's `container.json` (materialized from the `container_configs` table) — set it with `ncl groups config update --provider opencode`. Default is `claude`.
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
@@ -148,7 +148,7 @@ done
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the group still needs `provider` set to `opencode` (see [Select the provider](#select-the-provider) below).
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
@@ -215,7 +215,7 @@ OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
**Naming:** NanoClaw's **`provider: opencode`** (the `container.json` key, set via `ncl groups config update --provider opencode`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
**Host `.env` (typical Zen shape):**
@@ -236,9 +236,16 @@ onecli secrets create --name "OpenCode Zen" --type generic \
--header-name "x-api-key" --value-format "{value}"
```
### Per group / per session
### Select the provider
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Per group, from the host:
```bash
ncl groups config update --id <group-id> --provider opencode
ncl groups restart --id <group-id>
```
`ncl groups config update --provider` writes the `provider` value into the `container_configs` table; the host materializes it into `groups/<folder>/container.json` at spawn time and the in-container runner reads `provider` from there (defaulting to `claude`). The restart picks up the change. Switching is an operator action — run it from the host. Memory does NOT carry over automatically between providers — run `/migrate-memory` to carry it across.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
@@ -250,6 +257,6 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
## Next Steps
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, switch a test group with `ncl groups config update --id <group-id> --provider opencode && ncl groups restart --id <group-id>`, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
To remove this provider, see [REMOVE.md](REMOVE.md).
+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:
+7 -9
View File
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
```
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
## Add Directories
Ask which directories the user wants agents to access. For each path:
- Validate the path exists
- Ask if it should be read-only for non-main agents (default: yes)
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
Build the JSON config and write it:
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
```
Use `--force` to overwrite the existing config.
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
```
## Reset to Empty
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
## After Changes
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automaticallyno service restart needed.
Run from your NanoClaw project root:
To apply the new config to a group that already has a running container, restart just that group:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
ncl groups restart --id <group-id>
```
+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)
+19
View File
@@ -14,6 +14,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write # createLabel — auto-provisions the core-team label
steps:
- uses: actions/github-script@v7
with:
@@ -30,6 +31,24 @@ jobs:
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
// Lowercase GitHub logins; keep in sync with the core team roster.
const CORE_TEAM = ['gavrielc', 'koshkoshinsk', 'glifocat', 'gabi-simons', 'omri-maya', 'amit-shafnir', 'moshe-nanoco'];
const author = context.payload.pull_request.user.login.toLowerCase();
if (CORE_TEAM.includes(author)) {
labels.push('core-team');
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'core-team',
color: '1D76DB',
description: 'PR opened by a core team member',
});
} catch (e) {
if (e.status !== 422) throw e; // 422: label already exists
}
}
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
+5
View File
@@ -4,6 +4,11 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded(<reason>)` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.)
- [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.
+28 -15
View File
@@ -15,11 +15,11 @@ If you are a fresh install (you ran `git clone`, not `git pull`) and there are n
# NanoClaw
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
Personal AI assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
## Quick Context
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls the agent, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
@@ -32,7 +32,7 @@ agent_group_members (user_id, agent_group_id) — unprivileged access gate
user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
↕ many-to-many via messaging_group_agents (session_mode, engage_mode/engage_pattern, sender_scope, priority)
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
@@ -44,8 +44,8 @@ Privilege is user-level (owner/admin), not agent-group-level. See [docs/isolatio
Each session has **two** SQLite files under `data/v2-sessions/<session_id>/`:
- `inbound.db` — host writes, container reads. `messages_in`, routing, destinations, pending_questions, processing_ack.
- `outbound.db` — container writes, host reads. `messages_out`, session_state.
- `inbound.db` — host writes, container reads. `messages_in`, delivered, destinations, session_routing.
- `outbound.db` — container writes, host reads. `messages_out`, processing_ack, session_state, container_state.
Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd.
@@ -62,25 +62,28 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake |
| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) |
| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the precheck → guard → deny/hold/allow consult path for privileged delivery actions; the registry itself stays in `delivery.ts` |
| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence |
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup |
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded(<reason>)`); broadcast hooks stay plain registrations, and the privileged click path — the channel-registration card handler — consults the guard inline, like the a2a route and the unknown-sender gate (the free-text name reply is deliberately not re-authorized; the click is the auth, as on main). Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` |
| `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 |
| `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 |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
| `src/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 (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `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). |
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
@@ -105,6 +108,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) |
@@ -115,11 +119,13 @@ Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud, Signal, WeChat, DeltaChat, Emacs (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
- **`providers` branch** — OpenCode (and any future non-default agent providers). Installed via `/add-opencode`.
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:
@@ -137,7 +143,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).
@@ -170,7 +176,7 @@ No container restart needed — the gateway looks up secrets per request.
Approval-gating credentialed actions is a **two-sided** flow:
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@2.2.5`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only.
If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired.
@@ -182,7 +188,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/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-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 |
|-------|-------------|
@@ -216,7 +222,7 @@ Run commands directly — don't tell the user to run them.
```bash
# Host (Node + pnpm)
pnpm run dev # Host with hot reload
pnpm run dev # Host via tsx (no watch)
pnpm run build # Compile host TypeScript (src/)
./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest)
pnpm test # Host tests (vitest)
@@ -251,6 +257,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.
@@ -297,7 +310,7 @@ The agent container runs on **Bun**; the host runs on **Node** (pnpm). They comm
- **Writing a new named-param SQL insert/update in the container** → use `$name` in both SQL and JS keys: `.run({ $id: msg.id })`. `bun:sqlite` does not auto-strip the prefix the way `better-sqlite3` does on the host. Positional `?` params work normally.
- **Adding a test in `container/agent-runner/src/`** → import from `bun:test`, not `vitest`. Vitest runs on Node and can't load `bun:sqlite`. `vitest.config.ts` excludes this tree.
- **Adding a Node CLI the agent invokes at runtime** (like `agent-browser`, `claude-code`, `vercel`) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a new `ARG`. Don't use `bun install -g` — that bypasses the pnpm supply-chain policy.
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~301) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~503) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
- **Changing session-DB pragmas** (`container/agent-runner/src/db/connection.ts`) → `journal_mode=DELETE` is load-bearing for cross-mount visibility. Read the comment block at the top of the file first.
## CJK font support
+4 -4
View File
@@ -75,7 +75,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
#### 3. Operational skills (instruction-only)
Workflows and guides with no code changes. The SKILL.md is the entire skill — Claude follows the instructions to perform a task.
Workflows and guides with no code changes. The SKILL.md is the entire skill — the coding agent follows the instructions to perform a task.
**Location:** `.claude/skills/` on `main`
@@ -88,13 +88,13 @@ Workflows and guides with no code changes. The SKILL.md is the entire skill —
#### 4. Container skills (agent runtime)
Skills that run inside the agent container, not on the host. These teach the container agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
Skills that run inside the agent container, not on the host. These teach the NanoClaw agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
**Location:** `container/skills/<name>/`
**Examples:** `agent-browser` (web browsing), `capabilities` (/capabilities command), `status` (/status command), `slack-formatting` (Slack mrkdwn syntax)
**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:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves.
**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.
**Guidelines:**
- Follow the same SKILL.md + frontmatter format
+8 -11
View File
@@ -22,7 +22,7 @@
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Claude agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
## Quick Start
@@ -49,7 +49,7 @@ bash migrate-v2.sh
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including the Baileys keystore for WhatsApp — LID mapping is now resolved per-message by the Baileys v7 adapter, not migrated), builds the agent container.
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
@@ -78,11 +78,11 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
- **Scheduled tasks** — recurring jobs executed by the agent, which can message you the results
- **Web access** — search and fetch content from the web
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
- **Container isolation** — agents are sandboxed in Docker containers (macOS/Linux/WSL2)
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle via `ncl groups create --template <ref>`. Templates load from the local `templates/` folder; populate it by hand or by copying from the [public library](https://github.com/nanocoai/nanoclaw-templates). See [docs/templates.md](docs/templates.md).
## Usage
@@ -124,10 +124,7 @@ This keeps trunk as pure registry and infra, and every fork stays lean — users
### RFS (Request for Skills)
Skills we'd like to see:
**Communication Channels**
- `/add-signal` — Add Signal as a channel
No channel or provider skills are currently requested — propose one via an issue.
## Requirements
@@ -142,7 +139,7 @@ Skills we'd like to see:
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
```
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs the agent, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.
@@ -165,7 +162,7 @@ Key files:
**Why Docker?**
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem.
**Can I run this on Linux or Windows?**
+45 -5
View File
@@ -22,7 +22,9 @@ type RequestFrame = {
};
type ResponseFrame =
| { id: string; ok: true; data: unknown }
// `human` mirrors src/cli/frame.ts: an optional server-rendered string we
// print verbatim instead of running our own (drift-prone) formatter.
| { id: string; ok: true; data: unknown; human?: string }
| { id: string; ok: false; error: { code: string; message: string } };
// ---------------------------------------------------------------------------
@@ -63,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,
@@ -108,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);
@@ -182,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';
}
@@ -244,6 +281,9 @@ if (!resp) {
if (json) {
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
} else if (resp.ok && resp.human !== undefined) {
// Server-rendered view — print verbatim.
process.stdout.write(resp.human + '\n');
} else {
const output = formatHuman(resp);
if (!resp.ok) {
@@ -1,29 +0,0 @@
/**
* Per-batch context the poll loop publishes for downstream consumers
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
*
* Today the only field is `inReplyTo` the id of the first inbound
* message in the batch the agent is currently processing. MCP tools like
* `send_message` and `send_file` read this and stamp it onto the outbound
* row so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This is module-level state on purpose: the agent-runner is single-process
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
* before invoking the provider and `clearCurrentInReplyTo` after the batch
* completes (or errors out).
*/
let currentInReplyTo: string | null = null;
export function setCurrentInReplyTo(id: string | null): void {
currentInReplyTo = id;
}
export function clearCurrentInReplyTo(): void {
currentInReplyTo = null;
}
export function getCurrentInReplyTo(): string | null {
return currentInReplyTo;
}
+5 -9
View File
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
// so the singleton survives for the rest of the test.
if (_testMode && _inbound) {
const db = _inbound;
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
return {
prepare: (sql: string) => db.prepare(sql),
exec: (sql: string) => db.exec(sql),
close: () => {},
} as unknown as Database;
}
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
db.exec('PRAGMA busy_timeout = 5000');
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
_outbound?.close();
_outbound = null;
}
/**
* @deprecated Use getInboundDb() / getOutboundDb() instead.
* Kept for backward compatibility during migration.
*/
export function getSessionDb(): Database {
return getInboundDb();
}
-1
View File
@@ -1,7 +1,6 @@
export {
getInboundDb,
getOutboundDb,
getSessionDb,
initTestSessionDb,
closeSessionDb,
touchHeartbeat,
+25 -8
View File
@@ -56,7 +56,7 @@ function getMaxMessagesPerPrompt(): number {
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
* to skip messages already picked up by this or a previous container run.
*
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
* Returns the most recent `maxMessagesPerPrompt` pending rows in
* chronological order, regardless of their `trigger` flag: accumulated
* context (trigger=0) rides along with the wake-eligible rows so the agent
* sees the prior context it missed. Host's countDueMessages gates waking on
@@ -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). */
@@ -163,4 +181,3 @@ export function findQuestionResponse(questionId: string): MessageInRow | undefin
inbound.close();
}
}
+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;
}
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
export function clearContinuation(providerName: string): void {
deleteValue(continuationKey(providerName));
}
/**
* The a2a reply stamp: the id of the first inbound message in the batch the
* agent is currently processing. The poll loop publishes it at batch start;
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
* rows so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This lives in outbound.db rather than module state because the MCP server
* runs as a separate stdio subprocess from the poll loop module state set
* by the poll loop is invisible to it. Both processes open outbound.db
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
*/
const IN_REPLY_TO_KEY = 'current_in_reply_to';
/**
* Ignore a stamp older than this. The poll loop clears the stamp in a
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
* the guard stops a later out-of-batch read from picking up a dead stamp.
* Generous so a long-running batch's late sends still stamp correctly.
*/
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
export function setCurrentInReplyTo(id: string | null): void {
if (id === null) {
clearCurrentInReplyTo();
return;
}
setValue(IN_REPLY_TO_KEY, id);
}
export function clearCurrentInReplyTo(): void {
deleteValue(IN_REPLY_TO_KEY);
}
export function getCurrentInReplyTo(): string | null {
const row = getOutboundDb()
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
if (!row) return null;
const age = Date.now() - new Date(row.updated_at).getTime();
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
return row.value;
}
+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
@@ -4,14 +4,31 @@
* batch in poll-loop, and outbound writes from MCP tools (send_message,
* send_file) must pick it up so a2a return-path routing on the host can
* correlate replies back to the originating session.
*
* The stamp is published through session_state in outbound.db, not module
* state the MCP server runs as a separate stdio subprocess from the poll
* loop, so it can only see the stamp through the shared DB. These tests seed
* it the same way the poll-loop process does (a direct DB write) rather than
* via any in-memory helper, so they exercise the real process boundary.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
import { getUndeliveredMessages } from '../db/messages-out.js';
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
import { sendMessage } from './core.js';
/**
* Publish the a2a reply stamp the way the poll loop does: a direct write to
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
* staleness guard MCP tools apply when reading it.
*/
function publishInReplyTo(id: string, ageMs = 0): void {
const updatedAt = new Date(Date.now() - ageMs).toISOString();
getOutboundDb()
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.run('current_in_reply_to', id, updatedAt);
}
beforeEach(() => {
initTestSessionDb();
// Seed a peer agent destination
@@ -24,13 +41,12 @@ beforeEach(() => {
});
afterEach(() => {
clearCurrentInReplyTo();
closeSessionDb();
});
describe('send_message MCP tool — in_reply_to plumbing', () => {
it('stamps current batch in_reply_to on outbound rows', async () => {
setCurrentInReplyTo('inbound-msg-1');
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
publishInReplyTo('inbound-msg-1');
await sendMessage.handler({ to: 'peer', text: 'hello' });
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
});
it('writes null when no batch is active', async () => {
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBeNull();
});
it('ignores a stale stamp left behind by a killed container', async () => {
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
+1 -1
View File
@@ -9,9 +9,9 @@
import fs from 'fs';
import path from 'path';
import { getCurrentInReplyTo } from '../current-batch.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getCurrentInReplyTo } from '../db/session-state.js';
import { getSessionRouting } from '../db/session-routing.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
@@ -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]);
+26 -12
View File
@@ -1,9 +1,14 @@
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, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import {
clearContinuation,
clearCurrentInReplyTo,
migrateLegacyContinuation,
setContinuation,
setCurrentInReplyTo,
} from './db/session-state.js';
import {
formatMessages,
extractRouting,
@@ -202,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
@@ -233,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.
@@ -396,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
@@ -645,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
@@ -652,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;
@@ -449,7 +452,7 @@ export class ClaudeProvider implements AgentProvider {
yield { type: 'result', text, isError: m.is_error === true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
yield { type: 'error', message: 'API retry', retryable: true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
} else if (message.type === 'rate_limit_event') {
yield { type: 'error', message: 'Rate limit', retryable: false, classification: 'quota' };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
@@ -3,4 +3,3 @@
// level. Skills add a new provider by appending one import line below.
import './claude.js';
import './mock.js';
@@ -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.
-90
View File
@@ -1,90 +0,0 @@
# Apple Container Networking Setup (macOS 26)
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
## Quick Setup
Run these two commands (requires `sudo`):
```bash
# 1. Enable IP forwarding so the host routes container traffic
sudo sysctl -w net.inet.ip.forwarding=1
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
```
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
## Making It Persistent
These settings reset on reboot. To make them permanent:
**IP Forwarding** — add to `/etc/sysctl.conf`:
```
net.inet.ip.forwarding=1
```
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
```
nat on en0 from 192.168.64.0/24 to any -> (en0)
```
Then reload: `sudo pfctl -f /etc/pf.conf`
## IPv6 DNS Issue
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
The container image and runner are configured to prefer IPv4 via:
```
NODE_OPTIONS=--dns-result-order=ipv4first
```
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
## Verification
```bash
# Check IP forwarding is enabled
sysctl net.inet.ip.forwarding
# Expected: net.inet.ip.forwarding: 1
# Test container internet access
container run --rm --entrypoint curl nanoclaw-agent:latest \
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
# Expected: 404
# Check bridge interface (only exists when a container is running)
ifconfig bridge100
```
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
## How It Works
```
Container VM (192.168.64.x)
├── eth0 → gateway 192.168.64.1
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
├── IP forwarding (sysctl) routes packets from bridge100 → en0
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
en0 (your WiFi/Ethernet) → Internet
```
## References
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
-3
View File
@@ -6,8 +6,5 @@ The files in this directory are original design documents and developer referenc
| This directory | Documentation site |
|---|---|
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
+12 -12
View File
@@ -20,7 +20,7 @@ The entire codebase should be something you can read and understand. One Node.js
### Security Through True Isolation
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
### Built for the Individual User
@@ -47,23 +47,23 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp.
Skills we'd like to see contributed:
### Communication Channels
- `/add-signal` - Add Signal as a channel
- `/add-matrix` - Add Matrix integration
> **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
None currently — Signal and Matrix have since shipped as skills.
> **Note:** Telegram, Slack, Discord, Gmail, Signal, and Matrix skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
---
## Vision
A personal Claude assistant accessible via messaging, with minimal custom code.
A personal AI assistant accessible via messaging, with minimal custom code.
**Core components:**
- **Claude Agent SDK** as the core agent
- **Containers** for isolated agent execution (Linux VMs)
- **Containers** for isolated agent execution (Docker)
- **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need
- **Persistent memory** per conversation and globally
- **Scheduled tasks** that run Claude and can message back
- **Scheduled tasks** executed by the agent, which can message back
- **Web access** for search and browsing
- **Browser automation** via agent-browser
@@ -93,14 +93,14 @@ A personal Claude assistant accessible via messaging, with minimal custom code.
- Sessions auto-compact when context gets too long, preserving critical information
### Container Isolation
- All agents run inside containers (lightweight Linux VMs)
- All agents run inside Docker containers
- Each agent invocation spawns a container with mounted directories
- Containers provide filesystem isolation - agents can only see mounted paths
- Bash access is safe because commands run inside the container, not on the host
- Browser automation via agent-browser with Chromium in the container
### Scheduled Tasks
- Users can ask Claude to schedule recurring or one-time tasks from any group
- Users can ask the agent to schedule recurring or one-time tasks from any group
- Tasks run as full agents in the context of the group that created them
- Tasks have access to all tools including Bash (safe in container)
- Tasks can optionally send messages to their group via `send_message` tool, or complete silently
@@ -134,11 +134,11 @@ A personal Claude 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
+471 -434
View File
File diff suppressed because it is too large Load Diff
+106 -60
View File
@@ -1,70 +1,106 @@
# NanoClaw Security Model
> The canonical, continuously-verified version of this model lives at
> [docs.nanoclaw.dev/concepts/security](https://docs.nanoclaw.dev/concepts/security).
> This in-repo copy can drift; if the two disagree, verify against
> `src/container-runner.ts` (`buildMounts`).
## Trust Model
Privilege is **user-level**, persisted in the `user_roles` table (owner /
admin, global or scoped to an agent group) plus `agent_group_members` (the
unprivileged access gate).
| Entity | Trust Level | Rationale |
|--------|-------------|-----------|
| Main group | Trusted | Private self-chat, admin control |
| Non-main groups | Untrusted | Other users may be malicious |
| Container agents | Sandboxed | Isolated execution environment |
| Incoming messages | User input | Potential prompt injection |
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
## Security Boundaries
### 1. Container Isolation (Primary Boundary)
Agents execute in containers (lightweight Linux VMs), providing:
- **Process isolation** - Container processes cannot affect the host
- **Filesystem isolation** - Only explicitly mounted directories are visible
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
Agents execute in containers (Docker), providing:
- **Process isolation** — container processes cannot affect the host
- **Filesystem isolation** — only explicitly mounted directories are visible
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
This is the primary security boundary. Rather than relying on application-level
permission checks, the attack surface is limited by what's mounted.
### 2. Mount Security
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
- Outside project root
- Never mounted into containers
- Cannot be modified by agents
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
spawn. For the default (Claude) provider these are:
**Default Blocked Patterns:**
| Container path | Host source | Mode | Purpose |
|---|---|---|---|
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
| `/app/skills` | `container/skills/` | RO | Shared container skills |
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
**nested read-only mounts on top of the read-write group dir** — the agent can
read its config but cannot modify it. The project root is **never mounted**: the
container only ever sees the paths above plus any provider-contributed mounts
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
`package.json`) is not reachable.
**Additional-mount allowlist** — extra mounts from a group's container config
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
which is:
- Outside the project root
- Never mounted into containers
- Not modifiable by agents
Its schema:
```json
{
"allowedRoots": [
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
],
"blockedPatterns": ["password", "secret", "token"]
}
```
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
**Default blocked patterns** (merged with any in the file):
```
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
private_key, .secret
```
**Protections:**
- Symlink resolution before validation (prevents traversal attacks)
- Container path validation (rejects `..` and absolute paths)
- `nonMainReadOnly` option forces read-only for non-main groups
**Read-Only Project Root:**
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
**Enforcement** (`src/modules/mount-security/index.ts`):
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
### 3. Session Isolation
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
- Groups cannot see other groups' conversation history
- Session data includes full message history and file contents read
- Prevents cross-group information disclosure
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
(`.claude-shared`) and the working folder are scoped to the agent group, so:
- Different agent groups cannot see each other's conversation history or files.
- A group's sessions share that group's memory but keep separate message DBs.
### 4. IPC Authorization
This prevents cross-group information disclosure.
Messages and task operations are verified against group identity:
| Operation | Main Group | Non-Main Group |
|-----------|------------|----------------|
| Send message to own chat | ✓ | ✓ |
| Send message to other chats | ✓ | ✗ |
| Schedule task for self | ✓ | ✓ |
| Schedule task for others | ✓ | ✗ |
| View all tasks | ✓ | Own only |
| Manage other groups | ✓ | ✗ |
### 5. Credential Isolation (OneCLI Agent Vault)
### 4. Credential Isolation (OneCLI Agent Vault)
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
@@ -77,13 +113,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
**Per-agent policies:**
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
**NOT Mounted:**
- Channel auth sessions (`store/auth/`) — host only
- Mount allowlist — external, never mounted
- Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount
**Never on the container filesystem:**
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
### 6. Egress Lockdown (Forced Proxy)
### 5. Egress Lockdown (Forced Proxy)
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
ignores it (or a raw socket) could reach the internet directly and bypass
@@ -111,31 +146,42 @@ no `host-gateway` route).
exception: a heal failure there is logged but not fatal, since already-running
agents stay on the internal net (no leak) until the gateway returns.
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
the agent reaches the OneCLI gateway over the host-gateway path and outbound
traffic is not confined to the internal network.
**Configuration:**
| Env | Default | Meaning |
| --- | --- | --- |
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
These variables are read from the **host process** environment (the service's
environment / `.env`), not from inside the container. The agent container is
started with only `TZ` and any provider-declared variables — host environment
variables, including secrets, are never forwarded into the agent.
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
## Privilege Comparison
## Resource Limits
| Capability | Main Group | Non-Main Group |
|------------|------------|----------------|
| Project root access | `/workspace/project` (ro) | None |
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
| Global memory | Implicit via project | `/workspace/global` (ro) |
| Additional mounts | Configurable | Read-only unless allowed |
| Network access | Unrestricted | Unrestricted |
| MCP tools | All | All |
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
agent is not throttled unless the operator configures a limit:
| Env | Default | Meaning |
| --- | --- | --- |
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
the host having no swap (a deployment concern). On a swapless host a runaway is
OOM-killed at the limit.
## Security Architecture Diagram
@@ -149,7 +195,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
┌──────────────────────────────────────────────────────────────────┐
│ HOST PROCESS (TRUSTED) │
│ • Message routing │
│ • IPC authorization
│ • Role / access checks (user_roles, agent_group_members)
│ • Mount validation (external allowlist) │
│ • Container lifecycle │
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
+5 -20
View File
@@ -1,5 +1,7 @@
# NanoClaw Specification
> **⚠️ Historical v1 spec.** This document describes the original NanoClaw v1 architecture — the single `store/messages.db`, the file-based IPC watcher, the `task-scheduler.ts` loop, the `MAX_CONCURRENT_CONTAINERS` cap, and the `groups/{channel}_{name}/` folder convention. **None of these exist in v2.** v2 replaced them with the two-DB session split (`inbound.db`/`outbound.db`), the entity model (users → messaging groups → agent groups → sessions), and the system-action delivery path. Kept for reference only. For the current architecture start at [architecture.md](architecture.md) and the root [CLAUDE.md](../CLAUDE.md); the v1→v2 diff is in [v1-to-v2-changes.md](v1-to-v2-changes.md).
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
---
@@ -577,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.
```
@@ -592,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
@@ -618,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 |
---
+254 -209
View File
@@ -14,37 +14,62 @@ The boundary: the agent-runner decides **what** to send and **what to do** with
## AgentProvider Interface
Provider-wide settings (MCP servers, env, additional directories, model, effort,
assistant name) are passed to the provider **constructor** via `ProviderOptions`, not
per query. `QueryInput` carries only what changes turn to turn: the prompt, the
continuation token to resume, the working directory, and system context to inject.
```typescript
interface AgentProvider {
/** True if the SDK handles slash commands natively and wants them passed
* through raw. When false, the poll-loop formats them like any chat message. */
readonly supportsNativeSlashCommands: boolean;
/** Opt-in: scaffold a persistent memory/ tree at boot. Providers with native
* memory (Claude's CLAUDE.local.md) omit it. Never gated on a provider name. */
readonly usesMemoryScaffold?: boolean;
/** Optional. Called after each completed exchange so providers whose harness
* keeps no on-disk transcript can persist it themselves. Claude (the SDK
* writes its own .jsonl) omits this. */
onExchangeComplete?(exchange: ProviderExchange): void;
/** Start a new query. Returns a handle for streaming input and output. */
query(input: QueryInput): AgentQuery;
/** True if the error means the stored continuation is invalid (missing
* transcript, unknown session) and should be cleared. */
isSessionInvalid(err: unknown): boolean;
/** Optional pre-resume maintenance: given the stored continuation, return a
* reason string to drop it and start fresh (e.g. transcript too large/old to
* cold-resume before the host idle ceiling), or null to keep resuming. */
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
}
interface ProviderOptions {
assistantName?: string;
mcpServers?: Record<string, McpServerConfig>;
env?: Record<string, string | undefined>;
additionalDirectories?: string[];
model?: string; // alias (sonnet/opus/haiku) or full model ID
effort?: string; // low | medium | high | xhigh | max
}
interface QueryInput {
/** Initial prompt (already formatted by agent-runner).
* String for text-only. ContentBlock[] for multimodal (images, PDFs, audio). */
prompt: string | ContentBlock[];
/** Initial prompt, already formatted by the agent-runner into a string. */
prompt: string;
/** Session ID to resume, if any */
sessionId?: string;
/** Opaque continuation token from a previous query. The provider decides
* what it means (session ID, thread ID, or nothing). */
continuation?: string;
/** Resume from a specific point in the session (provider-specific, may be ignored) */
resumeAt?: string;
/** Working directory inside the container */
/** Working directory inside the container. */
cwd: string;
/** MCP server configurations (normalized format — provider translates) */
mcpServers: Record<string, McpServerConfig>;
/** System prompt / developer instructions */
systemPrompt?: string;
/** Environment variables for the SDK process */
env: Record<string, string | undefined>;
/** Additional directories the agent can access */
additionalDirectories?: string[];
/** System context to inject; the provider translates it into whatever its
* SDK expects (preset append, full system prompt, per-turn injection). */
systemContext?: { instructions?: string };
}
interface McpServerConfig {
@@ -54,40 +79,42 @@ interface McpServerConfig {
}
interface AgentQuery {
/** Push a follow-up message into the active query */
/** Push a follow-up message into the active query. */
push(message: string): void;
/** Signal that no more input will be sent */
/** Signal that no more input will be sent. */
end(): void;
/** Output event stream */
/** Output event stream. */
events: AsyncIterable<ProviderEvent>;
/** Force-stop the query (e.g., container shutting down) */
/** Force-stop the query (e.g., container shutting down). */
abort(): void;
}
type ProviderEvent =
| { type: 'init'; sessionId: string }
| { type: 'result'; text: string | null }
| { type: 'init'; continuation: string }
| { type: 'result'; text: string | null; isError?: boolean }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string };
| { type: 'progress'; message: string }
| { type: 'activity' };
```
### What the interface does NOT include
- **Message formatting** — the agent-runner formats messages before passing to the provider. The provider receives a ready-to-send prompt string.
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreCompact, PreToolUse, etc.). Other providers don't need them.
- **Tool allowlists** — Claude uses `allowedTools`. Codex uses `approvalPolicy`. OpenCode uses `permission`. Each provider configures this internally based on the same intent: "allow everything, no prompting."
- **Session persistence**Claude persists sessions to disk automatically. Codex and OpenCode manage their own session state. The agent-runner doesn't control this — it just passes `sessionId` and `resumeAt`.
- **Hooks** — Claude-specific. The Claude provider registers hooks internally (PreToolUse, PostToolUse, PreCompact). Other providers don't need them.
- **Tool allowlists** — Claude uses `allowedTools` + `disallowedTools`. Other SDKs use their own equivalents. Each provider configures this internally.
- **Session persistence**the agent-runner stores one opaque `continuation` token per provider (see [Session Resume](#session-resume)) and passes it back as `QueryInput.continuation`. What it means is provider-private; Claude persists its own `.jsonl` transcript on disk keyed by the continuation (session ID).
- **Sandbox configuration** — provider-specific. Each provider configures its own sandbox internally.
### Provider event semantics
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `sessionId` for future resume.
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). The agent-runner writes each result to messages_out.
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota', 'auth', 'transport').
- **`init`** — emitted once per query when the provider establishes or resumes a session. The agent-runner captures `continuation` and persists it for future resume.
- **`result`** — emitted when the agent produces a complete response. May be emitted multiple times per query (e.g., Claude's multi-turn with subagents). `isError` is set when the SDK flagged the turn as an error (e.g. a non-retryable billing error) so the poll-loop still surfaces the text instead of dropping it. The agent-runner writes each result to messages_out.
- **`error`** — emitted on failure. `retryable` indicates whether the agent-runner should retry. `classification` is optional detail (e.g., 'quota').
- **`progress`** — optional, for logging. The agent-runner logs these but doesn't act on them.
- **`activity`** — a liveness signal. Providers MUST yield it on every underlying SDK event (tool call, thinking, partial message) so the poll-loop's idle timer stays honest during long tool runs.
## Provider Implementations
@@ -97,58 +124,82 @@ Only the `claude` provider ships in trunk. The Codex and OpenCode sections below
Wraps `@anthropic-ai/claude-agent-sdk`'s `query()`.
The provider takes its settings (`mcpServers`, `env`, `additionalDirectories`,
`model`, `effort`, `assistantName`) in its constructor via `ProviderOptions`; `query()`
only reads the per-turn `QueryInput`.
```typescript
class ClaudeProvider implements AgentProvider {
readonly supportsNativeSlashCommands = true;
// ...constructor stores options.mcpServers, .env, .additionalDirectories,
// .model, .effort, .assistantName...
query(input: QueryInput): AgentQuery {
const stream = new MessageStream(); // AsyncIterable<SDKUserMessage>
stream.push(input.prompt);
const sdkQuery = query({
const sdkResult = sdkQuery({
prompt: stream,
options: {
cwd: input.cwd,
resume: input.sessionId,
resumeSessionAt: input.resumeAt,
systemPrompt: input.systemPrompt
? { type: 'preset', preset: 'claude_code', append: input.systemPrompt }
additionalDirectories: this.additionalDirectories,
resume: input.continuation,
pathToClaudeCodeExecutable: '/pnpm/claude',
systemPrompt: input.systemContext?.instructions
? { type: 'preset', preset: 'claude_code', append: input.systemContext.instructions }
: undefined,
mcpServers: input.mcpServers, // already the right shape
additionalDirectories: input.additionalDirectories,
env: input.env,
allowedTools: NANOCLAW_TOOL_ALLOWLIST,
// Base tools plus one `mcp__<server>__*` pattern per registered MCP
// server — without the explicit MCP patterns the SDK's allowedTools
// filter silently drops every MCP namespace.
allowedTools: [...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern)],
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
effort: this.effort,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user', 'local'],
mcpServers: this.mcpServers,
hooks: {
PreCompact: [{ hooks: [preCompactHook] }],
PreToolUse: [{ matcher: 'Bash', hooks: [sanitizeBashHook] }],
PreToolUse: [{ hooks: [preToolUseHook] }],
PostToolUse: [{ hooks: [postToolUseHook] }],
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
},
},
});
let aborted = false;
return {
push: (msg) => stream.push(msg),
end: () => stream.end(),
abort: () => sdkQuery.close(),
events: translateClaudeEvents(sdkQuery),
// Abort doesn't call into the SDK — it flips a flag the event generator
// checks and ends the input stream so the query drains and stops.
abort: () => { aborted = true; stream.end(); },
events: translateEvents(sdkResult, () => aborted),
};
}
}
```
`translateClaudeEvents` is an async generator that maps SDK messages to `ProviderEvent`:
- `message.type === 'system' && message.subtype === 'init'``{ type: 'init', sessionId }`
- `message.type === 'result'``{ type: 'result', text }`
- `message.type === 'system' && message.subtype === 'api_retry'``{ type: 'error', retryable: true }`
- `message.type === 'system' && message.subtype === 'rate_limit_event'``{ type: 'error', retryable: false, classification: 'quota' }`
- `message.type === 'system' && message.subtype === 'task_notification'``{ type: 'progress', message }`
- Everything else → logged, not emitted
`translateEvents` is an async generator that yields `{ type: 'activity' }` for **every**
SDK message (so the idle timer stays honest) and maps recognized messages to `ProviderEvent`:
- `system`/`init``{ type: 'init', continuation: session_id }`
- `result``{ type: 'result', text, isError }``text` is `result.result`, or the joined `result.errors[]` on error subtypes (billing/quota), so the notice still reaches the user
- `system`/`api_retry``{ type: 'error', retryable: true }`
- `system`/`rate_limit_event``{ type: 'error', retryable: false, classification: 'quota' }`
- `system`/`compact_boundary``{ type: 'result', text: 'Context compacted…' }`
- `system`/`task_notification``{ type: 'progress', message }`
- when the `aborted` flag is set → the generator returns immediately
**Claude-specific features preserved inside the provider:**
- `MessageStream` for async iterable input (push-based)
- `resumeSessionAt` for resume at specific message UUID
- PreCompact hook for transcript archiving
- PreToolUse hook for sanitizing bash env vars
- Full tool allowlist
**Claude-specific behavior inside the provider:**
- `MessageStream` for async iterable input (push-based follow-ups)
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
- **PreCompact** hook archives the transcript to `conversations/` before compaction
- `maybeRotateContinuation` drops an oversized/aged transcript (default caps 12 MB / 14 days, both operator-overridable) so a cold container isn't killed reloading days of `.jsonl` before the host idle ceiling; `isSessionInvalid` clears a continuation whose transcript is gone
- `additionalDirectories` for multi-directory access
### Codex Provider
@@ -159,8 +210,8 @@ Wraps `@openai/codex-sdk`.
class CodexProvider implements AgentProvider {
query(input: QueryInput): AgentQuery {
const codex = new Codex(this.buildOptions(input));
const thread = input.sessionId
? codex.resumeThread(input.sessionId, this.threadOptions(input))
const thread = input.continuation
? codex.resumeThread(input.continuation, this.threadOptions(input))
: codex.startThread(this.threadOptions(input));
const abortController = new AbortController();
@@ -188,13 +239,13 @@ class CodexProvider implements AgentProvider {
signal: abortController.signal,
});
let sessionId: string | undefined;
let continuation: string | undefined;
let resultText = '';
for await (const event of streamed.events) {
if (event.type === 'thread.started') {
sessionId = event.thread_id;
yield { type: 'init', sessionId };
continuation = event.thread_id;
yield { type: 'init', continuation };
}
if (event.type === 'item.completed' && event.item.type === 'agent_message') {
resultText = event.item.text || resultText;
@@ -264,7 +315,7 @@ class OpenCodeProvider implements AgentProvider {
private async *run(client, server, stream, input, getPendingFollowUp): AsyncIterable<ProviderEvent> {
const session = await client.session.create();
yield { type: 'init', sessionId: session.data.id };
yield { type: 'init', continuation: session.data.id };
await client.session.promptAsync({
path: { id: session.data.id },
@@ -356,62 +407,58 @@ The agent-runner transforms messages_in rows into a prompt string. The provider
**Routing field stripping:** `platform_id`, `channel_type`, `thread_id` are never included in the prompt. They're stored as context for writing messages_out.
**Single message formatting by kind:**
Every kind renders to a single self-contained XML element. The `id` attribute is the
message's `seq` (the agent-facing message ID it passes to `edit_message` / `add_reaction`).
The `from` attribute is the origin destination name (resolved from the routing fields via
the destination map), so the agent always knows where a message came from — routing fields
themselves are never shown.
- **`chat`** — format into message XML:
- **`chat`** — one `<message>` per row:
```xml
<message sender="John" time="2024-01-01 10:00">
Check this PR
</message>
<message id="5" from="family" sender="John" time="Jan 1, 10:00 AM">Check this PR</message>
```
A reply carries a `reply_to` attribute and an inline `<quoted_message from="…">…</quoted_message>`.
- **`chat-sdk`** — extract fields from serialized Chat SDK message:
- **`chat-sdk`** — same `<message>` shape, fields extracted from the serialized Chat SDK
message. Attachments are appended inline: `[image: screenshot.png — saved to /workspace/…]`
or `[image: screenshot.png (https://signed-url…)]`. Images/PDFs that Claude handles
natively are also passed as content blocks (see Media Handling below).
- **`task`** — a `<task>` element, script output first when present:
```xml
<message sender="John (john@slack)" time="2024-01-01 10:00">
Check this PR
[image: screenshot.png — https://signed-url...]
</message>
```
Attachments are listed inline. Images/PDFs that Claude handles natively are passed as content blocks (see Media Handling below).
- **`task`** — task prompt, optionally with script output:
```
[SCHEDULED TASK]
Script output:
{"data": ...}
<task from="scheduler" time="Jan 1, 9:00 AM">Script output:
{"data": …}
Instructions:
Review open PRs
Review open PRs</task>
```
- **`webhook`** — webhook payload:
```
[WEBHOOK: github/pull_request]
{"action": "opened", "pull_request": {...}}
- **`webhook`** — a `<webhook>` element wrapping the JSON payload:
```xml
<webhook from="github" source="github" event="pull_request">{"action": "opened", …}</webhook>
```
- **`system`** — host action result (response to an earlier system request):
```
[SYSTEM RESPONSE]
Action: register_agent_group
Status: success
Result: {"agent_group_id": "ag-456"}
- **`system`** — host action result, rendered as `<system_response>`:
```xml
<system_response from="host" action="create_agent" status="success">{"agent_group_id": "ag-456"}</system_response>
```
**Batch formatting:** Multiple pending messages are combined into one prompt:
**Batch formatting:** All pending messages are combined into one prompt. The prompt opens
with a self-closing `<context timezone="<IANA>" />` header (so the agent interprets every
timestamp — and every time it schedules — in the user's zone), then the chat messages
concatenated as consecutive `<message>` blocks, then any task/webhook/system elements,
joined by blank lines:
```xml
<context timezone="America/Los_Angeles">
<messages>
<message sender="John" time="10:00">Check this PR</message>
<message sender="Jane" time="10:01">Already on it</message>
</messages>
<context timezone="America/Los_Angeles" />
<message id="2" from="family" sender="John" time="10:00">Check this PR</message>
<message id="4" from="family" sender="Jane" time="10:01">Already on it</message>
```
Mixed kinds (e.g., a chat message + a system response) are combined with clear delimiters. Each section is labeled by kind.
There is **no** outer `<messages>` envelope — an earlier revision wrapped multi-message
batches that way, but the Claude Agent SDK answered the wrapped shape with a synthetic
"No response requested." stub instead of calling the API (#2555). Dropping the wrapper made
the single-message path just the N=1 case of the same concatenation.
**Command detection:** Messages starting with `/` are checked against a command list. Recognized commands bypass formatting and are passed raw to the provider (for Claude's slash command handling) or intercepted by the agent-runner (for NanoClaw-level commands like session reset).
@@ -430,54 +477,70 @@ interface RoutingContext {
When writing messages_out (either from provider results or MCP tool calls), the agent-runner copies this routing context by default. The agent never sees routing fields — it just produces text. The routing is implicit: "respond to whoever sent the message."
MCP tools that target a different destination (e.g., `send_to_agent`, `send_message` with explicit channel) override the routing context for that specific messages_out row.
MCP tools that target a named destination (`send_message` / `send_file` with a `to`
argument) resolve routing through the session's destination map instead of the default
reply context — including agent-to-agent sends, which are just a `to` pointing at an
`agent`-type destination.
### Status Management
The agent-runner manages the `status` and `status_changed` fields on messages_in:
`inbound.db` is a read-only mount inside the container, so the agent-runner never writes
`messages_in`. It tracks processing status in the `processing_ack` table in the
container-owned `outbound.db`; the host reads `processing_ack` and mirrors completion
back onto `messages_in.status`.
```
pending → processing → completed
→ failed (if provider returns error and max retries exhausted)
processing_ack: (no row) → processing → completed
```
- **Pick up:** `UPDATE messages_in SET status = 'processing', status_changed = now(), tries = tries + 1 WHERE id IN (...)`
- **Complete:** `UPDATE messages_in SET status = 'completed', status_changed = now() WHERE id IN (...)`
- **Error:** Agent-runner does NOT set `failed` — it leaves the message as `processing`. The host detects stale processing via `status_changed` and handles retry logic (reset to pending with backoff). This keeps retry policy on the host side.
- **Pick up:** `INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', now())` for each claimed row (`markProcessing`). Pending queries skip any row already present in `processing_ack`.
- **Complete:** same upsert with `status = 'completed'` (`markCompleted`). Every consumed batch ends here — error outcomes included. On a provider error the poll-loop writes an error **chat message** to `messages_out` (so the user sees it), then still acks the batch completed; errors surface as messages, not as an ack status. (A `markFailed` helper exists in `messages-in.ts` but currently has no callers.)
- The host's `syncProcessingAcks` mirrors acked ids onto `messages_in.status = 'completed'`. Its stale/retry policy is driven off the `.heartbeat` file mtime and the `processing_ack` claim timestamps. On startup the agent-runner clears leftover `processing` acks (crash recovery) so orphaned claims re-process.
### MCP Tools
The agent-runner runs an MCP server that exposes NanoClaw tools to the agent. All tools write to the session DB.
**DB path:** The MCP server receives the session DB path via environment variable. It opens a second connection to the same SQLite file (WAL mode allows concurrent access).
The agent-runner runs an MCP server (stdio) that exposes NanoClaw tools to the agent. The
tool modules use the same two-DB connection layer as the rest of the runner
(`container/agent-runner/src/db/connection.ts`): they read the host-written `inbound.db`
at `/workspace/inbound.db` **read-only** (destinations, session routing, question
responses, task lists) and write to the container-owned `outbound.db` at
`/workspace/outbound.db`. There is no shared single-file connection and no WAL — both files
are `journal_mode=DELETE` because WAL's memory-mapped `-shm` file does not stay coherent
across the VirtioFS host↔container mount.
#### send_message
Send a chat message to the current conversation (or a specified destination).
Send a chat message to a named destination. Agents address destinations by name, never by
raw platform/channel/thread IDs — the destination map (`destinations` table in `inbound.db`,
written by the host) resolves the name to routing fields.
```typescript
{
name: 'send_message',
params: {
text: string, // message content
channel?: string, // optional: target channel type (default: reply to origin)
platformId?: string, // optional: target platform ID
threadId?: string, // optional: target thread ID
text: string, // message content (required)
to?: string, // destination name (e.g. "family", "worker-1").
// Optional when the agent has exactly one destination.
}
}
```
Implementation: write a `messages_out` row with `kind: 'chat'`. If channel/platformId/threadId are provided, use those as routing. Otherwise, copy from the current routing context.
Implementation: `resolveRouting(to)` looks up the destination. With no `to`, it defaults to
the session's own reply routing (`session_routing`); if the destination resolves to the same
channel the session is bound to, the session's `thread_id` is preserved so the reply lands
in-thread, otherwise `thread_id` is null. The tool then writes a `messages_out` row with
`kind: 'chat'` and content `{ text }`, and returns the new `seq` as the message id.
#### send_file
Send a file to the current conversation.
Send a file to a named destination (same destination model as `send_message`).
```typescript
{
name: 'send_file',
params: {
path: string, // file path (relative to /workspace/agent/ or absolute)
path: string, // file path (relative to /workspace/agent/ or absolute) (required)
to?: string, // destination name; optional if the agent has one destination
text?: string, // optional accompanying message
filename?: string, // display name (default: basename of path)
}
@@ -485,10 +548,10 @@ Send a file to the current conversation.
```
Implementation:
1. Generate a message ID
2. Create `outbox/{messageId}/` directory
3. Copy the file into the outbox directory
4. Write a `messages_out` row with `files: [filename]` in the content
1. Resolve routing via `resolveRouting(to)` (as `send_message`)
2. Generate a message ID and create `/workspace/outbox/{messageId}/`
3. Copy the file into that outbox directory
4. Write a `messages_out` row (`kind: 'chat'`) with content `{ text, files: [filename] }`
#### send_card
@@ -523,11 +586,11 @@ Send an interactive question and wait for the user's response. This is a **block
```
Implementation:
1. Generate a `questionId`
2. Write a `messages_out` row with `operation: 'ask_question'`, the question, options, and questionId
3. Poll `messages_in` for a row with matching `questionId` in content
4. When found, return the `selectedOption` as the tool result
5. If timeout expires, return a timeout error as the tool result
1. Generate a `questionId` and normalize each option to `{ label, selectedLabel, value }`
2. Write a `messages_out` row with `kind: 'chat-sdk'` and content `{ type: 'ask_question', questionId, title, question, options }`
3. Poll `inbound.db` (read-only) for a pending `messages_in` row whose content carries the matching `questionId` (`findQuestionResponse`), skipping any already in `processing_ack`
4. When found, `markCompleted` the response row (a `processing_ack` write in `outbound.db`) and return its `selectedOption` as the tool result
5. If the deadline passes, return a timeout error as the tool result
The agent's execution is paused at this tool call. The provider's query keeps running (Claude holds the tool call open). The agent-runner polls for the response in a separate loop.
@@ -563,89 +626,63 @@ Add an emoji reaction to a message.
Implementation: write a `messages_out` row with `operation: 'reaction'`.
#### send_to_agent
#### Agent-to-agent sends (no dedicated tool)
Send a message to another agent group.
There is no `send_to_agent` tool. Agents and channels share one destination namespace, so
messaging another agent is just `send_message(to="<agent-name>")` where the named
destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row with
`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`.
#### ncl tasks
Schedule, inspect, and modify one-shot or recurring tasks.
```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 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
Create a long-lived companion sub-agent. The `name` becomes a destination the creating
agent can address. (There is no `register_agent_group` tool — this replaced it.)
```typescript
{
name: 'send_to_agent',
name: 'create_agent',
params: {
agentGroupId: string, // target agent group
text: string, // message content
sessionId?: string, // optional: target specific session
name: string, // human-readable name; also the destination name (required)
instructions?: string, // CLAUDE.md content for the new agent (role, personality)
}
}
```
Implementation: write a `messages_out` row with `channel_type: 'agent'`, `platform_id: agentGroupId`, `thread_id: sessionId`.
Implementation: fire-and-forget. Writes a `messages_out` row with `kind: 'system'`,
`action: 'create_agent'`, `requestId`, `name`, and `instructions`. The container is
untrusted and does not gate itself; the host authorizes by CLI scope — trusted owner groups
(scope `global`) create directly, confined groups require admin approval
(`src/modules/agent-to-agent/create-agent.ts`) — then creates the entity rows and notifies
the agent via a chat message when the agent is ready.
#### schedule_task
#### Self-modification: install_packages, add_mcp_server
Schedule a one-shot or recurring task.
Two fire-and-forget system-action tools let an agent extend its own runtime (both require
admin approval, applied host-side):
```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)
}
}
```
- **`install_packages`** — `{ apt?: string[], npm?: string[], reason?: string }`. Package
names are validated at the tool boundary and re-validated on the host. On approval the
host rebuilds the per-agent image and restarts the container.
- **`add_mcp_server`** — `{ name, command, args?, env? }`. Wires an existing third-party MCP
server into the agent's `container.json`; on approval the host updates the config and
restarts (no rebuild — Bun runs the TS directly).
Implementation: write a `messages_in` row (to self) with `kind: 'task'`, `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: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
#### 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: cancel/pause/resume update the live row(s) directly. update_task is sent as a system action — the host 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.
#### register_agent_group
Register a new agent group (admin only).
```typescript
{
name: 'register_agent_group',
params: {
name: string,
folder: string,
platformId: string, // messaging group to wire to
channelType: string,
triggerRules?: object,
sessionMode?: 'shared' | 'per-thread',
}
}
```
Implementation: write a `messages_out` row with `kind: 'system'`, `action: 'register_agent_group'`. The host reads, validates admin permission, creates the entity rows in the central DB, and writes a `system` messages_in response.
Both write a `messages_out` row with `kind: 'system'` and the matching `action`, then return
immediately; the host notifies the agent when approval resolves.
### Media Handling
@@ -701,20 +738,28 @@ Archive location: `/workspace/agent/conversations/{date}-{summary}.md`
### Session Resume
The agent-runner tracks `sessionId` and `resumeAt` across queries:
The agent-runner tracks a single opaque `continuation` token per provider:
- `sessionId` — captured from `ProviderEvent { type: 'init' }`. Passed back to `QueryInput.sessionId` on the next query.
- `resumeAt` — Claude-specific (last assistant message UUID). Stored by the agent-runner, passed to `QueryInput.resumeAt`. Providers that don't support this ignore it.
- Captured from `ProviderEvent { type: 'init', continuation }` and persisted to the
`session_state` table in `outbound.db` under the key `continuation:<provider>` (keyed per
provider because a continuation is provider-private — a Claude session id is meaningless to
another provider).
- Passed back as `QueryInput.continuation` on the next query. For Claude that becomes the
SDK `resume` option; the SDK reloads its on-disk `.jsonl` transcript for that session id.
These are ephemeral to the container's lifetime. When the container is killed and restarted, the host passes the stored `sessionId` from the central DB's sessions table. `resumeAt` is lost on container restart (the provider resumes from the end of the session).
Because it lives in the session folder's `outbound.db`, the continuation survives container
teardown and restart — a fresh container reads it back and resumes. `/clear` deletes the row
to start a clean session. Before resuming, `maybeRotateContinuation` may archive and drop an
oversized/aged transcript (so a cold container isn't killed reloading it), and
`isSessionInvalid` clears a continuation whose backing transcript has gone missing.
### Container Startup
The agent-runner receives configuration via:
- **Environment variables:** `AGENT_PROVIDER` (claude/codex/opencode), `NANOCLAW_ADMIN_USER_ID`, provider-specific vars (API keys, model overrides), `TZ`
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
- **Optional startup config:** Some config may be passed as a JSON file at a fixed path (e.g., `/workspace/config.json`) for things like the session ID to resume, assistant name, and admin user ID. This avoids overloading environment variables.
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
- **Fixed mount paths:** Host-written `inbound.db` (read-only) at `/workspace/inbound.db` and container-owned `outbound.db` at `/workspace/outbound.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
@@ -731,7 +776,7 @@ function createProvider(name: ProviderName, config: ProviderConfig): AgentProvid
}
```
The provider name comes from the container's environment (`AGENT_PROVIDER` env var), set by the host based on `agent_groups.agent_provider` or `sessions.agent_provider`.
The provider name comes from the `provider` key in `/workspace/agent/container.json` (defaulting to `'claude'`), which the host materializes from the `container_configs` table — set it with `ncl groups config update --provider`. It is not an environment variable.
`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`.
+50 -2
View File
@@ -13,6 +13,8 @@ interface ChannelSetup {
// Host callbacks
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
// Admin-transport adapters (e.g. CLI) route to an arbitrary channel via this instead of onInbound
onInboundEvent(event: InboundEvent): void;
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
}
@@ -34,7 +36,7 @@ interface ChannelAdapter {
isConnected(): boolean;
// Outbound delivery
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<string | undefined>;
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;
@@ -57,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.
@@ -307,7 +355,7 @@ function createWhatsAppChannel(): ChannelAdapter {
**Ask user question:**
```json
{
"operation": "ask_question",
"type": "ask_question",
"questionId": "q-123",
"title": "Failing Test",
"question": "How should we handle the failing test?",
+4 -4
View File
@@ -311,7 +311,6 @@ erDiagram
string name
string folder
string agent_provider
json container_config
}
messaging_groups {
int id
@@ -344,14 +343,15 @@ erDiagram
int messaging_group_id
int agent_group_id
string session_mode
json trigger_rules
string engage_mode "pattern | mention | mention-sticky"
string sender_scope "all | known"
int priority
}
sessions {
int id
int agent_group_id
int messaging_group_id
string sdk_session_id
string thread_id
string status
}
</pre>
@@ -391,7 +391,7 @@ flowchart LR
Container -->|writes · odd seq| Out
Container -->|touch every poll| HB
HostSweep[Host sweep] -->|stat mtime| HB
HostSweep -->|reads processing_ack| In
HostSweep -->|reads processing_ack| Out
</pre>
</div>
</section>
+10 -10
View File
@@ -28,18 +28,18 @@ flowchart TB
Approvals["configureManualApproval<br/>-> pending_approvals"]
end
subgraph Session["Per-Session Container (Docker / Apple Container)"]
subgraph Session["Per-Session Container (Docker)"]
direction TB
PollLoop["Poll Loop<br/>(container/agent-runner)"]
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
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"]
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/>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/>destinations<br/>processing_ack")]
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>heartbeat file")]
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")]
end
subgraph Groups["Agent Group Filesystem (groups/*)"]
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container_config"]
Folder["CLAUDE.md<br/>memory<br/>per-group skills<br/>container.json (materialized from container_configs)"]
end
P1 & P2 & P3 & P4 & P5 --> Bridge
@@ -140,7 +140,6 @@ erDiagram
string name
string folder
string agent_provider
json container_config
}
messaging_groups {
int id
@@ -173,14 +172,15 @@ erDiagram
int messaging_group_id
int agent_group_id
string session_mode "agent-shared | shared | per-thread"
json trigger_rules
string engage_mode "pattern | mention | mention-sticky"
string sender_scope "all | known"
int priority
}
sessions {
int id
int agent_group_id
int messaging_group_id
string sdk_session_id
string thread_id
string status
}
```
@@ -209,7 +209,7 @@ flowchart LR
Container -->|"writes only<br/>(odd seq)"| Out
Container -->|touch every poll| HB
HostSweep[Host sweep] -->|stat mtime| HB
HostSweep -->|reads processing_ack| In
HostSweep -->|reads processing_ack| Out
note1["Each file has exactly ONE writer.<br/>Eliminates SQLite cross-process write contention.<br/>Collision-free seq numbering."]
```
+168 -103
View File
@@ -1,8 +1,20 @@
# NanoClaw Architecture (Draft)
> **Draft — design intent, not a line-by-line spec.** Some passages predate the current implementation and can drift from it. The root [CLAUDE.md](../CLAUDE.md) and the cited source files (`src/`, `container/agent-runner/src/`) are the source of truth; when this doc and the code disagree, trust the code. Notably, scheduling MCP tools do **not** write `inbound.db` directly — they emit `messages_out` system actions that the host applies (see [agent-runner-details.md](agent-runner-details.md) and `src/modules/scheduling/`).
## Core Idea
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
Each agent session has a **pair** of mounted SQLite DBs. They are the one and only IO
mechanism between host and container. No IPC files, no stdin piping. `inbound.db` carries
host → agent-runner messages (`messages_in`); `outbound.db` carries agent-runner → host
messages (`messages_out`) plus the container's processing acks. Everything is a message.
The split exists so each file has exactly one writer: the host writes `inbound.db` (the
container opens it read-only) and the container writes `outbound.db` (the host opens it
read-only). One writer per file means no cross-process lock contention over the
host↔container mount. Both files run `journal_mode=DELETE`, **not** WAL: WAL's memory-mapped
`-shm` coherency does not propagate across VirtioFS, so a WAL reader in the guest would
freeze on an early snapshot and never see new host writes.
## Two-Level DB
@@ -11,15 +23,18 @@ Each agent session has a mounted SQLite DB. The DB is the one and only IO mechan
- Maps platform IDs → agent groups → sessions
- Channel adapters don't touch this directly — the host does the lookup
**Per-session DB (mounted into container):**
- messages_in (written by host, read by agent-runner)
- messages_out (written by agent-runner, read by host)
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use these two tables
- One DB per session, not per agent group
**Per-session DBs (mounted into container):**
- `inbound.db``messages_in` (written by host, read-only in container) plus host-written
lookup tables the container reads live: `destinations`, `session_routing`, `delivered`
- `outbound.db``messages_out` (written by agent-runner, read by host) plus
`processing_ack`, `session_state`, and `container_state` (all container-owned)
- Everything is a message: chat, tasks, webhooks, system actions, agent-to-agent — all use
`messages_in` / `messages_out`
- One pair per session, not per agent group
## Agent Groups vs Sessions
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own DB mounted at a known path. Each session = a separate container with the same agent group's filesystem but a different session DB.
An agent group has its own filesystem — folder, CLAUDE.md, skills, container config. Multiple sessions can share the same agent group (same filesystem, same skills) but each session gets its own `inbound.db`/`outbound.db` pair mounted at known paths. Each session = a separate container with the same agent group's filesystem but a different DB pair.
## Message Flow
@@ -28,13 +43,13 @@ Platform event
→ Channel adapter (trigger check, ID extraction)
→ Returns: { platformChannelId, platformThreadId, triggered }
→ Host maps platformChannelId + platformThreadId → agent group + session
→ Host writes message to session's DB
→ Host writes messages_in row to the session's inbound.db
→ Host calls wakeUpAgent(session)
→ Container spins up (or is already running)
→ Agent-runner polls its session DB, finds new messages
→ Agent-runner processes with Claude
→ Agent-runner writes response to session DB
→ Host polls active session DBs for responses
→ Agent-runner polls inbound.db (read-only), finds new messages
→ Agent-runner processes with the configured provider
→ Agent-runner writes response to messages_out in outbound.db
→ Host polls active sessions' outbound.db for responses
→ Host reads response, looks up conversation, delivers through channel adapter
```
@@ -128,9 +143,8 @@ Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) impleme
The host is an orchestrator:
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
3. **Limits** — MAX_CONCURRENT_CONTAINERS caps active containers
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
When a container spins up, the agent-runner immediately starts polling `inbound.db`. Messages are already there waiting.
## Media Handling
@@ -184,50 +198,66 @@ Dedup is the channel adapter's responsibility. Chat SDK handles this internally.
## Session DB Schema
Two tables. JSON blobs for content — schema-free, format varies by `kind`.
Split across the two files. JSON blobs for content — schema-free, format varies by `kind`.
`seq` is a global ordering counter with a **disjoint parity**: the host writes even seqs to
`messages_in`, the container writes odd seqs to `messages_out`. Each side reads the other's
MAX(seq) to pick its next value, so seq is a single monotonic message id across both tables —
which is why the agent-facing message id it returns from `send_message` (and accepts in
`edit_message` / `add_reaction`) is unambiguous.
```sql
-- Host writes, agent-runner reads
-- inbound.db — host writes, container opens read-only
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- even (host-assigned)
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- 'pending' | 'processing' | 'completed' | 'failed'
status_changed TEXT, -- ISO timestamp of last status change
status TEXT DEFAULT 'pending', -- host-owned; the host mirrors the container's
-- processing_ack terminal states onto this column
process_after TEXT, -- ISO timestamp. NULL = process immediately.
recurrence TEXT, -- cron expression. NULL = one-shot.
series_id TEXT, -- groups a recurring task's occurrences
tries INTEGER DEFAULT 0, -- number of processing attempts
-- routing (agent-runner copies to messages_out; agent never sees these)
platform_id TEXT,
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = accumulated context (don't wake), 1 = wake
platform_id TEXT, -- routing (stripped before the agent sees content)
channel_type TEXT,
thread_id TEXT,
-- payload (structure depends on kind)
content TEXT NOT NULL -- JSON blob
content TEXT NOT NULL, -- JSON blob (structure depends on kind)
source_session_id TEXT, -- a2a return path: source session that emitted the trigger
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on a container's first poll
);
-- Agent-runner writes, host reads
-- outbound.db — container writes, host opens read-only
CREATE TABLE messages_out (
id TEXT PRIMARY KEY,
in_reply_to TEXT, -- references messages_in.id (optional)
seq INTEGER UNIQUE, -- odd (container-assigned)
in_reply_to TEXT, -- references messages_in.id (optional)
timestamp TEXT NOT NULL,
delivered INTEGER DEFAULT 0,
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
recurrence TEXT, -- cron expression. NULL = one-shot.
-- routing (default: copied from messages_in by agent-runner)
kind TEXT NOT NULL, -- 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system'
platform_id TEXT,
deliver_after TEXT, -- ISO timestamp. NULL = deliver immediately.
recurrence TEXT, -- cron expression. NULL = one-shot.
kind TEXT NOT NULL, -- copied from messages_in by default
platform_id TEXT, -- routing (default: copied from messages_in)
channel_type TEXT,
thread_id TEXT,
-- payload (format matches kind)
content TEXT NOT NULL -- JSON blob
content TEXT NOT NULL -- JSON blob (format matches kind)
);
-- outbound.db — container's processing status (it can't write inbound.db).
-- Host reads this to drive the message lifecycle; stale 'processing' rows are
-- cleared on container startup (crash recovery).
CREATE TABLE processing_ack (
message_id TEXT PRIMARY KEY, -- references messages_in.id
status TEXT NOT NULL, -- 'processing' | 'completed' | 'failed'
status_changed TEXT NOT NULL
);
```
Delivery is tracked host-side in a `delivered` table in `inbound.db` (not a column on
`messages_out`, which the host can't write). `inbound.db` also holds the host-written
`destinations` and `session_routing` tables the container reads live; `outbound.db` also
holds `session_state` (the resume continuation, per provider) and `container_state`
(current tool in flight).
### Scheduling
One-shot and recurring tasks use the same tables — no separate scheduler.
@@ -236,15 +266,15 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
**Recurring:** Same, plus a `recurrence` cron expression. After the host marks a row as handled/delivered, if `recurrence` is set, it inserts a new row with `process_after`/`deliver_after` advanced to the next cron occurrence. Next time is computed from the scheduled time (not wall clock) to prevent drift.
**Host sweep** (every ~60s across all session DBs):
- `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
- `messages_in WHERE status = 'processing' AND status_changed < (now - stale_threshold)` → stale detection, increment tries, reset to pending with backoff
- `messages_out WHERE delivered = 0 AND (deliver_after IS NULL OR deliver_after <= now())` → deliver
**Host sweep** (every ~60s across all sessions):
- `inbound.db``messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())` → wake agent
- A `processing_ack` (in `outbound.db`) whose claim, or the `.heartbeat` mtime, is older than the stale threshold → stale detection, increment tries, reschedule `process_after` with backoff
- `outbound.db` → due `messages_out` rows not yet in the host's `delivered` table (in `inbound.db`) → deliver
- After completing/delivering a row with `recurrence`, insert next occurrence
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
**Agent-runner creates schedules** by writing messages_in (to itself) or messages_out (reminders/notifications) with `process_after` and optionally `recurrence`.
**Agent-runner creates schedules** by emitting a `messages_out` row with `kind: 'system'` and an `action` (`schedule_task`, `cancel_task`, …) — it cannot write host-owned `inbound.db` directly. The host applies the action during delivery (`src/modules/scheduling/actions.ts`), inserting/updating the `kind: 'task'` `messages_in` row with `process_after` and optionally `recurrence`.
### messages_in content by kind
@@ -331,7 +361,7 @@ Two patterns, both handled at the host level:
In both cases, the approval and action execution happen on the host side, not the agent side.
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/access.ts`, `src/user-dm.ts`.
**Approval routing:** Privilege is a user-level concept. `user_roles` records `owner` (global only — first user to pair becomes owner) and `admin` (global or scoped to a specific `agent_group_id`). When an action requires approval, `pickApprover(agentGroupId)` returns candidates in order: scoped admins for that agent group → global admins → owners (deduplicated). `pickApprovalDelivery` then takes the first candidate reachable via `ensureUserDm` (with a same-channel-kind tie-break so a Discord approval request prefers a Discord-using approver). The approval card lands in the approver's DM messaging group, not the origin chat. Delivery is resolved through the Chat SDK's `openDM` for resolution-required channels (Discord/Slack/…) or the user's handle directly for direct-addressable channels (Telegram/WhatsApp/…), and the mapping is cached in `user_dms` for subsequent requests. See `src/modules/permissions/access.ts` and `src/modules/permissions/user-dm.ts` (`ensureUserDm`); the approver-picking primitives live in `src/modules/approvals/primitive.ts`.
**Editing a sent message:**
@@ -408,14 +438,16 @@ This is documented as a pattern, not a built-in feature.
## Design Decisions
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `session.db` and the Claude SDK's `.claude/` directory. The session identity IS the folder — no need to track Claude SDK session IDs.
**Session DB location:** Not in the agent group folder. Separate directory (e.g., `sessions/{session_id}/`). Each session gets its own folder containing `inbound.db`, `outbound.db`, and — when using the claude provider — the SDK's `.claude/` directory. The session identity IS the folder. The SDK's resume token (session id) is persisted in `outbound.db`'s `session_state` table, so a fresh container picks the conversation back up without the host tracking it centrally.
**Container mount structure:**
```
/workspace/ ← mount: session folder (read-write)
.claude/ ← Claude SDK session data (auto-created)
session.db ← session SQLite DB
.claude/ ← Claude SDK session data / transcripts (auto-created)
inbound.db ← host writes, container reads (opened read-only)
outbound.db ← container writes, host reads (opened read-only)
.heartbeat ← container touches this; host watches its mtime
outbox/ ← agent-runner writes outbound files here
agent/ ← mount: agent group folder (nested, read-write)
CLAUDE.md ← agent instructions
@@ -423,11 +455,17 @@ This is documented as a pattern, not a built-in feature.
... working files
```
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace). The session DB is at `/workspace/session.db`.
Two directory mounts: session folder at `/workspace`, agent group folder at `/workspace/agent/`. The agent-runner CDs into `/workspace/agent/` to run the agent. Claude SDK writes `.claude/` at `/workspace/.claude/` (root of the workspace).
This works on both Docker (nested bind mounts) and Apple Container (directory mounts only — no file-level mounts, but nested directory mounts are supported).
The runtime is Docker (`src/container-runtime.ts` hardcodes the `docker` binary); nested bind mounts make this layout straightforward. The layout deliberately sticks to directory mounts (no file-level mounts) so it stays portable to runtimes that only support directory mounts.
**Session DB concurrent access:** The host writes messages_in, the agent-runner writes messages_out. Both access the same SQLite file simultaneously. WAL mode handles this — SQLite allows concurrent readers, and the two sides write to different tables so writer contention is minimal. The host enables WAL mode when creating the session DB.
**Cross-mount DB access:** The two files exist precisely so each has a single writer — the
host writes `inbound.db`, the container writes `outbound.db` — which removes writer
contention across the mount. Both files use `journal_mode=DELETE`, **not** WAL: WAL keeps its
index in a memory-mapped `-shm` file, and VirtioFS does not propagate that mmap coherency
from host to guest, so a WAL reader in the container would freeze on an early snapshot and
silently never see new host writes. Readers that must see fresh host writes promptly (the
`messages_in` poll) open `inbound.db` with `mmap_size = 0` to bypass SQLite's page cache.
**Session management:** Host-managed. The host creates session folders and mounts them. The container only sees its own session folder.
@@ -438,7 +476,7 @@ This works on both Docker (nested bind mounts) and Apple Container (directory mo
3. More messages arrive before container starts → host finds the existing session, writes to the same session DB
4. Container starts, mounts the folder, agent-runner finds messages waiting
The central DB session row creation is the serialization point. No Claude SDK session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
The central DB session row creation is the serialization point. No provider session ID to coordinate — the SDK discovers its own session data in `.claude/` when the agent runs.
**System actions:** The agent uses MCP tools (register group, reset session, schedule task, etc.). The agent-runner handles these tool calls and writes a structured, deterministic messages_out row with `kind: 'system'`. This is not natural language — it's a programmatic, structured payload that the host processes deterministically. Host validates permissions, executes, and writes the result back as a `system` messages_in row.
@@ -448,7 +486,9 @@ The central DB session row creation is the serialization point. No Claude SDK se
### Output Delivery
NanoClaw does not stream tokens to users. The Claude Agent SDK's `query()` yields complete results. The agent-runner writes one complete message to messages_out per result. The host delivers complete messages to channels.
NanoClaw does not stream tokens to users. The provider's query interface yields complete results, but a result's text is not delivered as-is: the agent-runner parses it for `<message to="name">...</message>` blocks (`dispatchResultText` in poll-loop.ts) and writes one messages_out row per block, addressed to that destination with its thread context resolved per destination. Everything outside a block — including `<internal>...</internal>` — is scratchpad: logged, never sent. A block naming an unknown destination is dropped into the scratchpad log.
If a result produced text but no valid block, the agent-runner pushes a one-time `<system>` nudge into the live turn asking the agent to re-wrap its response. The exception is a non-retryable error result (e.g. a billing error) with no envelope, which is delivered as an error notice instead of being dropped as scratchpad. Mid-turn interim updates go out through the `send_message` MCP tool; the final-text envelope parsing is how a turn's reply reaches the user. The host delivers complete messages_out rows to channels.
Message editing is supported as an explicit operation (agent calls an `edit_message` tool), not as a streaming mechanism.
@@ -456,21 +496,30 @@ Typing indicators: host sets typing when a container is active for a session, cl
### Message Batching
When multiple messages arrive while the container is down, they accumulate as `handled = 0` rows in messages_in. When the container wakes up, the agent-runner queries all unhandled messages and processes them as a batch — multiple messages are formatted into a single `<messages>` XML block.
When multiple messages arrive while the container is down, they accumulate as `status = 'pending'` rows in `messages_in`. When the container wakes up, the agent-runner reads all pending messages (those not yet in `processing_ack`) and processes them as a batch — formatted as a `<context timezone="…" />` header followed by the messages concatenated as consecutive `<message>` blocks. (There is no `<messages>` wrapper element; see [agent-runner-details.md](agent-runner-details.md#message-formatting).)
### Message Lifecycle
```
pending → processing → completed
→ failed (after max retries)
messages_in.status: pending ──────────► completed (mirrored from ack)
└─────────► failed (host-set, retries exhausted)
processing_ack.status: processing → completed
```
- **pending**: Written by host. Ready to be picked up (if `process_after` is null or past).
- **processing**: Agent-runner sets this when it picks up the message. `status_changed` is set to now. Prevents other polls from re-picking the same message.
- **completed**: Agent-runner sets this after successful processing.
- **failed**: Set after max retries exhausted.
Because `inbound.db` is read-only in the container, the agent-runner never mutates
`messages_in.status`. It records lifecycle in `processing_ack` (in `outbound.db`); the host
reads that and mirrors completion back.
**Stale detection**: If a message is `processing` but `status_changed` is too old (e.g., >10 minutes), the host assumes the container crashed. It resets the message to `pending`, increments `tries`, and sets `process_after` with exponential backoff.
- **pending**: Host writes the `messages_in` row. Ready to be picked up (if `process_after` is null or past).
- **processing**: Agent-runner upserts a `processing_ack` row (`status = 'processing'`) when it claims the message. Subsequent polls skip any id already in `processing_ack`, so it isn't re-picked.
- **completed**: Agent-runner sets `processing_ack.status = 'completed'` for **every** consumed batch, error outcomes included — a provider error is surfaced to the user as an error chat message in `messages_out`, then the batch is still acked completed. The host's `syncProcessingAcks` copies it onto `messages_in.status`.
- **failed**: Set by the **host** (sweep's `markMessageFailed`) when retries are exhausted — never by the container.
**Liveness / stale detection**: The container touches a `/workspace/.heartbeat` file rather
than writing the DB. The host sweep watches that mtime (widening its tolerance when
`container_state` shows a long-declared Bash running) to decide a container has crashed, then
increments `tries` and reschedules `process_after` with exponential backoff. On the next
container startup, leftover `processing` acks are cleared so orphaned claims re-process.
### Error Handling and Retries
@@ -555,7 +604,7 @@ const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
```
Shared config (DATA_DIR, TIMEZONE, MAX_CONCURRENT_CONTAINERS) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
Shared config (DATA_DIR, TIMEZONE) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
### Code Style
@@ -594,13 +643,15 @@ src/db/
- **No inline ALTER TABLE.** A migration runner with a `schema_version` table replaces `try { ALTER TABLE } catch { /* exists */ }` blocks. On startup, it checks the current version and applies pending migrations in order. Each migration is a function: `(db: Database) => void`.
- **Skills add migrations.** A skill that needs a new column adds a new numbered migration file. No conflicts with other skills' migrations as long as numbers don't collide (use timestamps or high-enough numbers for skill branches).
**Agent-runner session DB** uses the same pattern but lighter — no migrations needed since session DBs are created fresh by the host:
**Agent-runner session DBs** use the same pattern but lighter — no migrations needed since the DB files are created fresh by the host:
```
container/agent-runner/src/db/
connection.ts ← open session.db at fixed path, WAL mode
messages-in.ts ← read pending, update status
messages-out.ts ← write results, outbox queries
connection.ts ← open inbound.db (read-only) + outbound.db (DELETE mode) at fixed paths
messages-in.ts ← read pending from inbound.db, ack via processing_ack in outbound.db
messages-out.ts ← write results/outbox rows to outbound.db (odd seq)
session-state.ts ← resume continuation, keyed per provider
session-routing.ts ← read the host-written default reply routing
index.ts ← barrel
```
@@ -663,9 +714,13 @@ CREATE TABLE agent_groups (
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
agent_provider TEXT, -- default for sessions (null = system default)
container_config TEXT, -- JSON: { additionalMounts, timeout }
created_at TEXT NOT NULL
);
-- Container config is NOT a column here — it lives in a separate container_configs
-- table (migration 014), keyed by agent_group_id, with columns: provider, model,
-- effort, image_tag, assistant_name, max_messages_per_prompt, cli_scope, and JSON
-- columns skills / mcp_servers / packages_apt / packages_npm / additional_mounts.
-- The host materializes it into /workspace/agent/container.json for the container.
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
@@ -720,16 +775,21 @@ CREATE TABLE user_dms (
PRIMARY KEY (user_id, channel_type)
);
-- Which agent groups handle which messaging groups, with what rules
-- Which agent groups handle which messaging groups, with what rules.
-- The opaque trigger_rules JSON + response_scope enum were replaced (migration
-- 010) by four orthogonal axes:
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
trigger_rules TEXT, -- JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
response_scope TEXT DEFAULT 'all', -- 'all' | 'triggered' | 'allowlisted'
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
created_at TEXT NOT NULL,
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
engage_mode TEXT NOT NULL DEFAULT 'mention', -- 'pattern' | 'mention' | 'mention-sticky'
engage_pattern TEXT, -- regex; required for engage_mode='pattern'
-- ('.' = match every message, the "always" flavor)
sender_scope TEXT NOT NULL DEFAULT 'all', -- 'all' | 'known'
ignored_message_policy TEXT NOT NULL DEFAULT 'drop', -- 'drop' | 'accumulate'
session_mode TEXT DEFAULT 'shared', -- 'shared' | 'per-thread'
priority INTEGER DEFAULT 0, -- higher = checked first when multiple agents match
created_at TEXT NOT NULL,
UNIQUE(messaging_group_id, agent_group_id)
);
@@ -794,7 +854,7 @@ stopped → running → idle → stopped
## Agent-Runner Architecture
The agent-runner is the process inside the container. It mediates between the session DB and the Claude SDK — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
The agent-runner is the process inside the container. It mediates between the session DB and the agent provider — polling for work, formatting messages for the agent, translating tool calls into DB rows, and managing the agent lifecycle.
### IO Model
@@ -807,50 +867,55 @@ All IO goes through the session DB. No stdin, no stdout markers, no IPC files.
### Poll Loop
1. Query `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`
2. If rows found: set `status = 'processing'`, `status_changed = now()` on each
1. Query `inbound.db` (read-only) for `messages_in WHERE status = 'pending' AND (process_after IS NULL OR process_after <= now())`, skipping any id already in `processing_ack`
2. If rows found: upsert `processing_ack` rows with `status = 'processing'` in `outbound.db` (the container can't write `messages_in`)
3. Batch messages into a single prompt (strip routing fields, format by kind)
4. Push into Claude SDK's MessageStream
4. Push into the provider's input stream
5. Process agent output → write `messages_out` rows
6. Set processed messages to `status = 'completed'`
6. Set the processed ids' `processing_ack.status = 'completed'` (the host mirrors that onto `messages_in.status`)
7. Back to step 1. If no messages found, sleep briefly and re-poll (container stays warm for idle timeout)
### Message Formatting by Kind
Agent-runner strips routing fields (`platform_id`, `channel_type`, `thread_id`) before formatting. The agent never sees routing info — it only sees content.
- **`chat`** — format into `<messages>` XML block
- **`chat-sdk`** — extract text, author, attachments from serialized message; format into `<messages>` XML
- **`task`** — format as `[SCHEDULED TASK]` prefix + prompt. Run pre-script if present.
- **`webhook`** — format as `[WEBHOOK: source/event]` + JSON payload
- **`system`** — host action results (e.g., "register_group succeeded"). Format as system context, not chat.
- **`chat`** — format into a `<message id="…" from="…" sender="…" time="…">` element
- **`chat-sdk`** — extract text, author, attachments from serialized message; same `<message>` element
- **`task`** — format as a `<task from="…" time="…">` element (script output first if present). Run pre-script if present.
- **`webhook`** — format as a `<webhook source="…" event="…">` element wrapping the JSON payload
- **`system`** — host action results, formatted as `<system_response action="…" status="…">`, not chat
Mixed batches (e.g., a chat message + a system result both pending) are combined into one prompt with clear delimiters.
### MCP Tools
MCP tools write directly to the session DB.
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, create an agent, self-modify) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
**Core tools:**
**Messaging & interaction:**
| Tool | What it does |
|------|-------------|
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
| `schedule_task` | Write `messages_in` row (to self) with `process_after` + `recurrence`. Or `messages_out` with `deliver_after` for outbound reminders. |
| `list_tasks` | Query `messages_in WHERE recurrence IS NOT NULL` |
| `pause_task` / `resume_task` / `cancel_task` | Modify `messages_in` rows (update status, clear/set recurrence) |
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
**New tools:**
| Tool | What it does |
|------|-------------|
| `ask_user_question` | Write `messages_out` with question card. Hold tool call open, poll `messages_in` for response matching `questionId`. Return selection as tool result. |
| `edit_message` | Write `messages_out` with `operation: 'edit'` |
| `send_message` | Resolve `to` (destination name) → routing, write `messages_out` row, `kind: 'chat'`. Omit `to` to reply in place. Also the agent-to-agent path: a `to` naming an `agent`-type destination. |
| `send_file` | Copy file to `outbox/{msg_id}/`, write `messages_out` (`kind: 'chat'`) with filenames, same `to` resolution |
| `send_card` | Write `messages_out`, `kind: 'chat-sdk'`, content `{ type: 'card', … }` |
| `ask_user_question` | Write `messages_out` (`kind: 'chat-sdk'`, `type: 'ask_question'`). Hold tool call open, poll `inbound.db` for the response matching `questionId`. Return selection as tool result. |
| `edit_message` | Write `messages_out` with `operation: 'edit'` (targets the original message's destination) |
| `add_reaction` | Write `messages_out` with `operation: 'reaction'` |
| `send_to_agent` | Write `messages_out` with `channel_type: 'agent'`, `platform_id: '{target}'` |
| `send_card` | Write `messages_out` with card structure |
(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)
**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):
| Tool | What it does |
|------|-------------|
| `create_agent` | `action: 'create_agent'` (name + instructions); host creates the agent group (replaces the old `register_agent_group`) |
| `install_packages` | `action: 'install_packages'`; on approval host rebuilds the per-agent image and restarts |
| `add_mcp_server` | `action: 'add_mcp_server'`; on approval host updates `container.json` and restarts |
See [agent-runner-details.md](agent-runner-details.md) for full MCP tool parameter definitions.
@@ -886,11 +951,11 @@ The command lists are hardcoded in the agent-runner. Admin verification happens
The agent-runner processes recurring task messages like any other messages_in row. After the agent-runner marks a recurring message as `completed`, the **host** handles inserting the next occurrence (new messages_in row with `process_after` advanced to next cron time). The agent-runner doesn't manage recurrence — it just processes what it finds.
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking Claude.
Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent = false`, mark completed without invoking the provider.
### Agent-to-Agent Messaging
**Outbound:** Agent calls `send_to_agent` tool → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to target session's messages_in.
**Outbound:** Agent calls `send_message(to="<agent-name>")` where the named destination is of type `agent` → agent-runner writes messages_out with `channel_type: 'agent'`, `platform_id` = target agent group ID. Host validates permissions and writes to the target session's `inbound.db` (recording `source_session_id` so the reply routes back to this exact session).
**Inbound:** Messages from other agents arrive as normal `chat` messages_in rows. The content includes `sender` and `senderId` (e.g., `"senderId": "agent:pr-admin"`). No special formatting — the agent sees it as a chat message.
@@ -906,7 +971,7 @@ Pre-scripts: if a task message has a `script` field, run it first. If `wakeAgent
- **Approval routing** — how does the host find the admin's DM conversation? What if no DM channel exists? Is the approval list configurable per agent group or global?
- **MCP server lifecycle** — does the MCP server process persist across multiple queries in the same container, or restart each time?
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The session DB is at a fixed mount path. System prompt comes from CLAUDE.md. Provider name comes from env. What else?
- **Container startup config** — what config (if any) is passed to the container at launch beyond env vars? The DB files are at fixed mount paths. System prompt comes from CLAUDE.md. Provider name comes from `container.json` (materialized from the `container_configs` table), not env. What else?
- **Idle detection with pending questions** — when `ask_user_question` is waiting for a response, the container should not be considered idle. Also need to detect when the agent is still working (active tool calls, subagents) and avoid killing the container even if no messages_out have been written recently.
## Related Documents
+3 -3
View File
@@ -33,13 +33,13 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
## Supply chain
- **Host + global CLIs** (pnpm): `minimumReleaseAge: 4320` (3-day hold on new versions), `onlyBuiltDependencies` allowlist for postinstall scripts. See `pnpm-workspace.yaml` and `docs/SECURITY.md`.
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus version-pinned CLIs/Bun itself via Dockerfile ARGs. When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
- **Agent-runner** (Bun): no release-age policy — Bun doesn't have an equivalent today. The defenses are `bun.lock` pinning plus a version-pinned Bun itself via a Dockerfile ARG (global CLIs are pinned separately in `container/cli-tools.json`). When bumping `@anthropic-ai/claude-agent-sdk` or any runtime dep, review the release date on npm and bump deliberately, not via `bun update`.
## Image build surface
`container/Dockerfile` is a single-stage build on `node:22-slim`:
- **Pinned ARGs**`BUN_VERSION`, `CLAUDE_CODE_VERSION`, `AGENT_BROWSER_VERSION`, `VERCEL_VERSION`. Bump deliberately in PRs.
- **Pinned ARGs**`BUN_VERSION`, `PNPM_VERSION`, `INSTALL_CJK_FONTS`. Bump deliberately in PRs. Global CLI versions (`@anthropic-ai/claude-code`, `agent-browser`, `vercel`) are pinned separately in `container/cli-tools.json`, not as ARGs.
- **CJK fonts**`ARG INSTALL_CJK_FONTS=false`. `container/build.sh` reads `INSTALL_CJK_FONTS` from `.env` and passes it through. Default build saves ~200MB; opt in when the user works with Chinese/Japanese/Korean content.
- **BuildKit cache mounts**`/var/cache/apt`, `/var/lib/apt`, `/root/.bun/install/cache`, `/root/.cache/pnpm`. Rebuilds where `package.json`/`bun.lock` haven't changed are fast. Requires BuildKit (default on Docker 23+, Apple Container-compat).
- **`tini` as init** — reaps Chromium zombies, forwards signals so in-flight `outbound.db` writes finalize on SIGTERM.
@@ -49,7 +49,7 @@ Both are committed. CI and the Dockerfile run `--frozen-lockfile` variants — a
## Session wake (two paths)
1. **Base image ENTRYPOINT** — used for stdin-piped test invocations like the sample in `container/build.sh`: `tini --> entrypoint.sh` captures stdin to `/tmp/input.json`, then `exec bun run src/index.ts`.
2. **Host-spawned session**`src/container-runner.ts` at line ~301 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
2. **Host-spawned session**`src/container-runner.ts` at line ~503 uses `--entrypoint bash` with `-c 'exec bun run /app/src/index.ts'`. Bypasses tini (Docker's default PID 1 handling applies). Stdin is unused; all IO flows through the mounted session DBs.
Both paths end with Bun running the same source file from `/app/src/index.ts`.

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