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>
Teaches agents WhatsApp's mention syntax (@<phone-digits>, never display
names) and where to find the sender's phone JID in inbound metadata
(content.sender). Without this, agents default to @<displayName>, which
WhatsApp can't tag — it just renders as plain text with no notification.
Two files:
- SKILL.md — frontmatter + description so the Claude Agent SDK can
discover it via skill metadata for ad-hoc lookups.
- instructions.md — always-on guidance. claude-md-compose.ts inlines
any skill that ships an instructions.md into every group's CLAUDE.md
on container spawn, so the rule is in the agent's context for every
reply (not just when the agent decides to invoke the Skill tool).
Mirrors the existing container/skills/slack-formatting/ layout for the
analogous Slack mrkdwn rules. Pairs with the adapter-side fix on the
`channels` branch that wires `mentions` through to Baileys' contextInfo
— both layers are needed for tags to render end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to #2467. The trailing "anything outside these tags is also
treated as scratchpad" clause contradicted the rest of the system prompt,
which requires bare text to be wrapped in `<message>` blocks. Removing it
keeps the description focused on what `<internal>` actually does.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The welcome skill told the agent to send the greeting via `send_message`,
but the destinations system prompt also requires the final response to
be wrapped in `<message to="…">` blocks (since 1d4d920). The agent
followed both, sending the greeting once via the MCP tool and once via
the wrapped final output.
- welcome/SKILL.md: drop the mechanism — "send a short, warm greeting"
lets the system prompt steer how it's delivered.
- destinations.ts: reframe `<message>` blocks and `send_message` as the
same delivery surface, with the explicit note that each call/block
lands as its own message — so they compose into a sequence rather than
reading as additive duplicates of the same content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The parenthetical "(single-destination: just write)" was stale after
9db39b2 removed the bare-text routing fallback. Agents following this
hint had their responses silently dropped to scratchpad.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the agent outputs bare text without <message to="..."> blocks,
nothing gets delivered — silent failure. Now the poll-loop pushes a
one-shot correction back into the active query telling the agent to
re-send with proper wrapping. Capped at once per user turn to avoid
loops; resets when a new follow-up message arrives.
Also updates destination instructions to require explicit <internal>
wrapping for scratchpad instead of treating bare text as implicit
scratchpad.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tell the compactor to include the <message to="name"> wrapping reminder
verbatim at the END of the summary so it's the last thing the agent sees
after compaction. Previously the instruction just asked to "preserve"
routing info, which the compactor could place anywhere or summarize away.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The compacted event handler injected a system-tagged reminder into the
live query after SDK auto-compaction, which caused the agent to send
an unintended message. Reverts the four changes from #2327:
- Remove `compacted` variant from ProviderEvent union
- Restore `result` yield for compact_boundary in ClaudeProvider
- Remove compacted event handler and getAllDestinations import in poll-loop
- Remove compaction integration tests and CompactingProvider helper
Closes#2325 differently — the reminder approach is not viable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The container opens inbound.db read-only, so it can't ALTER TABLE.
If the host hasn't run migrateMessagesInTable yet (e.g., container
rebuilt before host restart), the on_wake column won't exist and
the query crashes, causing a restart loop.
Detect the column via PRAGMA table_info and conditionally include
the filter clause.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sweep of outbound strings, doc URLs, comments, and clone instructions
that were missed in the original org rename. One both-match case in
setup/lib/channels-remote.sh (URL detection) accepts either name so
existing forks with a `qwibitai` remote continue to resolve cleanly;
everywhere else is a straight rename.
Historical mentions left intact:
- CHANGELOG.md (v2.0.0 entry, frozen history)
- .claude/skills/add-gmail-tool/SKILL.md (pre-v2 qwibitai skill — historical)
- repo-tokens/badge.svg (auto-regenerated by update-tokens.yml)
12 patch versions ahead. The 2.1.120 binary baseline introduced a
number of plugin and skill behaviors that have since landed in the
public Claude Code docs: ${CLAUDE_EFFORT} substitution, settled
`arguments` field in skill frontmatter, plugin `channels` field.
No breaking changes for nanoclaw's runtime contract. Verified by
running container/skills/{agent-browser,vercel-cli,slack-formatting}
under the bumped image; all three load and execute as expected.
SDK at ^0.2.116 (caret) remains compatible with claude-code 2.1.128.
Bumping CLAUDE_CODE_VERSION invalidates the pnpm install layer in
container/Dockerfile and triggers a full rebuild of the agent image.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Allow individual agent groups to opt into different models or effort levels
without changing host-wide defaults. Useful when one group is high-stakes
(opus, high effort) but most are routine (sonnet/haiku, low effort).
container.json gains two optional fields:
- model: alias ("sonnet" | "opus" | "haiku") or full model ID
- effort: "low" | "medium" | "high" | "xhigh" | "max"
Both omitted = SDK default (current behavior). The host plumbs them as
NANOCLAW_MODEL / NANOCLAW_EFFORT env vars at container spawn time; the
agent-runner reads them in providers/index.ts and threads through to the
provider via ProviderOptions. The Claude provider passes them straight to
sdkQuery options.
`effort` is currently typed as `any` because the @anthropic-ai/claude-
agent-sdk type doesn't surface it yet — passing it through still works at
runtime via the SDK's loose option handling. Drop the cast once the SDK
adds an `effort` field to its options type.
One-liner in cli.instructions.md pointing to `ncl groups config help`.
Each config operation's description now says whether restart or rebuild
is needed — agent discovers it via progressive disclosure.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Decouple container restart from config updates — config CLI ops now only
write to the DB; restart is a separate `ncl groups restart` command with
--rebuild and --message flags. Add on_wake column to messages_in so wake
messages are only picked up by a fresh container's first poll, preventing
dying containers from stealing them during the SIGTERM grace window.
killContainer accepts an onExit callback for race-free respawn. Agent-
called restart auto-scopes to the calling session.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`ncl groups config get` now works alongside `ncl groups config-get`.
Parser joins all positionals with dashes; dispatcher falls back by
trimming the last segment as a target ID (`ncl groups get abc123`).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- genericList now accepts column filters (--flag value) and LIMIT (default 200)
- Remove early inDb.close() in container pollResponse to avoid double-close
- Document filtering and --limit in cli.instructions.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rename the CLI binary, socket path, container wrapper, error prefixes,
and all references from `nc` to `ncl`. Add ~/.local/bin symlink during
setup and pnpm script alias.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the dynamic `onecli.getGatewaySkill()` fetch from `buildMounts` —
the skill content ships as a static SKILL.md. This avoids adding latency
to every container spawn and dirtying the source tree at runtime.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Auto-discovered by composeGroupClaudeMd() as module-cli.md fragment,
included in every agent group's composed CLAUDE.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Squash merge of PR #2267 by ddaniels.
When an agent group has more than one active session, A2A replies landed
in the newest session via findSessionByAgentGroup's ORDER BY created_at
DESC. The session that asked the question never saw the answer.
Adds origin-aware return-path routing with three layers:
1. Direct return-path: if the reply has in_reply_to, look up the
triggering inbound row's source_session_id and route there.
2. Peer-affinity fallback: find the most recent A2A inbound from this
peer and use its source_session_id.
3. Legacy fallback: newest active session (pre-migration compat).
Container-side: MCP send_message/send_file now thread the current
batch's in_reply_to through to outbound rows via current-batch.ts.
Also flips our A2A bug-documenting test (#2332) from asserting the
broken behavior to asserting the fixed behavior.
Co-Authored-By: Doug Daniels <ddaniels888@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Host-side (vitest):
- Routed message preserves platformId/channelType/threadId on messages_in
- Fan-out gives each agent correct per-agent routing
- writeSessionRouting populates session_routing from messaging group
- writeSessionRouting writes null routing for agent-shared sessions
- Per-thread session includes thread_id in session_routing
- Agent-shared resolves to same session on repeated calls
- Agent-shared session has null messaging_group_id
- findSessionByAgentGroup returns channel-bound session (documents #2332)
- Skip: agent-shared/channel-bound coexistence (blocked on #2332 fix)
Container-side (bun:test):
- Internal tags stripped between message blocks
- Mixed task + chat batch with correct routing
The agent-shared tests uncovered the exact bug from #2332:
findSessionByAgentGroup doesn't distinguish agent-shared from
channel-bound sessions, so A2A resolution reuses a channel session
when one exists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 14 tests covering key routing and dispatch flows that previously had
zero direct coverage:
dispatchResultText:
- bare text produces no outbound (scratchpad only)
- unknown destination dropped, valid destination sent
- multiple <message> blocks each produce correct outbound
- internal tags stripped from scratchpad
originAttr / from= metadata:
- chat/task/webhook/system messages include from= when destination matches
- fallback to raw unknown:channel:platform when no match
- from= omitted when routing is null
resolveDestinationThread:
- null thread_id when no prior inbound from destination
- most recent thread_id wins with multiple inbound messages
Also fix merge issue: restore getAllDestinations import removed by our PR
but still needed by #2327's compaction reminder. Fix stale destinations
test assertion from #2328 ("no special wrapping needed" → "Every response
must be wrapped").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Corepack with no version pin pulls latest pnpm (currently 11.0.8), which
silently stops honoring `only-built-dependencies[]=` in `.npmrc` for
global installs. The allowlist file ends up correctly written but
ignored, so:
- `@anthropic-ai/claude-code`'s postinstall — which downloads the
platform-native Claude binary — never runs. Agents then crash at
runtime with "claude native binary not installed... postinstall did
not run."
- `agent-browser`'s postinstall, which chmods the linux-arm64 binary,
is also skipped, so the binary fails with EPERM the first time it's
invoked.
Pin the container's pnpm to 10.33.0 (the same version host's
package.json already pins via `packageManager`). Keep the two in
lockstep so a host bump triggers a deliberate container bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add integration test for per-destination thread_id resolution: seeds two
destinations with different thread IDs, verifies each outbound message
carries the correct thread_id (not a global one from the batch routing).
- Add log line in resolveDestinationThread catch block for debuggability.
- Remove stray "(ensurePreCompactHook is defined after the main function.)"
comment from group-init.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The poll loop had a bare-text routing fallback in dispatchResultText: when
the agent produced text without <message to="..."> wrapping, it would auto-
route to the session's originating channel (via a frozen RoutingContext) or
to the single configured destination. This caused three problems:
1. Routing drift: RoutingContext was extracted once from the initial batch
and never refreshed. When the initial batch was a null-routed cron task
and a real chat arrived mid-query, replies were silently dropped to
scratchpad because the frozen routing had all-null fields.
2. Cross-channel thread bleed: sendToDestination applied a single
routing.threadId to every outbound message regardless of destination.
In agent-shared sessions (multiple channels sharing one session), one
channel's thread ID was stamped onto messages to a different channel.
3. Inconsistent formatting: task, webhook, and system messages had no
origin metadata in their formatted output, so the agent couldn't tell
which destination they came from — even when the underlying messages_in
rows carried routing fields.
Changes:
- Remove the bare-text routing fallbacks in dispatchResultText (both the
routing-based and single-destination shortcuts). All agent output must
be wrapped in <message to="name">...</message>. Bare text is scratchpad.
- Update buildDestinationsSection() to require explicit wrapping for all
groups, including single-destination. No more "no special wrapping
needed" shortcut.
- Resolve thread_id per-destination via resolveDestinationThread(), which
queries messages_in for the most recent message matching the target
channel+platform. Falls back to null (top-level channel message) when
no prior inbound exists for that destination.
- Extract originAttr() helper in formatter.ts and apply it to all message
types. Tasks now render as <task from="dest" time="...">, webhooks as
<webhook from="dest" source="..." event="...">, system responses as
<system_response from="dest" ...>. The agent always sees where a
message originated.
- Add a PreCompact shell hook (compact-instructions.ts) that outputs
custom compaction instructions, telling the compactor to preserve
recent message XML structure and routing metadata in the summary.
Wired via settings.json in the .claude-shared scaffold, with a
migration path (ensurePreCompactHook) for existing groups.
Relation to open PRs:
- #2277 (mergeRouting) becomes unnecessary — the routing fallback it
patches no longer exists. Can be closed.
- #2327 (post-compaction destination reminder) is complementary — it
handles the post-compaction push, this handles pre-compaction
instructions. Both can merge independently.
- #2328 (default routing instruction) is complementary — it adds "reply
to the from= destination" guidance to the multi-destination section.
Compatible with the unified instruction format here.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closesqwibitai/nanoclaw#2325.
When the Claude Code SDK auto-compacts the conversation context, the
compaction summary tends to drop the agent's learned <message to="…">
wrapping discipline. The destinations table is still populated and the
system prompt still lists them, but the behavioral pattern degrades —
A2A sends and multi-channel routing silently revert to bare-text or
single-channel delivery for the rest of the session, until the next
/clear.
Three small changes wire a reminder back into the live query when this
fires:
- New `compacted` event on ProviderEvent. Distinct from `result` so it
doesn't mark the turn completed or get dispatched as a chat message
(which is also why "Context compacted (N tokens compacted)." stops
appearing as noise in user-facing chats — it was a side-effect of
reusing the result event path).
- ClaudeProvider yields `compacted` instead of `result` for the SDK's
compact_boundary system event.
- Poll-loop's event handler reacts by pushing a system-tagged reminder
back into the active query when there are >1 destinations. Single-
destination groups skip the push since they have a fallback that
works without wrapping.
Tests cover both branches (multi-destination → reminder fires;
single-destination → no reminder) using a CompactingProvider that
emits the new event mid-stream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>