Compare commits

..

247 Commits

Author SHA1 Message Date
Koshkoshinsk ef5d09680a fix: render WhatsApp warning title cleanly 2026-07-12 13:22:28 +03:00
Koshkoshinsk 33b33b84b9 fix: warn before shared WhatsApp setup 2026-07-12 13:05:36 +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
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
github-actions[bot] aecad864e6 chore: bump version to 2.1.24 2026-07-02 11:57:00 +00:00
gavrielc c87f2e55dc Merge pull request #2890 from amit-shafnir/worktree-nanoclaw-templates
feat(templates): local template loader, ncl --template, and docs
2026-07-02 14:56:45 +03:00
Amit Shafnir 411f5e71df feat(templates): local template loader, ncl --template, provider-agnostic persona and skills seams
Agent templates: folder-only templates under templates/ (context/instructions.md +
optional context extras, .mcp.json, skills/). Stamping via ncl groups create
--template writes the provider-neutral instructions.prepend.md (inlined at the top
of CLAUDE.md/AGENTS.md every spawn), copies context extras preserving their
template-relative layout, writes MCP servers to container config, and installs the
per-group skills overlay. Includes docs (docs/templates.md, templates/README.md).

Setup-wizard wiring ships separately on top of this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:36:33 +03:00
gavrielc cb6e3d117c Merge pull request #2885 from PartridgeNet/fix/slack-setup-socket-mode
fix(setup): offer Slack Socket Mode in the guided setup flow
2026-06-30 23:05:45 +03:00
gavrielc ed7e3f70da Merge branch 'main' into fix/slack-setup-socket-mode 2026-06-30 23:04:38 +03:00
github-actions[bot] 557e073c2f chore: bump version to 2.1.23 2026-06-30 19:28:09 +00:00
gavrielc 91ebc9def2 chore(container): bump claude-code, agent SDK to latest
- @anthropic-ai/claude-code (cli-tools.json): 2.1.170 → 2.1.197
- @anthropic-ai/claude-agent-sdk: ^0.3.170 → ^0.3.197
- @anthropic-ai/sdk: ^0.100.0 → ^0.108.0

Typecheck and all 112 agent-runner tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:27:46 +03:00
github-actions[bot] 14c89e9716 docs: update token count to 204k tokens · 102% of context window 2026-06-30 15:49:15 +00:00
github-actions[bot] 549c424a38 chore: bump version to 2.1.22 2026-06-30 15:49:12 +00:00
gavrielc 186b9befcd Merge pull request #2880 from johnmathews/fix/2828-inbox-symlink-containment-upstream
fix(security): contain inbox symlink escapes in attachment writes (#2828)
2026-06-30 18:48:59 +03:00
John Mathews 863d413d32 Merge branch 'main' into fix/2828-inbox-symlink-containment-upstream 2026-06-29 18:27:07 +02:00
Rob Stevenson cf8478ffbb fix(setup): offer Slack Socket Mode in the guided setup flow
PR #2837 added Slack Socket Mode end-to-end ("adapter + guided setup") but
was merged into the `channels` branch, not `main`. As a result the
`setup:auto` Slack flow on main is webhook-only: it always collects a
signing secret and pushes the user toward a public Request URL, with no
Socket Mode option — even though setup/verify.ts already recognizes
SLACK_APP_TOKEN and the channels-branch adapter supports it.

Forward-port the setup-side of #2837 onto current main, re-authored on top
of main's current flow (back-nav, inline agent wiring, operator-role prompt,
welcome DM all preserved):

- setup/channels/slack.ts: add a mode picker (askSlackMode), mode-specific
  app-creation steps, collectAppToken() for the xapp- app-level token,
  conditional credential collection + env, and a mode-aware post-install
  checklist (Socket Mode skips the public-URL guidance).
- setup/add-slack.sh: require/persist either SLACK_APP_TOKEN (Socket Mode)
  or SLACK_SIGNING_SECRET (webhook) instead of mandating the signing secret.

Scope is setup-side only: the adapter's socket support already lives on
`channels` (#2837/#2839) and reaches users via /add-slack; the add-slack
SKILL.md doc is handled by #2700.
2026-06-29 14:45:31 +01:00
github-actions[bot] 8be5be93ba docs: update token count to 203k tokens · 101% of context window 2026-06-29 05:58:05 +00:00
omri-maya add3fc8f70 Merge pull request #2882 from nanocoai/fix/ncl-messaging-group-instance
fix(ncl): default messaging-groups create instance to channel_type
2026-06-29 08:57:53 +03:00
Omri Maya 0d841bcd05 fix(ncl): default messaging-groups create instance to channel_type
`ncl messaging-groups create` failed with a NOT NULL violation on the
`instance` column (migration 016). The generic CRUD insert builds its
column list from the resource definition, and `instance` wasn't declared
there — so the INSERT omitted the column entirely. The router path never
hit this because it goes through `createMessagingGroup`, which has its own
`instance ?? channel_type` fallback.

There is no operator-facing reason to require `--instance`: the default
instance IS the channel type (migration 016, `createMessagingGroup`, and
the default-instance resolver all encode this). So rather than force a
flag, default it.

- crud: add `defaultFrom` to ColumnDef — default a column to another
  already-resolved column's value on create. Generic, reusable.
- messaging-groups: declare `instance` with `defaultFrom: 'channel_type'`
  (placed after channel_type so it resolves first), still overridable via
  `--instance` for multi-instance setups.
- test: drive the real dispatch('messaging-groups-create') path; asserts
  omitted -> channel_type and explicit --instance preserved. Goes red if
  the column/defaultFrom wiring is deleted (insert fails NOT NULL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:38:22 +03:00
John Mathews dd1d0e5677 fix(security): contain channel-inbound attachments via shared inbox guard (#2828)
extractAttachmentFiles (the channel-inbound attachment path) hardened only
the per-message inbox subdir, not the `inbox` root itself. A compromised
container can write inside its own session dir, so it could replace `inbox`
with a symlink: mkdirSync({recursive}) then followed it, and the realpath
containment check passed because it compared against realpathSync(inboxRoot)
— which had already followed the symlink. A brand-new attachment file (the
`wx` flag only blocks an existing dst) therefore landed outside the session
sandbox. This is the same symlink-follow class fixed for the A2A path in
#2828 (CWE-59), but reachable from ordinary inbound messages.

Extract the guard both inbound paths need into src/inbox-safety.ts
(ensureContainedInboxDir + isPathInside): lstat-reject a pre-placed symlink
or non-dir at the inbox root AND the per-message subdir before mkdir, then a
realpath containment check. forwardAttachedFiles and extractAttachmentFiles
now share it, removing the duplicated guard logic. Callers still write with
an exclusive flag (COPYFILE_EXCL / wx) and skip-with-warn on failure.

Adds src/session-manager.attachments.test.ts (red before this change, green
after) covering the symlinked inbox-root vector on the channel path.
2026-06-29 00:27:58 +02:00
John Mathews 36afa40857 fix(agent-to-agent): containment-check target inbox in forwardAttachedFiles (#2828)
forwardAttachedFiles hardened only the source side of A2A attachment
forwarding; the target side called fs.mkdirSync({recursive}) and
fs.copyFileSync without any symlink or containment checks. A compromised
target agent that can write inside its own session dir could pre-place
`inbox` (or `inbox/<future-msgId>`) as a symlink pointing anywhere
host-writable — mkdirSync silently follows it and copyFileSync lands
attacker-influenced bytes outside the sandbox (CWE-59, GHSA #2828). This
mirrors the existing defensive pattern in src/session-manager.ts
saveAttachments(): lstat-reject a pre-existing symlink/non-dir at the
inbox root and the per-message subdir before mkdir, realpath + isPathInside
containment check, and an exclusive (COPYFILE_EXCL) copy that refuses to
follow or overwrite a pre-placed symlinked destination. Failures log.warn
with structured context and skip rather than throw, so one bad attachment
never kills a batch. Tests cover a symlinked inbox dir, a symlinked
inbox/<msgId> subdir, a pre-existing symlinked destination file, and a
normal end-to-end forward regression.
2026-06-29 00:27:58 +02:00
gavrielc 2afbd18233 Merge pull request #2859 from cben0ist/fix/migrate-v2-is-main
fix(migrate-v2): don't SELECT is_main from v1 registered_groups
2026-06-26 13:38:42 +03:00
gavrielc 953496dc37 Merge branch 'main' into fix/migrate-v2-is-main 2026-06-26 13:38:27 +03:00
Christophe Benoist 797491d8b3 fix(migrate-v2): don't SELECT is_main from v1 registered_groups
The v2 DB seed queried `is_main` from the v1 `registered_groups` table, but
that column was a later v1 addition — older v1 installs (e.g. 1.1.0) don't have
it, so the migration's `1b-db` step crashes with `no such column: is_main` and
v2.db is never created, cascading into the sessions and tasks steps failing.

`is_main` was selected into the V1Group interface but never read anywhere, so
this just drops it from the SELECT and the interface. The accompanying comment
already states the intent ("Query only the columns we know exist in all v1
installs") — the code now matches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:43:19 -04:00
github-actions[bot] 2df754459b chore: bump version to 2.1.21 2026-06-25 18:59:54 +00:00
gavrielc 0896d4089e Merge pull request #2832 from nanocoai/feat/reject-with-reason
feat(approvals): reject with reason
2026-06-25 21:59:42 +03:00
gavrielc d153d91307 Merge branch 'main' into feat/reject-with-reason 2026-06-25 21:45:12 +03:00
gavrielc ce55af12d5 Merge pull request #2843 from robbyczgw-cla/feat/learn-skill
feat: add /learn skill — distill or refine a reusable skill from anything
2026-06-25 21:41:23 +03:00
gavrielc 545800a94e Merge branch 'main' into feat/learn-skill 2026-06-25 21:40:55 +03:00
github-actions[bot] bfb309bd0c chore: bump version to 2.1.20 2026-06-25 18:34:34 +00:00
gavrielc 38d9390eea Merge pull request #2856 from nanocoai/container-limits
feat(container): per-container CPU/memory limits (opt-in)
2026-06-25 21:34:16 +03:00
gavrielc 8d3eca7027 Merge branch 'main' into container-limits 2026-06-25 21:34:00 +03:00
Omri Maya 1d6bba4d3f feat(container): per-container CPU/memory limits (opt-in)
Pass CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT through to `docker run`
as --cpus / --memory in buildContainerArgs. Both default to empty, so spawn
args are byte-identical to today unless an operator opts in — no risk of
OOM-ing existing workloads. Caps an agent container's CPU/memory so one agent
can't monopolize the host. Swap is a deployment concern (--memory is a hard
cap on a swapless host); not managed here.

Structural tests assert each flag is pushed and guarded by its env knob,
matching the existing buildContainerArgs structural-test convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:39:16 +03:00
amit-shafnir 9bb69c0e50 Merge pull request #2830 from amit-shafnir/fix/peer-dead-plist-reaper
fix(setup): reap dead peer service registrations whose binary is gone
2026-06-25 11:27:54 +03:00
robbyczgw-cla 520ec44aec feat: add /learn skill — distill or refine a reusable skill from anything
Instruction-only skill that distills a reusable skill from any source
(directory, URL, pasted notes, or the current conversation) or refines an
existing skill in place. Uses existing agent tools (Read/Grep/Glob/WebFetch/
Write) and injects the project's skill-authoring guidelines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:49 +02:00
gavrielc 8c6a243ffd Merge branch 'main' into feat/reject-with-reason 2026-06-23 15:46:02 +03:00
gavrielc add6145f1c Merge pull request #2826 from nanocoai/fix/skill-updates-nudge-and-container-rebuild
fix(update-skills): nudge into skill updates, rebuild container on re-apply
2026-06-23 15:41:05 +03:00
gavrielc 4e14d08173 Merge pull request #2834 from nanocoai/chore/bump-chat-sdk-4.29.0
chore(deps): move chat SDK + channel-adapter pins to 4.29.0
2026-06-23 15:26:50 +03:00
Gabi Simons 8f2f788b6e chore(deps): bump channel adapter install pins to 4.29.0 (skills + setup)
The prior commit moves `chat` to 4.29.0, but main's own install pins were left
behind — and were inconsistent: the 8 /add-<channel> SKILL.md steps pinned
@chat-adapter/*@4.27.0 while the 12 setup/*.sh scripts pinned @4.26.0. Unify all
to @4.29.0 so `/add-<channel>` (and setup:auto) on a main install fetch an
adapter whose ChatInstance matches the bridge.

20 files, version-string only. Shell scripts pass `bash -n`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:47:31 +03:00
Gabi Simons e96d7fd961 chore(deps): pin chat SDK to 4.29.0
`chat` and the `@chat-adapter/*` channel adapters are version-locked — the
adapter's ChatInstance must match the bridge's, so the pair must move together.
Pin `chat` exactly to 4.29.0 (was 4.26.0 via `^4.24.0`); a caret range floats to
4.31.0 and reintroduces the skew.

Host build + full test suite green at 4.29.0 (chat is consumed only as type
imports by the Chat SDK bridge). The channels-branch adapters bump to 4.29.0 in
lockstep; CHANGELOG notes the reinstall migration for existing channel installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:03:48 +03:00
Moshe Krupper 2ac7809385 feat(agent-to-agent): clarify the a2a gate approval prompt
Replace the terse "Approve delivery?" with a one-line legend that names all
three buttons and notes that "Reject with reason…" prompts the approver to
type a reason relayed back to the sender. The longer line also widens the
card bubble, easing the three-button-row truncation on platforms that size
buttons to the message width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:16:03 +03:00
Amit Shafnir 15292ae76c fix(setup): reap dead peer service registrations whose binary is gone
The setup preflight unloads *crash-looping* peers but ignores a more common
leftover: a launchd plist (or systemd unit) whose program no longer exists,
left behind when a NanoClaw checkout is deleted without running the
uninstaller. The health probe can't see these because an unloaded/inactive job
doesn't report via `launchctl print` / `systemctl show`, so they accumulate —
the OS keeps retrying a missing binary forever.

Detect a registration as dead when its `dist/index.js` target is absent on
disk, then unload (best-effort) and delete the orphaned config file. Own-label
and still-valid registrations are never touched.

Adds peer-cleanup.test.ts (the file previously had no tests) covering both
platforms: dead target removed, live target kept, own registration spared,
unrecognized config ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:58:51 +03:00
Koshkoshinsk 055cf49bd5 fix(update-skills): nudge into skill updates, rebuild container on re-apply
/update-nanoclaw Step 7 framed skill updates as an optional, "safe to skip"
extra, so an important channel/provider fix — shipped on the channels/providers
branches the host merge never touches — could be silently missed. Reframe it as
part of the update: default into /update-skills, name the installed skills, and
leave one minimal opt-out.

Move the container image rebuild into /update-skills Step 4: when a re-apply
changes files under container/ (e.g. a provider's runtime), rebuild so new
sessions actually run the new code. Living in update-skills covers both the
standalone and via-update-nanoclaw paths; the update-nanoclaw Step 7.5 that
briefly owned this is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHaa6bp25E62AuUJyW1V5J
2026-06-21 17:08:51 +03:00
Lee Twito 8ee7915418 Merge branch 'main' into feat/add-clidash-skill 2026-06-21 16:19:48 +03:00
Moshe Krupper e8148bc0a7 feat(approvals): reject-with-reason — relay an optional decline reason to the agent
Add a third "Reject with reason…" button to module approval cards. Plain
Reject stays the instant fast path; the new option holds the row
(status='awaiting_reason'), DM-prompts the approver, and captures their
next DM (≤280 chars, truncated) as a one-line reason relayed to the
requesting agent as a single combined message. A ghosted hold is
finalized as a plain reject by the host sweep after ~5 min — restart-safe
via the durable DB row.

- Generalize the router message-interceptor to a list
  (registerMessageInterceptor) so approvals can capture replies alongside
  the permissions agent-naming flow.
- Share reject finalization across the instant, captured, and swept paths
  via finalizeReject.
- Scope: all module approvals (create_agent, install_packages,
  add_mcp_server); OneCLI credential cards are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:55:01 +03:00
amit-shafnir 625264ba4b Merge pull request #2811 from amit-shafnir/setup-agent-provider-flag
fix(setup): allow env-selected agent provider
2026-06-18 22:08:37 +03:00
github-actions[bot] f34e590bcd docs: update token count to 199k tokens · 100% of context window 2026-06-18 15:19:05 +00:00
github-actions[bot] d208fd7bf5 chore: bump version to 2.1.19 2026-06-18 15:18:56 +00:00
Moshe Krupper 886c65725b Merge pull request #2793 from nanocoai/feat/a2a-approval-policies
feat(agent-to-agent): per-message approval policies on connected agents
2026-06-18 18:18:43 +03:00
Moshe Krupper 9977af68d7 chore(migrations): number the new migration files (017, 018)
Rename the two new migration files to the numbered convention used by the core
migrations (001–016), with matching migrationNNN exports, instead of the
module- prefix. Versions (17, 18) and stable migration `name`s are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8e44f07dd4 refactor(approvals): carry approver on a pending_approvals column, not the payload
Per review: move the assigned approver from the approval payload to a dedicated
`approver_user_id` column on pending_approvals.

- New migration adds the column; createPendingApproval + requestApproval write it.
- isAuthorizedApprovalClick reads approval.approver_user_id directly (drops the
  payload-parsing helper); when set, only that exact user may resolve.
- The gate no longer stuffs `approver` into the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8c43f13d93 refactor(approvals): assigned approver is strict — only the named user may resolve
Per review: drop the owner/global-admin override on assigned approvals. When an
approval names an approver, only that exact user can resolve it. (Non-assigned
approvals are unchanged — still group/owner authorized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 5cf4ff1bd2 refactor: destructure approverUserId / policy.approver instead of repeated access
Per review: pull `approverUserId` into the `opts` destructure in requestApproval,
and `approver` out of `policy` in the gate, instead of accessing the property
twice. (policies.ts already binds args.* to locals.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 6e475e5503 chore(agent-to-agent): drop self-explanatory comments
Remove redundant doc/inline comments where the code speaks for itself; keep only
the non-obvious notes (return-vs-throw consume, ghost-gate cleanup, caller-does-
auth, reject-handled-elsewhere, stored-vs-click payload). Also drops a couple of
now-stale "target admin" descriptions. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 0f8499b141 refactor(agent-to-agent): drop set-time admin check on policy approver
With payload-based click-auth (clicker === approver), the approver no longer
needs to be a group admin — the operator (operator-only command) designates
whoever should approve, and only that user (or an owner) can resolve the card.
Removes the now-redundant hasAdminPrivilege validation and its import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 82e1dc4ae8 feat(approvals): authorize by approver named in payload; policy approver may be source or target
Per review (no new pending_approvals column): the gate carries `approver` inside
the existing approval `payload`, and isAuthorizedApprovalClick authorizes the
named approver (or an owner/global admin) when an approval names one — reading
the real value at click time, no group re-derivation.

- `ncl policies set --approver` validates the user is an admin/owner of the
  source OR target.
- Drops `approverAgentGroupId` and the agent_group_id stamp; `requestApproval`
  keeps `approverUserId` only for delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper e70b021cde refactor(agent-to-agent): destructure payload in applyA2aMessageGate
Per review: destructure the approval payload once instead of repeating
`payload.x`, and narrow `platform_id` up front so it's used directly (drops the
separate `targetAgentGroupId` local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper ea90a12846 feat(agent-to-agent): make policy approver mandatory
Per review, the policy approver is now required, not optional. Every policy
names one specific admin/owner of the target who approves.

- `approver` column is NOT NULL; `AgentMessagePolicy.approver` is non-nullable.
- `ncl policies set --approver <user-id>` is required and validated to be an
  admin/owner of the target.
- The gate always delivers the card to `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3b1f4501d6 feat(agent-to-agent): optional single approver per policy
Per review, add an optional `approver` to a policy: a specific admin/owner of
the target who receives the approval card (instead of all target admins). NULL
keeps the default (all target admins/owners).

- `approver` column on agent_message_policies; carried on AgentMessagePolicy.
- `ncl policies set --approver <user-id>` validates the user is an admin/owner
  of the target at set-time, so the existing click-auth gate is unchanged.
- `requestApproval` gains `approverUserId` (single) to deliver the card to that
  one user; the gate passes `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 385fb014fc refactor(agent-to-agent): split content parsing out of buildGateQuestion
Address PR review: extract `parseMessageContent` (text + attachment names from
the message content JSON) so `buildGateQuestion` reads as pure formatting, and
name the body-length cap (`GATE_CARD_BODY_MAX`) instead of a bare 1500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b2160a56aa refactor(agent-to-agent): drop named-approver list from v1
Address PR review: remove the `approvers` option entirely for v1 — the
approver is always the target group's admins/owners. Drops the `approvers`
DB column, the `--approvers` flag + its set-time validation, the now-unused
`approverUserIds` param on requestApproval, and the related tests. The
target-scoped approver pick (`approverAgentGroupId`) stays. Named approvers
can be re-added later via a migration when needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper f72658bb50 refactor(agent-to-agent): extract sourceAgentGroupId in routeAgentMessage
Address PR review: hoist session.agent_group_id into a named local
`sourceAgentGroupId`, mirroring `targetAgentGroupId`, and use it throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3180f3f881 chore(agent-to-agent): trim comments to match repo convention
Shorten the verbose doc/inline comments added with the approval-policy gate
down to terse one-liners, matching the surrounding style (e.g. agent-destinations,
write-destinations). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b0bdc57b37 refactor(agent-to-agent): align policy files with resource conventions
- policies.ts: drop the 10-line top banner. Sibling resource files carry no
  descriptive header (only destinations.ts, and only for a non-obvious
  side-effect); the prose already lives in the resource `description`.
- agent-message-policies.ts: remove `listMessagePolicies` — no production
  caller (the `ncl policies list` op uses the generic table-based CRUD); only
  its own test referenced it.
- message-gate.test.ts: assert the upsert-no-duplicate invariant via a direct
  row count instead of the removed helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Moshe Krupper 314b91efc0 feat(agent-to-agent): per-message approval policies on connected agents
Add an optional, directed, per-message require-approval gate on top of an
existing agent-to-agent connection. No policy = today's free flow (fully
backward compatible). When a policy exists for A→B, each message A sends to B
is held, an approval card showing the message goes to B's admins, and the
message is delivered on approve / declined on reject. Rejecting one message
never blocks the connection.

- New `agent_message_policies` table (directed from→to; row exists = require
  approval; `approvers` JSON, NULL = target admins). Deleted alongside its
  connection so a stale rule can't reactivate on re-wire.
- Gate inside `routeAgentMessage` after the self/`hasDestination` checks:
  holds the message via `requestApproval` and returns to consume it (like a
  system action); the held message rides in the approval payload and is
  re-routed by `applyA2aMessageGate` on approve. Self/internal messages are
  never gated.
- `requestApproval` gains `approverAgentGroupId` / `approverUserIds` and stamps
  `agent_group_id` on the pending row so the target's admins pass the
  click-auth gate.
- `ncl policies list/set/remove`, operator-only (not in the container cli_scope
  allowlist); `set` validates named approvers are admins/owners of the target.

Reuses the existing requestApproval / pending_approvals / approval-handler
spine (same shape as create_agent). Host-only; no container changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Amit Shafnir 53ed3b77c9 fix(setup): allow env-selected agent provider 2026-06-18 17:49:13 +03:00
Daniel M 070714ec58 Merge pull request #2810 from nanocoai/refactor/agents-claude-symlinks
refactor: mirror .claude skills + CLAUDE.md into .agents via symlinks
2026-06-18 17:11:26 +03:00
exe.dev user e4907c2c33 refactor: mirror .claude into .agents via symlinks
.agents/skills -> ../.claude/skills and AGENTS.md -> CLAUDE.md so the
agents-convention paths resolve to the canonical ones. Drops the
host/skills indirection in favor of .claude as the single source of truth.
2026-06-18 14:03:38 +00:00
gavrielc 9a142302df Merge branch 'main' into feat/add-clidash-skill 2026-06-18 09:18:10 +03:00
github-actions[bot] 3f39f57653 chore: bump version to 2.1.18 2026-06-18 06:15:49 +00:00
gavrielc 1b86950f10 Merge pull request #2803 from sturdy4days/refactor/remove-dead-resolvegroupipcpath
refactor: remove dead resolveGroupIpcPath
2026-06-18 09:15:36 +03:00
gavrielc 8b435eb02d Merge branch 'main' into refactor/remove-dead-resolvegroupipcpath 2026-06-18 09:15:23 +03:00
gavrielc 7e2004f945 Merge pull request #2806 from arkjun/docs/add-korean-readme
docs: add Korean README
2026-06-18 09:14:52 +03:00
gavrielc 63901d1bde Merge branch 'main' into docs/add-korean-readme 2026-06-18 09:14:35 +03:00
gavrielc e5d96e348f Merge pull request #2805 from amit-shafnir/fix/setup-token-pty-parsing
fix(setup): parse Claude OAuth token from wrapped PTY capture
2026-06-18 09:12:56 +03:00
Juntai Park 439c24f1b7 docs: link Korean README in language switchers 2026-06-18 11:21:51 +09:00
Juntai Park 2a144bb8d6 docs: add Korean README 2026-06-18 11:21:50 +09:00
Amit Shafnir 197faaaa14 fix(setup): parse Claude OAuth token from wrapped PTY capture
`claude setup-token` runs under script(1) so the browser OAuth flow keeps a
TTY while we capture the printed token. On terminals that wrap long lines
(e.g. sbx), the token lands split across lines with padding spaces, and the
old parser — which stripped only ANSI codes and newlines — matched just the
first fragment and failed the trailing `AA` check. Login succeeded; only our
parse of the human-oriented output failed (`No sk-ant-oat…AA token found`).

Add setup/lib/captured-token.ts: normalize the capture (strip ANSI/control
bytes and all whitespace, un-wrapping the token) then extract. The TS caller
(claude-assist.ts) and the bash registration script now share it, so the
normalization rules can't drift. Placeholder lines like
`export CLAUDE_CODE_OAUTH_TOKEN=<token>` are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:20:21 +03:00
sturdy4days 3ffd6dde00 refactor: remove dead resolveGroupIpcPath
resolveGroupIpcPath has no production callers (only its own test); IPC was
removed in the v2 architecture (host<->container communicate solely via the two
session DBs). Drop the function, the now-unused DATA_DIR import, and its tests.
2026-06-17 15:19:29 -04: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
github-actions[bot] ee7f891698 docs: update token count to 196k tokens · 98% of context window 2026-06-16 11:15:10 +00:00
github-actions[bot] 7fde348e2b chore: bump version to 2.1.17 2026-06-16 11:15:04 +00:00
Gabi Simons 122135e6dc Merge pull request #2759 from assapin/fix/budget-error-surfaced-to-user
fix(agent-runner): deliver budget/billing error turns instead of dropping them
2026-06-16 14:14:48 +03:00
Gabi Simons 8563fb0681 Merge remote-tracking branch 'origin/main' into fix/budget-error-surfaced-to-user
# Conflicts:
#	CHANGELOG.md
2026-06-16 11:35:45 +03:00
omri-maya 0155ab1943 Merge pull request #2775 from nanocoai/docs/onecli-gateway-upgrade-notice
docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
2026-06-16 09:55:25 +03:00
Gabi Simons 59c4d33adc Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 17:42:01 +03:00
Gabi Simons e03c5c194a Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 12:17:20 +03:00
assafpin 01433bae32 fix(agent-runner): deliver budget/billing error turns instead of dropping them
A turn that ends in a non-retryable provider error (e.g. an Anthropic
403 billing_error) comes back from the streaming SDK as a result with
is_error=true and no <message> envelope. dispatchResultText treated it
as scratchpad and dropped it, then the poll-loop pushed a re-wrap nudge
-> new turn -> same error, re-hammering the gateway until idle-kill. The
user saw silence.

- providers/claude.ts: surface is_error on the result event, and fall
  back to errors[] for the message text (error subtypes carry no result).
- poll-loop.ts: when a result has no <message> blocks and is_error, deliver
  the notice verbatim to the originating channel and skip the nudge.

Verified live (real agent image + SDK, 403 mock): the notice is delivered
to the channel and the retry loop is gone.

Refs #2751
2026-06-14 12:56:02 +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
283 changed files with 15909 additions and 3679 deletions
+1
View File
@@ -0,0 +1 @@
../.claude/skills
+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
+1 -1
View File
@@ -46,7 +46,7 @@ import './discord.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/discord@4.27.0
pnpm install @chat-adapter/discord@4.29.0
```
### 5. Build and validate
+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.
+1 -1
View File
@@ -46,7 +46,7 @@ import './gchat.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/gchat@4.27.0
pnpm install @chat-adapter/gchat@4.29.0
```
### 5. Build and validate
+15 -16
View File
@@ -50,7 +50,7 @@ import './github.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/github@4.27.0
pnpm install @chat-adapter/github@4.29.0
```
### 5. Build and validate
@@ -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.
+11 -10
View File
@@ -59,7 +59,7 @@ import './linear.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.27.0
pnpm install @chat-adapter/linear@4.29.0
```
### 5. Build and validate
@@ -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
+9 -1
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):
@@ -46,7 +54,7 @@ import './slack.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/slack@4.27.0
pnpm install @chat-adapter/slack@4.29.0
```
### 5. Build and validate
+1 -1
View File
@@ -46,7 +46,7 @@ import './teams.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/teams@4.27.0
pnpm install @chat-adapter/teams@4.29.0
```
### 5. Build and validate
+1 -1
View File
@@ -60,7 +60,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/telegram@4.27.0
pnpm install @chat-adapter/telegram@4.29.0
```
### 6. Build and validate
+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) => {
+1 -1
View File
@@ -46,7 +46,7 @@ import './whatsapp-cloud.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/whatsapp@4.27.0
pnpm install @chat-adapter/whatsapp@4.29.0
```
### 5. Build and validate
+95 -6
View File
@@ -7,6 +7,34 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
## Number safety check (required)
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
Otherwise, use `AskUserQuestion`:
**Which WhatsApp number will NanoClaw use?**
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
- **Shared / personal number** — the user's existing everyday WhatsApp number
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
If `NUMBER_MODE=shared`, show this warning exactly:
> ⚠️ **Risk to your WhatsApp account**
>
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
>
> We strongly recommend using a separate, dedicated number for NanoClaw.
Then use `AskUserQuestion`:
- **Go back and use a dedicated number (Recommended)**
- **I understand the risk — continue with my shared number**
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
@@ -20,6 +48,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 +69,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 +120,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. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
@@ -192,16 +230,58 @@ 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
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
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.
If dedicated, add to `.env`:
- **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.
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
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. Use the mode established by the required safety check 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 +359,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).
+97
View File
@@ -0,0 +1,97 @@
---
name: learn
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
---
# Learn — Distill a Skill from Anything
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
## When to use
Invoke when the user wants to *capture* a workflow as a reusable skill:
- `/learn <path>` — read a project/dir and build a skill for working with it
- `/learn <url>` — read docs / an API page and build a usage skill
- `/learn what we just did` — distill the current conversation's workflow
- `/learn` + pasted notes — turn notes into a structured skill
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
## Workflow
### 1. Identify the source — and whether this is a new skill or a refine
- A **path** → read the code/files.
- A **URL** → fetch and read the page.
- **"what we just did" / "this"** → use the current conversation as the source.
- **Pasted text** → use it directly.
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
### 2. Gather the material
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
### 3. Distill — find the reusable procedure
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
- the trigger / when-to-use,
- the step-by-step procedure (commands, file paths, decision points),
- the non-obvious **gotchas** that were hit — usually the most valuable part,
- any scripts or templates worth shipping alongside.
### 4. Author the SKILL.md
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
- Keep what is still correct; weave the new learnings into the right sections.
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
- Correct anything the new source proves stale (a changed path, command, or flag).
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
**Frontmatter (required):**
```yaml
---
name: <kebab-case, matches the folder>
description: "<what it does + when to use it + likely trigger phrases>"
---
```
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
**House authoring rules (from `docs/skill-guidelines.md`):**
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
### 5. Place and verify
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
- Tell the user the skill exists and how to invoke it (`/<name>`).
## Example
`/learn what we just did` after a multi-step setup:
1. Re-read the conversation's commands and gotchas.
2. Distill the repeatable procedure.
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
## Notes
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
- The most valuable content is the **gotchas**, not the happy path.
- This skill is prose and safe to re-run — use it again to refine an existing 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
+57 -19
View File
@@ -246,30 +246,68 @@ If one or more `[BREAKING]` lines are found:
- For each skill the user selects, invoke it using the Skill tool.
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
# Step 7: Check for skill and channel/provider updates
# Step 7: Skill updates (part of updating NanoClaw)
## 7a: Skill branches
Check if skills are distributed as branches in this repo:
- `git branch -r --list 'upstream/skill/*'`
Updating your installed skills is **part of** updating NanoClaw, not an optional
extra. Channel and provider code ships on long-lived branches (`channels`,
`providers`) that the host merge above doesn't touch — so stopping here leaves
that code on whatever version you installed, which is how an important upstream
fix gets silently left behind. The default is to continue into `/update-skills`,
which re-applies your installed channels/providers to pull their latest code.
If any `upstream/skill/*` branches exist:
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
- If user selects yes, invoke `/update-skills` using the Skill tool.
Detect whether anything is installed: read `src/channels/index.ts` and
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
`cli`).
## 7b: Channel and provider updates
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
- If nothing is installed: skip silently and proceed to Step 7.9.
- If one or more are installed: continue into skill updates.
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
- List the installed channels/providers.
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
- "Skip — I'll update them later"
- Set `multiSelect: true`
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
Name the installed skills in the question so the choice is concrete:
- Question: "Skill updates are part of this NanoClaw update — your installed
channels/providers (<list the detected ones>) ride separate branches the host
update didn't touch. Continue into `/update-skills` to bring them up to date?"
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
`/update-skills`, which re-applies your installed channels/providers to pull
their latest upstream code. You pick which ones there."
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
installed skill code stays as-is and may be behind upstream."
If no channels/providers are installed, skip silently.
Keep it to these two options — the per-skill selection lives inside
`/update-skills`, not here.
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
touches container code, `/update-skills` rebuilds the agent image itself — see
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.
+1
View File
@@ -85,6 +85,7 @@ For each selected skill (process one at a time):
After all selected skills are re-applied:
- `pnpm run build`
- `pnpm test` (do not fail the flow if tests are not configured)
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
+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,
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+6
View File
@@ -4,6 +4,12 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
- [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.
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
+27 -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.
@@ -65,22 +65,23 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `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/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 +106,7 @@ ncl help
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| sessions | list, get | Active sessions (read-only) |
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
@@ -115,11 +117,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 +141,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
@@ -170,7 +174,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 +186,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `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 +220,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 +255,13 @@ Check these first when something goes wrong:
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
## Timestamps
Two rules, no exceptions:
- **Storage**: every timestamp written from JS is `new Date().toISOString()` (ISO-8601 UTC with `Z`). Never `datetime('now')` — its naive `YYYY-MM-DD HH:MM:SS` shape is misparsed as local time by `new Date()` and breaks string comparisons against ISO values. In pure-SQL contexts (skill snippets) use `strftime('%Y-%m-%dT%H:%M:%fZ','now')`. SQL-side *comparisons* wrap both sides in `datetime()`.
- **Display**: anything shown to an agent or a user renders in the install timezone — `formatLocalTime` (prose) or `formatLocalStamp` (log lines) from `src/timezone.ts` / `container/agent-runner/src/timezone.ts`. `--json` output, DB values, and operator logs stay ISO.
## Supply Chain Security (pnpm)
This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them.
@@ -280,6 +291,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
## Container Build Cache
@@ -296,7 +308,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
+8 -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
@@ -125,6 +125,10 @@ Instructions here...
- Put code in separate files, not inline in the markdown
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
## Templates
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
## Testing
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
+9 -10
View File
@@ -11,6 +11,7 @@
<a href="https://docs.nanoclaw.dev">docs</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
@@ -21,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
@@ -48,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.
@@ -77,10 +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 `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
@@ -122,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
@@ -140,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.
@@ -163,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?**
+1
View File
@@ -11,6 +11,7 @@
<a href="https://docs.nanoclaw.dev">ドキュメント</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
+228
View File
@@ -0,0 +1,228 @@
<p align="center">
<img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400">
</p>
<p align="center">
에이전트를 각자의 컨테이너에서 안전하게 실행하는 AI 어시스턴트입니다. 가볍고, 쉽게 이해할 수 있으며, 여러분의 필요에 맞게 완전히 커스터마이즈할 수 있도록 만들어졌습니다.
</p>
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">문서</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
---
## NanoClaw를 만든 이유
[OpenClaw](https://github.com/openclaw/openclaw)는 인상적인 프로젝트지만, 제가 이해하지 못하는 복잡한 소프트웨어에 제 삶 전체에 대한 접근 권한을 줬다면 저는 잠을 이루지 못했을 것입니다. OpenClaw는 거의 50만 줄에 달하는 코드, 53개의 설정 파일, 70개 이상의 의존성을 가지고 있습니다. 보안은 진정한 OS 수준의 격리가 아니라 애플리케이션 수준(허용 목록, 페어링 코드)에 의존합니다. 모든 것이 메모리를 공유하는 하나의 Node 프로세스에서 실행됩니다.
NanoClaw는 그와 동일한 핵심 기능을 제공하지만, 이해할 수 있을 만큼 작은 코드베이스로 구현합니다. 하나의 프로세스와 몇 개의 파일뿐입니다. Claude 에이전트는 단순한 권한 검사 뒤가 아니라, 파일시스템이 격리된 각자의 Linux 컨테이너에서 실행됩니다.
## 빠른 시작
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
`nanoclaw.sh`는 갓 준비한 머신에서 시작해 메시지를 보낼 수 있는 이름 붙은 에이전트까지 안내합니다. 누락된 경우 Node, pnpm, Docker를 설치하고, Anthropic 자격 증명을 OneCLI에 등록하며, 에이전트 컨테이너를 빌드하고, 첫 채널(Telegram, Discord, WhatsApp 또는 로컬 CLI)을 페어링합니다. 어떤 단계가 실패하면 Claude Code가 자동으로 호출되어 원인을 진단하고 중단된 지점부터 재개합니다.
<details>
<summary><strong>NanoClaw v1에서 마이그레이션하시나요?</strong></summary>
기존 v1 설치 옆에 새로운 v2 체크아웃을 만들어 실행하세요:
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
`migrate-v2.sh`는 v1 설치(형제 디렉터리, 또는 `NANOCLAW_V1_PATH=/path/to/nanoclaw`)를 찾아 상태를 v2 체크아웃으로 마이그레이션한 다음, 판단이 필요한 부분(소유자 시딩, CLAUDE.local.md 정리, 포크 커스터마이징 재적용)을 마무리하기 위해 Claude Code로 `exec`합니다.
이 스크립트는 Claude 세션 내부가 아니라 직접 실행하세요. 결정론적인 부분에서 Node/pnpm 부트스트랩, Docker, OneCLI, 컨테이너 빌드를 위해 대화형 프롬프트와 실제 셸 I/O가 필요합니다.
**무엇을 하는가:** `.env`를 병합하고, `registered_groups`로부터 v2 DB를 시딩하며, 그룹 폴더 + 세션 데이터 + 예약 작업을 복사하고, 선택한 채널 어댑터를 설치하며, 채널 인증 상태(WhatsApp의 Baileys 키스토어 + LID 매핑 포함)를 복사하고, 에이전트 컨테이너를 빌드합니다.
**무엇을 하지 않는가:** 시스템 서비스를 전환하지 않습니다. 프롬프트에서 *"switch to v2"*를 선택하거나, 테스트 후 수동으로 전환하세요. 기존 v1 설치는 그대로 유지됩니다.
무엇이 달라졌는지는 [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md)를, 개발 노트는 [docs/migration-dev.md](docs/migration-dev.md)를 참고하세요.
</details>
## 철학
**이해할 수 있을 만큼 작게.** 하나의 프로세스, 몇 개의 소스 파일, 마이크로서비스 없음. NanoClaw 코드베이스 전체를 이해하고 싶다면 Claude Code에게 안내해 달라고 요청하기만 하면 됩니다.
**격리를 통한 보안.** 에이전트는 Linux 컨테이너에서 실행되며 명시적으로 마운트된 것만 볼 수 있습니다. 명령이 호스트가 아니라 컨테이너 안에서 실행되기 때문에 Bash 접근도 안전합니다.
**개별 사용자를 위해 설계.** NanoClaw는 거대한 단일 프레임워크가 아니라, 각 사용자의 정확한 필요에 맞는 소프트웨어입니다. 비대한 소프트웨어가 되는 대신, NanoClaw는 맞춤형이 되도록 설계되었습니다. 직접 포크를 만들고 Claude Code가 여러분의 필요에 맞게 수정하도록 합니다.
**커스터마이징 = 코드 변경.** 설정의 난립이 없습니다. 다른 동작을 원하시나요? 코드를 수정하세요. 코드베이스가 충분히 작아서 안전하게 변경할 수 있습니다.
**AI 네이티브, 설계상 하이브리드.** 설치와 온보딩 흐름은 최적화된 스크립트 경로로, 빠르고 결정론적입니다. 어떤 단계에 판단이 필요할 때 — 설치 실패, 안내가 필요한 결정, 커스터마이징 등 — 제어권이 Claude Code로 매끄럽게 넘어갑니다. 설정 이후에도 모니터링 대시보드나 디버깅 UI가 없습니다. 채팅으로 문제를 설명하면 Claude Code가 처리합니다.
**기능보다 스킬.** 트렁크는 특정 채널 어댑터나 대체 에이전트 프로바이더가 아니라 레지스트리와 인프라를 제공합니다. 채널(Discord, Slack, Telegram, WhatsApp, …)은 오래 유지되는 `channels` 브랜치에, 대체 프로바이더(OpenCode, Ollama)는 `providers` 브랜치에 있습니다. `/add-telegram`, `/add-opencode` 등을 실행하면 스킬이 여러분이 필요로 하는 모듈만 정확히 포크로 복사합니다. 요청하지 않은 기능은 없습니다.
**최고의 하니스, 최고의 모델.** NanoClaw는 Anthropic의 공식 Claude Agent SDK를 통해 Claude Code를 네이티브로 사용하므로, 최신 Claude 모델과 Claude Code의 전체 도구 세트를 누릴 수 있습니다. 여기에는 자신의 NanoClaw 포크를 직접 수정하고 확장하는 능력도 포함됩니다. 다른 프로바이더는 드롭인 옵션입니다. OpenAI의 Codex는 `/add-codex`(ChatGPT 구독 또는 API 키), OpenRouter·Google·DeepSeek 등은 OpenCode를 통한 `/add-opencode`, 로컬 오픈 웨이트 모델은 `/add-ollama-provider`로 추가합니다. 프로바이더는 에이전트 그룹별로 설정할 수 있습니다.
## 지원 기능
- **멀티 채널 메시징** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, 그리고 Resend를 통한 이메일. `/add-<channel>` 스킬로 필요할 때 설치합니다. 하나 또는 여러 개를 동시에 실행할 수 있습니다.
- **유연한 격리** — 완전한 프라이버시를 위해 각 채널을 자체 에이전트에 연결하거나, 대화는 분리하되 메모리는 통합하기 위해 하나의 에이전트를 여러 채널에서 공유하거나, 여러 채널을 하나의 공유 세션으로 묶어 하나의 대화가 여러 채널에 걸쳐 이어지도록 할 수 있습니다. `/manage-channels`로 채널별로 선택하세요. [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
- **에이전트별 작업 공간** — 각 에이전트 그룹은 자체 `CLAUDE.md`, 자체 메모리, 자체 컨테이너, 그리고 여러분이 허용한 마운트만 갖습니다. 직접 연결하지 않는 한 경계를 넘는 것은 아무것도 없습니다.
- **예약 작업** — Claude를 실행하고 여러분에게 다시 메시지를 보낼 수 있는 반복 작업
- **웹 접근** — 웹에서 검색하고 콘텐츠를 가져오기
- **컨테이너 격리** — 에이전트는 Docker(macOS/Linux/WSL2)에서 샌드박스화되며, 선택적으로 [Docker Sandboxes](docs/docker-sandboxes.md) 마이크로 VM 격리나 macOS 네이티브 런타임인 Apple Container를 사용할 수 있습니다
- **자격 증명 보안** — 에이전트는 원시 API 키를 절대 보유하지 않습니다. 아웃바운드 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 요청 시점에 자격 증명을 주입하고 에이전트별 정책과 속도 제한을 적용합니다.
## 사용법
트리거 단어(기본값: `@Andy`)로 어시스턴트에게 말을 거세요:
```
@Andy 매주 평일 오전 9시에 영업 파이프라인 개요를 보내줘 (내 Obsidian 보관함 폴더에 접근 가능)
@Andy 매주 금요일에 지난 한 주간의 git 히스토리를 검토하고, 내용이 어긋나면 README를 업데이트해줘
@Andy 매주 월요일 오전 8시에 Hacker News와 TechCrunch에서 AI 관련 소식을 모아 브리핑을 보내줘
```
여러분이 소유하거나 관리하는 채널에서는 그룹과 작업을 관리할 수 있습니다:
```
@Andy 모든 그룹에 걸친 예약 작업을 전부 나열해줘
@Andy 월요일 브리핑 작업을 일시 정지해줘
@Andy Family Chat 그룹에 참여해줘
```
## 커스터마이징
NanoClaw는 설정 파일을 사용하지 않습니다. 변경하려면 Claude Code에게 원하는 것을 말하기만 하면 됩니다:
- "트리거 단어를 @Bob으로 바꿔줘"
- "앞으로는 응답을 더 짧고 직접적으로 하도록 기억해줘"
- "내가 좋은 아침이라고 인사하면 맞춤 인사를 추가해줘"
- "매주 대화 요약을 저장해줘"
또는 안내형 변경을 위해 `/customize`를 실행하세요.
코드베이스가 충분히 작아서 Claude가 안전하게 수정할 수 있습니다.
## 기여하기
**기능을 추가하지 마세요. 스킬을 추가하세요.**
새로운 채널이나 에이전트 프로바이더를 추가하고 싶다면 트렁크에 추가하지 마세요. 새 채널 어댑터는 `channels` 브랜치에, 새 에이전트 프로바이더는 `providers` 브랜치에 들어갑니다. 사용자는 `/add-<name>` 스킬로 자신의 포크에 설치하며, 이 스킬은 관련 모듈을 표준 경로로 복사하고, 등록을 연결하며, 의존성을 고정합니다.
이를 통해 트렁크는 순수한 레지스트리이자 인프라로 유지되고, 모든 포크는 가벼운 상태를 유지합니다. 사용자는 요청한 채널과 프로바이더만 얻고 그 외에는 아무것도 얻지 않습니다.
### RFS (Request for Skills)
저희가 보고 싶은 스킬:
**커뮤니케이션 채널**
- `/add-signal` — Signal을 채널로 추가
## 요구 사항
- macOS 또는 Linux (Windows는 WSL2 경유)
- Node.js 20+ 및 pnpm 10+ (설치 프로그램이 누락 시 둘 다 설치합니다)
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) 또는 Docker Engine (Linux)
- `/customize`, `/debug`, 설정 중 오류 복구, 그리고 모든 `/add-<channel>` 스킬을 위한 [Claude Code](https://claude.ai/download)
## 아키텍처
```
메시징 앱 → 호스트 프로세스(라우터) → inbound.db → 컨테이너(Bun, Claude Agent SDK) → outbound.db → 호스트 프로세스(전송) → 메시징 앱
```
하나의 Node 호스트가 세션별 에이전트 컨테이너를 오케스트레이션합니다. 메시지가 도착하면 호스트는 엔티티 모델(사용자 → 메시징 그룹 → 에이전트 그룹 → 세션)을 통해 라우팅하고, 세션의 `inbound.db`에 기록한 뒤 컨테이너를 깨웁니다. 컨테이너 내부의 에이전트 러너는 `inbound.db`를 폴링하고, Claude를 실행하며, 응답을 `outbound.db`에 기록합니다. 호스트는 `outbound.db`를 폴링하여 채널 어댑터를 통해 다시 전송합니다.
세션당 두 개의 SQLite 파일이 있으며 각각 정확히 하나의 작성자만 갖습니다. 교차 마운트 경합이 없고, IPC가 없으며, stdin 파이핑이 없습니다. 채널과 대체 프로바이더는 시작 시 자체 등록됩니다. 트렁크는 레지스트리와 Chat SDK 브리지를 제공하고, 어댑터 자체는 포크별로 스킬을 통해 설치됩니다.
전체 아키텍처 설명은 [docs/architecture.md](docs/architecture.md)를, 3단계 격리 모델은 [docs/isolation-model.md](docs/isolation-model.md)를 참고하세요.
핵심 파일:
- `src/index.ts` — 진입점: DB 초기화, 채널 어댑터, 전송 폴링, 스윕
- `src/router.ts` — 인바운드 라우팅: 메시징 그룹 → 에이전트 그룹 → 세션 → `inbound.db`
- `src/delivery.ts``outbound.db` 폴링, 어댑터를 통한 전송, 시스템 액션 처리
- `src/host-sweep.ts` — 60초 스윕: 정체 감지, 예정 메시지 깨우기, 반복 처리
- `src/session-manager.ts` — 세션 확인, `inbound.db` / `outbound.db` 열기
- `src/container-runner.ts` — 에이전트 그룹별 컨테이너 생성, OneCLI 자격 증명 주입
- `src/db/` — 중앙 DB (사용자, 역할, 에이전트 그룹, 메시징 그룹, 연결, 마이그레이션)
- `src/channels/` — 채널 어댑터 인프라 (어댑터는 `/add-<channel>` 스킬로 설치)
- `src/providers/` — 호스트 측 프로바이더 설정 (`claude`는 기본 내장, 그 외는 스킬 경유)
- `container/agent-runner/` — Bun 에이전트 러너: 폴 루프, MCP 도구, 프로바이더 추상화
- `groups/<folder>/` — 에이전트 그룹별 파일시스템 (`CLAUDE.md`, 스킬, 컨테이너 설정)
## FAQ
**왜 Docker인가요?**
Docker는 크로스 플랫폼 지원(macOS, Linux, 그리고 WSL2 경유 Windows)과 성숙한 생태계를 제공합니다. macOS에서는 더 가벼운 네이티브 런타임인 Apple Container도 지원됩니다. 추가 격리를 위해 [Docker Sandboxes](docs/docker-sandboxes.md)는 각 컨테이너를 마이크로 VM 안에서 실행합니다.
**Linux나 Windows에서 실행할 수 있나요?**
네. Docker가 기본 런타임이며 macOS, Linux, Windows(WSL2 경유)에서 작동합니다. `bash nanoclaw.sh`를 실행하기만 하면 됩니다.
**이것은 안전한가요?**
에이전트는 애플리케이션 수준의 권한 검사 뒤가 아니라 컨테이너에서 실행됩니다. 명시적으로 마운트된 디렉터리만 접근할 수 있습니다. 자격 증명은 컨테이너에 들어가지 않습니다. 아웃바운드 API 요청은 [OneCLI의 Agent Vault](https://github.com/onecli/onecli)를 통해 라우팅되며, 프록시 수준에서 인증을 주입하고 속도 제한과 접근 정책을 지원합니다. 여전히 실행하는 것을 검토해야 하지만, 코드베이스가 충분히 작아서 실제로 검토할 수 있습니다. 전체 보안 모델은 [보안 문서](https://docs.nanoclaw.dev/concepts/security)를 참고하세요.
**왜 설정 파일이 없나요?**
설정의 난립을 원하지 않습니다. 모든 사용자는 일반적인 시스템을 설정하는 대신, 코드가 정확히 원하는 대로 동작하도록 NanoClaw를 커스터마이즈해야 합니다. 설정 파일을 선호한다면 Claude에게 추가해 달라고 할 수 있습니다.
**서드파티나 오픈소스 모델을 사용할 수 있나요?**
네. 지원되는 경로는 `/add-opencode`(OpenCode 설정을 통한 OpenRouter, OpenAI, Google, DeepSeek 등) 또는 `/add-ollama-provider`(Ollama를 통한 로컬 오픈 웨이트 모델)입니다. 둘 다 에이전트 그룹별로 설정할 수 있으므로, 같은 설치 내에서 서로 다른 에이전트가 서로 다른 백엔드에서 실행될 수 있습니다.
일회성 실험의 경우, Claude API 호환 엔드포인트라면 `.env`를 통해서도 작동합니다:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
**문제를 어떻게 디버깅하나요?**
Claude Code에게 물어보세요. "스케줄러가 왜 실행되지 않지?" "최근 로그에 뭐가 있지?" "이 메시지는 왜 응답을 받지 못했지?" 그것이 NanoClaw의 바탕에 깔린 AI 네이티브 접근 방식입니다.
**설정이 왜 작동하지 않나요?**
어떤 단계가 실패하면 `nanoclaw.sh`는 진단하고 재개하기 위해 Claude Code로 넘깁니다. 그래도 해결되지 않으면 `claude`를 실행한 뒤 `/debug`를 실행하세요. Claude가 다른 사용자에게도 영향을 줄 만한 문제를 발견하면, 관련 설정 단계나 스킬에 대한 PR을 열어주세요.
**NanoClaw를 어떻게 제거하나요?**
```bash
bash nanoclaw.sh --uninstall
```
모든 설치는 체크아웃별 ID로 태깅되므로, 제거 프로그램은 해당 사본에 속한 것만 제거합니다: 백그라운드 서비스, 컨테이너와 이미지, 앱 데이터와 로그, 에이전트 파일, 그리고 이 사본의 OneCLI 볼트 에이전트입니다. 공유되는 것 — OneCLI 앱과 여러분의 자격 증명, 머신의 다른 NanoClaw 사본 — 은 그대로 둡니다. 무엇을 발견했는지 정확히 보여주고 그룹별로 확인을 요청합니다. 여러분이 동의하기 전까지는 아무것도 삭제되지 않습니다. 변경 없이 미리 보려면 `--dry-run`을, 프롬프트를 건너뛰려면 `--yes`를 사용하세요. `.env`는 제거 전에 백업됩니다. 마무리하려면 체크아웃 폴더 자체를 삭제하세요.
**어떤 변경이 코드베이스에 받아들여지나요?**
기본 구성에는 보안 수정, 버그 수정, 명확한 개선만 받아들여집니다. 그게 전부입니다.
그 외의 모든 것(새로운 기능, OS 호환성, 하드웨어 지원, 향상)은 스킬로 기여해야 합니다. 채널과 프로바이더 코드는 `channels`/`providers` 레지스트리 브랜치에, 그 외에는 자체 완결형 스킬로 기여합니다. [docs/customizing.md](docs/customizing.md)와 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
이를 통해 기본 시스템을 최소한으로 유지하고, 모든 사용자가 원하지 않는 기능을 떠안지 않으면서 자신의 설치를 커스터마이즈할 수 있습니다.
## 커뮤니티
질문이 있나요? 아이디어가 있나요? [Discord에 참여하세요](https://discord.gg/VDdww8qS42).
## 변경 이력
호환성을 깨는 변경 사항은 [CHANGELOG.md](CHANGELOG.md)를, 또는 문서 사이트의 [전체 릴리스 히스토리](https://docs.nanoclaw.dev/changelog)를 참고하세요.
## 라이선스
MIT
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />
+1
View File
@@ -11,6 +11,7 @@
<a href="https://docs.nanoclaw.dev">文档</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="README_ko.md">한국어</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
</p>
+12 -12
View File
@@ -5,8 +5,8 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
@@ -19,25 +19,25 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.170", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.197", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.197" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170", "", { "os": "darwin", "cpu": "x64" }, "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170", "", { "os": "linux", "cpu": "arm64" }, "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170", "", { "os": "linux", "cpu": "x64" }, "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170", "", { "os": "win32", "cpu": "arm64" }, "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170", "", { "os": "win32", "cpu": "x64" }, "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.108.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-XBnl7Nszpbzg0aLnOCmdBi0bOU5goAsQ/L+NPNiuUPowDj8Mbzx0vlIIc1M79BjIvmw5nUM5G3jbrCBStT/0fQ=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
+2 -2
View File
@@ -9,8 +9,8 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.170",
"@anthropic-ai/sdk": "^0.100.0",
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
+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]);
+60 -1
View File
@@ -4,8 +4,9 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
import { getPendingMessages, markCompleted } from './db/messages-in.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { formatMessages, extractRouting } from './formatter.js';
import { isCorruptionError } from './poll-loop.js';
import { isCorruptionError, processQuery } from './poll-loop.js';
import { MockProvider } from './providers/mock.js';
import type { AgentQuery, ProviderEvent } from './providers/types.js';
beforeEach(() => {
initTestSessionDb();
@@ -379,6 +380,64 @@ describe('end-to-end with mock provider', () => {
});
});
/**
* Build a one-shot stub query that yields init + a single result event, then
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
*/
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
const pushes: string[] = [];
async function* events(): AsyncGenerator<ProviderEvent> {
yield { type: 'init', continuation: 'sess-1' };
yield result;
}
return {
pushes,
query: {
push: (m: string) => {
pushes.push(m);
},
end: () => {},
events: events(),
abort: () => {},
},
};
}
const ERR_ROUTING = {
platformId: 'chan-1',
channelType: 'discord',
threadId: null,
inReplyTo: 'm1',
};
describe('error result with no <message> envelope', () => {
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe(budgetText);
expect(out[0].platform_id).toBe('chan-1');
expect(out[0].channel_type).toBe('discord');
// No re-wrap nudge — an error result must not re-hammer the gateway.
expect(pushes).toHaveLength(0);
});
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
expect(getUndeliveredMessages()).toHaveLength(0);
expect(pushes).toHaveLength(1);
expect(pushes[0]).toContain('was not delivered');
});
});
describe('isCorruptionError', () => {
it('matches the Docker Desktop macOS torn-read symptom', () => {
expect(isCorruptionError('database disk image is malformed')).toBe(true);
+83 -34
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.
@@ -323,7 +328,7 @@ interface QueryResult {
continuation?: string;
}
async function processQuery(
export async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
@@ -396,15 +401,15 @@ 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
@@ -482,28 +487,43 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
if (sent === 0 && event.isError === true) {
// Non-retryable error turn (e.g. a 403 billing_error) with no
// <message> envelope: deliver the notice instead of dropping it as
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
// the failing gateway turn after turn.
deliverErrorResult(event.text, routing);
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: 'error',
});
archivePrompts.shift();
} else {
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
} else {
archivePrompts.shift();
}
@@ -557,6 +577,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
}
}
/**
* Deliver a turn's text straight to the channel the batch arrived on. Used when
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
* This is the same user-facing write the outer catch block does, minus the
* `Error:` prefix the provider's text is already a user-facing message.
*/
function deliverErrorResult(text: string, routing: RoutingContext): void {
log('Error result with no <message> envelope — delivering to channel');
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text }),
});
}
/**
* Parse the agent's final text for <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
@@ -610,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
@@ -617,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,
+13 -5
View File
@@ -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;
@@ -440,11 +443,16 @@ export class ClaudeProvider implements AgentProvider {
if (message.type === 'system' && message.subtype === 'init') {
yield { type: 'init', continuation: message.session_id };
} else if (message.type === 'result') {
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
yield { type: 'result', text };
// `result` text exists only on subtype:"success"; error subtypes
// (e.g. a non-retryable 403 billing_error) carry their message in
// `errors[]` instead. Surface either so the poll-loop can deliver a
// billing/quota notice to the user rather than dropping the turn.
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
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';
@@ -125,7 +125,13 @@ export interface AgentQuery {
export type ProviderEvent =
| { type: 'init'; continuation: string }
| { type: 'result'; text: string | null }
/**
* A completed turn. `isError` is set when the underlying SDK flagged the
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
* poll-loop uses it to surface the result text to the user instead of
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
*/
| { type: 'result'; text: string | null; isError?: boolean }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string }
/**
@@ -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) {

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