The schedule_task MCP tool wrote routing fields (platform_id, channel_type,
thread_id) onto the outbound system message's row columns, but
handleSystemAction (src/delivery.ts) parses content JSON and forwards only
that to handlers. handleScheduleTask (src/modules/scheduling/actions.ts)
reads content.platformId/channelType/threadId — which the writer never
populated — so every kind='task' row landed in messages_in with all-null
routing.
When host-sweep wakes a scheduled task, dispatchResultText's fast path
requires routing on the message and bails when it's null, falling through
to the "Routing recovery" retry prompt. End-user delivery still works
because the agent can pick a destination from its destinations table on
retry — so the bug went undetected, silently costing one extra LLM turn
per scheduled-task wake. Sessions whose destinations table has no channel
row (e.g. agent-only destinations) fail outright with a recovery loop.
Fix: add the routing fields to the content JSON so the writer matches the
contract handleScheduleTask already expects. cancel/pause/resume/update_task
operate by id alone and don't need routing.
Three MCP tool groups were orphaned from the ambient CLAUDE.md context
because they shipped no `*.instructions.md` alongside their source.
Backfill them so the composer picks them up as fragments on next spawn:
- core.instructions.md: add `send_file` (artifact delivery, path relative
to /workspace/agent/) and `add_reaction` (by `#N` id with emoji
shortcode name).
- interactive.instructions.md: `ask_user_question` (blocking
multiple-choice with selectedLabel/value option objects, 300s default
timeout) and `send_card` (non-blocking structured render with
fallbackText). Opens with a one-line framing of the contrast between
the two.
- agents.instructions.md: `create_agent` with how-it-works, when-to-use
(companions vs collaborators — persistent memory vs independent
parallel work), when-NOT-to-use (short tasks should use the SDK `Agent`
tool instead), and guidance for writing the seed instructions string.
No composer changes — scan in `src/claude-md-compose.ts` already picks up
any file matching `*.instructions.md` in the mcp-tools directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install_packages and add_mcp_server already did the right thing on approve
(install auto-rebuilt+killed, add_mcp_server just killed), so request_rebuild
was redundant plumbing agents sometimes called after an install — wasting an
admin approval round-trip. Delete it end-to-end:
- container/agent-runner/src/mcp-tools/self-mod.ts: remove requestRebuild
tool + registration; update install_packages description.
- src/modules/self-mod/{request,apply,index}.ts: drop handleRequestRebuild
+ applyRequestRebuild + registrations; rewrite the rebuild-failed notify
to point admins at retrying install_packages instead.
- src/modules/{approvals,self-mod}/{agent,project}.md and skill/self-
customize/SKILL.md: scrub agent-facing references; clarify that
add_mcp_server needs no rebuild (bun runs TS directly).
- docs/{module-contract,architecture-diagram,checklist,db-central,shared-
source,v1-vs-v2/*}.md, CLAUDE.md, pending-approvals migration comment,
approvals/index.ts docstring, REFACTOR.md: trailing references.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move every agent-specific instruction out of the shared container/CLAUDE.md
so the base is genuinely universal. Persona/identity now comes from the
system-prompt addendum (buildSystemPromptAddendum now takes assistantName
and prepends "# You are {name}"). Per-module instructions live alongside
each MCP tool source:
container/agent-runner/src/mcp-tools/core.instructions.md
container/agent-runner/src/mcp-tools/scheduling.instructions.md
container/agent-runner/src/mcp-tools/self-mod.instructions.md
composeGroupClaudeMd() scans that directory and emits `module-<name>.md`
fragments as symlinks to /app/src/mcp-tools/<name>.instructions.md (valid
via the existing RO source mount). Skill fragments renamed to
`skill-<name>.md` for naming consistency with `module-*` and `mcp-*`.
Mount tightening so composer-managed files can't be clobbered by agent
writes: nested RO mounts for /workspace/agent/CLAUDE.md and
/workspace/agent/.claude-fragments/. CLAUDE.local.md (per-group memory)
stays RW as the only writable CLAUDE.md-family file.
.gitignore: ignore CLAUDE.local.md, .claude-shared.md, .claude-fragments/
everywhere, and simplify groups/ rules to ignore the whole tree (per-
installation state, not tracked).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The agent needs to perceive times in the user's timezone, not UTC.
Dropping this in the v1→v2 port produced a class of bugs where the agent
would schedule tasks for the wrong hour, suggest dinner at midnight, etc.
This restores v1 parity.
Container side:
- New container/agent-runner/src/timezone.ts mirrors src/timezone.ts with
isValidTimezone / resolveTimezone / formatLocalTime, plus:
* TIMEZONE constant resolved at load from process.env.TZ (host sets this
from src/container-runner.ts:254)
* parseZonedToUtc(input, tz) — treats a naive ISO as wall-clock time in
`tz`, returns the corresponding UTC Date. Strings with Z or offset
are passed through.
- formatter.ts:
* formatMessages() now prepends <context timezone="IANA"/>\n — matches
v1 src/v1/router.ts:20-22
* formatSingleChat uses formatLocalTime(ts, TIMEZONE) instead of a
home-rolled HH:MM 24h formatter → outputs like "Jun 15, 2026, 8:00 AM"
* reply_to="<id>" attribute + <quoted_message from="X">Y</quoted_message>
element — matches v1 format exactly; old <reply-to/> shape is gone
* stripInternalTags() exported for the dispatch path to reuse
- poll-loop.ts uses the exported stripInternalTags() instead of inline regex.
- mcp-tools/scheduling.ts:
* schedule_task/update_task descriptions now explicitly document that
processAfter accepts either UTC or naive local time (interpreted in
the user's TZ from the context header)
* handlers normalize through parseZonedToUtc() and store a UTC ISO
Host side:
- src/modules/scheduling/recurrence.ts passes { tz: TIMEZONE } to
CronExpressionParser.parse. Without this, "0 9 * * *" fires at 09:00
UTC instead of 09:00 user-local — this was the v1 behavior
(src/v1/task-scheduler.ts:20-49).
Tests:
- container/agent-runner/src/timezone.test.ts — mirror of src/timezone.test.ts
+ new parseZonedToUtc cases
- container/agent-runner/src/formatter.test.ts — context header, reply_to,
quoted_message, XML escaping, stripInternalTags (ported from v1
formatting.test.ts)
- src/modules/scheduling/recurrence.test.ts — cron TZ respected, completed
rows only cloned when recurrence is set
Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 18 + timezone-formatting-v1-recreation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive change — existing code paths still run via inline fallbacks.
Prepares core for per-module extractions in PR #3 onward.
Four registries added with empty defaults:
- delivery action handlers (delivery.ts)
- router inbound gate (router.ts)
- response dispatcher (index.ts)
- MCP tool self-registration (container/agent-runner/src/mcp-tools/server.ts)
Default modules moved to src/modules/ for signaling:
- src/modules/typing/ (extracted from delivery.ts)
- src/modules/mount-security/ (moved from src/mount-security.ts)
Both are imported directly by core — no hook, no registry. Removal
requires editing core imports.
Migrator now keys applied rows by name (uniqueness) so module
migrations can pick arbitrary version numbers. Stored version column
is auto-assigned as an applied-order sequence.
sqlite_master guards added around core calls into module-owned tables
(user_roles, agent_destinations, pending_questions). No-ops today;
load-bearing after the owning modules are extracted.
MODULE-HOOK markers placed at scheduling's two skill-edit sites
(host-sweep.ts recurrence call, poll-loop.ts pre-task gate). PR #4
replaces the marked blocks when scheduling moves to its module.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the in-chat credential-collection flow introduced in e92b245. Agents
can no longer collect API keys via a secure modal — users must add secrets
through OneCLI directly. Keeps the OneCLI manual-approval handler and
threaded-routing work from the same commit intact.
Removed:
* container/agent-runner/src/mcp-tools/credentials.ts (MCP tool)
* src/credentials.ts (host-side modal/OneCLI pipeline)
* src/db/credentials.ts + migration 005 (pending_credentials table)
* src/onecli-secrets.ts (createSecret CLI facade, only caller was credentials.ts)
* findCredentialResponse from agent-runner DB layer
* PendingCredential types
* Four credential hooks from ChannelSetup (getCredentialForModal,
onCredentialReject, onCredentialSubmit, onCredentialChannelUnsupported)
* Credential card/modal handling in chat-sdk-bridge (nccr/nccm prefixes,
Modal/TextInput imports)
* credential_request text fallback in WhatsApp adapter
* request_credential system-action case in delivery.ts
Added:
* Migration 009 drops pending_credentials on existing installs.
Vercel skill now tells the agent to ask the user to register the token via
OneCLI instead of invoking the removed tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
update_task lets the agent adjust prompt/recurrence/processAfter/script
on a live scheduled task without losing the series id the user already
knows. Empty string clears recurrence/script.
list_tasks now groups by series_id so recurring tasks show as one row
(the live pending/paused occurrence) instead of one per firing — the
id displayed is the stable series handle that update/cancel/pause/resume
all match against.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dev-agent-in-worktree approach for source self-modification is abandoned
in favor of a direct draft/activate flow with OS-level RO enforcement
(planned, not yet implemented). Strip the whole subgraph:
src/builder-agent/, pending-swaps DB module + migration 006, builder-agent
MCP tools, and all host wiring (startup sweep, approval routing, deadman,
worktree mount, freeze gate). Tool descriptions in self-mod.ts / agents.ts
no longer cross-reference create_dev_agent. CLAUDE.md + v2-checklist updated
to describe the new direction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Checkpoints the builder-agent dev-agent/worktree/swap flow (create_dev_agent,
request_swap, classifier, deadman, promote) before pivoting to a unified
draft-activate approach with OS-level RO enforcement. Lifts container_config
out of the agent_groups row into groups/<folder>/container.json so install_packages,
add_mcp_server, and rebuild flows can eventually route through the same draft
path as source edits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the agent-group-centric "main group" concept with user-level
privileges and adds the cold-DM infrastructure needed for proactive
outbound messaging (pairing, approvals, welcome flows).
Privilege model
- New tables: users, user_roles (owner global-only; admin global or
scoped to an agent_group), agent_group_members (explicit non-
privileged access; admin/owner imply membership), user_dms (cold-DM
resolution cache).
- Removed agent_groups.is_admin, messaging_groups.admin_user_id. Replaced
with messaging_groups.unknown_sender_policy (strict | request_approval
| public) for per-chat unknown-sender gating.
- src/access.ts: canAccessAgentGroup, pickApprover, pickApprovalDelivery.
- src/router.ts: access gate on every inbound, honoring
unknown_sender_policy for unknown senders.
- src/channels/telegram.ts: pairing interceptor upserts the paired user
and promotes them to owner if hasAnyOwner() is false (first-pair-wins).
Cold DM infrastructure
- ChannelAdapter.openDM?(handle) — optional method. Chat-SDK-bridge wires
it to chat.openDM() for resolution-required channels (Discord, Slack,
Teams, Webex, gChat); direct-addressable channels (Telegram, WhatsApp,
iMessage, Matrix, Resend) fall through to the handle directly.
- src/user-dm.ts: ensureUserDm(userId) — resolves + caches via user_dms.
Approval routing
- onecli-approvals + delivery use pickApprover + pickApprovalDelivery:
scoped admins → global admins → owners (dedup), first reachable via
ensureUserDm, same-channel-kind tie-break. Approvals land in the
approver's DM, not the origin chat.
Delivery fixes
- delivery.ts ACL rejection now throws instead of returning undefined —
the outer loop previously marked rejected messages as delivered.
- Implicit-origin allow: session.messaging_group_id === target skips the
destination check.
- createMessagingGroupAgent auto-creates the companion agent_destinations
row (normalized local_name from the messaging group's name, collision-
broken within the agent's namespace).
Container
- container-runner.ts: /workspace/global always read-only; drops
NANOCLAW_IS_ADMIN; adds NANOCLAW_ADMIN_USER_IDS (owners + global admins
+ scoped admins for this agent group). Agent-runner poll-loop gates
slash commands against that set.
New skill: /init-first-agent
- Walks the operator through standing up the first agent for a channel:
channel pick → identity lookup (reads each channel SKILL.md's
## Channel Info > how-to-find-id) → DM platform_id resolution (direct-
addressable, cold-DM via "user DMs bot first + sqlite lookup", or
Telegram pair-code fallback) → run scripts/init-first-agent.ts →
verify via tail of nanoclaw.log.
- scripts/init-first-agent.ts: parameterized helper that upserts the
user + grants owner (if none), creates dm-with-<display-name> agent
group + initGroupFilesystem, reuses/creates the DM messaging_group,
wires it (auto-creates destination), resolves the session, and writes
a kind:'chat' / sender:'system' welcome message into inbound.db. Host
sweep wakes the container and the agent DMs the operator via the
normal delivery path.
/manage-channels rewrite
- Drops --is-main / --jid / main-vs-non-main isolation references.
- First-channel flow delegates to /init-first-agent.
- Explains createMessagingGroupAgent auto-creates destinations.
- Adds a privileged-users show section.
setup/
- register.ts: drop --is-main, --jid, --local-name, --trigger
requiresTrigger defaults; call initGroupFilesystem; normalize to
v2 schema (no is_admin, no admin_user_id, sets unknown_sender_policy
'strict'); let createMessagingGroupAgent handle the destination row.
- pair-telegram.ts: emit PAIRED_USER_ID (namespaced "telegram:<id>")
instead of ADMIN_USER_ID; update header comment.
- register.test.ts deleted — was v1-only, tested a registered_groups
table that no longer exists.
Docs
- v2-architecture-diagram.{md,html}: ER diagram updated to drop
is_admin/admin_user_id, add unknown_sender_policy, and include
users/user_roles/agent_group_members/user_dms.
- v2-architecture-draft.md: approval-routing paragraph rewritten for
pickApprover/pickApprovalDelivery/ensureUserDm; SQL schema block
updated; admin-verification paragraph references
NANOCLAW_ADMIN_USER_IDS.
- v2-setup-wiring.md: entity-model sketch rewritten.
- v2-checklist.md: marked privilege refactor / container filtering /
approval routing / unknown-sender gating done; removed obsolete
admin_user_id and main-vs-non-main items.
Scripts
- scripts/init-first-agent.ts (new) replaces scripts/welcome-owner-dm.ts
(removed; welcome-owner was a Discord-specific one-off).
- test-v2-host.ts, test-v2-channel-e2e.ts, seed-discord.ts: drop
is_admin + admin_user_id, use unknown_sender_policy.
Tests
- src/access.test.ts (new): 14 tests for canAccessAgentGroup, role
helpers, pickApprover, ensureUserDm, pickApprovalDelivery.
- src/db/db-v2.test.ts: adds 3 tests for the auto-created
agent_destinations row (normalized name, no duplicates, collision
break within an agent group).
- host-core.test.ts, channel-registry.test.ts: updated fixtures to
use unknown_sender_policy: 'public' where the test exercises routing
rather than the access gate.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Approval cards now carry a required title (Add MCP Request, Install
Packages Request, Rebuild Request, Credentials Request) and structured
options with distinct pre-click label, post-click selectedLabel (e.g.
"✅ Approved" / "❌ Rejected"), and value used for click routing. The
title and normalized options are persisted in pending_questions so the
post-click card edit can render the correct per-type title and selected
label on both chat-sdk channels and Discord interactions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
send_file and send_message with an explicit `to` parameter were always
setting thread_id to null, causing files and messages to land in the
Discord channel root instead of the thread the session is bound to.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three features built on top of @onecli-sh/sdk 0.3.1, landed together because
they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK
bridge, channel adapter contract).
## OneCLI manual-approval handler
* `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's
`configureManualApproval`; on each request, delivers an `ask_question` card
to the admin agent group's first messaging group, persists a
`pending_approvals` row, and waits on an in-memory Promise resolved by the
admin's button click or an expiry timer. Expired cards are edited to
"Expired (...)" and a startup sweep flushes any rows left over from a
previous process.
* Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the
Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays
in the persisted payload for audit.
* Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware
columns from the start (`agent_group_id`, `channel_type`, `platform_id`,
`platform_message_id`, `expires_at`, `status`), `session_id` relaxed to
nullable so cross-session approvals fit.
* `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals
through `resolveOneCLIApproval` before falling back to the
session-bound approval path.
## Credential collection from chat
New `trigger_credential_collection` MCP tool — the agent researches a
third-party API, calls the tool with `{name, hostPattern, headerName,
valueFormat, description}`, and blocks until the host reports saved, rejected,
or failed. The credential value never enters the agent's context: the user
submits it into a Chat SDK Modal on the host side, the host writes it to
OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to
`onecli secrets create`, shape mirrors the SDK we expect upstream), and only
the status string flows back to the container via a system message.
* `src/credentials.ts` — host-side handler: delivers the card to the
conversation's own channel (not the admin channel — credential collection
is a user-facing flow, distinct from admin approval), persists a
`pending_credentials` row, drives the submit → `createSecret` → notify
pipeline. Falls back gracefully when the channel doesn't support modals.
* `src/db/credentials.ts` + migration 005: `pending_credentials` table.
* `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card,
handles the `nccr:` action prefix by opening a Modal with a TextInput,
registers an `onModalSubmit` handler for the `nccm:` callback prefix.
* `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP
tool, mirroring the `ask_user_question` polling pattern.
* `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse`
helper to pick up the system message the host writes back.
## Threaded adapter routing
The destination layer previously didn't carry thread context, so agent replies
to Discord always landed in the root channel regardless of which thread the
inbound came from.
* `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill
at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat,
Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix,
Resend, iMessage.
* `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads
collapse to channel-level sessions). Threaded adapters override the
wiring's `session_mode` to `'per-thread'` so each thread = a session
(except `agent-shared`, which is preserved as a cross-channel intent the
adapter can't know about).
* `session_routing` table in `inbound.db` — single-row default reply routing
written by the host on every container wake from
`session.messaging_group_id` + `session.thread_id`. Forward-compat
`CREATE TABLE IF NOT EXISTS` handles older session DBs lazily.
* `container/agent-runner/src/db/session-routing.ts` — container-side reader.
* `send_message` / `send_file` / `ask_user_question` / `send_card` /
scheduling tools all default their routing (channel, platform, **and**
thread) from the session when no explicit `to` is given. Explicit `to`
uses the destination's channel with `thread_id = null` (cross-destination
sends start a new conversation elsewhere).
* `poll-loop.ts::sendToDestination` (the final-text single-destination
shortcut) now inherits `thread_id` from `RoutingContext` too — this was
the root cause of Discord replies landing in the root channel even after
`send_message` was wired correctly.
## Related cleanups
* `src/container-runner.ts`: OneCLI agent identifier switched from the lossy
folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)`
a trivial reverse lookup for per-agent scoping.
* `wakeContainer` race fix via an in-flight promise map — concurrent wakes
during the async buildContainerArgs / OneCLI `applyContainerConfig` window
no longer double-spawn containers against the same session directory.
* `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema
version assertion — it had to be bumped on every migration addition.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The per-session destination map was being written as a sidecar JSON file
(/workspace/.nanoclaw-destinations.json) — inconsistent with the rest of
v2, where all host↔container IO goes through inbound.db / outbound.db.
Move it into a `destinations` table in INBOUND_SCHEMA. The host writes
it before every container wake AND on demand (e.g. after create_agent)
so the creator sees the new child destination mid-session without a
restart. The container queries the table live on every lookup — no
cache, no staleness window.
- src/db/schema.ts: add `destinations` table to INBOUND_SCHEMA.
- src/session-manager.ts: writeDestinationsFile → writeDestinations,
writes via DELETE + INSERT inside a transaction.
- src/delivery.ts: create_agent handler calls writeDestinations on the
creator's session after inserting the new destination rows.
- container/agent-runner/src/destinations.ts: queries inbound.db
directly in every findByName/getAllDestinations/findByRouting call.
No more cache. No setDestinationsForTest (obsolete). No fs import.
- container/agent-runner/src/index.ts and mcp-tools/index.ts: remove
loadDestinations() calls — no longer needed.
- Test helper initTestSessionDb creates the destinations table.
Integration test inserts a row directly instead of mocking the cache.
No backwards compatibility: sessions predating the schema update must
be recreated. This is fine on the v2 branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an agent has exactly one configured destination, wrapping output in
<message to="..."> blocks is unnecessary. Plain text goes to the sole
destination automatically. This preserves the simple "just reply" flow
for the common case of one user on one channel.
Applies in three places:
- System prompt addendum: single-destination case gets a simplified
explanation ("your messages are delivered to X, just write directly").
Multi-destination case keeps the <message to="..."> syntax docs.
- Main output parser: if zero <message> blocks are found and there is
exactly one destination, the entire cleaned text (with <internal>
stripped) is sent to that destination.
- send_message / send_file MCP tools: `to` parameter is now optional.
With one destination, omitted defaults to it. With multiple, omitting
returns an error listing the options.
Multi-destination behavior is unchanged — explicit <message to="..."> is
still required, and untagged text is still scratchpad.
groups/global/CLAUDE.md updated to describe both cases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces implicit routing context (NANOCLAW_PLATFORM_ID env vars) with
per-agent named destination maps. Agents reference channels and peer
agents by local names; the host re-validates every outbound route against
a new agent_destinations table that is both the routing map and the ACL.
Model changes:
- New migration 004 adds agent_destinations (agent_group_id, local_name,
target_type, target_id). Backfills from existing messaging_group_agents.
- Host writes /workspace/.nanoclaw-destinations.json before every container
wake so admin changes take effect on next start.
- Container loads map at startup, appends system-prompt addendum listing
available destinations and the <message to="name">…</message> syntax.
- Agent main output is parsed for <message to="..."> blocks; each block
becomes a messages_out row with routing resolved via the local map.
Untagged text and <internal>…</internal> are scratchpad (logged only).
- send_message MCP tool now takes `to` (destination name) instead of raw
routing fields. send_to_agent deleted (redundant — agents are just
destinations). send_file/edit_message/add_reaction route via map too.
- Inbound formatter adds from="name" attribute via reverse-lookup so the
agent sees a consistent namespace in both directions.
Permission enforcement:
- Host checks hasDestination() before every channel delivery AND every
agent-to-agent route. Unauthorized messages dropped and logged.
- routeAgentMessage simplified: ~15 lines, no JSON parse, content copied
verbatim (target formatter resolves the sender via its own local map).
- create_agent is admin-only, checked at both the container (tool not
registered for non-admins) and the host (re-check on receive). Inserts
bidirectional destination rows so parent↔child comms work immediately.
Includes path-traversal guard on folder name.
Self-modification cleanup:
- add_mcp_server now requires admin approval (previously had none).
- install_packages validates package names on BOTH sides (container tool
+ host receiver) with strict regex. Max 20 packages per request.
- All three self-mod tools are fire-and-forget: write request, return
immediately with "submitted" message. Admin approval triggers a chat
notification to the requesting agent — no tool-call polling, no 5-min
holds. On rebuild/mcp_server approval, the container is killed so the
next wake picks up new config/image.
- Approval delivery extracted into requestApproval() helper (the one
place where three call sites were literally identical).
Also folded in the phase-1 dynamic import cleanup (create_agent no longer
does `await import('./db/agent-groups.js')`) and removes NANOCLAW_PLATFORM_ID
/ CHANNEL_TYPE / THREAD_ID env-var routing entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eliminates SQLite write contention across the host-container mount
boundary by splitting the single session.db into two files, each with
exactly one writer:
inbound.db — host writes (messages_in, delivered tracking)
outbound.db — container writes (messages_out, processing_ack)
Key changes:
- Host uses even seq numbers, container uses odd (collision-free)
- Container heartbeat via file touch instead of DB UPDATE
- Scheduling MCP tools now emit system actions via messages_out
(host applies them to inbound.db during delivery)
- Host sweep reads processing_ack + heartbeat file for stale detection
- OneCLI ensureAgent() call added (was missing from v2, caused
applyContainerConfig to reject unknown agent identifiers)
Verified: tsc clean, 327 tests pass, real e2e through Docker works.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>