container.json's resolved harnessCapabilities map threads RunnerConfig →
ProviderOptions → ClaudeProvider. The fixed disallow set gains DesignSync
(desktop tool, nothing to sync with headless; 9.3KB/turn schema);
buildDisallowedTools adds Workflow unless workflow=on — defense-in-depth
behind the host-reconciled disableWorkflows settings key, fail-closed on
absent/garbage state. preToolUseHook becomes a factory so the hook blocks
the same per-instance list. Unknown resolved keys warn at construction;
agent-teams is known-but-host-managed so it never warns. Scheduling
instructions gain the harness-task-list vs schedule_task naming note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TOOL_ALLOWLIST named five tools that don't exist on claude-code 2.1.197
(Task was renamed Agent upstream; TodoWrite, TeamCreate, TeamDelete, and
ToolSearch are gone). Remove the phantoms and correct the comment (and
the stale claim in docs/agent-runner-details.md): allowedTools is a
permission auto-approve list, not an availability filter — paired wire
captures show no allowlist effect on the offered tool surface, and this
runner's bypassPermissions moots its permission role. No wire-visible
change.
Add a wire-captured fixture (sdk-tools-baseline.json, regenerated via
dump-sdk-tools.ts inside the agent image with a zero-API-cost 401-stub
capture) and claude.tools.test.ts, which fails on any future
claude-code CLI or Agent SDK bump until the fixture is regenerated and
the lists re-verified. The SDK dep is pinned exactly (0.3.197, was
caret) so drift can only enter deliberately.
Wire measurement on this pin also shows run-to-run NONDETERMINISM in
the offered surface: conditional tools (Glob/Grep) flicker, and
disallowedTools stripping of flag-gated tools is per-query best-effort.
The fixture pairs captures within one regen run to control for this,
records a disallow probe as a diagnostic, and the dump-script header
documents the variance; the deterministic tool block remains the
runner's PreToolUse hook.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
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
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
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
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>
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>
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>
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.
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>
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>
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>
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>
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
The "read imported-agent-memory.md, treat it as binding" doctrine sat in the
memory definition that every group loads, but it only matters when an import
actually happened. Move it into the /migrate-memory skill — the step that
writes the imported file and its index pointer (which the agent inlines into
its prompt each turn) — and drop the always-on block from definition.md.
Addresses review feedback on #2756.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent's global Node CLIs (claude-code, agent-browser, vercel) were each
a hardcoded ARG + RUN layer in the Dockerfile, so adding or bumping one meant
editing the Dockerfile — a code reach-in every tool-installing skill had to make.
Move the tool list into container/cli-tools.json. A skill now adds a CLI by
appending a {name, version} entry (a json-merge) — the safest change shape:
deterministic, idempotent, removable. install-cli-tools.sh parses the manifest
with node (no new jq dep), writes the per-tool only-built-dependencies opt-ins,
and runs one pinned `pnpm install -g`, so the pnpm supply-chain path is unchanged.
Behavior is byte-for-byte: same opt-ins, same pinned installs. agent-browser is
now pinned (0.27.1, what `latest` last resolved to) instead of floating.
container/cli-tools.test.ts guards the seam: red if a baseline tool is dropped,
a version unpins, or the Dockerfile wiring / pnpm path is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the agent provider a first-class, operator-chosen property instead of a
Claude-only assumption. Trunk gains the seams; the actual non-default payloads
(Codex first) install from the `providers` branch.
Setup
- A provider registry feeds a hard-wired setup picker (Claude | Codex). Picking
a non-default provider installs its payload (setup/add-codex.sh, channel-style),
runs a vault-only auth walkthrough (--step provider-auth), and records the pick
on the first agent before its first spawn.
- Picking Claude changes nothing — default installs are byte-for-byte unaffected.
Provider as a DB property
- Provider lives on container_configs.provider (materialized to container.json,
read by resolveProviderName). Creation stays provider-agnostic; the picked
provider is applied via the picked-provider seam. The deprecated
agent_groups.agent_provider path is not used.
Switching + memory
- Switch a live group with `ncl groups config update --provider` + restart.
- Memory never migrates at runtime — each provider keeps its own store. The
/migrate-memory skill carries a group's memory across a switch in either
direction (flat CLAUDE.local.md <-> memory/ scaffold). group-init seeds an
imported-agent-memory note for non-default providers; the runner's memory
definition reads it first turn. See docs/provider-migration.md.
No install-wide default, no runtime provider guard — switching is operator-by-
convention, consistent with the no-install-gating posture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inverts conversation archiving into an optional onExchangeComplete provider
hook: the runner never archives on a provider's behalf, and the markdown
writer ships with the provider that needs it. Dormant for the default
provider.
Slash commands now interrupt an in-flight turn — a runner-handled command
(/clear, /compact, /cost, …) arriving mid-turn aborts the active stream and
runs immediately instead of waiting out the turn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
create_agent writes central-DB state (agent_groups, container_configs,
agent_destinations) and scaffolds host filesystem state, but the only
gate lived inside the untrusted container and is bypassed by writing the
outbound system row directly (the "host re-checks permission" comment was
false). Authorize host-side by CLI scope: trusted owner agent groups
(global scope) create sub-agents directly; confined groups require admin
approval via requestApproval. Adds regression tests for the branch.
Alternative to #2383 (which denies confined groups outright); co-authored
from that work.
Co-Authored-By: hinotoi-agent <paperlantern.agent@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare * in the pre-filled secret_url path doesn't survive (the gateway
URL-encodes everything, so an unencoded * collapses to just /, which only
exact-matches the path /). Leave the path blank instead so the created
secret matches all of huggingface.co, not a single endpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop "(host pattern pre-filled)" and "— no restart needed" from the HF
setup instructions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The not-signed-in message hardcoded both a local and a hosted OneCLI
dashboard URL because the container can't tell which gateway it's behind.
But the gateway already tells us: a credential-less proxied request comes
back with the right URL in its error body —
- credential_not_found → secret_url (pre-filled "new secret" form)
- access_restricted → manage_url (grant this agent access)
- app_not_connected → connect_url
Capture whoami's body + status (drop -f so the JSON survives the 401),
extract that URL, and present it. It's always the correct gateway, local
or hosted, with zero extra wiring. The secret_url's pre-filled `path`
defaults to the failing request path (/api/whoami-v2), so broaden it to
/* — otherwise the created secret wouldn't cover the upload endpoints.
Falls back to generic text when there's no gateway JSON to read.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The default OneCLI secret mode for auto-created agents is `all`, not
`selective` — a fresh agent created via ensureAgent({name, identifier})
comes back with secretMode "all", so matching vault secrets inject
automatically. Drop the now-unnecessary per-agent assignment step.
- upload-trace.ts: remove step 3 (set-secret-mode) from the not-authed
message; creating the token and adding it to the vault is enough
- CLAUDE.md: trim the secret-mode gotcha to reflect `all` as the default
- init-onecli skill: replace stale `onecli start` (gone in 1.4.x) and the
`ps aux | grep onecli` check with the real Docker Compose start path
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a runner-handled /upload-trace slash command (admin-gated, like /clear)
that uploads the current session's Claude Code transcript to the user's own
private {hf_user}/nanoclaw-traces dataset, browsable in the HF Agent Trace
Viewer. The transcript is already in the format the viewer auto-detects, so
the command just locates the newest one and pushes it via the Hub commit API.
Auth is handled by the OneCLI gateway: curl goes out through the injected
HTTPS_PROXY, which adds the user's HF token — no credential ever touches
agent code. A missing/unassigned token yields a clear setup message.
- container/agent-runner/src/upload-trace.ts: isUploadTraceCommand() + uploadTrace()
- poll-loop.ts: recognize and handle /upload-trace in the runner
- command-gate.ts: admin-gate /upload-trace on the host
- upload-trace.test.ts: unit + integration coverage for the command
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude-code CLI 2.1.128 -> 2.1.154 (Dockerfile build-arg). agent-runner SDK 0.2.128 -> 0.3.154: the 0.3 major moved @anthropic-ai/sdk and @modelcontextprotocol/sdk from regular deps to peer deps, so add @anthropic-ai/sdk ^0.100.0 as a direct dep and raise @modelcontextprotocol/sdk to ^1.29.0. Regenerate bun.lock. Typecheck + agent-runner tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent-runner runs the Agent SDK with settingSources: ['project', 'user'], which omits 'local'. Per the SDK docs the 'local' source is what loads CLAUDE.local.md (the 'project' source loads CLAUDE.md). So every group's CLAUDE.local.md is silently never read, even though container/CLAUDE.md tells each agent to use it as per-group memory.
Closes#2185.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The follow-up poll catches and logs SQLite errors but never recovers
from them. On Docker Desktop macOS, the kernel page cache for the
inbound.db bind mount can latch a torn snapshot mid-host-write (a known
virtiofs / gRPC-FUSE coherency issue), after which every fresh
openInboundDb() in the same process sees the same broken view and
emits 'database disk image is malformed' at the poll rate (2/sec).
Reopening the DB handle inside the container does not recover — only
a fresh container mount does. The fix: after CORRUPTION_STREAK_EXIT
consecutive corruption errors (~5s), log a clear message and
process.exit(75) so host-sweep respawns the container with a fresh
mount. Transient single torn reads are still tolerated.
- Add isCorruptionError() helper covering the three SQLite read-side
corruption symptoms (disk image malformed, SQLITE_CORRUPT, file is
not a database).
- Add streak counter scoped to processQuery's pollHandle so it resets
on any successful or non-corruption error.
- Add unit tests for the matcher.
Refs the cross-mount invariants documented in db/connection.ts:11-18.
fe2e881b (#2556) removed the <messages> wrapper from formatChatMessages
so the Claude Agent SDK calls the API instead of emitting a synthetic
stub, but poll-loop.test.ts still asserted the wrapper. The test has
failed on every PR built against main since. Assert the current shape:
no envelope, one self-contained <message> block per message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS=0 (or negative) is documented to
disable age-based rotation, but transcriptRotateAgeMs() routed it
into the same branch as an unset var and returned the 14-day default.
Sessions intentionally configured to stay long-lived were still
rotated at 14 days, causing unexpected resets and context loss.
Distinguish unset/non-numeric (default 14d) from an explicit
non-positive override (Infinity = disabled; size alone governs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A long-lived hub session never rotates its continuation, so the on-disk
.jsonl grows without bound — days of history plus base64 image blocks the
agent Read (screenshots from QA lanes, etc.). The SDK reloads the whole
transcript on every --resume, and past a threshold the first turn alone
exceeds the host's 30-min idle ceiling: the container is SIGKILLed before
it can reply, then the next message repeats the cycle forever. Symptom:
a hub that was responsive for days suddenly goes silent on a heavy turn.
Before resuming, the Claude provider now checks the transcript backing the
stored continuation; if it exceeds a size cap (default 12MB) or age cap
(default 14 days, from the first entry's timestamp) it archives a markdown
summary to conversations/ and starts a fresh session. Both caps are
operator-overridable via CLAUDE_TRANSCRIPT_ROTATE_BYTES /
CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS. The PreCompact archiver is refactored
into a shared archiveTranscriptFile() reused by the rotation path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When 2+ pending messages were bundled into <messages>...</messages> at
container/agent-runner/src/formatter.ts:162-167, the Claude Agent SDK
responded with a synthetic stub (model="<synthetic>", stop_reason=
"stop_sequence", content="No response requested.") instead of calling
the real API. The poll loop never yielded a `result` event, so the
inbound message was never marked completed; the container exited; the
next sweep tick respawned it with the same batch; same synthetic; the
transcript file ballooned with each retry until tries=5 → failed.
Single-message turns (which skipped the wrapper) worked normally — the
SDK's heuristic appears to treat the wrapped envelope as a context dump
rather than a real user turn. Each `<message id=... from=...>...</message>`
block is already self-contained, so dropping the outer wrapper lets the
N>1 case work the same way the N=1 case always has.
Fix:
function formatChatMessages(messages: MessageInRow[]): string {
return messages.map(formatSingleChat).join('\n');
}
Updates one existing test that asserted on the envelope, and adds two
regression tests: one negative (no `<messages>` wrapper), one positive
(each inbound row produces a `<message>` block in order).
Confirmed working in a real install: two stuck lanes recovered after
reducing their pending queue to 1 message, and both produced normal
replies from claude after the wipe + this fix were both applied (the
wipe alone wasn't enough — a fresh session given the same batch shape
hit the same synthetic loop).
Refs nanocoai/nanoclaw#2555 for full repro + transcript evidence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>