From 4d5af78d3589e868cbbb7a05ca407fabf56f79ad Mon Sep 17 00:00:00 2001 From: Ethan Munoz Date: Tue, 5 May 2026 23:34:14 +0200 Subject: [PATCH 01/55] fix(migrate-v2): probe correct OneCLI health endpoint (/api/health) migrate-v2.sh probes ${ONECLI_URL_CHECK}/health (with ONECLI_URL_CHECK defaulting to http://127.0.0.1:10254, the OneCLI web port). That path returns 404, so the detection branch never matches an already-running OneCLI instance and the script falls through to the install path. The web app's health endpoint is /api/health (apps/web/src/app/api/health/route.ts) and has been since the OneCLI repo was made public. /health was never exposed by the web on :10254 nor by the gateway on :10255 (the gateway uses /healthz). Verified against a running OneCLI v1.21.0: GET :10254/api/health -> 200 {"status":"ok","version":"1.21.0",...} GET :10254/health -> 404 (Next.js fallback HTML) GET :10255/healthz -> 200 GET :10255/health -> 400 (gateway parses non-/healthz as CONNECT) Closes #2285 --- migrate-v2.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index 2325edd..ef3bda8 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -450,7 +450,7 @@ ONECLI_OK=false ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//') ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}" -if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then +if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")" ONECLI_OK=true log "OneCLI: running at $ONECLI_URL_CHECK" From ec23bd7a7e0c853fa838715fb0d5dfabd6eedd6e Mon Sep 17 00:00:00 2001 From: Ethan Munoz Date: Tue, 5 May 2026 23:49:18 +0200 Subject: [PATCH 02/55] fix(host-sweep): parse SQLite timestamps as UTC, not local time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite TIMESTAMP columns store UTC without a zone marker. `Date.parse` treats timezoneless ISO strings as local time, so on any non-UTC host every claim and processAfter looks (TZ offset) hours stale. That makes fresh claims trip the kill-claim path on the first sweep tick — every container gets killed within seconds of spawn. Two affected sites in host-sweep.ts: - decideStuckAction reads claim.status_changed and computes claimAge. On a TZ=Europe/Madrid host (UTC+2), a claim made 5s ago looks 7205s old and exceeds CLAIM_STUCK_MS (60s). - The orphan retry loop reads msg.processAfter and skips messages rescheduled into the future. On the same host, future timestamps look (TZ offset) hours in the past, so the skip is missed and tries gets bumped on every tick. Fix: introduce parseSqliteUtc(s) which appends "Z" only when no zone marker is present, then call it from both sites. Behavior under TZ=UTC is unchanged. Verified on a production v2 install on TZ=Europe/Madrid: with the patch applied, an idle container survived 30+ minutes without being killed (previously: killed within 60s of spawn). Tests: 5 new cases covering the bare/Z/+offset/invalid input matrix and a TZ-independence check. All 19 host-sweep tests pass and tsc clears against main. --- src/host-sweep.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/host-sweep.ts | 15 +++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index bd2e233..0249f4d 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -12,6 +12,7 @@ import { CLAIM_STUCK_MS, _resetStuckProcessingRowsForTesting, decideStuckAction, + parseSqliteUtc, } from './host-sweep.js'; import type { Session } from './types.js'; @@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { expect(row.tries).toBe(1); // not bumped, the skip path held }); }); + +describe('parseSqliteUtc', () => { + // Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse + // treats those as local time. On non-UTC hosts this made every claim look + // (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages. + // The helper appends "Z" only when no marker is present, so parsing is + // always anchored to UTC regardless of host timezone. + + const utcMs = Date.parse('2026-04-20T12:00:00.000Z'); + + it('treats a SQLite-style timestamp (no zone) as UTC', () => { + expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs); + }); + + it('preserves an explicit Z marker', () => { + expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs); + }); + + it('preserves an explicit numeric offset', () => { + // 14:00+02:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs); + // 07:00-05:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs); + }); + + it('returns NaN for unparseable input', () => { + expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true); + }); + + it('does not drift across host timezones for SQLite-style input', () => { + // The helper itself is timezone-independent because it forces UTC parsing. + // (Verifying the regex branch — without the helper, `Date.parse` of the + // bare string returns different values depending on the host TZ.) + const bare = '2026-04-20T12:00:00'; + expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z')); + }); +}); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 93a7e87..fbdd7e6 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; import type { Session } from './types.js'; +/** + * SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse + * treats timezoneless ISO strings as local time, so on non-UTC hosts every + * timestamp looks (TZ offset) hours stale — leading to spurious kill-claim + * decisions on freshly-claimed messages. Append "Z" when no zone marker is + * present so Date.parse interprets the string as UTC. + */ +export function parseSqliteUtc(s: string): number { + return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z'); +} + const SWEEP_INTERVAL_MS = 60_000; // Absolute idle ceiling for a running container. If the heartbeat file hasn't // been touched in this long, the container is either stuck or doing genuinely @@ -95,7 +106,7 @@ export function decideStuckAction(args: { const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0); for (const claim of claims) { - const claimedAt = Date.parse(claim.status_changed); + const claimedAt = parseSqliteUtc(claim.status_changed); if (Number.isNaN(claimedAt)) continue; const claimAge = now - claimedAt; if (claimAge <= tolerance) continue; @@ -275,7 +286,7 @@ function resetStuckProcessingRows( // Already rescheduled for a future retry — don't bump tries again. The // wake path (sweep step 2) will fire when process_after elapses and a // fresh container will clean the orphan claim on startup. - if (msg.processAfter && Date.parse(msg.processAfter) > now) continue; + if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue; if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id); From 2db5173f07de03a3fc849d72ceea29f890a4b3b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 21:56:17 +0000 Subject: [PATCH 03/55] chore: bump version to 2.0.33 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 96f4ae9..3f4794c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.32", + "version": "2.0.33", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From eacb93c4e5071189bf43d0438c41f0d2296e7804 Mon Sep 17 00:00:00 2001 From: Ethan Munoz Date: Wed, 6 May 2026 00:29:54 +0200 Subject: [PATCH 04/55] fix(manage-channels): include canonical SQL queries in SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill's "Assess Current State" step said only "query agent_groups, messaging_groups, ..." without specifying columns. The `register` CLI takes `--assistant-name ""` (mentioned three times in the same SKILL.md), but the schema column is `name`, not `assistant_name` — and the SKILL.md never linked the two. When the agent had to compose a SELECT against `agent_groups` from the SKILL.md vocabulary alone, it extrapolated `--assistant-name` into a column name and produced: SELECT id, folder, assistant_name FROM agent_groups; -> Error: in prepare, no such column: assistant_name Replace the prose pointer with canonical SQL queries that match the real schema. The `name AS assistant_name` alias preserves the familiar term in the agent's output. Verified locally as a drop-in: `/manage-channels` runs clean from end to end with this version, no further inference needed. Closes #2289 --- .claude/skills/manage-channels/SKILL.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 9d84d3d..0b348d1 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -11,7 +11,16 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user ## Assess Current State -Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. +Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`): + +```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 user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC; +``` + +Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**. From 22715c163a1c8ef206194555762a4627a55124ca Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 01:36:13 +0300 Subject: [PATCH 05/55] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 69f9ea2..f364d27 100644 --- a/README.md +++ b/README.md @@ -215,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist ## License MIT + + From a36acd3413b53d1670d478aaadb7b6afb20c72d8 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 6 May 2026 09:27:09 +0000 Subject: [PATCH 06/55] setup: tidy Slack app-creation card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the "Get started: …" URL above the numbered instructions and render it in bright white so it pops against the brand-cyan body. (Headless-only — interactive runs still auto-open the URL in a browser, no card line.) - Group the OAuth scope list vertically by family (im, channels, groups, chat, users, reactions) instead of one comma-run wall. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/slack.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0918075..f4bbdd1 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -27,7 +27,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; -import { formatNoteLink, openUrl } from '../lib/browser.js'; +import { openUrl } from '../lib/browser.js'; import { isHeadless } from '../platform.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; @@ -126,22 +126,31 @@ export async function runSlackChannel(displayName: string): Promise { + // Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s + // per-line formatter so the URL stands out against the rest of the body. + const linkBlock = isHeadless() + ? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, ''] + : []; + note( [ "You'll create a Slack app that the assistant talks through.", "Free and stays inside the workspaces you pick.", '', + ...linkBlock, ' 1. Create a new app "From scratch", name it, pick a workspace', ' 2. OAuth & Permissions → add Bot Token Scopes:', - ' chat:write, im:write, channels:history, groups:history,', - ' im:history, channels:read, groups:read, users:read,', - ' reactions:write', + ' • im:write, im:history', + ' • channels:read, channels:history', + ' • groups:read, groups:history', + ' • chat:write', + ' • users:read', + ' • reactions:write', ' 3. App Home → enable "Messages Tab" and "Allow users to send', ' slash commands and messages from the messages tab"', ' 4. Basic Information → copy the "Signing Secret"', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', - formatNoteLink(SLACK_APPS_URL), - ].filter((line): line is string => line !== null).join('\n'), + ].join('\n'), 'Create a Slack app', ); From 0d7458c6f371c13e9041b2b551466d20f8a4d76a Mon Sep 17 00:00:00 2001 From: NanoClaw bot user Date: Wed, 6 May 2026 19:38:33 +0200 Subject: [PATCH 07/55] fix(skills): replace sqlite3 CLI with in-tree better-sqlite3 wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup deliberately avoids the sqlite3 CLI (`setup/verify.ts:5` calls this out: "Uses better-sqlite3 directly (no sqlite3 CLI)") and never installs or probes for the binary. Despite that, 13 skills shelled out to `sqlite3 ...` directly, breaking on hosts where the CLI isn't preinstalled — the same root cause as #2191 but spread across the skill surface. Add `scripts/q.ts`, a ~30-LOC wrapper over the `better-sqlite3` dep that setup already installs and verifies. Default output matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically — only the binary changes. SELECT/WITH queries go through `db.prepare().all()`; everything else (INSERT/UPDATE/DELETE, including compound statements) goes through `db.exec()`. Migrate every in-tree caller: - 17 hardcoded invocations across 8 SKILL.md files (init-first-agent, add-deltachat, add-signal, add-emacs, add-whatsapp, add-ollama-provider, debug, add-parallel) plus add-deltachat/VERIFY.md. - `manage-channels/SKILL.md` shows canonical SQL but never prescribed a tool, so the assistant defaulted to `sqlite3` and silently failed. Add a one-line wrapper hint above the SQL block. - `migrate-v2.sh` schema/count probes (was the original #2191 case). Replace `.tables` with `SELECT name FROM sqlite_master`. - Document the wrapper convention in root `CLAUDE.md` under "Central DB". Add `scripts/q.test.ts` with 6 vitest cases covering both modes, NULL rendering, empty-result, compound mutations, and arg validation. Wire `scripts/**/*.test.ts` into `vitest.config.ts`. Out of scope (flagged for follow-up): - `debug` and `add-parallel` still reference the v1-only path `store/messages.db`. Routing through the wrapper now produces a cleaner "no such file" error, but the surrounding sections are v1-era throughout — a v1-content cleanup is its own PR. - `cleanup-sessions.sh` is being addressed in #1889 (different style, hard-fail rather than wrap); left untouched here to avoid stepping on that author's work. Closes #2191. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-deltachat/SKILL.md | 6 +- .claude/skills/add-deltachat/VERIFY.md | 4 +- .claude/skills/add-emacs/SKILL.md | 4 +- .claude/skills/add-ollama-provider/SKILL.md | 4 +- .claude/skills/add-parallel/SKILL.md | 2 +- .claude/skills/add-signal/SKILL.md | 8 +- .claude/skills/add-whatsapp/SKILL.md | 4 +- .claude/skills/debug/SKILL.md | 2 +- .claude/skills/init-first-agent/SKILL.md | 4 +- .claude/skills/manage-channels/SKILL.md | 8 +- CLAUDE.md | 2 + migrate-v2.sh | 12 ++- scripts/q.test.ts | 95 +++++++++++++++++++++ scripts/q.ts | 46 ++++++++++ vitest.config.ts | 2 +- 15 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 scripts/q.test.ts create mode 100644 scripts/q.ts diff --git a/.claude/skills/add-deltachat/SKILL.md b/.claude/skills/add-deltachat/SKILL.md index 45aa416..3dd5df6 100644 --- a/.claude/skills/add-deltachat/SKILL.md +++ b/.claude/skills/add-deltachat/SKILL.md @@ -140,7 +140,7 @@ After accepting, DeltaChat exchanges keys and creates the chat automatically. Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID: ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5" ``` @@ -226,7 +226,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart. 1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log` 2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log` 3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat -4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` +4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` ### Stale lock file after crash @@ -248,7 +248,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -20 The messaging group exists but may not be wired to an agent group. Run: ```bash -sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" ``` If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`. diff --git a/.claude/skills/add-deltachat/VERIFY.md b/.claude/skills/add-deltachat/VERIFY.md index 839fa85..ae25c58 100644 --- a/.claude/skills/add-deltachat/VERIFY.md +++ b/.claude/skills/add-deltachat/VERIFY.md @@ -37,7 +37,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -10 ## 4. Check messaging group was created ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5" ``` @@ -48,7 +48,7 @@ If a row appears, the inbound routing is working. If not, the adapter isn't rece If the message arrived but the agent didn't respond, the sender may not have access: ```bash -sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" ``` Grant access as shown in the SKILL.md "Grant user access" section. diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 82a5098..4a24eca 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo " ### No response from agent 1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) -2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` +2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` 3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20` If no messaging group row exists, run the `register` command above. @@ -292,5 +292,5 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Remove the NanoClaw block from your Emacs config # Optionally clean up the messaging group: -sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" +pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" ``` diff --git a/.claude/skills/add-ollama-provider/SKILL.md b/.claude/skills/add-ollama-provider/SKILL.md index 83f7e5a..fe42249 100644 --- a/.claude/skills/add-ollama-provider/SKILL.md +++ b/.claude/skills/add-ollama-provider/SKILL.md @@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh` Ask the user (plain text, not AskUserQuestion): -1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"` +1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"` 2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'` 3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts. @@ -111,7 +111,7 @@ Read the agent group's shared Claude settings: ```bash # Find the agent group ID -AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='';") +AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='';") SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json ``` diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index a9dff8f..c391f53 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured` - Check agent-runner logs for "Parallel AI MCP servers configured" message **Task polling not working:** -- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"` +- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"` - Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"` - Ensure task prompt includes proper Parallel MCP tool names diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 7dcc8ad..4495715 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -200,7 +200,7 @@ systemctl --user restart nanoclaw After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then: ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" ``` @@ -212,7 +212,7 @@ Add the Signal number to a group from your phone, send any message, then wire th ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " +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 @@ -226,7 +226,7 @@ New Signal users (including the owner's Signal identity) are silently dropped wi ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " +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) @@ -282,7 +282,7 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA ### Bot not responding 1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` -2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` 3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) ### Lost connection mid-session diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 232725f..edec479 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **type**: `whatsapp` - **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members. -- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. +- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. - **supports-threads**: no - **typical-use**: Interactive chat — direct messages or small groups - **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. @@ -256,7 +256,7 @@ systemctl --user start nanoclaw 1. Auth exists: `test -f store/auth/creds.json` 2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1` -3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` +3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` 4. Service running: `systemctl --user status nanoclaw` ### "conflict" disconnection diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 128b8c3..1fa459f 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -279,7 +279,7 @@ rm -rf data/sessions/ rm -rf data/sessions/{groupFolder}/.claude/ # Also clear the session ID from NanoClaw's tracking (stored in SQLite) -sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" +pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" ``` To verify session resumption is working, check the logs for the same session ID across messages: diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index 6b110d3..67ab80b 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -54,7 +54,7 @@ Tell the user: Wait for the user's confirmation. Then look up the most recent DM messaging groups: ```bash -sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" ``` Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues. @@ -103,7 +103,7 @@ Wait for the user's reply. If they confirm receipt, the skill is done. If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't): -- `sqlite3 data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. +- `pnpm exec tsx scripts/q.ts data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. - `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes. - `ls data/v2-sessions//sessions/*/outbound.db` — confirm the session exists. diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 0b348d1..21b3e19 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -11,7 +11,13 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user ## Assess Current State -Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`): +Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`). + +Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db "" +``` ```sql SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups; diff --git a/CLAUDE.md b/CLAUDE.md index c17001b..f33dca7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,8 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f `data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`. +For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts ""`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically. + ## Key Files | File | Purpose | diff --git a/migrate-v2.sh b/migrate-v2.sh index ef3bda8..46a6670 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -242,8 +242,12 @@ fi V1_DB="$V1_PATH/store/messages.db" -# Quick schema check — make sure the tables we need exist -TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true) +# Quick schema check — make sure the tables we need exist. +# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via +# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI, +# and #2191 documented how a missing CLI here used to surface as a +# misleading "registered_groups missing" abort. +TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 2>/dev/null || true) if echo "$TABLES" | grep -q "registered_groups"; then step_ok "v1 database has registered_groups" @@ -253,8 +257,8 @@ else fi # Show what we found -GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) -TASK_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) +GROUP_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) +TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) ENV_KEYS=0 if [ -f "$V1_PATH/.env" ]; then ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0) diff --git a/scripts/q.test.ts b/scripts/q.test.ts new file mode 100644 index 0000000..4685e2b --- /dev/null +++ b/scripts/q.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +import Database from 'better-sqlite3'; + +/** + * Smoke tests for the q.ts sqlite-CLI replacement wrapper. + * + * Verifies the two modes (SELECT prints rows in sqlite3 default "list" + * format; mutation runs via db.exec) and a few edge cases that real + * skill invocations rely on. + */ + +const Q = path.resolve(__dirname, 'q.ts'); + +describe('scripts/q.ts', () => { + let tempDir: string; + let dbPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-')); + dbPath = path.join(tempDir, 'test.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE t (id INTEGER, name TEXT, note TEXT); + INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL); + `); + db.close(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function run(sql: string): { stdout: string; stderr: string; status: number } { + const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], { + encoding: 'utf-8', + cwd: path.resolve(__dirname, '..'), + }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; + } + + it('SELECT prints pipe-separated rows in default order', () => { + const r = run('SELECT id, name FROM t ORDER BY id'); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe('1|alice\n2|bob'); + }); + + it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => { + const r = run('SELECT id, note FROM t ORDER BY id'); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe('1|hi\n2|'); + }); + + it('SELECT with no rows prints nothing', () => { + const r = run("SELECT id FROM t WHERE name = 'nobody'"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + }); + + it('INSERT runs via db.exec and persists', () => { + const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + + const db = new Database(dbPath, { readonly: true }); + const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string }; + db.close(); + expect(row.name).toBe('carol'); + }); + + it('compound mutation statements execute together', () => { + const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');"); + expect(r.status).toBe(0); + + const db = new Database(dbPath, { readonly: true }); + const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map( + (r) => r.id, + ); + db.close(); + expect(ids).toEqual([2, 9]); + }); + + it('exits 2 with usage when args are missing', () => { + const r = spawnSync('pnpm', ['exec', 'tsx', Q], { + encoding: 'utf-8', + cwd: path.resolve(__dirname, '..'), + }); + expect(r.status).toBe(2); + expect(r.stderr).toMatch(/Usage/); + }); +}); diff --git a/scripts/q.ts b/scripts/q.ts new file mode 100644 index 0000000..71a4676 --- /dev/null +++ b/scripts/q.ts @@ -0,0 +1,46 @@ +/** + * scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations. + * + * Usage: + * pnpm exec tsx scripts/q.ts "" + * + * Detects SELECT vs mutation on the first keyword. SELECT/WITH queries + * print rows in sqlite3 CLI default ("list") format — pipe-separated, + * no header — so existing skill text reads identically. Anything else + * runs through db.exec() and prints nothing on success. + * + * Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids + * depending on the sqlite3 CLI binary; setup never installs or probes + * for it. Skills that shell out to `sqlite3` therefore fail on hosts + * where it isn't preinstalled (common on fresh Ubuntu — see #2191). + * This wrapper preserves the skill-text shape (path then SQL string) + * while routing through the better-sqlite3 dep that setup already + * installs and verifies. + */ +import Database from 'better-sqlite3'; + +const [, , dbPath, sql] = process.argv; + +if (!dbPath || sql === undefined) { + console.error('Usage: pnpm exec tsx scripts/q.ts ""'); + process.exit(2); +} + +const db = new Database(dbPath); +try { + const firstKeyword = sql.trim().split(/\s+/)[0]?.toUpperCase() ?? ''; + if (firstKeyword === 'SELECT' || firstKeyword === 'WITH') { + const rows = db.prepare(sql).all() as Record[]; + for (const row of rows) { + console.log( + Object.values(row) + .map((v) => (v === null ? '' : String(v))) + .join('|'), + ); + } + } else { + db.exec(sql); + } +} finally { + db.close(); +} diff --git a/vitest.config.ts b/vitest.config.ts index d961d1b..71afb78 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { // container/agent-runner tests run under Bun (they depend on bun:sqlite). // See container/agent-runner/package.json "test" script. - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'scripts/**/*.test.ts'], }, }); From 18635e7c7d79d2b7553751ecda9b3cbebc01ab9b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 21:12:25 +0300 Subject: [PATCH 08/55] fix(scripts/q): use stmt.reader instead of keyword sniffing for SELECT detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-keyword check (`WITH` → SELECT path) was wrong for CTEs that precede mutations (e.g. `WITH stale AS (...) DELETE FROM t WHERE ...`). These would be routed through `db.prepare().all()` instead of executing the mutation. Use better-sqlite3's `stmt.reader` property, which asks SQLite's own parser whether the statement returns data. Single mutations go through `stmt.run()`; compound statements (which `prepare()` rejects) fall back to `db.exec()`. Add a regression test for WITH...DELETE. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/q.test.ts | 11 +++++++++++ scripts/q.ts | 42 +++++++++++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/scripts/q.test.ts b/scripts/q.test.ts index 4685e2b..4901db5 100644 --- a/scripts/q.test.ts +++ b/scripts/q.test.ts @@ -84,6 +84,17 @@ describe('scripts/q.ts', () => { expect(ids).toEqual([2, 9]); }); + it('WITH...DELETE is treated as a mutation, not a query', () => { + const r = run("WITH stale AS (SELECT id FROM t WHERE name = 'alice') DELETE FROM t WHERE id IN (SELECT id FROM stale)"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + + const db = new Database(dbPath, { readonly: true }); + const rows = db.prepare('SELECT name FROM t').all() as { name: string }[]; + db.close(); + expect(rows).toEqual([{ name: 'bob' }]); + }); + it('exits 2 with usage when args are missing', () => { const r = spawnSync('pnpm', ['exec', 'tsx', Q], { encoding: 'utf-8', diff --git a/scripts/q.ts b/scripts/q.ts index 71a4676..3d1ba74 100644 --- a/scripts/q.ts +++ b/scripts/q.ts @@ -4,10 +4,11 @@ * Usage: * pnpm exec tsx scripts/q.ts "" * - * Detects SELECT vs mutation on the first keyword. SELECT/WITH queries - * print rows in sqlite3 CLI default ("list") format — pipe-separated, - * no header — so existing skill text reads identically. Anything else - * runs through db.exec() and prints nothing on success. + * Uses better-sqlite3's stmt.reader property to distinguish queries + * (SELECT / WITH...SELECT) from mutations. Queries print rows in + * sqlite3 CLI default ("list") format — pipe-separated, no header — + * so existing skill text reads identically. Mutations run via + * stmt.run() (single statement) or db.exec() (compound). * * Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids * depending on the sqlite3 CLI binary; setup never installs or probes @@ -28,18 +29,29 @@ if (!dbPath || sql === undefined) { const db = new Database(dbPath); try { - const firstKeyword = sql.trim().split(/\s+/)[0]?.toUpperCase() ?? ''; - if (firstKeyword === 'SELECT' || firstKeyword === 'WITH') { - const rows = db.prepare(sql).all() as Record[]; - for (const row of rows) { - console.log( - Object.values(row) - .map((v) => (v === null ? '' : String(v))) - .join('|'), - ); + try { + const stmt = db.prepare(sql); + if (stmt.reader) { + const rows = stmt.all() as Record[]; + for (const row of rows) { + console.log( + Object.values(row) + .map((v) => (v === null ? '' : String(v))) + .join('|'), + ); + } + } else { + stmt.run(); + } + } catch (e: unknown) { + // better-sqlite3 throws on compound statements ("contains more than + // one statement"). Compound SQL in skills is always mutations + // (e.g. "DELETE ...; INSERT ...;"), so fall back to db.exec(). + if (e instanceof Error && /more than one statement/i.test(e.message)) { + db.exec(sql); + } else { + throw e; } - } else { - db.exec(sql); } } finally { db.close(); From 88ff54cf834b172580ccdae0200d3d78b91fb01c Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 7 May 2026 08:05:26 +0000 Subject: [PATCH 09/55] setup: add back-to-channels exit at every Teams step gate Teams setup is 6+ Azure steps over 30+ minutes. Today, every "Done / Stuck / Show again" gate forces continuation; the only escape is Ctrl-C, which kills setup entirely. Add a fourth option at each gate that returns to the channel picker so a stuck operator can pick a different channel without losing the rest of setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/teams.ts | 66 ++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 3691beb..9375995 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -95,12 +95,25 @@ export async function runTeamsChannel(_displayName: string): Promise { +}): Promise<'continue' | 'back'> { note( [ `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, @@ -262,15 +275,17 @@ async function stepAppRegistration(args: { ); } - await stepGate({ + const gate = await stepGate({ stepName: 'teams-app-registration', stepDescription: 'registering an app in Azure and collecting App ID + tenant type', reshow: () => stepAppRegistration(args), args, }); + if (gate === 'back') return 'back'; args.completed.push( `App registered: ${args.collected.appId} (${args.collected.appType})`, ); + return 'continue'; } async function askAppType(args: { @@ -313,7 +328,7 @@ async function askAppType(args: { async function stepClientSecret(args: { collected: Collected; completed: string[]; -}): Promise { +}): Promise<'continue' | 'back'> { note( [ `1. In your app registration, open "Certificates & secrets"`, @@ -356,13 +371,15 @@ async function stepClientSecret(args: { break; } - await stepGate({ + const gate = await stepGate({ stepName: 'teams-client-secret', stepDescription: 'creating and copying the client secret', reshow: () => stepClientSecret(args), args, }); + if (gate === 'back') return 'back'; args.completed.push('Client secret captured.'); + return 'continue'; } // ─── step: Azure Bot resource ────────────────────────────────────────── @@ -370,7 +387,7 @@ async function stepClientSecret(args: { async function stepAzureBot(args: { collected: Collected; completed: string[]; -}): Promise { +}): Promise<'continue' | 'back'> { const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`; const tenantFlag = args.collected.appType === 'SingleTenant' @@ -405,14 +422,16 @@ async function stepAzureBot(args: { 'Step 3 of 6 — Create Azure Bot resource', ); - await stepGate({ + const gate = await stepGate({ stepName: 'teams-azure-bot', stepDescription: 'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint', reshow: () => stepAzureBot(args), args, }); + if (gate === 'back') return 'back'; args.completed.push('Azure Bot created; messaging endpoint configured.'); + return 'continue'; } // ─── step: enable Teams channel ──────────────────────────────────────── @@ -420,7 +439,7 @@ async function stepAzureBot(args: { async function stepEnableTeamsChannel(args: { collected: Collected; completed: string[]; -}): Promise { +}): Promise<'continue' | 'back'> { note( [ '1. Open your Azure Bot resource → Channels', @@ -431,13 +450,15 @@ async function stepEnableTeamsChannel(args: { ].join('\n'), 'Step 4 of 6 — Enable Teams channel on the bot', ); - await stepGate({ + const gate = await stepGate({ stepName: 'teams-enable-channel', stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource', reshow: () => stepEnableTeamsChannel(args), args, }); + if (gate === 'back') return 'back'; args.completed.push('Teams channel enabled on the bot.'); + return 'continue'; } // ─── step: manifest zip ──────────────────────────────────────────────── @@ -490,7 +511,7 @@ async function stepSideload(args: { collected: Collected; completed: string[]; zipPath: string; -}): Promise { +}): Promise<'continue' | 'back'> { note( [ '1. Open Microsoft Teams', @@ -505,13 +526,15 @@ async function stepSideload(args: { ].join('\n'), 'Step 5 of 6 — Sideload the app into Teams', ); - await stepGate({ + const gate = await stepGate({ stepName: 'teams-sideload', stepDescription: 'uploading the generated zip into Teams as a custom app', - reshow: () => stepSideload(args), + reshow: () => stepSideload({ ...args, zipPath: args.zipPath }), args, }); + if (gate === 'back') return 'back'; args.completed.push('App sideloaded into Teams.'); + return 'continue'; } // ─── step: install adapter ───────────────────────────────────────────── @@ -623,9 +646,9 @@ async function finishWithHandoff( async function stepGate(args: { stepName: string; stepDescription: string; - reshow: () => Promise | Promise; + reshow: () => Promise<'continue' | 'back'>; args: { collected: Collected; completed: string[] }; -}): Promise { +}): Promise<'continue' | 'back'> { while (true) { const choice = ensureAnswer( await brightSelect({ @@ -634,10 +657,12 @@ async function stepGate(args: { { value: 'done', label: "Done — let's continue" }, { value: 'help', label: 'Stuck — hand me off to Claude' }, { value: 'reshow', label: 'Show me the steps again' }, + { value: 'back', label: '← Back to channel selection' }, ], }), ); - if (choice === 'done') return; + if (choice === 'done') return 'continue'; + if (choice === 'back') return 'back'; if (choice === 'help') { await offerHandoff({ step: args.stepName, @@ -647,8 +672,7 @@ async function stepGate(args: { continue; } if (choice === 'reshow') { - await args.reshow(); - return; + return args.reshow(); } } } From 1eb55e85a02f66ed68704f2d3b540e47bd57edf8 Mon Sep 17 00:00:00 2001 From: Ali Goldberg Date: Thu, 7 May 2026 08:28:12 +0000 Subject: [PATCH 10/55] =?UTF-8?q?setup:=20add=20back-to-channels=20exit=20?= =?UTF-8?q?to=20"Other=E2=80=A6"=20channel-name=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After picking "Other…" from the channel picker, today's flow drops the user straight into a free-text prompt with no way back. Replace it with a brightSelect that offers either "Type the channel name" (existing behavior) or "← Back to channel selection" — same back-affording pattern the channel sub-flows already use. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 91ad83a..e45ab69 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -468,7 +468,7 @@ async function main(): Promise { } else if (channelChoice === 'imessage') { result = await runIMessageChannel(displayName!); } else if (channelChoice === 'other') { - await askOtherChannelName(); + result = await askOtherChannelName(); } else { p.log.info( brandBody( @@ -1099,10 +1099,26 @@ async function askChannelChoice(): Promise { return choice; } -async function askOtherChannelName(): Promise { +async function askOtherChannelName(): Promise { + const action = ensureAnswer( + await brightSelect<'type' | 'back'>({ + message: 'Which channel would you like to install?', + options: [ + { + value: 'type', + label: 'Type the channel name', + hint: 'e.g. matrix, github, linear, webex', + }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'type', + }), + ); + if (action === 'back') return BACK_TO_CHANNEL_SELECTION; + const answer = ensureAnswer( await p.text({ - message: 'Which channel would you like to install?', + message: 'Channel name', placeholder: 'e.g. matrix, github, linear, webex', }), ); From 7e0c256fa0174c95bbd55761949e4947fdc2f137 Mon Sep 17 00:00:00 2001 From: Ali Goldberg Date: Thu, 7 May 2026 08:19:41 +0000 Subject: [PATCH 11/55] setup: drop "E.164" jargon from iMessage handle card Replace "full E.164, e.g. +15551234567" with plain-language guidance mirroring the WhatsApp setup card: "start with + and your country code, no spaces or dashes" plus a worked example. "E.164" is the technical name for the format and means nothing to non-telecom users; the explanation it stands in for is one sentence. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/imessage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 5730fca..c7c2b77 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -290,7 +290,8 @@ async function askOperatorHandle(): Promise { "What phone number or email do you iMessage with?", "That's where your assistant will send its welcome message.", '', - k.dim(' • Phone: full E.164, e.g. +15551234567'), + k.dim(' • Phone: start with + and your country code, no spaces or dashes'), + k.dim(' Example: +14155551234 (country code 1, then 4155551234)'), k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'), ].join('\n'), 'Your iMessage handle', From 8eff3e558cbe0d14f2444498f215362732219040 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Thu, 7 May 2026 12:43:08 +0300 Subject: [PATCH 12/55] =?UTF-8?q?feat(skills):=20add=20/add-mnemon=20skill?= =?UTF-8?q?=20=E2=80=94=20persistent=20semantic=20memory=20for=20agent=20g?= =?UTF-8?q?roups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a skill that installs the mnemon CLI into agent containers, giving each agent group a persistent, queryable knowledge graph across sessions. Mnemon stores facts (insights) with categories, importance scores, and entity tags, and connects them with typed edges (causal, semantic, temporal, entity). The agent can remember, recall, search, link, and forget facts — surviving container restarts and context compaction. Installation: drops the mnemon binary from the channels branch, creates the per-agent-group data directory, and configures the agent's CLAUDE.md to load the skill on every spawn. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-mnemon/SKILL.md | 208 +++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 .claude/skills/add-mnemon/SKILL.md diff --git a/.claude/skills/add-mnemon/SKILL.md b/.claude/skills/add-mnemon/SKILL.md new file mode 100644 index 0000000..db0d029 --- /dev/null +++ b/.claude/skills/add-mnemon/SKILL.md @@ -0,0 +1,208 @@ +--- +name: add-mnemon +description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn. +--- + +# Add Mnemon — Persistent Memory + +Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts. + +## Provider Compatibility + +**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all. + +Check your provider: + +```bash +grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null +``` + +- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps. +- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step. + +## Phase 1: Pre-flight + +### Check if already applied + +```bash +grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied" +``` + +If already applied, skip to Phase 3 (Verify). + +### Check latest mnemon version + +```bash +curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"' +``` + +Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step. + +## Phase 2: Apply Changes (Claude Code path) + +### 1. Dockerfile — install mnemon binary + +Add after the AWS CLI block, before the Bun runtime section: + +```dockerfile +# ---- mnemon — persistent agent memory ---------------------------------------- +ARG MNEMON_VERSION=0.1.1 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin mnemon && \ + chmod +x /usr/local/bin/mnemon + +ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon +``` + +`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed. + +### 2. Entrypoint — run mnemon setup on each container start + +`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin: + +```bash +#!/bin/bash +# NanoClaw agent container entrypoint. +# +# ...existing header comment... + +set -e + +mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1 + +cat > /tmp/input.json + +exec bun run /app/src/index.ts < /tmp/input.json +``` + +`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner. + +### 3. Rebuild and smoke-test the image + +```bash +./container/build.sh +docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version +``` + +## Phase 3: Restart and Verify + +### Restart the service + +```bash +systemctl --user restart nanoclaw # Linux +# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +``` + +### Confirm mnemon hooks are registered + +After the next container starts, check that setup ran: + +```bash +docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon +``` + +Then inspect the hooks inside the running container: + +```bash +docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \ + cat /home/node/.claude/settings.json | grep -A5 mnemon +``` + +### Test memory recall + +Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it. + +## Phase 2 (OpenCode path) — context injection + +mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`. + +**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions. + +**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts. + +**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely. + +```dockerfile +ARG MNEMON_VERSION=0.1.1 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin mnemon && \ + chmod +x /usr/local/bin/mnemon +ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon +``` + +Then rebuild: `./container/build.sh` + +### Verify (OpenCode) + +Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run. + +```bash +# Also confirm the binary is present in the image: +docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version +``` + +## Memory Storage + +Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path: + +```bash +docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \ + --format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}' +``` + +To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path. + +## Migration Guide Update + +If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`: + +**Dockerfile — after AWS CLI, before Bun runtime:** +```dockerfile +ARG MNEMON_VERSION=0.1.1 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin mnemon && \ + chmod +x /usr/local/bin/mnemon +ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon +``` + +**`container/entrypoint.sh` — add after `set -e`:** +```bash +mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1 +``` + +## Troubleshooting + +### `mnemon: command not found` in container + +The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart. + +### Memory not persisting across restarts + +Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory): + +```bash +docker exec sh -c 'ls -la $MNEMON_DATA_DIR' +``` + +If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above. + +### Agent not using past memory + +`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify: + +```bash +docker exec cat /home/node/.claude/settings.json +``` + +If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon. + +### Setup fails at container start + +Run setup manually inside a running container to see the full error: + +```bash +docker exec -it mnemon setup --target claude-code --yes --global +``` From 877d2a370a6efd9b0a7f6ab7dd1d023efc605f02 Mon Sep 17 00:00:00 2001 From: Ira Abramov Date: Thu, 7 May 2026 13:06:33 +0300 Subject: [PATCH 13/55] docs(skills): update SKILL.md for debug, init-onecli, add-gmail-tool, add-opencode, add-signal, add-vercel Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-gmail-tool/SKILL.md | 9 ++++-- .claude/skills/add-opencode/SKILL.md | 13 +++++--- .claude/skills/add-signal/SKILL.md | 5 +++ .claude/skills/add-vercel/SKILL.md | 12 +++---- .claude/skills/debug/SKILL.md | 45 +++++++++++++++++++++++++- .claude/skills/init-onecli/SKILL.md | 35 ++++++++++++++++++++ 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md index 095c285..03df0e2 100644 --- a/.claude/skills/add-gmail-tool/SKILL.md +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -82,11 +82,14 @@ For each target agent group, confirm OneCLI will inject Gmail secrets into its c onecli agents list ``` -If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets: +If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first): ```bash -onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app) -onecli agents set-secrets --id --secret-ids +GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")') +CURRENT=$(onecli agents secrets --id | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id --secret-ids "$MERGED" +onecli agents secrets --id ``` ## Phase 2: Apply Code Changes diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md index 555f0fe..841baaa 100644 --- a/.claude/skills/add-opencode/SKILL.md +++ b/.claude/skills/add-opencode/SKILL.md @@ -132,12 +132,15 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned: -```bash -# Find the agent id and secret id, then: -onecli agents set-secrets --id --secret-ids , -``` +Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first: -Always include existing secret IDs in the list — `set-secrets` replaces, not appends. +```bash +AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="") | .id') +CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT," | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED" +onecli agents secrets --id "$AGENT_ID" +``` #### Example: DeepSeek diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 7dcc8ad..2f81b48 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -284,6 +284,11 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA 1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` 2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` 3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) +4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix. + +### Messages delivered but never arrive (null platformMsgId) + +Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend. ### Lost connection mid-session diff --git a/.claude/skills/add-vercel/SKILL.md b/.claude/skills/add-vercel/SKILL.md index dbd9780..be3b201 100644 --- a/.claude/skills/add-vercel/SKILL.md +++ b/.claude/skills/add-vercel/SKILL.md @@ -90,12 +90,12 @@ onecli secrets list | grep -i vercel OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent: ```bash -# For each agent, add the Vercel secret to its assigned secrets list. -# First get current assignments, then set them with the new secret appended. -VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//') -for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do - CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//') - onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID" +# set-secrets replaces the entire list — read and merge for each agent. +VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1) +for agent in $(onecli agents list | jq -r '.data[].id'); do + CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")') + MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -) + onecli agents set-secrets --id "$agent" --secret-ids "$MERGED" done ``` diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 128b8c3..25c5dcf 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -57,7 +57,50 @@ Debug level shows: ## Common Issues -### 1. "Claude Code process exited with code 1" +### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId) + +**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated: +``` +WARN No adapter for channel type channelType="telegram" +WARN No adapter for channel type channelType="signal" +``` +The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it. + +**Root cause: two NanoClaw service instances running simultaneously.** + +When a second service instance (often `nanoclaw-v2-.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them. + +**Diagnosis:** +```bash +# Check for duplicate running instances +ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep + +# Check which services are active +systemctl --user list-units 'nanoclaw*' --all + +# Confirm channel adapters registered by the current process +grep "Channel adapter started" logs/nanoclaw.log | tail -10 +``` + +**Fix:** +1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log). +2. Stop and disable the stale duplicate service: + ```bash + systemctl --user stop nanoclaw.service # or whichever is the old one + systemctl --user disable nanoclaw.service + ``` +3. If the remaining service unit is missing `EnvironmentFile`, add it: + ```bash + # Edit the service unit — add this line under [Service]: + # EnvironmentFile=/home/iraa/nanoclaw/.env + systemctl --user daemon-reload + systemctl --user restart nanoclaw-v2-.service + ``` +4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep` + +**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message. + +### 2. "Claude Code process exited with code 1" **Check the container log file** in `groups/{folder}/logs/container-*.log` diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md index b3d441f..ab64b73 100644 --- a/.claude/skills/init-onecli/SKILL.md +++ b/.claude/skills/init-onecli/SKILL.md @@ -259,6 +259,41 @@ Tell the user: - To manage secrets: `onecli secrets list`, or open ${ONECLI_URL} - To add rate limits or policies: `onecli rules create --help` +## Granting secrets to agents (safe merge) + +`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets: + +```bash +AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="") | .id') +CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT," | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED" +onecli agents secrets --id "$AGENT_ID" +``` + +- `` — the `agentGroupId` field in `groups//container.json` +- `` — the `id` from `onecli secrets list` +- Multiple new secrets: append them comma-separated before the `printf` step + +### git over HTTPS + +OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate. + +**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup. + +If an agent uses `git` or `gh`, add to `data/v2-sessions//.claude-shared/settings.json`: + +```json +"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem", +"GIT_TERMINAL_PROMPT": "0", +"GIT_CONFIG_COUNT": "1", +"GIT_CONFIG_KEY_0": "credential.helper", +"GIT_CONFIG_VALUE_0": "", +"GH_TOKEN": "ghp_onecli_proxy_replaces_this" +``` + +**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint. + ## Troubleshooting **"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed. From 4305c6a87d8a5f6657dbee0987e3c0e6d92a8464 Mon Sep 17 00:00:00 2001 From: johnnyfish Date: Thu, 7 May 2026 13:09:20 +0300 Subject: [PATCH 14/55] fix: slim credential docs in group CLAUDE.md and add onecli-gateway container skill --- CLAUDE.md | 6 +- container/skills/onecli-gateway/SKILL.md | 67 +++++++++++++++++++ .../skills/onecli-gateway/instructions.md | 7 ++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 container/skills/onecli-gateway/SKILL.md create mode 100644 container/skills/onecli-gateway/instructions.md diff --git a/CLAUDE.md b/CLAUDE.md index f33dca7..92824fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch | | `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 | +| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) | | `groups//` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) | | `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). | @@ -100,7 +100,7 @@ A second tier (direct source-level self-edits via a draft/activate flow) is plan ## Secrets / Credentials / OneCLI -API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. +API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`. ### Gotcha: auto-created agents start in `selective` secret mode @@ -144,7 +144,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. `/claw`). - **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/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`). +- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`). | Skill | When to Use | |-------|-------------| diff --git a/container/skills/onecli-gateway/SKILL.md b/container/skills/onecli-gateway/SKILL.md new file mode 100644 index 0000000..0c22c3e --- /dev/null +++ b/container/skills/onecli-gateway/SKILL.md @@ -0,0 +1,67 @@ +--- +name: onecli-gateway +description: >- + Handle credentials and authentication for external services. Use when you + hit a 401, 403, or app_not_connected error, or when the user asks you to + access an external service (Gmail, GitHub, Slack, Calendar, Stripe, etc.). + Do NOT use browser extensions or manual auth flows — make HTTP requests + directly; the OneCLI proxy injects credentials automatically. +--- + +# OneCLI Gateway: Credentials & Authentication + +Your container routes all HTTPS traffic through the OneCLI proxy, which +injects stored credentials (API keys, OAuth tokens) at the proxy boundary. +You never see or handle credential values directly. + +## Making Requests + +Call the real API URL. The proxy intercepts and injects credentials automatically. + +```bash +curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5" +curl -s "https://api.github.com/user/repos?per_page=10" +curl -s "https://api.stripe.com/v1/charges?limit=5" +``` + +Any HTTP client (curl, fetch, axios, Python requests, Go net/http, git) honors +`HTTPS_PROXY` automatically. You do not need to set auth headers. + +If a tool or library validates credentials locally before making the request, +pass any placeholder value (a fake string). The proxy replaces it with real +credentials at request time. + +## When a Request Fails (401 / 403 / app_not_connected) + +### Step 1 — Show the user a connect link + +If the error response includes a `connect_url`, share it directly: + +> To connect [service], open this link: +> [connect_url from the error response] + +If there's no `connect_url`, tell the user to open the OneCLI dashboard and +connect the service there. + +Do NOT ask the user for API keys or tokens. Do NOT suggest pasting credentials +into chat. The fix is always connecting the service in OneCLI. + +### Step 2 — Retry after the user connects + +After showing the link, let the user know you'll retry once they've connected. +When they confirm (or after a reasonable pause), retry the original request. + +If the retry still fails, ask the user if they need help with the OneCLI setup. + +## Rules + +- **Never** say "I don't have access to X" without first making the HTTP + request through the proxy. +- **Never** use browser extensions, gcloud, or manual auth flows. The proxy + handles credentials for you. +- **Never** ask the user for API keys, tokens, or passwords directly. +- **Never** suggest the user open Gmail/Calendar/GitHub in their browser + when they ask you to read or interact with those services. You have API + access — use it. +- If the proxy returns a policy error (403 with a JSON body), respect the + block. Do not retry or circumvent it. diff --git a/container/skills/onecli-gateway/instructions.md b/container/skills/onecli-gateway/instructions.md new file mode 100644 index 0000000..26d347a --- /dev/null +++ b/container/skills/onecli-gateway/instructions.md @@ -0,0 +1,7 @@ +# Credentials & External Services + +Your HTTP requests go through the OneCLI proxy, which injects real credentials automatically. Just call any API directly (Gmail, GitHub, Slack, etc.) — the proxy adds auth before it reaches the service. + +Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time. + +If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI. From 348e200c1119c4396bdd0312ff78b6a7c66f2ce1 Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 7 May 2026 13:09:40 +0200 Subject: [PATCH 15/55] =?UTF-8?q?fix(add-karpathy-llm-wiki):=20update=20fo?= =?UTF-8?q?r=20v2=20=E2=80=94=20schedule=5Ftask=20MCP=20+=20no=20build=20s?= =?UTF-8?q?tep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/add-karpathy-llm-wiki/SKILL.md | 33 ++----------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/.claude/skills/add-karpathy-llm-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md index 12b9b37..f8bfa5f 100644 --- a/.claude/skills/add-karpathy-llm-wiki/SKILL.md +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -71,38 +71,11 @@ AskUserQuestion: "Want periodic wiki health checks?" 2. **Monthly** 3. **Skip** — lint manually -If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database: +If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation. No direct DB insertion needed. + +## Step 6: Restart ```bash -pnpm exec tsx -e " -const Database = require('better-sqlite3'); -const { CronExpressionParser } = require('cron-parser'); -const db = new Database('store/messages.db'); -const interval = CronExpressionParser.parse('', { tz: process.env.TZ || 'UTC' }); -const nextRun = interval.next().toISOString(); -db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run( - 'wiki-lint', - '', - '', - 'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.', - 'cron', - '', - 'group', - nextRun, - 'active', - new Date().toISOString() -); -db.close(); -" -``` - -Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am). - -## Step 6: Build and restart - -```bash -pnpm run build -./container/build.sh launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` From 6d8d085f9686d8c546b772133e5a2f87c2a56767 Mon Sep 17 00:00:00 2001 From: Ali Goldberg Date: Thu, 7 May 2026 11:33:07 +0000 Subject: [PATCH 16/55] =?UTF-8?q?setup:=20add=20"Skip=20=E2=80=94=20I'll?= =?UTF-8?q?=20connect=20later"=20option=20to=20Claude=20auth=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today the Claude auth picker has only three real-auth options. A user without a Pro/Max subscription, an OAuth token, or an API key has no graceful escape — Ctrl-C kills setup entirely. Add a fourth option that confirms the trade-off (no agent runtime + no Claude debug help during setup) and, on Yes, marks auth skipped and lets setup continue. On No, loop back to the picker. Existing NANOCLAW_SKIP=auth env hatch is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/setup/auto.ts b/setup/auto.ts index 91ad83a..dd95034 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -740,12 +740,38 @@ async function runAuthStep(): Promise { label: 'Paste an Anthropic API key', hint: 'pay-per-use via console.anthropic.com', }, + { + value: 'skip', + label: "Skip — I'll connect later", + hint: 'not recommended — Claude helps debug setup issues', + }, ], }), - ) as 'subscription' | 'oauth' | 'api'; + ) as 'subscription' | 'oauth' | 'api' | 'skip'; setupLog.userInput('auth_method', method); phEmit('auth_method_chosen', { method }); + if (method === 'skip') { + const confirmed = ensureAnswer( + await p.confirm({ + message: + "Skip Claude sign-in? The agent won't be able to run until you connect, and we won't be able to help debug setup errors.", + initialValue: false, + }), + ); + if (!confirmed) { + // Loop back to the auth picker so they can choose a real method. + return runAuthStep(); + } + setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped' }); + p.log.warn( + brandBody( + 'Claude sign-in skipped. Re-run setup or run `bash nanoclaw.sh` to finish later.', + ), + ); + return; + } + if (method === 'subscription') { await runSubscriptionAuth(); } else { From 57dad14a0100e51be0b6397dc5c9eb42300780c0 Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 7 May 2026 16:50:59 +0200 Subject: [PATCH 17/55] fix(destinations): default to replying to the origin destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a multi-destination agent receives an inbound message, the model had no explicit guidance about which destination to address by default and would sometimes pick the wrong one — e.g. Casa replying to the admin's group questions in Laura's DM instead of in the group itself. The formatter already injects `from=""` on every inbound tag (formatter.ts:184), so the origin is right there in the prompt — the system prompt just never told the agent to use it. Added one line to buildDestinationsSection() that nudges the agent toward replying via the same destination the message came from, with an out for explicit cross-destination requests ("tell Laura that…"). Single-destination groups are unaffected (they take a separate short-circuit path with a fallback that auto-replies to the origin). Tests cover the multi-destination, single-destination, and no-destination cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agent-runner/src/destinations.test.ts | 52 +++++++++++++++++++ container/agent-runner/src/destinations.ts | 4 ++ 2 files changed, 56 insertions(+) create mode 100644 container/agent-runner/src/destinations.test.ts diff --git a/container/agent-runner/src/destinations.test.ts b/container/agent-runner/src/destinations.test.ts new file mode 100644 index 0000000..f5e5818 --- /dev/null +++ b/container/agent-runner/src/destinations.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js'; +import { buildSystemPromptAddendum } from './destinations.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES (?, ?, 'channel', ?, ?, NULL)`, + ) + .run(name, displayName, channelType, platformId); +} + +describe('buildSystemPromptAddendum — multi-destination routing guidance', () => { + it('includes default-routing nudge when there are >1 destinations', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('Default routing'); + expect(prompt).toContain('from="name"'); + expect(prompt).toContain('`casa`'); + expect(prompt).toContain('`whatsapp-mg-17780`'); + }); + + it('omits the default-routing nudge for a single destination (short-circuited)', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + + const prompt = buildSystemPromptAddendum('Casa'); + + // Single-destination path uses the simpler "no special wrapping needed" copy + expect(prompt).toContain('no special wrapping needed'); + expect(prompt).not.toContain('Default routing'); + }); + + it('handles the no-destination case without crashing', () => { + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('no configured destinations'); + expect(prompt).not.toContain('Default routing'); + }); +}); diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index 013bd3b..c17b59a 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -128,6 +128,10 @@ function buildDestinationsSection(): string { lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.'); lines.push('Use `...` to make scratchpad intent explicit.'); lines.push(''); + lines.push( + '**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").', + ); + lines.push(''); lines.push( 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.', ); From 12719be6e15025a47797dae75d64b999d226707f Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 7 May 2026 15:57:07 +0200 Subject: [PATCH 18/55] feat(poll-loop): inject destination reminder after SDK auto-compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes qwibitai/nanoclaw#2325. When the Claude Code SDK auto-compacts the conversation context, the compaction summary tends to drop the agent's learned 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) --- .../agent-runner/src/integration.test.ts | 108 ++++++++++++++++++ container/agent-runner/src/poll-loop.ts | 20 ++++ .../agent-runner/src/providers/claude.ts | 2 +- container/agent-runner/src/providers/types.ts | 10 +- 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 3447c38..12d3b57 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -91,8 +91,116 @@ describe('poll loop integration', () => { await loopPromise.catch(() => {}); }); + + it('should inject destination reminder after a compacted event', async () => { + // Two destinations — required for the reminder to fire (single-destination + // groups have a fallback path that works without wrapping). + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`, + ) + .run(); + + insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new CompactingProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500); + + await waitFor(() => getUndeliveredMessages().length > 0, 2500); + controller.abort(); + + expect(provider.pushes.length).toBeGreaterThanOrEqual(1); + const reminder = provider.pushes.find((p) => p.includes('Context was just compacted')); + expect(reminder).toBeDefined(); + expect(reminder).toContain('2 destinations'); + expect(reminder).toContain('discord-test'); + expect(reminder).toContain('discord-second'); + expect(reminder).toContain(''); + + await loopPromise.catch(() => {}); + }); + + it('should NOT inject destination reminder with a single destination', async () => { + insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new CompactingProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500); + + await waitFor(() => getUndeliveredMessages().length > 0, 2500); + controller.abort(); + + // Only the original prompt push (if any) — no reminder, since beforeEach + // seeds exactly one destination. + const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted')); + expect(reminders).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); }); +/** + * Provider that emits a single compacted event mid-stream, then returns a + * result. Captures every push() call so tests can assert on the injected + * reminder content. + */ +class CompactingProvider { + readonly supportsNativeSlashCommands = false; + readonly pushes: string[] = []; + + isSessionInvalid(): boolean { + return false; + } + + query(_input: { prompt: string; cwd: string }) { + const pushes = this.pushes; + let ended = false; + let aborted = false; + let resolveWaiter: (() => void) | null = null; + + async function* events() { + yield { type: 'activity' as const }; + yield { type: 'init' as const, continuation: 'compaction-test-session' }; + yield { type: 'activity' as const }; + yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' }; + + // Wait for poll-loop to push the reminder (or end / abort) + await new Promise((resolve) => { + resolveWaiter = resolve; + // Belt-and-braces: don't hang forever if the reminder never arrives + setTimeout(resolve, 200); + }); + + yield { type: 'activity' as const }; + yield { type: 'result' as const, text: 'ack' }; + while (!ended && !aborted) { + await new Promise((resolve) => { + resolveWaiter = resolve; + setTimeout(resolve, 50); + }); + } + } + + return { + push(message: string) { + pushes.push(message); + resolveWaiter?.(); + }, + end() { + ended = true; + resolveWaiter?.(); + }, + abort() { + aborted = true; + resolveWaiter?.(); + }, + events: events(), + }; + } +} + // Helper: run poll loop until aborted or timeout async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { return Promise.race([ diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e825184..d4391bd 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -366,6 +366,23 @@ async function processQuery( if (event.text) { dispatchResultText(event.text, routing); } + } else if (event.type === 'compacted') { + // The SDK auto-compacted the conversation. After compaction the + // model often drops the learned `` wrapping + // discipline (the destinations are still in the system prompt, + // but the behavioral pattern is summarized away). Inject a + // reminder back into the live query so the next turn re-anchors + // on the destination model. Only do this when there's >1 + // destination — single-destination groups have a fallback that + // works without wrapping. See qwibitai/nanoclaw#2325. + const destinations = getAllDestinations(); + if (destinations.length > 1) { + const names = destinations.map((d) => d.name).join(', '); + query.push( + `[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` + + `Use blocks to address them. Bare text goes to the scratchpad fallback only.`, + ); + } } } } finally { @@ -390,6 +407,9 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { case 'progress': log(`Progress: ${event.message}`); break; + case 'compacted': + log(`Compacted: ${event.text}`); + break; } } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6c30cc2..6850e51 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -329,7 +329,7 @@ export class ClaudeProvider implements AgentProvider { } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') { const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata; const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : ''; - yield { type: 'result', text: `Context compacted${detail}.` }; + yield { type: 'compacted', text: `Context compacted${detail}.` }; } else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { const tn = message as { summary?: string }; yield { type: 'progress', message: tn.summary || 'Task notification' }; diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..b4b1fc8 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -79,4 +79,12 @@ export type ProviderEvent = * event (tool call, thinking, partial message, anything) so the * poll-loop's idle timer stays honest during long tool runs. */ - | { type: 'activity' }; + | { type: 'activity' } + /** + * The provider's underlying SDK auto-compacted the conversation context. + * The poll-loop reacts by injecting a destination reminder back into + * the live query so the agent doesn't drop `` wrapping + * after compaction. Distinct from `result` so it doesn't mark the turn + * completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325. + */ + | { type: 'compacted'; text: string }; From f7c610ac4a619331b2e0be3f9b0cf2a71169fb70 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 7 May 2026 18:49:57 +0300 Subject: [PATCH 19/55] Apply suggestion from @gavrielc --- .claude/skills/add-karpathy-llm-wiki/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/add-karpathy-llm-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md index f8bfa5f..79bfed9 100644 --- a/.claude/skills/add-karpathy-llm-wiki/SKILL.md +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -71,7 +71,7 @@ AskUserQuestion: "Want periodic wiki health checks?" 2. **Monthly** 3. **Skip** — lint manually -If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation. No direct DB insertion needed. +If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation. ## Step 6: Restart From 9db39b291de21f8cea17b0a37202f8f7e54c798e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 7 May 2026 19:47:46 +0300 Subject: [PATCH 20/55] fix(agent-runner): require explicit destination addressing, fix per-destination threading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poll loop had a bare-text routing fallback in dispatchResultText: when the agent produced text without 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 .... 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 , webhooks as , system responses as . 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) --- .../agent-runner/src/compact-instructions.ts | 34 +++++++++ container/agent-runner/src/destinations.ts | 26 +++---- container/agent-runner/src/formatter.ts | 41 ++++++---- container/agent-runner/src/poll-loop.test.ts | 12 +-- container/agent-runner/src/poll-loop.ts | 74 +++++++++---------- src/group-init.ts | 44 +++++++++++ 6 files changed, 155 insertions(+), 76 deletions(-) create mode 100644 container/agent-runner/src/compact-instructions.ts diff --git a/container/agent-runner/src/compact-instructions.ts b/container/agent-runner/src/compact-instructions.ts new file mode 100644 index 0000000..b682061 --- /dev/null +++ b/container/agent-runner/src/compact-instructions.ts @@ -0,0 +1,34 @@ +/** + * PreCompact hook script — outputs custom compaction instructions to stdout. + * + * Claude Code captures the stdout of PreCompact shell hooks and passes it + * as `customInstructions` to the compaction prompt. This ensures the + * compaction summary preserves message routing context that the agent needs + * to correctly address responses. + * + * Invoked by the PreCompact hook in .claude-shared/settings.json: + * "command": "bun /app/src/compact-instructions.ts" + */ +import { getAllDestinations } from './destinations.js'; + +const destinations = getAllDestinations(); +const names = destinations.map((d) => d.name); + +const instructions = [ + 'Preserve the following in the compaction summary:', + '', + '1. For recent messages, keep the full XML structure including all attributes:', + ' - for chat messages', + ' - for scheduled tasks', + ' - for webhooks', + ' The message content can be summarized if long, but the XML tags and attributes must remain.', + '', + '2. Preserve the chronological message/reply sequence of recent exchanges.', + ' The agent needs to see: who said what, in what order, and from which destination.', + '', + '3. The `from` attribute identifies which destination sent the message.', + ' The agent MUST wrap all responses in ... blocks.', + ` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`, +]; + +console.log(instructions.join('\n')); diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index 013bd3b..f9429d5 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -102,28 +102,20 @@ function buildDestinationsSection(): string { ].join('\n'); } - // Single-destination shortcut: the agent just writes its response normally. + const lines = ['## Sending messages', '']; if (all.length === 1) { const d = all[0]; const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; - return [ - '## Sending messages', - '', - `Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`, - '', - 'To mark something as scratchpad (logged but not sent), wrap it in `...`.', - '', - 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.', - ].join('\n'); - } - - const lines = ['## Sending messages', '', '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(`Your destination is \`${d.name}\`${label}.`); + } 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(''); - lines.push('To send a message, wrap it in a `...` block.'); + lines.push('**Every response must be wrapped** in a `...` block.'); lines.push('You can include multiple `` blocks in one response to send to multiple destinations.'); lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.'); lines.push('Use `...` to make scratchpad intent explicit.'); diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 348d5ab..236dbfb 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -177,40 +177,49 @@ function formatSingleChat(msg: MessageInRow): string { const replyPrefix = formatReplyContext(content.replyTo); const attachmentsSuffix = formatAttachments(content.attachments); - // Look up the destination name for the origin (reverse map lookup). - // If not found, fall back to a raw channel:platform_id marker so nothing - // gets silently dropped — this should only happen if the destination was - // removed between when the message was received and when it's being processed. - const fromDest = findByRouting(msg.channel_type, msg.platform_id); - const fromAttr = fromDest - ? ` from="${escapeXml(fromDest.name)}"` - : msg.channel_type || msg.platform_id - ? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"` - : ''; + const fromAttr = originAttr(msg); return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } +/** + * Build a ` from="destination_name"` attribute string from a message's routing + * fields. Shared by all formatters so the agent always knows where a message + * originated — critical for explicit addressing. + */ +function originAttr(msg: MessageInRow): string { + const fromDest = findByRouting(msg.channel_type, msg.platform_id); + if (fromDest) return ` from="${escapeXml(fromDest.name)}"`; + if (msg.channel_type || msg.platform_id) { + return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`; + } + return ''; +} + function formatTaskMessage(msg: MessageInRow): string { const content = parseContent(msg.content); - const parts = ['[SCHEDULED TASK]']; + const from = originAttr(msg); + const time = formatLocalTime(msg.timestamp, TIMEZONE); + const parts: string[] = []; if (content.scriptOutput) { - parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2)); + parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), ''); } - parts.push('', 'Instructions:', content.prompt || ''); - return parts.join('\n'); + parts.push('Instructions:', content.prompt || ''); + return `${parts.join('\n')}`; } function formatWebhookMessage(msg: MessageInRow): string { const content = parseContent(msg.content); const source = content.source || 'unknown'; const event = content.event || 'unknown'; - return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`; + const from = originAttr(msg); + return `${JSON.stringify(content.payload || content, null, 2)}`; } function formatSystemMessage(msg: MessageInRow): string { const content = parseContent(msg.content); - return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`; + const from = originAttr(msg); + return `${JSON.stringify(content.result || null)}`; } /** diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 356108f..6a0bcbd 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -47,7 +47,7 @@ describe('formatter', () => { insertMessage('m1', 'task', { prompt: 'Review open PRs' }); const messages = getPendingMessages(); const prompt = formatMessages(messages); - expect(prompt).toContain('[SCHEDULED TASK]'); + expect(prompt).toContain(' { insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } }); const messages = getPendingMessages(); const prompt = formatMessages(messages); - expect(prompt).toContain('[WEBHOOK: github/push]'); + expect(prompt).toContain(' { insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } }); const messages = getPendingMessages(); const prompt = formatMessages(messages); - expect(prompt).toContain('[SYSTEM RESPONSE]'); - expect(prompt).toContain('register_group'); + expect(prompt).toContain(' { @@ -72,7 +74,7 @@ describe('formatter', () => { const messages = getPendingMessages(); const prompt = formatMessages(messages); expect(prompt).toContain('sender="John"'); - expect(prompt).toContain('[SYSTEM RESPONSE]'); + expect(prompt).toContain(' { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e825184..076d29d 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,7 +1,7 @@ -import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js'; +import { findByName, type DestinationEntry } from './destinations.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; -import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; +import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; import { clearContinuation, migrateLegacyContinuation, @@ -396,14 +396,10 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { /** * Parse the agent's final text for ... blocks * and dispatch each one to its resolved destination. Text outside of blocks - * (including ...) is normally scratchpad — logged but - * not sent. + * (including ...) is scratchpad — logged but not sent. * - * Single-destination shortcut: if the agent has exactly one configured - * destination AND the output contains zero blocks, the entire - * cleaned text (with tags stripped) is sent to that destination. - * This preserves the simple case of one user on one channel — the agent - * doesn't need to know about wrapping syntax at all. + * The agent must always wrap output in ... + * blocks, even with a single destination. Bare text is scratchpad only. */ function dispatchResultText(text: string, routing: RoutingContext): void { const MESSAGE_RE = /([\s\S]*?)<\/message>/g; @@ -436,30 +432,6 @@ function dispatchResultText(text: string, routing: RoutingContext): void { const scratchpad = stripInternalTags(scratchpadParts.join('')); - // Single-destination shortcut: the agent wrote plain text — send to - // the session's originating channel (from session_routing) if available, - // otherwise fall back to the single destination. - if (sent === 0 && scratchpad) { - if (routing.channelType && routing.platformId) { - // Reply to the channel/thread the message came from - 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: scratchpad }), - }); - return; - } - const all = getAllDestinations(); - if (all.length === 1) { - sendToDestination(all[0], scratchpad, routing); - return; - } - } - if (scratchpad) { log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); } @@ -472,20 +444,46 @@ function dispatchResultText(text: string, routing: RoutingContext): void { 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'; - // Inherit thread_id from the inbound routing context so replies land in the - // same thread the conversation is in. For non-threaded adapters the router - // strips thread_id at ingest, so this will already be null. + // 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 + // routing.threadId would stamp one channel's thread onto another. + const destRouting = resolveDestinationThread(channelType, platformId); writeMessageOut({ id: generateId(), - in_reply_to: routing.inReplyTo, + in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo, kind: 'chat', platform_id: platformId, channel_type: channelType, - thread_id: routing.threadId, + thread_id: destRouting?.threadId ?? null, content: JSON.stringify({ text: body }), }); } +/** + * Find the thread_id and message id from the most recent inbound message + * matching the given channel+platform. Returns null if no match found. + */ +function resolveDestinationThread( + channelType: string, + platformId: string, +): { threadId: string | null; inReplyTo: string | null } | null { + try { + const db = getInboundDb(); + const row = db + .prepare( + `SELECT thread_id, id FROM messages_in + WHERE channel_type = ? AND platform_id = ? + ORDER BY seq DESC LIMIT 1`, + ) + .get(channelType, platformId) as { thread_id: string | null; id: string } | undefined; + if (row) return { threadId: row.thread_id, inReplyTo: row.id }; + } catch { + // Fall through — DB may not have these columns on older sessions + } + return null; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/group-init.ts b/src/group-init.ts index 437d10f..0e6aeb1 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -14,6 +14,18 @@ const DEFAULT_SETTINGS_JSON = CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', }, + hooks: { + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: 'bun /app/src/compact-instructions.ts', + }, + ], + }, + ], + }, }, null, 2, @@ -71,10 +83,13 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s if (!fs.existsSync(settingsFile)) { fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON); initialized.push('settings.json'); + } else { + ensurePreCompactHook(settingsFile, initialized); } // Skills directory — created empty here; symlinks are synced at spawn // time by container-runner.ts based on container.json skills selection. + // (ensurePreCompactHook is defined after the main function.) const skillsDst = path.join(claudeDir, 'skills'); if (!fs.existsSync(skillsDst)) { fs.mkdirSync(skillsDst, { recursive: true }); @@ -90,3 +105,32 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s }); } } + +const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts'; + +/** + * Patch an existing settings.json to add the PreCompact hook if missing. + * Runs on every group init so pre-existing groups pick up the hook. + */ +function ensurePreCompactHook(settingsFile: string, initialized: string[]): void { + try { + const raw = fs.readFileSync(settingsFile, 'utf-8'); + const settings = JSON.parse(raw); + + // Check if there's already a PreCompact hook with our command. + const existing = settings.hooks?.PreCompact as unknown[] | undefined; + if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return; + + // Add the hook, preserving existing hooks. + if (!settings.hooks) settings.hooks = {}; + if (!settings.hooks.PreCompact) settings.hooks.PreCompact = []; + settings.hooks.PreCompact.push({ + hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }], + }); + + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n'); + initialized.push('settings.json (added PreCompact hook)'); + } catch { + // Don't break init if settings.json is malformed — it'll use whatever's there. + } +} From e3645f799c83a9c0a8e8fd394f98114e9dbd407e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 7 May 2026 20:33:06 +0300 Subject: [PATCH 21/55] address review: add thread resolution test, log catch, remove stray comment - 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) --- .../agent-runner/src/integration.test.ts | 38 +++++++++++++++++++ container/agent-runner/src/poll-loop.ts | 4 +- src/group-init.ts | 1 - 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 3447c38..f309cc3 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -74,6 +74,44 @@ describe('poll loop integration', () => { await loopPromise.catch(() => {}); }); + it('should resolve thread_id per-destination, not from global routing', async () => { + // Seed a second destination + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`, + ) + .run(); + + // Insert messages from each destination with distinct thread IDs + insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' }); + insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' }); + + // Agent replies to both destinations + const provider = new MockProvider({}, () => + 'reply-dreply-s', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length >= 2, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + const discordOut = out.find((m) => m.platform_id === 'chan-1'); + const slackOut = out.find((m) => m.platform_id === 'chan-2'); + + expect(discordOut).toBeDefined(); + expect(discordOut!.thread_id).toBe('discord-thread-1'); + expect(discordOut!.in_reply_to).toBe('m-discord'); + + expect(slackOut).toBeDefined(); + expect(slackOut!.thread_id).toBe('slack-thread-99'); + expect(slackOut!.in_reply_to).toBe('m-slack'); + + await loopPromise.catch(() => {}); + }); + it('should process messages arriving after loop starts', async () => { const provider = new MockProvider({}, () => 'Processed'); const controller = new AbortController(); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 076d29d..35abb83 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -478,8 +478,8 @@ function resolveDestinationThread( ) .get(channelType, platformId) as { thread_id: string | null; id: string } | undefined; if (row) return { threadId: row.thread_id, inReplyTo: row.id }; - } catch { - // Fall through — DB may not have these columns on older sessions + } catch (err) { + log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`); } return null; } diff --git a/src/group-init.ts b/src/group-init.ts index 0e6aeb1..b325150 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -89,7 +89,6 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s // Skills directory — created empty here; symlinks are synced at spawn // time by container-runner.ts based on container.json skills selection. - // (ensurePreCompactHook is defined after the main function.) const skillsDst = path.join(claudeDir, 'skills'); if (!fs.existsSync(skillsDst)) { fs.mkdirSync(skillsDst, { recursive: true }); From 860d1310cae9225a55748516529ad40b987c60ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 17:35:26 +0000 Subject: [PATCH 22/55] chore: bump version to 2.0.34 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3f4794c..f71d8ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.33", + "version": "2.0.34", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 9ccafcda82f932977e5aaaf7b2b4a1d802966dac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 17:35:37 +0000 Subject: [PATCH 23/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20142?= =?UTF-8?q?k=20tokens=20=C2=B7=2071%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index e68caf4..15c0fe0 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 141k tokens, 71% of context window + + 142k tokens, 71% of context window @@ -15,8 +15,8 @@ tokens - - 141k + + 142k From 42e8ae004efe2091b01b97b2a8a12162f6559648 Mon Sep 17 00:00:00 2001 From: krejov100 Date: Thu, 7 May 2026 17:56:33 +0000 Subject: [PATCH 24/55] fix(channels): exponential backoff for gateway listener restarts Without this, an unrecoverable failure such as TokenInvalid causes the gateway listener to restart ~10x/sec, which Discord's Cloudflare layer treats as abuse and answers with a multi-hour IP block. Both the clean- expiry path and the error path now share a backoff that doubles up to 1h, with a >5min healthy run resetting the counter. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 43 ++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 18ab2cb..c23e9ee 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -305,8 +305,14 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // Start local HTTP server to receive forwarded Gateway events (including interactions) const webhookUrl = await startLocalWebhookServer(gatewayAdapter, setupConfig, config.botToken); + // Exponential backoff capped at 1h. Without this, an unrecoverable + // failure (e.g., TokenInvalid) restarts ~10×/sec and Discord's + // Cloudflare layer issues a multi-hour IP block. A run that lasts + // longer than 5 minutes counts as healthy and resets the counter. + let consecutiveFailures = 0; const startGateway = () => { if (gatewayAbort?.signal.aborted) return; + const startedAt = Date.now(); // Capture the long-running listener promise via waitUntil let listenerPromise: Promise | undefined; gatewayAdapter.startGatewayListener!( @@ -321,21 +327,30 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter ).then(() => { // startGatewayListener resolves immediately with a Response; // the actual work is in the listenerPromise passed to waitUntil - if (listenerPromise) { - listenerPromise - .then(() => { - if (!gatewayAbort?.signal.aborted) { - log.info('Gateway listener expired, restarting', { adapter: adapter.name }); - startGateway(); - } - }) - .catch((err) => { - if (!gatewayAbort?.signal.aborted) { - log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err }); - setTimeout(startGateway, 5000); - } + if (!listenerPromise) return; + const reschedule = (err?: unknown) => { + if (gatewayAbort?.signal.aborted) return; + const ranForMs = Date.now() - startedAt; + if (ranForMs > 5 * 60 * 1000) consecutiveFailures = 0; + else consecutiveFailures++; + const delayMs = Math.min(60 * 60 * 1000, 2 ** consecutiveFailures * 1000); + if (err) { + log.error('Gateway listener error, retrying', { + adapter: adapter.name, + err, + consecutiveFailures, + delayMs, }); - } + } else { + log.info('Gateway listener expired, restarting', { + adapter: adapter.name, + consecutiveFailures, + delayMs, + }); + } + setTimeout(startGateway, delayMs); + }; + listenerPromise.then(() => reschedule()).catch(reschedule); }); }; startGateway(); From 1240a0cf4fd8c33604e869d072fc483352de93e7 Mon Sep 17 00:00:00 2001 From: johnnyfish Date: Thu, 7 May 2026 21:03:39 +0300 Subject: [PATCH 25/55] feat: fetch gateway skill from OneCLI API with static fallback --- .../skills/onecli-gateway/SKILL.fallback.md | 85 +++++++++++++++++++ container/skills/onecli-gateway/SKILL.md | 67 --------------- package.json | 2 +- pnpm-lock.yaml | 10 +-- src/container-runner.ts | 23 ++++- 5 files changed, 111 insertions(+), 76 deletions(-) create mode 100644 container/skills/onecli-gateway/SKILL.fallback.md delete mode 100644 container/skills/onecli-gateway/SKILL.md diff --git a/container/skills/onecli-gateway/SKILL.fallback.md b/container/skills/onecli-gateway/SKILL.fallback.md new file mode 100644 index 0000000..2e77d45 --- /dev/null +++ b/container/skills/onecli-gateway/SKILL.fallback.md @@ -0,0 +1,85 @@ +--- +name: onecli-gateway +description: >- + OneCLI Gateway: transparent HTTPS proxy that injects stored credentials + into outbound calls. You MUST use this skill when the user asks you to + read emails, check calendar, access GitHub repos, create issues, check + Stripe payments, or interact with ANY external service or API. Do NOT + use browser extensions or OAuth CLI tools. Make HTTP requests directly; + the gateway injects credentials automatically. +compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`) +metadata: + author: onecli + version: "0.5.0" +--- + +# OneCLI Gateway + +Your outbound HTTPS traffic is transparently proxied through the OneCLI +gateway, which injects stored credentials at the proxy boundary. You never +see or handle credential values directly. + +## How to Access External Services + +You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub, +Google Calendar, Google Drive, etc.) and API key services are all available +through the gateway. Just make the request directly; the gateway injects +credentials if the app is connected. If not, it returns an error with a +connect URL you can present to the user. + +## Making Requests + +Call the real API URL. The gateway intercepts the request and injects +credentials automatically. + +```bash +curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5" +curl -s "https://api.github.com/user/repos?per_page=10" +curl -s "https://api.stripe.com/v1/charges?limit=5" +``` + +Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all +honor the `HTTPS_PROXY` environment variable automatically. You do not need +to set any auth headers. + +## Credential Stubs for MCP Servers + +Some MCP servers need local credential files to start. Stubs for connected +apps are pre-written automatically. Files containing `"onecli-managed"` +values are managed by OneCLI — do NOT modify or delete them. + +If an MCP server won't start due to missing credentials, create stubs +**before** starting it. Use `"onecli-managed"` as the placeholder for all +secret values, with file permissions `0600`. See the guide at: +https://www.onecli.sh/docs/guides/credential-stubs/general-app + +## When a Request Fails + +If you get a 401, 403, or a gateway error (e.g., `app_not_connected`): + +**Step 1 — Show the user a connect link.** Use the `connect_url` from the +error response: + +> To connect [service], open this link: +> [connect_url from the error response] + +If there is no `connect_url` in the error, tell the user to open the +OneCLI dashboard and connect the service there. + +**Step 2 — Retry after the user connects.** Let the user know you will +retry once they have connected. When they confirm, retry the original +request. If the retry still fails, ask if they need help with the setup. + +## Rules + +- **Never** say "I don't have access to X" without first making the HTTP + request through the proxy. +- **Never** use browser extensions, gcloud, or manual auth flows. The + gateway handles credentials for you. +- **Never** ask the user for API keys or tokens directly. Direct them to + connect the service in the OneCLI dashboard. +- **Never** suggest the user open Gmail/Calendar/GitHub in their browser + when they ask you to read or interact with those services. You have API + access. Use it. +- If the gateway returns a policy error (403 with a JSON body), respect + the block. Do not retry or circumvent it. diff --git a/container/skills/onecli-gateway/SKILL.md b/container/skills/onecli-gateway/SKILL.md deleted file mode 100644 index 0c22c3e..0000000 --- a/container/skills/onecli-gateway/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: onecli-gateway -description: >- - Handle credentials and authentication for external services. Use when you - hit a 401, 403, or app_not_connected error, or when the user asks you to - access an external service (Gmail, GitHub, Slack, Calendar, Stripe, etc.). - Do NOT use browser extensions or manual auth flows — make HTTP requests - directly; the OneCLI proxy injects credentials automatically. ---- - -# OneCLI Gateway: Credentials & Authentication - -Your container routes all HTTPS traffic through the OneCLI proxy, which -injects stored credentials (API keys, OAuth tokens) at the proxy boundary. -You never see or handle credential values directly. - -## Making Requests - -Call the real API URL. The proxy intercepts and injects credentials automatically. - -```bash -curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5" -curl -s "https://api.github.com/user/repos?per_page=10" -curl -s "https://api.stripe.com/v1/charges?limit=5" -``` - -Any HTTP client (curl, fetch, axios, Python requests, Go net/http, git) honors -`HTTPS_PROXY` automatically. You do not need to set auth headers. - -If a tool or library validates credentials locally before making the request, -pass any placeholder value (a fake string). The proxy replaces it with real -credentials at request time. - -## When a Request Fails (401 / 403 / app_not_connected) - -### Step 1 — Show the user a connect link - -If the error response includes a `connect_url`, share it directly: - -> To connect [service], open this link: -> [connect_url from the error response] - -If there's no `connect_url`, tell the user to open the OneCLI dashboard and -connect the service there. - -Do NOT ask the user for API keys or tokens. Do NOT suggest pasting credentials -into chat. The fix is always connecting the service in OneCLI. - -### Step 2 — Retry after the user connects - -After showing the link, let the user know you'll retry once they've connected. -When they confirm (or after a reasonable pause), retry the original request. - -If the retry still fails, ask the user if they need help with the OneCLI setup. - -## Rules - -- **Never** say "I don't have access to X" without first making the HTTP - request through the proxy. -- **Never** use browser extensions, gcloud, or manual auth flows. The proxy - handles credentials for you. -- **Never** ask the user for API keys, tokens, or passwords directly. -- **Never** suggest the user open Gmail/Calendar/GitHub in their browser - when they ask you to read or interact with those services. You have API - access — use it. -- If the proxy returns a policy error (403 with a JSON body), respect the - block. Do not retry or circumvent it. diff --git a/package.json b/package.json index 3f4794c..babfd62 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dependencies": { "@clack/core": "^1.2.0", "@clack/prompts": "^1.2.0", - "@onecli-sh/sdk": "^0.3.1", + "@onecli-sh/sdk": "^0.5.0", "better-sqlite3": "11.10.0", "chat": "^4.24.0", "cron-parser": "5.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f74033..902b6ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^1.2.0 version: 1.2.0 '@onecli-sh/sdk': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.5.0 + version: 0.5.0 better-sqlite3: specifier: 11.10.0 version: 11.10.0 @@ -303,8 +303,8 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@onecli-sh/sdk@0.3.1': - resolution: {integrity: sha512-oMSa4DUCVS52vec41nFOg3XdCBTbMVEZdCFCsaUd9sRXVorCPWd3VyZq4giXsmk4g09DA/zLjsnrY7l6G94Ulg==} + '@onecli-sh/sdk@0.5.0': + resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==} engines: {node: '>=20'} '@oxc-project/types@0.124.0': @@ -1665,7 +1665,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@onecli-sh/sdk@0.3.1': {} + '@onecli-sh/sdk@0.5.0': {} '@oxc-project/types@0.124.0': {} diff --git a/src/container-runner.ts b/src/container-runner.ts index 27b0f5c..26af379 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -132,7 +132,7 @@ async function spawnContainer(session: Session): Promise { // buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once. const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig); - const mounts = buildMounts(agentGroup, session, containerConfig, contribution); + const mounts = await buildMounts(agentGroup, session, containerConfig, contribution); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; // OneCLI agent identifier is always the agent group id — stable across // sessions and reversible via getAgentGroup() for approval routing. @@ -239,12 +239,12 @@ function resolveProviderContribution( return { provider, contribution }; } -function buildMounts( +async function buildMounts( agentGroup: AgentGroup, session: Session, containerConfig: import('./container-config.js').ContainerConfig, providerContribution: ProviderContainerContribution, -): VolumeMount[] { +): Promise { const projectRoot = process.cwd(); // Per-group filesystem state lives forever after first creation. Init is @@ -252,6 +252,23 @@ function buildMounts( // is a no-op for groups that have spawned before. initGroupFilesystem(agentGroup); + // Fetch the latest gateway skill from the API; fall back to the static copy. + const skillDir = path.join(projectRoot, 'container', 'skills', 'onecli-gateway'); + const skillPath = path.join(skillDir, 'SKILL.md'); + const fallbackPath = path.join(skillDir, 'SKILL.fallback.md'); + try { + const skill = await onecli.getGatewaySkill(); + const existing = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : ''; + if (skill && skill !== existing) { + fs.writeFileSync(skillPath, skill); + } + } catch { + if (!fs.existsSync(skillPath) && fs.existsSync(fallbackPath)) { + fs.copyFileSync(fallbackPath, skillPath); + } + log.warn('Could not fetch gateway skill from OneCLI API; using static fallback'); + } + // Sync skill symlinks based on container.json selection before mounting. const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); syncSkillSymlinks(claudeDir, containerConfig); From cd69bf5c45ebf6ab09888653356759ee5649eec0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 19:53:37 +0000 Subject: [PATCH 26/55] chore: bump version to 2.0.35 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f71d8ce..74c571a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.34", + "version": "2.0.35", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 1afbba6a9171d41f696812e2c2e3f0172eae7cf1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 19:53:46 +0000 Subject: [PATCH 27/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20143?= =?UTF-8?q?k=20tokens=20=C2=B7=2071%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 15c0fe0..6b5f9e9 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 142k tokens, 71% of context window + + 143k tokens, 71% of context window @@ -15,8 +15,8 @@ tokens - - 142k + + 143k From 6f0b8f1961c1f3e2bb55837c80a09679703fe54f Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 7 May 2026 13:37:15 -0700 Subject: [PATCH 28/55] fix(container): pin pnpm to 10.33.0 to match host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- container/Dockerfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/container/Dockerfile b/container/Dockerfile index efa58b6..bc7a6be 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -91,7 +91,13 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \ # the SDK fails at spawn time with "native binary not found". ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable +# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped +# honoring `only-built-dependencies[]=` in .npmrc for global installs, which +# silently skips claude-code's native-binary postinstall and agent-browser's +# bin chmod — the agent then crashes at runtime with "native binary not +# installed". Keep this in lockstep with package.json's `packageManager`. +ARG PNPM_VERSION=10.33.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate RUN --mount=type=cache,target=/root/.cache/pnpm \ echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ From b40d43725f8e5671e8c1189e4aa14265efa4f079 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 20:45:04 +0000 Subject: [PATCH 29/55] chore: bump version to 2.0.36 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74c571a..958a354 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.35", + "version": "2.0.36", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 93732a49784a79083f0ea2f2f9a19d1f0cf064b3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 20:57:42 +0000 Subject: [PATCH 30/55] chore: bump version to 2.0.37 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 958a354..1ccaa6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.36", + "version": "2.0.37", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From a6995cc17eb9ac5279ffe010ecccb075af49ff6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 20:58:02 +0000 Subject: [PATCH 31/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20144?= =?UTF-8?q?k=20tokens=20=C2=B7=2072%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 6b5f9e9..e8ea93a 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 143k tokens, 71% of context window + + 144k tokens, 72% of context window @@ -15,8 +15,8 @@ tokens - - 143k + + 144k From 1594a0c682cb2fee431bd6e565d3a7336e5e730b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:10:24 +0300 Subject: [PATCH 32/55] Apply suggestion from @gavrielc --- .claude/skills/debug/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 25c5dcf..524ff0c 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -92,7 +92,7 @@ grep "Channel adapter started" logs/nanoclaw.log | tail -10 3. If the remaining service unit is missing `EnvironmentFile`, add it: ```bash # Edit the service unit — add this line under [Service]: - # EnvironmentFile=/home/iraa/nanoclaw/.env + # EnvironmentFile=/home/[user]/nanoclaw/.env systemctl --user daemon-reload systemctl --user restart nanoclaw-v2-.service ``` From ca17683e3202acdf619b57fc29a89a0701702797 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:12:12 +0000 Subject: [PATCH 33/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20145?= =?UTF-8?q?k=20tokens=20=C2=B7=2072%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index e8ea93a..bbc3020 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 144k tokens, 72% of context window + + 145k tokens, 72% of context window @@ -15,8 +15,8 @@ tokens - - 144k + + 145k From 61ab60041c70c646857e4f2354456bc57a633eff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:12:21 +0000 Subject: [PATCH 34/55] chore: bump version to 2.0.38 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1ccaa6a..f705be5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.37", + "version": "2.0.38", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 3af6e70c0582bf05046cc9a31ad9fa274ad7b2fd Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:23:03 +0300 Subject: [PATCH 35/55] test(agent-runner): add dispatch, origin metadata, and thread resolution tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../agent-runner/src/destinations.test.ts | 19 ++- .../agent-runner/src/integration.test.ts | 119 ++++++++++++++++++ container/agent-runner/src/poll-loop.test.ts | 70 +++++++++++ container/agent-runner/src/poll-loop.ts | 2 +- 4 files changed, 205 insertions(+), 5 deletions(-) diff --git a/container/agent-runner/src/destinations.test.ts b/container/agent-runner/src/destinations.test.ts index f5e5818..14243f2 100644 --- a/container/agent-runner/src/destinations.test.ts +++ b/container/agent-runner/src/destinations.test.ts @@ -33,14 +33,14 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', () expect(prompt).toContain('`whatsapp-mg-17780`'); }); - it('omits the default-routing nudge for a single destination (short-circuited)', () => { + it('requires explicit wrapping even for a single destination', () => { seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); const prompt = buildSystemPromptAddendum('Casa'); - // Single-destination path uses the simpler "no special wrapping needed" copy - expect(prompt).toContain('no special wrapping needed'); - expect(prompt).not.toContain('Default routing'); + expect(prompt).toContain('Every response must be wrapped'); + expect(prompt).toContain(''); + expect(prompt).toContain('`casa`'); }); it('handles the no-destination case without crashing', () => { @@ -49,4 +49,15 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', () expect(prompt).toContain('no configured destinations'); expect(prompt).not.toContain('Default routing'); }); + + it('includes default-routing and wrapping instructions for single destination', () => { + seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us'); + + const prompt = buildSystemPromptAddendum('Casa'); + + expect(prompt).toContain('Every response must be wrapped'); + expect(prompt).toContain(''); + expect(prompt).toContain('Default routing'); + expect(prompt).toContain('`casa`'); + }); }); diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 9d243b2..cc537b5 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -112,6 +112,125 @@ describe('poll loop integration', () => { await loopPromise.catch(() => {}); }); + it('bare text produces no outbound messages (scratchpad only)', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' }); + + // Agent responds with bare text — no wrapping + const provider = new MockProvider({}, () => 'I am thinking about this...'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + // Wait long enough for the poll loop to process + await sleep(1000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); + + it('unknown destination is dropped, valid destination is sent', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'droppeddelivered', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + // Only the valid destination should produce output + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('delivered'); + expect(out[0].platform_id).toBe('chan-1'); + + await loopPromise.catch(() => {}); + }); + + it('multiple blocks each produce an outbound message', async () => { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`, + ) + .run(); + + insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'for discordfor slack', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length >= 2, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(2); + const discord = out.find((m) => m.platform_id === 'chan-1'); + const slack = out.find((m) => m.platform_id === 'chan-2'); + expect(discord).toBeDefined(); + expect(JSON.parse(discord!.content).text).toBe('for discord'); + expect(slack).toBeDefined(); + expect(JSON.parse(slack!.content).text).toBe('for slack'); + + await loopPromise.catch(() => {}); + }); + + it('sends null thread_id when no prior inbound from destination', async () => { + // Seed a second destination that has NO inbound messages + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`, + ) + .run(); + + // Only insert a message from discord — slack-new has never sent anything + insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' }); + + const provider = new MockProvider({}, () => 'hello slack'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].platform_id).toBe('chan-new'); + expect(out[0].thread_id).toBeNull(); + + await loopPromise.catch(() => {}); + }); + + it('resolves most recent thread_id when destination has multiple inbound messages', async () => { + // Two messages from same destination, different threads + insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' }); + insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' }); + + const provider = new MockProvider({}, () => 'reply'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].thread_id).toBe('thread-new'); + expect(out[0].in_reply_to).toBe('m-new'); + + await loopPromise.catch(() => {}); + }); + it('should process messages arriving after loop starts', async () => { const provider = new MockProvider({}, () => 'Processed'); const controller = new AbortController(); diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 6a0bcbd..82f9f75 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -149,6 +149,76 @@ describe('routing', () => { }); }); +describe('origin metadata (from= attribute)', () => { + function seedDestination(name: string, channelType: string, platformId: string): void { + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES (?, ?, 'channel', ?, ?, NULL)`, + ) + .run(name, name, channelType, platformId); + } + + function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`, + ) + .run(id, kind, platformId, channelType, JSON.stringify(content)); + } + + it('chat message includes from= when destination matches', () => { + seedDestination('discord-main', 'discord', 'chan-1'); + insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain('from="discord-main"'); + }); + + it('chat message falls back to raw routing when no destination matches', () => { + insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain('from="unknown:telegram:chat-999"'); + }); + + it('chat message omits from= when routing is null', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).not.toContain('from='); + }); + + it('task message includes from= when destination matches', () => { + seedDestination('slack-ops', 'slack', 'C-OPS'); + insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + insertMessage('t1', 'task', { prompt: 'check status' }); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + seedDestination('github-ch', 'github', 'repo-1'); + insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + seedDestination('discord-main', 'discord', 'chan-1'); + insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { it('should produce init + result events', async () => { const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 804d1f2..f22fc7d 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,4 +1,4 @@ -import { findByName, type DestinationEntry } from './destinations.js'; +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 { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; From eb6502a1b251a2afbc6afae5d926bfcf3206c15a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:23:30 +0000 Subject: [PATCH 36/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20147?= =?UTF-8?q?k=20tokens=20=C2=B7=2073%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index bbc3020..d55f598 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 145k tokens, 72% of context window + + 147k tokens, 73% of context window @@ -15,8 +15,8 @@ tokens - - 145k + + 147k From e1251da3946f672bec5ffcb5a5f0ab44d6728dc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:23:33 +0000 Subject: [PATCH 37/55] chore: bump version to 2.0.39 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f705be5..2f34958 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.38", + "version": "2.0.39", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 684a98d078f2fa2dd4308ea85f31f4b804e68fa1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:26:28 +0300 Subject: [PATCH 38/55] test: add host-side routing and session resolution tests 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) --- .../agent-runner/src/integration.test.ts | 45 +++ src/host-core.test.ts | 301 +++++++++++++++++- 2 files changed, 345 insertions(+), 1 deletion(-) diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index cc537b5..4a2b806 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -249,6 +249,51 @@ describe('poll loop integration', () => { await loopPromise.catch(() => {}); }); + it('internal tags between message blocks are stripped from scratchpad', async () => { + insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new MockProvider( + {}, + () => 'thinking about this...answerdone thinking', + ); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('answer'); + + await loopPromise.catch(() => {}); + }); + + it('handles mixed task + chat batch with correct origin metadata', async () => { + // Seed destination for routing lookup + insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' }); + // Task with same routing — simulates a scheduled task in a channel session + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ prompt: 'daily check' })); + + const provider = new MockProvider({}, () => 'done'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].platform_id).toBe('chan-1'); + + await loopPromise.catch(() => {}); + }); + it('should inject destination reminder after a compacted event', async () => { // Two destinations — required for the reminder to fire (single-destination // groups have a fallback path that works without wrapping). diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 043b6b1..70669dd 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -19,6 +19,7 @@ import { import { resolveSession, writeSessionMessage, + writeSessionRouting, initSessionFolder, sessionDir, inboundDbPath, @@ -26,7 +27,7 @@ import { readOutboxFiles, clearOutbox, } from './session-manager.js'; -import { getSession, findSession } from './db/sessions.js'; +import { getSession, findSession, findSessionByAgentGroup } from './db/sessions.js'; import type { InboundEvent } from './channels/adapter.js'; // Mock container runner to prevent actual Docker spawning @@ -595,6 +596,304 @@ describe('router', () => { }); }); +describe('routing metadata preservation', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-1', + name: 'Test Agent', + folder: 'test-agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-1', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + }); + + it('routed message carries platformId, channelType, threadId on the messages_in row', async () => { + const { routeInbound } = await import('./router.js'); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: 'thread-42', + message: { id: 'msg-r1', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'hi' }), timestamp: now() }, + }); + + const session = findSession('mg-1', null); + const db = new Database(inboundDbPath('ag-1', session!.id)); + const row = db.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?').get('msg-r1%') as { + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }; + db.close(); + + expect(row.platform_id).toBe('chan-123'); + expect(row.channel_type).toBe('discord'); + expect(row.thread_id).toBe('thread-42'); + }); + + it('fan-out gives each agent its own routing, not leaked from sibling', async () => { + const { routeInbound } = await import('./router.js'); + + createAgentGroup({ + id: 'ag-2', + name: 'Agent Two', + folder: 'agent-two', + agent_provider: null, + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-2', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-2', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: 'thread-fanout', + message: { id: 'msg-fo', kind: 'chat', content: JSON.stringify({ text: 'fan' }), timestamp: now() }, + }); + + // Both agents should have the message with correct routing + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + for (const agId of ['ag-1', 'ag-2']) { + const sessions = getSessionsByAgentGroup(agId); + expect(sessions).toHaveLength(1); + const db = new Database(inboundDbPath(agId, sessions[0].id)); + const row = db.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in LIMIT 1').get() as { + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }; + db.close(); + expect(row.platform_id).toBe('chan-123'); + expect(row.channel_type).toBe('discord'); + expect(row.thread_id).toBe('thread-fanout'); + } + }); +}); + +describe('writeSessionRouting', () => { + it('populates session_routing from the messaging group', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'telegram', + platform_id: 'tg:12345', + name: 'Chat', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBe('telegram'); + expect(row!.platform_id).toBe('tg:12345'); + expect(row!.thread_id).toBeNull(); + }); + + it('writes null routing for agent-shared session (no messaging group)', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session } = resolveSession('ag-1', null, null, 'agent-shared'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBeNull(); + expect(row!.platform_id).toBeNull(); + expect(row!.thread_id).toBeNull(); + }); + + it('includes thread_id from per-thread session', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session } = resolveSession('ag-1', 'mg-1', 'thread-77', 'per-thread'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBe('discord'); + expect(row!.platform_id).toBe('chan-123'); + expect(row!.thread_id).toBe('thread-77'); + }); +}); + +describe('agent-shared session resolution', () => { + it('resolves to the same session on repeated calls', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session: s1, created: c1 } = resolveSession('ag-1', null, null, 'agent-shared'); + const { session: s2, created: c2 } = resolveSession('ag-1', null, null, 'agent-shared'); + + expect(c1).toBe(true); + expect(c2).toBe(false); + expect(s1.id).toBe(s2.id); + }); + + it('agent-shared session has null messaging_group_id', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session } = resolveSession('ag-1', null, null, 'agent-shared'); + expect(session.messaging_group_id).toBeNull(); + }); + + // BUG (#2332): agent-shared resolveSession reuses an existing channel-bound + // session via findSessionByAgentGroup instead of creating a dedicated + // agent-shared session. The two cannot coexist today — the agent-shared + // call finds the channel session and returns it. This test documents the + // current (broken) behavior; fixing #2332 should make it pass as written. + it.skip('agent-shared and channel-bound sessions coexist for the same agent group', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session: shared } = resolveSession('ag-1', 'mg-1', null, 'shared'); + const { session: agentShared } = resolveSession('ag-1', null, null, 'agent-shared'); + + expect(shared.id).not.toBe(agentShared.id); + expect(shared.messaging_group_id).toBe('mg-1'); + expect(agentShared.messaging_group_id).toBeNull(); + }); + + it('findSessionByAgentGroup returns existing channel-bound session (bug #2332)', () => { + // Documents the current behavior: findSessionByAgentGroup doesn't + // distinguish agent-shared from channel-bound. When a channel session + // exists, agent-shared resolution reuses it instead of creating a + // separate session. This is the root cause of A2A misrouting. + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session: channelSession } = resolveSession('ag-1', 'mg-1', null, 'shared'); + const found = findSessionByAgentGroup('ag-1'); + + // Bug: picks the channel session — an agent-shared call would get this + // instead of a dedicated session. + expect(found).toBeDefined(); + expect(found!.id).toBe(channelSession.id); + expect(found!.messaging_group_id).toBe('mg-1'); // should be null for agent-shared + }); +}); + describe('delivery', () => { it('should detect undelivered messages in outbound DB', () => { createAgentGroup({ From 7da08b3327063953e4b941130c9b53e9b12d0d1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:26:57 +0000 Subject: [PATCH 39/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20147?= =?UTF-8?q?k=20tokens=20=C2=B7=2074%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index d55f598..c0a2c2e 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 147k tokens, 73% of context window + + 147k tokens, 74% of context window From 1a358dc7e3c149cd588a923bce2dc3879d0bcb3b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:34:34 +0300 Subject: [PATCH 40/55] test(a2a): add tests documenting A2A routing bugs (#2332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests that exercise agent-to-agent routing and document the broken behavior that #2332 describes: 1. A2A outbound lands in target session — basic happy path, passes. 2. A2A return path resolves to wrong session when source agent has multiple channel sessions. Researcher responds to PA, but findSessionByAgentGroup picks PA's newest session (Discord) instead of the Slack session that originated the A2A call. Test asserts the buggy behavior (response in Discord, nothing in Slack). 3. A2A-only session gets null session_routing. writeSessionRouting on a session with messaging_group_id=NULL writes all nulls — the target agent has no default routing for replies. Test asserts the nulls. These tests pass today by asserting the broken state. When #2332 is fixed (origin-aware return routing), these assertions should flip to the correct behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/host-core.test.ts | 187 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 171 insertions(+), 16 deletions(-) diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 70669dd..976544f 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { initTestDb, closeDb, + getDb, runMigrations, createAgentGroup, createMessagingGroup, @@ -640,7 +641,9 @@ describe('routing metadata preservation', () => { const session = findSession('mg-1', null); const db = new Database(inboundDbPath('ag-1', session!.id)); - const row = db.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?').get('msg-r1%') as { + const row = db + .prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?') + .get('msg-r1%') as { platform_id: string | null; channel_type: string | null; thread_id: string | null; @@ -724,11 +727,13 @@ describe('writeSessionRouting', () => { writeSessionRouting('ag-1', session.id); const db = new Database(inboundDbPath('ag-1', session.id)); - const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as { - channel_type: string | null; - platform_id: string | null; - thread_id: string | null; - } | undefined; + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; db.close(); expect(row).toBeDefined(); @@ -750,11 +755,13 @@ describe('writeSessionRouting', () => { writeSessionRouting('ag-1', session.id); const db = new Database(inboundDbPath('ag-1', session.id)); - const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as { - channel_type: string | null; - platform_id: string | null; - thread_id: string | null; - } | undefined; + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; db.close(); expect(row).toBeDefined(); @@ -785,11 +792,13 @@ describe('writeSessionRouting', () => { writeSessionRouting('ag-1', session.id); const db = new Database(inboundDbPath('ag-1', session.id)); - const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as { - channel_type: string | null; - platform_id: string | null; - thread_id: string | null; - } | undefined; + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; db.close(); expect(row).toBeDefined(); @@ -894,6 +903,152 @@ describe('agent-shared session resolution', () => { }); }); +describe('agent-to-agent routing', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-pa', + name: 'PA', + folder: 'pa-agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-slack', + channel_type: 'slack', + platform_id: 'C-GENERAL', + name: 'Slack General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + createAgentGroup({ + id: 'ag-researcher', + name: 'Researcher', + folder: 'researcher-agent', + agent_provider: null, + created_at: now(), + }); + + // Wire bidirectional A2A destinations (table created by runMigrations) + const db = getDb(); + db.prepare( + `INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES ('ag-pa', 'researcher', 'agent', 'ag-researcher', ?)`, + ).run(now()); + db.prepare( + `INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES ('ag-researcher', 'pa', 'agent', 'ag-pa', ?)`, + ).run(now()); + }); + + it('A2A outbound lands in a session for the target agent', async () => { + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + + await routeAgentMessage( + { id: 'out-a2a-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research this' }) }, + paSlackSession, + ); + + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSessions = getSessionsByAgentGroup('ag-researcher'); + expect(researcherSessions.length).toBeGreaterThanOrEqual(1); + + const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id)); + const rows = rDb.prepare('SELECT platform_id, channel_type, content FROM messages_in').all() as Array<{ + platform_id: string | null; + channel_type: string | null; + content: string; + }>; + rDb.close(); + + expect(rows).toHaveLength(1); + expect(rows[0].channel_type).toBe('agent'); + expect(rows[0].platform_id).toBe('ag-pa'); + expect(JSON.parse(rows[0].content).text).toBe('research this'); + }); + + it('BUG: A2A return path resolves to wrong session when multiple channel sessions exist (#2332)', async () => { + // PA has Slack session, then gets wired to Discord (newer session). + // Researcher responds to PA. routeAgentMessage calls + // resolveSession('ag-pa', null, null, 'agent-shared') which calls + // findSessionByAgentGroup — picks newest (Discord) instead of the + // Slack session that originated the A2A call. + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + + createMessagingGroup({ + id: 'mg-discord', + channel_type: 'discord', + platform_id: 'chan-discord', + name: 'Discord', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + const { session: paDiscordSession } = resolveSession('ag-pa', 'mg-discord', null, 'shared'); + + // PA sends from Slack + await routeAgentMessage( + { id: 'out-fwd', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research' }) }, + paSlackSession, + ); + + // Researcher responds back to PA + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSession = getSessionsByAgentGroup('ag-researcher')[0]; + + await routeAgentMessage( + { id: 'out-reply', platform_id: 'ag-pa', content: JSON.stringify({ text: 'found it' }) }, + researcherSession, + ); + + const slackDb = new Database(inboundDbPath('ag-pa', paSlackSession.id)); + const slackA2a = slackDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all(); + slackDb.close(); + + const discordDb = new Database(inboundDbPath('ag-pa', paDiscordSession.id)); + const discordA2a = discordDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all(); + discordDb.close(); + + // Document the bug: response lands in Discord (newest) not Slack (origin) + expect(discordA2a).toHaveLength(1); // BUG: should be 0 + expect(slackA2a).toHaveLength(0); // BUG: should be 1 + }); + + it('BUG: A2A-only session gets null session_routing (#2332)', async () => { + // Researcher only has an agent-shared session (no channel wiring). + // writeSessionRouting writes nulls because messaging_group_id is null. + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + await routeAgentMessage( + { id: 'out-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'go' }) }, + paSession, + ); + + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSessions = getSessionsByAgentGroup('ag-researcher'); + expect(researcherSessions).toHaveLength(1); + + writeSessionRouting('ag-researcher', researcherSessions[0].id); + + const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id)); + const routing = rDb.prepare('SELECT channel_type, platform_id FROM session_routing WHERE id = 1').get() as { + channel_type: string | null; + platform_id: string | null; + } | undefined; + rDb.close(); + + // BUG: session_routing is all null — researcher has no default routing + expect(routing).toBeDefined(); + expect(routing!.channel_type).toBeNull(); + expect(routing!.platform_id).toBeNull(); + }); +}); + describe('delivery', () => { it('should detect undelivered messages in outbound DB', () => { createAgentGroup({ From 3b07c0ceaf414992e81a4cd6d4631ed317d1e86c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:35:08 +0000 Subject: [PATCH 41/55] chore: bump version to 2.0.40 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2f34958..ac7a58d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.39", + "version": "2.0.40", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 107945f10c09d6934566202fe6857c5a87ea3b82 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:48:10 +0300 Subject: [PATCH 42/55] fix(agent-to-agent): route A2A replies back to originating session (#2267) 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 Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/current-batch.ts | 29 ++ .../agent-runner/src/mcp-tools/core.test.ts | 50 ++++ container/agent-runner/src/mcp-tools/core.ts | 19 +- container/agent-runner/src/poll-loop.ts | 24 +- src/db/schema.ts | 8 +- src/db/session-db.test.ts | 38 ++- src/db/session-db.ts | 55 +++- src/delivery.ts | 1 + src/host-core.test.ts | 14 +- .../agent-to-agent/agent-route.test.ts | 250 +++++++++++++++++- src/modules/agent-to-agent/agent-route.ts | 61 ++++- src/session-manager.ts | 7 + 12 files changed, 517 insertions(+), 39 deletions(-) create mode 100644 container/agent-runner/src/current-batch.ts create mode 100644 container/agent-runner/src/mcp-tools/core.test.ts diff --git a/container/agent-runner/src/current-batch.ts b/container/agent-runner/src/current-batch.ts new file mode 100644 index 0000000..b699c13 --- /dev/null +++ b/container/agent-runner/src/current-batch.ts @@ -0,0 +1,29 @@ +/** + * 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; +} + diff --git a/container/agent-runner/src/mcp-tools/core.test.ts b/container/agent-runner/src/mcp-tools/core.test.ts new file mode 100644 index 0000000..4cef950 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/core.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for the core MCP tools' interaction with the per-batch routing + * context. The agent-runner sets a current `inReplyTo` at the top of each + * 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. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js'; +import { getUndeliveredMessages } from '../db/messages-out.js'; +import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js'; +import { sendMessage } from './core.js'; + +beforeEach(() => { + initTestSessionDb(); + // Seed a peer agent destination + getInboundDb() + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`, + ) + .run(); +}); + +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'); + + await sendMessage.handler({ to: 'peer', text: 'hello' }); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(out[0].in_reply_to).toBe('inbound-msg-1'); + }); + + it('writes null when no batch is active', async () => { + // No setCurrentInReplyTo before this call — 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(); + }); +}); diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index bf89ef8..48f87d5 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -9,6 +9,7 @@ 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 { getSessionRouting } from '../db/session-routing.js'; @@ -50,9 +51,7 @@ function destinationList(): string { */ function resolveRouting( to: string | undefined, -): - | { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } - | { error: string } { +): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } { if (!to) { // Default: reply to whatever thread/channel this session is bound to. const session = getSessionRouting(); @@ -82,9 +81,7 @@ function resolveRouting( // preserve the thread_id so replies land in the correct thread. const session = getSessionRouting(); const threadId = - session.channel_type === dest.channelType && session.platform_id === dest.platformId - ? session.thread_id - : null; + session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null; return { channel_type: dest.channelType!, platform_id: dest.platformId!, @@ -98,12 +95,14 @@ function resolveRouting( export const sendMessage: McpToolDefinition = { tool: { name: 'send_message', - description: - 'Send a message to a named destination. If you have only one destination, you can omit `to`.', + description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.', inputSchema: { type: 'object' as const, properties: { - to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' }, + to: { + type: 'string', + description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.', + }, text: { type: 'string', description: 'Message content' }, }, required: ['text'], @@ -119,6 +118,7 @@ export const sendMessage: McpToolDefinition = { const id = generateId(); const seq = writeMessageOut({ id, + in_reply_to: getCurrentInReplyTo(), kind: 'chat', platform_id: routing.platform_id, channel_type: routing.channel_type, @@ -165,6 +165,7 @@ export const sendFile: McpToolDefinition = { writeMessageOut({ id, + in_reply_to: getCurrentInReplyTo(), kind: 'chat', platform_id: routing.platform_id, channel_type: routing.channel_type, diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index f22fc7d..e0ac722 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -2,12 +2,17 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { 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, - migrateLegacyContinuation, - setContinuation, -} from './db/session-state.js'; -import { formatMessages, extractRouting, categorizeMessage, isClearCommand, isRunnerCommand, stripInternalTags, type RoutingContext } from './formatter.js'; + formatMessages, + extractRouting, + categorizeMessage, + isClearCommand, + isRunnerCommand, + stripInternalTags, + type RoutingContext, +} from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -170,6 +175,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise { // Process the query while concurrently polling for new messages const skippedSet = new Set(skipped); 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. + setCurrentInReplyTo(routing.inReplyTo); try { const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { @@ -198,6 +206,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise { thread_id: routing.threadId, content: JSON.stringify({ text: `Error: ${errMsg}` }), }); + } finally { + clearCurrentInReplyTo(); } // Ensure completed even if processQuery ended without a result event @@ -402,7 +412,9 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`); break; case 'error': - log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`); + log( + `Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`, + ); break; case 'progress': log(`Progress: ${event.message}`); diff --git a/src/db/schema.ts b/src/db/schema.ts index 8433035..48d9ce3 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -171,7 +171,13 @@ CREATE TABLE IF NOT EXISTS messages_in ( platform_id TEXT, channel_type TEXT, thread_id TEXT, - content TEXT NOT NULL + content TEXT NOT NULL, + -- For agent-to-agent inbound rows: the source session that emitted the + -- triggering outbound. Used as a return path when the target replies — + -- the reply routes back to this exact session, not to the source agent + -- group's "newest" session. NULL on channel-side inbound and on a2a rows + -- written before this column existed. + source_session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id); diff --git a/src/db/session-db.test.ts b/src/db/session-db.test.ts index 5307900..a202100 100644 --- a/src/db/session-db.test.ts +++ b/src/db/session-db.test.ts @@ -10,7 +10,7 @@ import fs from 'fs'; import path from 'path'; import { describe, it, expect, afterEach } from 'vitest'; -import { migrateMessagesInTable } from './session-db.js'; +import { getInboundSourceSessionId, migrateMessagesInTable } from './session-db.js'; const TEST_DIR = '/tmp/nanoclaw-session-db-test'; const DB_PATH = path.join(TEST_DIR, 'inbound.db'); @@ -55,4 +55,40 @@ describe('migrateMessagesInTable', () => { expect(row.series_id).toBe('legacy-1'); db.close(); }); + + it('adds source_session_id on a legacy DB, leaves existing rows NULL, is idempotent', () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = new Database(DB_PATH); + db.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + process_after TEXT, + recurrence TEXT, + tries INTEGER DEFAULT 0, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + `); + db.prepare( + "INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES (?, ?, 'chat', datetime('now'), 'pending', '{}')", + ).run('legacy-2', 2); + + migrateMessagesInTable(db); + migrateMessagesInTable(db); // idempotent + + const cols = (db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name); + expect(cols).toContain('source_session_id'); + + expect(getInboundSourceSessionId(db, 'legacy-2')).toBeNull(); + expect(getInboundSourceSessionId(db, 'does-not-exist')).toBeNull(); + db.close(); + }); }); diff --git a/src/db/session-db.ts b/src/db/session-db.ts index addc39d..6713702 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -108,14 +108,21 @@ export function insertMessage( * Host countDueMessages gates on this; container reads everything. */ trigger?: 0 | 1; + /** + * For agent-to-agent inbound: the source session id that emitted the + * outbound message which became this inbound row. Used as the return + * path for the target's reply. NULL on channel-side inbound. + */ + sourceSessionId?: string | null; }, ): void { db.prepare( - `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger) - VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger)`, + `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id) + VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId)`, ).run({ ...message, trigger: message.trigger ?? 1, + sourceSessionId: message.sourceSessionId ?? null, seq: nextEvenSeq(db), }); } @@ -239,6 +246,7 @@ export interface OutboundMessage { channel_type: string | null; thread_id: string | null; content: string; + in_reply_to: string | null; } export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] { @@ -305,4 +313,47 @@ export function migrateMessagesInTable(db: Database.Database): void { // the agent" semantics, so backfill 1 and default 1 for new inserts. db.prepare('ALTER TABLE messages_in ADD COLUMN trigger INTEGER NOT NULL DEFAULT 1').run(); } + if (!cols.has('source_session_id')) { + // For agent-to-agent return-path routing. NULL on existing rows is fine — + // their replies fall back to the legacy "newest active session" lookup. + db.prepare('ALTER TABLE messages_in ADD COLUMN source_session_id TEXT').run(); + } +} + +/** + * Look up an inbound row's source_session_id by its message id. Returns null + * if the row doesn't exist or the column is NULL (channel inbound or + * pre-migration a2a inbound). Used by a2a routing to route replies back to + * the originating session. + */ +export function getInboundSourceSessionId(db: Database.Database, messageId: string): string | null { + const row = db.prepare('SELECT source_session_id FROM messages_in WHERE id = ?').get(messageId) as + | { source_session_id: string | null } + | undefined; + return row?.source_session_id ?? null; +} + +/** + * Find the source_session_id of the most recent a2a inbound row from a + * specific peer (by agent group id). Used as a peer-affinity fallback in + * a2a routing when an outbound reply has no `in_reply_to` (e.g. the + * container's send_message MCP tool path didn't thread the batch's + * in_reply_to through). + * + * Heuristic: "the last time this peer talked to me, which session was it?" + * Returns null when no prior a2a inbound from that peer carries a + * non-null source_session_id (typical for pre-migration installs). + */ +export function getMostRecentPeerSourceSessionId(db: Database.Database, peerAgentGroupId: string): string | null { + const row = db + .prepare( + `SELECT source_session_id FROM messages_in + WHERE channel_type = 'agent' + AND platform_id = ? + AND source_session_id IS NOT NULL + ORDER BY seq DESC + LIMIT 1`, + ) + .get(peerAgentGroupId) as { source_session_id: string | null } | undefined; + return row?.source_session_id ?? null; } diff --git a/src/delivery.ts b/src/delivery.ts index 036153a..a47fec2 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -239,6 +239,7 @@ async function deliverMessage( channel_type: string | null; thread_id: string | null; content: string; + in_reply_to: string | null; }, session: Session, inDb: Database.Database, diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 976544f..b9ba62a 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -969,12 +969,10 @@ describe('agent-to-agent routing', () => { expect(JSON.parse(rows[0].content).text).toBe('research this'); }); - it('BUG: A2A return path resolves to wrong session when multiple channel sessions exist (#2332)', async () => { + it('A2A return path routes to originating session, not newest (#2332)', async () => { // PA has Slack session, then gets wired to Discord (newer session). - // Researcher responds to PA. routeAgentMessage calls - // resolveSession('ag-pa', null, null, 'agent-shared') which calls - // findSessionByAgentGroup — picks newest (Discord) instead of the - // Slack session that originated the A2A call. + // Researcher responds to PA. With the return-path fix, the reply + // routes back to the Slack session (originator) not Discord (newest). const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); @@ -1013,9 +1011,9 @@ describe('agent-to-agent routing', () => { const discordA2a = discordDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all(); discordDb.close(); - // Document the bug: response lands in Discord (newest) not Slack (origin) - expect(discordA2a).toHaveLength(1); // BUG: should be 0 - expect(slackA2a).toHaveLength(0); // BUG: should be 1 + // Fixed: response lands in Slack (origin) not Discord (newest) + expect(slackA2a).toHaveLength(1); + expect(discordA2a).toHaveLength(0); }); it('BUG: A2A-only session gets null session_routing (#2332)', async () => { diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts index 4d48f6f..274565d 100644 --- a/src/modules/agent-to-agent/agent-route.test.ts +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -1,20 +1,53 @@ -import { describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'fs'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; -import { isSafeAttachmentName } from './agent-route.js'; +import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js'; +import { createDestination } from './db/agent-destinations.js'; +import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js'; +import { createSession } from '../../db/sessions.js'; +import { initSessionFolder, inboundDbPath } from '../../session-manager.js'; +import type { Session } from '../../types.js'; + +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-a2a-route' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-a2a-route'; + +function now(): string { + return new Date().toISOString(); +} + +function readInbound(agentGroupId: string, sessionId: string) { + const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true }); + const rows = db + .prepare('SELECT id, platform_id, channel_type, content, source_session_id FROM messages_in ORDER BY seq') + .all() as Array<{ + id: string; + platform_id: string | null; + channel_type: string | null; + content: string; + source_session_id: string | null; + }>; + db.close(); + return rows; +} -/** - * `forwardAttachedFiles` has a filesystem side that's awkward to unit-test - * without mocking DATA_DIR. The guarantee worth pinning is that the - * filename validator rejects everything that could escape the inbox dir — - * `forwardAttachedFiles` runs this guard before any I/O, so traversal is - * impossible as long as this matrix holds. - */ describe('isSafeAttachmentName', () => { it('accepts plain filenames', () => { expect(isSafeAttachmentName('baby-duck.png')).toBe(true); expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true); expect(isSafeAttachmentName('report.v2.docx')).toBe(true); - expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..` + expect(isSafeAttachmentName('.hidden')).toBe(true); }); it('rejects empty / sentinel values', () => { @@ -44,3 +77,200 @@ describe('isSafeAttachmentName', () => { expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false); }); }); + +/** + * Return-path routing: when an a2a reply targets an agent group with multiple + * sessions, it must land in the *originating* session — not the newest one. + * + * Setup: agent A has two active sessions S1 (older) + S2 (newer). + * Agent B is the peer A talks to. Bidirectional destinations wired. + */ +describe('routeAgentMessage return-path', () => { + const A = 'ag-A'; + const B = 'ag-B'; + let S1: Session; + let S2: Session; + let SB: Session; + + beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ id: A, name: 'A', folder: 'a', agent_provider: null, created_at: now() }); + createAgentGroup({ id: B, name: 'B', folder: 'b', agent_provider: null, created_at: now() }); + + // S1 (older), S2 (newer) — both active sessions on A. + S1 = { + id: 'sess-A-old', + agent_group_id: A, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-01-01T00:00:00.000Z', + }; + S2 = { + id: 'sess-A-new', + agent_group_id: A, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-02-01T00:00:00.000Z', + }; + SB = { + id: 'sess-B', + agent_group_id: B, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-01-15T00:00:00.000Z', + }; + createSession(S1); + createSession(S2); + createSession(SB); + initSessionFolder(A, S1.id); + initSessionFolder(A, S2.id); + initSessionFolder(B, SB.id); + + createDestination({ + agent_group_id: A, + local_name: 'b', + target_type: 'agent', + target_id: B, + created_at: now(), + }); + createDestination({ + agent_group_id: B, + local_name: 'a', + target_type: 'agent', + target_id: A, + created_at: now(), + }); + }); + + afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + }); + + it('forward direction: stamps source_session_id on the target inbound row', async () => { + // A.S1 emits an outbound a2a to B. + await routeAgentMessage( + { + id: 'msg-from-A-S1', + platform_id: B, + content: JSON.stringify({ text: 'hello B' }), + in_reply_to: null, + }, + S1, + ); + + const bRows = readInbound(B, SB.id); + expect(bRows).toHaveLength(1); + expect(bRows[0].platform_id).toBe(A); + expect(bRows[0].source_session_id).toBe(S1.id); // <- the return address + }); + + it('reply direction: routes back to the originating session, not the newest', async () => { + // A.S1 sends to B. + await routeAgentMessage( + { + id: 'msg-from-A-S1', + platform_id: B, + content: JSON.stringify({ text: 'ping' }), + in_reply_to: null, + }, + S1, + ); + + // Capture the synthetic id the host stamped on B's inbound — that's what + // B's container would reference as `in_reply_to` when replying. + const bRows = readInbound(B, SB.id); + const yId = bRows[0].id; + + // B replies to that message. + await routeAgentMessage( + { + id: 'msg-from-B', + platform_id: A, + content: JSON.stringify({ text: 'pong' }), + in_reply_to: yId, + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + + // The reply lands in S1 (originator) even though S2 is newer. + expect(s1Rows).toHaveLength(1); + expect(s1Rows[0].platform_id).toBe(B); + expect(JSON.parse(s1Rows[0].content).text).toBe('pong'); + expect(s2Rows).toHaveLength(0); + }); + + it('fallback: a2a with no in_reply_to falls through to newest-session lookup', async () => { + // No prior conversation. B initiates an a2a to A out of the blue. + await routeAgentMessage( + { + id: 'msg-from-B-fresh', + platform_id: A, + content: JSON.stringify({ text: 'unsolicited' }), + in_reply_to: null, + }, + SB, + ); + + // Newest session wins (current heuristic, preserved). + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('peer-affinity fallback: with no in_reply_to, routes to most recent peer-source session', async () => { + // A.S1 sends to B (establishing affinity: B's last contact from A was via S1). + await routeAgentMessage( + { + id: 'msg-from-A-S1-pre', + platform_id: B, + content: JSON.stringify({ text: 'context-establishing' }), + in_reply_to: null, + }, + S1, + ); + + // B sends a follow-up but its container forgot to set in_reply_to (e.g. + // emitted via an MCP tool path that doesn't thread the batch's in_reply_to + // through). The host should still route this to S1 because S1 is the + // session most recently in conversation with B — not the chronologically + // newest session of A. + await routeAgentMessage( + { + id: 'msg-from-B-followup', + platform_id: A, + content: JSON.stringify({ text: 'standing by' }), + in_reply_to: null, + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + // Affinity wins: reply to S1, not the newer S2. + expect(s1Rows).toHaveLength(1); + expect(JSON.parse(s1Rows[0].content).text).toBe('standing by'); + expect(s2Rows).toHaveLength(0); + }); +}); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 613a1ed..58e1419 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -23,10 +23,11 @@ import path from 'path'; import { isSafeAttachmentName } from '../../attachment-safety.js'; import { getAgentGroup } from '../../db/agent-groups.js'; +import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; import { log } from '../../log.js'; -import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; +import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; @@ -101,6 +102,61 @@ export interface RoutableAgentMessage { id: string; platform_id: string | null; content: string; + /** + * For replies, the id of the inbound message being replied to. The + * container's formatter sets this from the first inbound in the batch + * (`container/agent-runner/src/formatter.ts`). Used here to route the + * reply back to the originating session — see `resolveTargetSession`. + */ + in_reply_to: string | null; +} + +/** + * Pick which session of `targetAgentGroupId` should receive this a2a message. + * + * Three layers, highest-fidelity first: + * + * 1. **Direct return-path** (in_reply_to lookup): if the message is a reply + * (`in_reply_to` set), open the source agent's inbound DB and read the + * triggering row's `source_session_id`. That column was stamped when the + * original outbound was routed — it's the session that started the + * conversation, and replies should land there even when the target has + * multiple active sessions. + * + * 2. **Peer-affinity fallback**: if (1) misses (in_reply_to is null or the + * referenced row isn't an a2a inbound), look up the most recent a2a + * inbound *from the target agent group* in source's inbound and use its + * `source_session_id`. The intuition: the last time this peer talked to + * me, which target session was driving? Route the reply there, since + * that's the session most plausibly in active conversation. + * + * 3. **Newest active session**: legacy heuristic. Used when no prior a2a + * has been recorded with `source_session_id` (e.g. fresh installs, + * pre-migration data). + */ +function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session, targetAgentGroupId: string): Session { + const srcDb = openInboundDb(sourceSession.agent_group_id, sourceSession.id); + let originSessionId: string | null = null; + try { + if (msg.in_reply_to) { + originSessionId = getInboundSourceSessionId(srcDb, msg.in_reply_to); + } + if (!originSessionId) { + // Peer-affinity fallback — covers the case where the container's + // outbound write didn't carry in_reply_to (e.g. legacy MCP send_message + // path, container running pre-fix code). + originSessionId = getMostRecentPeerSourceSessionId(srcDb, targetAgentGroupId); + } + } finally { + srcDb.close(); + } + if (originSessionId) { + const candidate = getSession(originSessionId); + if (candidate && candidate.agent_group_id === targetAgentGroupId && candidate.status === 'active') { + return candidate; + } + } + return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session; } export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise { @@ -119,7 +175,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess if (!getAgentGroup(targetAgentGroupId)) { throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`); } - const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); + const targetSession = resolveTargetSession(msg, session, targetAgentGroupId); const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // If the source message references files (via `send_file`), forward the @@ -137,6 +193,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess channelType: 'agent', threadId: null, content: forwardedContent, + sourceSessionId: session.id, }); log.info('Agent message routed', { from: session.agent_group_id, diff --git a/src/session-manager.ts b/src/session-manager.ts index e3f3f7a..5c423ea 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -210,6 +210,12 @@ export function writeSessionMessage( * a trigger-1 message does arrive. */ trigger?: 0 | 1; + /** + * For agent-to-agent inbound: the source session id that emitted the + * outbound message which became this inbound row. Used as the return + * path so the target's reply routes back to that exact session. + */ + sourceSessionId?: string | null; }, ): void { // Extract base64 attachment data, save to inbox, replace with file paths @@ -228,6 +234,7 @@ export function writeSessionMessage( processAfter: message.processAfter ?? null, recurrence: message.recurrence ?? null, trigger: message.trigger ?? 1, + sourceSessionId: message.sourceSessionId ?? null, }); } finally { db.close(); From 35233dabe886fe05150e396ea2578434428f7638 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:48:28 +0000 Subject: [PATCH 43/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20149?= =?UTF-8?q?k=20tokens=20=C2=B7=2075%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index c0a2c2e..d1f452a 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 147k tokens, 74% of context window + + 149k tokens, 75% of context window @@ -15,8 +15,8 @@ tokens - - 147k + + 149k From 3b64d6cf76ab197081c34128c6b1022b82882ab5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:48:36 +0000 Subject: [PATCH 44/55] chore: bump version to 2.0.41 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac7a58d..9a7908f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.40", + "version": "2.0.41", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 6ea49898dde37b7cdd7f40f8f15399b88a546d0d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:50:08 +0300 Subject: [PATCH 45/55] test: remove stale A2A session coexistence tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skipped coexistence test and the findSessionByAgentGroup bug-documenting test were written before the A2A return-path fix (#2267). That fix sidesteps findSessionByAgentGroup entirely — A2A replies now use source_session_id for routing, so the "newest session wins" behavior is only a fallback for unsolicited first-contact A2A where any session will do. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/host-core.test.ts | 74 ++++--------------------------------------- 1 file changed, 7 insertions(+), 67 deletions(-) diff --git a/src/host-core.test.ts b/src/host-core.test.ts index b9ba62a..51bd724 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -28,7 +28,7 @@ import { readOutboxFiles, clearOutbox, } from './session-manager.js'; -import { getSession, findSession, findSessionByAgentGroup } from './db/sessions.js'; +import { getSession, findSession } from './db/sessions.js'; import type { InboundEvent } from './channels/adapter.js'; // Mock container runner to prevent actual Docker spawning @@ -839,68 +839,6 @@ describe('agent-shared session resolution', () => { expect(session.messaging_group_id).toBeNull(); }); - // BUG (#2332): agent-shared resolveSession reuses an existing channel-bound - // session via findSessionByAgentGroup instead of creating a dedicated - // agent-shared session. The two cannot coexist today — the agent-shared - // call finds the channel session and returns it. This test documents the - // current (broken) behavior; fixing #2332 should make it pass as written. - it.skip('agent-shared and channel-bound sessions coexist for the same agent group', () => { - createAgentGroup({ - id: 'ag-1', - name: 'Agent', - folder: 'agent', - agent_provider: null, - created_at: now(), - }); - createMessagingGroup({ - id: 'mg-1', - channel_type: 'discord', - platform_id: 'chan-123', - name: 'General', - is_group: 1, - unknown_sender_policy: 'public', - created_at: now(), - }); - - const { session: shared } = resolveSession('ag-1', 'mg-1', null, 'shared'); - const { session: agentShared } = resolveSession('ag-1', null, null, 'agent-shared'); - - expect(shared.id).not.toBe(agentShared.id); - expect(shared.messaging_group_id).toBe('mg-1'); - expect(agentShared.messaging_group_id).toBeNull(); - }); - - it('findSessionByAgentGroup returns existing channel-bound session (bug #2332)', () => { - // Documents the current behavior: findSessionByAgentGroup doesn't - // distinguish agent-shared from channel-bound. When a channel session - // exists, agent-shared resolution reuses it instead of creating a - // separate session. This is the root cause of A2A misrouting. - createAgentGroup({ - id: 'ag-1', - name: 'Agent', - folder: 'agent', - agent_provider: null, - created_at: now(), - }); - createMessagingGroup({ - id: 'mg-1', - channel_type: 'discord', - platform_id: 'chan-123', - name: 'General', - is_group: 1, - unknown_sender_policy: 'public', - created_at: now(), - }); - - const { session: channelSession } = resolveSession('ag-1', 'mg-1', null, 'shared'); - const found = findSessionByAgentGroup('ag-1'); - - // Bug: picks the channel session — an agent-shared call would get this - // instead of a dedicated session. - expect(found).toBeDefined(); - expect(found!.id).toBe(channelSession.id); - expect(found!.messaging_group_id).toBe('mg-1'); // should be null for agent-shared - }); }); describe('agent-to-agent routing', () => { @@ -1034,10 +972,12 @@ describe('agent-to-agent routing', () => { writeSessionRouting('ag-researcher', researcherSessions[0].id); const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id)); - const routing = rDb.prepare('SELECT channel_type, platform_id FROM session_routing WHERE id = 1').get() as { - channel_type: string | null; - platform_id: string | null; - } | undefined; + const routing = rDb.prepare('SELECT channel_type, platform_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + } + | undefined; rDb.close(); // BUG: session_routing is all null — researcher has no default routing From 9b670563b81f33081921d11ba3ea33445aebabfc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:50:35 +0000 Subject: [PATCH 46/55] chore: bump version to 2.0.42 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a7908f..f395e24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.41", + "version": "2.0.42", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From f3e19872ac13b7a311c6b31fee9f74c8d8bc6daa Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 01:07:09 +0300 Subject: [PATCH 47/55] refactor: use static gateway skill instead of fetching on spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../{SKILL.fallback.md => SKILL.md} | 0 src/container-runner.ts | 23 +++---------------- 2 files changed, 3 insertions(+), 20 deletions(-) rename container/skills/onecli-gateway/{SKILL.fallback.md => SKILL.md} (100%) diff --git a/container/skills/onecli-gateway/SKILL.fallback.md b/container/skills/onecli-gateway/SKILL.md similarity index 100% rename from container/skills/onecli-gateway/SKILL.fallback.md rename to container/skills/onecli-gateway/SKILL.md diff --git a/src/container-runner.ts b/src/container-runner.ts index 26af379..27b0f5c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -132,7 +132,7 @@ async function spawnContainer(session: Session): Promise { // buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once. const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig); - const mounts = await buildMounts(agentGroup, session, containerConfig, contribution); + const mounts = buildMounts(agentGroup, session, containerConfig, contribution); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; // OneCLI agent identifier is always the agent group id — stable across // sessions and reversible via getAgentGroup() for approval routing. @@ -239,12 +239,12 @@ function resolveProviderContribution( return { provider, contribution }; } -async function buildMounts( +function buildMounts( agentGroup: AgentGroup, session: Session, containerConfig: import('./container-config.js').ContainerConfig, providerContribution: ProviderContainerContribution, -): Promise { +): VolumeMount[] { const projectRoot = process.cwd(); // Per-group filesystem state lives forever after first creation. Init is @@ -252,23 +252,6 @@ async function buildMounts( // is a no-op for groups that have spawned before. initGroupFilesystem(agentGroup); - // Fetch the latest gateway skill from the API; fall back to the static copy. - const skillDir = path.join(projectRoot, 'container', 'skills', 'onecli-gateway'); - const skillPath = path.join(skillDir, 'SKILL.md'); - const fallbackPath = path.join(skillDir, 'SKILL.fallback.md'); - try { - const skill = await onecli.getGatewaySkill(); - const existing = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : ''; - if (skill && skill !== existing) { - fs.writeFileSync(skillPath, skill); - } - } catch { - if (!fs.existsSync(skillPath) && fs.existsSync(fallbackPath)) { - fs.copyFileSync(fallbackPath, skillPath); - } - log.warn('Could not fetch gateway skill from OneCLI API; using static fallback'); - } - // Sync skill symlinks based on container.json selection before mounting. const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); syncSkillSymlinks(claudeDir, containerConfig); From 028cb017edc1f30fb8b8003acc13e66956eb4026 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 22:09:22 +0000 Subject: [PATCH 48/55] chore: bump version to 2.0.43 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 69a5290..9dbf849 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.42", + "version": "2.0.43", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 635a49369f3cb2194d9830961ba8d8b7895d6f6e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 01:22:42 +0300 Subject: [PATCH 49/55] test(agent-to-agent): add missing routing coverage - Stale origin fallback (archived session falls through to newest) - Cross-agent-group guard (origin from wrong group rejected) - Non-a2a in_reply_to (channel message ref falls through) - Self-message bypass (no destination row needed) - File forwarding (bytes copied from outbox to inbox) - Unbounded ping-pong documenting #2063 loop gap Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-to-agent/agent-route.test.ts | 164 +++++++++++++++++- 1 file changed, 162 insertions(+), 2 deletions(-) diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts index 274565d..41ae380 100644 --- a/src/modules/agent-to-agent/agent-route.test.ts +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -1,12 +1,13 @@ import Database from 'better-sqlite3'; import fs from 'fs'; +import path from 'path'; import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js'; import { createDestination } from './db/agent-destinations.js'; import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js'; -import { createSession } from '../../db/sessions.js'; -import { initSessionFolder, inboundDbPath } from '../../session-manager.js'; +import { createSession, updateSession } from '../../db/sessions.js'; +import { initSessionFolder, inboundDbPath, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { Session } from '../../types.js'; vi.mock('../../container-runner.js', () => ({ @@ -273,4 +274,163 @@ describe('routeAgentMessage return-path', () => { expect(JSON.parse(s1Rows[0].content).text).toBe('standing by'); expect(s2Rows).toHaveLength(0); }); + + it('stale origin fallback: archived origin session falls through to newest active', async () => { + // A.S1 sends to B, establishing source_session_id = S1.id on B's inbound. + await routeAgentMessage( + { id: 'msg-fwd', platform_id: B, content: JSON.stringify({ text: 'hello' }), in_reply_to: null }, + S1, + ); + const bRows = readInbound(B, SB.id); + const inboundId = bRows[0].id; + + // Archive S1 — simulates session cleanup or channel disconnect. + updateSession(S1.id, { status: 'archived' }); + + // B replies. origin points to S1 (archived), should fall through to S2. + await routeAgentMessage( + { id: 'msg-reply-stale', platform_id: A, content: JSON.stringify({ text: 'reply' }), in_reply_to: inboundId }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('cross-agent-group guard: origin session belonging to wrong agent group is rejected', async () => { + // Third agent group C sends to B, stamping source_session_id = SC on B's inbound. + const C = 'ag-C'; + createAgentGroup({ id: C, name: 'C', folder: 'c', agent_provider: null, created_at: now() }); + const SC: Session = { + id: 'sess-C', + agent_group_id: C, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-03-01T00:00:00.000Z', + }; + createSession(SC); + initSessionFolder(C, SC.id); + createDestination({ agent_group_id: C, local_name: 'b', target_type: 'agent', target_id: B, created_at: now() }); + + await routeAgentMessage( + { id: 'msg-from-C', platform_id: B, content: JSON.stringify({ text: 'from C' }), in_reply_to: null }, + SC, + ); + const bRows = readInbound(B, SB.id); + const cInboundId = bRows.find((r) => r.platform_id === C)!.id; + + // B replies to A, but in_reply_to references the C-originated row. + // Guard rejects (SC belongs to C, not A) → falls through to newest of A. + await routeAgentMessage( + { id: 'msg-reply-tamper', platform_id: A, content: JSON.stringify({ text: 'misdirected' }), in_reply_to: cInboundId }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('in_reply_to referencing a non-a2a row falls through to newest session', async () => { + // Write a channel message into B's inbound (no source_session_id). + writeSessionMessage(B, SB.id, { + id: 'channel-msg-1', + kind: 'chat', + timestamp: now(), + platformId: 'user-123', + channelType: 'slack', + threadId: null, + content: 'hello from slack', + }); + + // B replies to A with in_reply_to pointing to the channel message. + // source_session_id is null → peer-affinity finds nothing → newest of A. + await routeAgentMessage( + { id: 'msg-reply-channel', platform_id: A, content: JSON.stringify({ text: 'response' }), in_reply_to: 'channel-msg-1' }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('self-message is allowed without a destination row', async () => { + // A targets itself — no agent_destinations row exists for A→A. + await routeAgentMessage( + { id: 'self-msg', platform_id: A, content: JSON.stringify({ text: 'self-note' }), in_reply_to: null }, + S1, + ); + + // Lands in S2 (newest active session of A via resolveSession fallback). + const s2Rows = readInbound(A, S2.id); + expect(s2Rows).toHaveLength(1); + expect(JSON.parse(s2Rows[0].content).text).toBe('self-note'); + }); + + it('BUG: no volume cap on a2a routing — unbounded ping-pong is allowed (#2063)', async () => { + // Two agents can exchange unlimited messages with no rate limit or loop + // detection. This test documents the gap — it should FAIL once #2063 lands. + const errors: string[] = []; + for (let i = 0; i < 20; i++) { + try { + await routeAgentMessage( + { id: `ping-${i}`, platform_id: B, content: JSON.stringify({ text: `ping ${i}` }), in_reply_to: null }, + S1, + ); + await routeAgentMessage( + { id: `pong-${i}`, platform_id: A, content: JSON.stringify({ text: `pong ${i}` }), in_reply_to: null }, + SB, + ); + } catch (e) { + errors.push((e as Error).message); + break; + } + } + // BUG: all 40 messages go through — no cap, no throttle. + // Once loop prevention lands, this should throw or reject after a threshold. + const bRows = readInbound(B, SB.id); + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(errors).toHaveLength(0); + expect(bRows).toHaveLength(20); + expect(s1Rows.length + s2Rows.length).toBe(20); + }); + + it('file forwarding: copies bytes from source outbox to target inbox', async () => { + // Place a file in S1's outbox for the message. + const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-file'); + fs.mkdirSync(outboxDir, { recursive: true }); + fs.writeFileSync(path.join(outboxDir, 'report.pdf'), 'fake-pdf-bytes'); + + await routeAgentMessage( + { + id: 'msg-with-file', + platform_id: B, + content: JSON.stringify({ text: 'see attached', files: ['report.pdf'] }), + in_reply_to: null, + }, + S1, + ); + + const bRows = readInbound(B, SB.id); + expect(bRows).toHaveLength(1); + const parsed = JSON.parse(bRows[0].content); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0].name).toBe('report.pdf'); + expect(parsed.attachments[0].type).toBe('file'); + + // Verify actual file bytes were copied to the target inbox. + const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath); + expect(fs.existsSync(targetPath)).toBe(true); + expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes'); + }); }); From 6e9f35a646ad0042b5b3fdef8bc9a7718229ae04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 22:23:26 +0000 Subject: [PATCH 50/55] chore: bump version to 2.0.44 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9dbf849..3a7e5c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.43", + "version": "2.0.44", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 85850874ab4ad854bafd8a733306da8925c76f0a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 15:24:37 +0300 Subject: [PATCH 51/55] test: add delivery retry, permission check, and poll-loop error recovery coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivery: - Retry exhaustion: adapter fails 3x → markDeliveryFailed - Retry recovery: transient failure then success clears counter - Permission check: unauthorized channel destination blocked Poll-loop (container): - Provider error: error written to outbound, loop continues - Stale session: isSessionInvalid → continuation cleared - /clear command: session wiped, confirmation written Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-runner/src/integration.test.ts | 140 ++++++++++++++++++ src/delivery.test.ts | 120 ++++++++++++++- 2 files changed, 258 insertions(+), 2 deletions(-) diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 4a2b806..7396cfe 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; import { getUndeliveredMessages } from './db/messages-out.js'; import { getPendingMessages } from './db/messages-in.js'; +import { getContinuation, setContinuation } from './db/session-state.js'; import { MockProvider } from './providers/mock.js'; import { runPollLoop } from './poll-loop.js'; @@ -429,3 +430,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +describe('poll loop — provider error recovery', () => { + it('writes error to outbound and continues loop on provider throw', async () => { + insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new ThrowingProvider('API rate limit exceeded'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toContain('Error:'); + expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded'); + + // Input message should be marked completed despite the error + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +describe('poll loop — stale session recovery', () => { + it('clears continuation when provider reports session invalid', async () => { + // Pre-seed a continuation so the local variable in runPollLoop is set. + // Without this, the `if (continuation && isSessionInvalid)` check skips. + setContinuation('mock', 'pre-existing-session'); + + insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' }); + + const provider = new InvalidSessionProvider(); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + // Error was written to outbound + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toContain('Error:'); + + // Continuation was cleared (isSessionInvalid returned true) + expect(getContinuation('mock')).toBeUndefined(); + + await loopPromise.catch(() => {}); + }); +}); + +describe('poll loop — /clear command', () => { + it('clears session, writes confirmation, skips query', async () => { + // Seed a continuation so we can verify it gets cleared + setContinuation('mock', 'existing-session-id'); + expect(getContinuation('mock')).toBe('existing-session-id'); + + // Insert a /clear command + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ text: '/clear' })); + + const provider = new MockProvider({}, () => 'should not run'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); + + await waitFor(() => getUndeliveredMessages().length > 0, 2000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + expect(JSON.parse(out[0].content).text).toBe('Session cleared.'); + + // Continuation was cleared + expect(getContinuation('mock')).toBeUndefined(); + + // Command message was completed + const pending = getPendingMessages(); + expect(pending).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +/** + * Provider that throws on every query, simulating API failures. + */ +class ThrowingProvider { + readonly supportsNativeSlashCommands = false; + private errorMessage: string; + + constructor(errorMessage: string) { + this.errorMessage = errorMessage; + } + + isSessionInvalid(): boolean { + return false; + } + + query(_input: { prompt: string; cwd: string }) { + const errorMessage = this.errorMessage; + return { + push() {}, + end() {}, + abort() {}, + events: (async function* () { + throw new Error(errorMessage); + })(), + }; + } +} + +/** + * Provider that throws with an error that triggers isSessionInvalid. + * First emits an init event (setting continuation), then throws. + */ +class InvalidSessionProvider { + readonly supportsNativeSlashCommands = false; + + isSessionInvalid(): boolean { + return true; + } + + query(_input: { prompt: string; cwd: string }) { + return { + push() {}, + end() {}, + abort() {}, + events: (async function* () { + yield { type: 'init' as const, continuation: 'doomed-session' }; + throw new Error('session not found'); + })(), + }; + } +} diff --git a/src/delivery.test.ts b/src/delivery.test.ts index a5e1efd..5d23536 100644 --- a/src/delivery.test.ts +++ b/src/delivery.test.ts @@ -26,8 +26,9 @@ vi.mock('./config.js', async () => { const TEST_DIR = '/tmp/nanoclaw-test-delivery'; -import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from './db/index.js'; -import { resolveSession, outboundDbPath } from './session-manager.js'; +import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.js'; +import { getDeliveredIds } from './db/session-db.js'; +import { resolveSession, outboundDbPath, openInboundDb } from './session-manager.js'; import { deliverSessionMessages, setDeliveryAdapter } from './delivery.js'; function now(): string { @@ -146,3 +147,118 @@ describe('deliverSessionMessages — concurrent invocations', () => { expect(callCount).toBe(1); }); }); + +describe('deliverSessionMessages — retry and permanent failure', () => { + it('retries on adapter failure and marks failed after MAX_DELIVERY_ATTEMPTS (3)', async () => { + seedAgentAndChannel(); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + insertOutbound('ag-1', session.id, 'out-flaky'); + + let callCount = 0; + setDeliveryAdapter({ + async deliver() { + callCount++; + throw new Error('network timeout'); + }, + }); + + // Attempt 1 + await deliverSessionMessages(session); + expect(callCount).toBe(1); + + // Attempt 2 + await deliverSessionMessages(session); + expect(callCount).toBe(2); + + // Attempt 3 — should mark as permanently failed + await deliverSessionMessages(session); + expect(callCount).toBe(3); + + // Attempt 4 — message is now in delivered (as failed), adapter not called + await deliverSessionMessages(session); + expect(callCount).toBe(3); + + // Verify the message is in the delivered table with 'failed' status + const inDb = openInboundDb('ag-1', session.id); + const delivered = getDeliveredIds(inDb); + inDb.close(); + expect(delivered.has('out-flaky')).toBe(true); + }); + + it('clears attempt counter on successful delivery', async () => { + seedAgentAndChannel(); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + insertOutbound('ag-1', session.id, 'out-retry-ok'); + + let callCount = 0; + setDeliveryAdapter({ + async deliver() { + callCount++; + if (callCount === 1) throw new Error('transient'); + return 'plat-ok'; + }, + }); + + // Attempt 1 — fails + await deliverSessionMessages(session); + expect(callCount).toBe(1); + + // Attempt 2 — succeeds + await deliverSessionMessages(session); + expect(callCount).toBe(2); + + // Attempt 3 — not called, message already delivered + await deliverSessionMessages(session); + expect(callCount).toBe(2); + }); +}); + +describe('deliverSessionMessages — permission check', () => { + it('rejects delivery to an unauthorized channel destination', async () => { + seedAgentAndChannel(); + + // Create a second messaging group that the agent is NOT wired to + createMessagingGroup({ + id: 'mg-2', + channel_type: 'discord', + platform_id: 'discord:456', + name: 'Unauthorized Chat', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + + // Session is on mg-1 (telegram) + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // Insert an outbound message targeting mg-2 (discord) — not the origin chat + const outDb = new Database(outboundDbPath('ag-1', session.id)); + outDb.prepare( + `INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content) + VALUES (?, datetime('now'), 'chat', 'discord:456', 'discord', ?)`, + ).run('out-unauth', JSON.stringify({ text: 'sneaky' })); + outDb.close(); + + const calls: string[] = []; + setDeliveryAdapter({ + async deliver(_ct, _pid, _tid, _kind, content) { + calls.push(content); + return 'plat-msg'; + }, + }); + + // Deliver 3 times to exhaust retries + await deliverSessionMessages(session); + await deliverSessionMessages(session); + await deliverSessionMessages(session); + + // Adapter never called — permission check throws before reaching it + expect(calls).toHaveLength(0); + + // Message is marked as permanently failed + const inDb = openInboundDb('ag-1', session.id); + const delivered = getDeliveredIds(inDb); + inDb.close(); + expect(delivered.has('out-unauth')).toBe(true); + }); +}); From 9629d1cc4a9308abb27886720a34bcf95e18a46f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 12:25:00 +0000 Subject: [PATCH 52/55] =?UTF-8?q?docs:=20update=20token=20count=20to=20150?= =?UTF-8?q?k=20tokens=20=C2=B7=2075%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index d1f452a..941546a 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 149k tokens, 75% of context window + + 150k tokens, 75% of context window @@ -15,8 +15,8 @@ tokens - - 149k + + 150k From 81cb13ec469fc2df7bc483277e67af5b8977d61c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 15:29:36 +0300 Subject: [PATCH 53/55] fix(tests): add missing in_reply_to fields, correct session status type - host-core.test.ts: add in_reply_to: null to routeAgentMessage calls (required after #2267 added the field to RoutableAgentMessage) - agent-route.test.ts: use 'closed' instead of 'archived' (not a valid Session status) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/host-core.test.ts | 8 ++++---- src/modules/agent-to-agent/agent-route.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 51bd724..1225b76 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -885,7 +885,7 @@ describe('agent-to-agent routing', () => { const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); await routeAgentMessage( - { id: 'out-a2a-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research this' }) }, + { id: 'out-a2a-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research this' }), in_reply_to: null }, paSlackSession, ); @@ -928,7 +928,7 @@ describe('agent-to-agent routing', () => { // PA sends from Slack await routeAgentMessage( - { id: 'out-fwd', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research' }) }, + { id: 'out-fwd', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research' }), in_reply_to: null }, paSlackSession, ); @@ -937,7 +937,7 @@ describe('agent-to-agent routing', () => { const researcherSession = getSessionsByAgentGroup('ag-researcher')[0]; await routeAgentMessage( - { id: 'out-reply', platform_id: 'ag-pa', content: JSON.stringify({ text: 'found it' }) }, + { id: 'out-reply', platform_id: 'ag-pa', content: JSON.stringify({ text: 'found it' }), in_reply_to: null }, researcherSession, ); @@ -961,7 +961,7 @@ describe('agent-to-agent routing', () => { const { session: paSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); await routeAgentMessage( - { id: 'out-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'go' }) }, + { id: 'out-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'go' }), in_reply_to: null }, paSession, ); diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts index 41ae380..fca0d4b 100644 --- a/src/modules/agent-to-agent/agent-route.test.ts +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -275,7 +275,7 @@ describe('routeAgentMessage return-path', () => { expect(s2Rows).toHaveLength(0); }); - it('stale origin fallback: archived origin session falls through to newest active', async () => { + it('stale origin fallback: closed origin session falls through to newest active', async () => { // A.S1 sends to B, establishing source_session_id = S1.id on B's inbound. await routeAgentMessage( { id: 'msg-fwd', platform_id: B, content: JSON.stringify({ text: 'hello' }), in_reply_to: null }, @@ -284,10 +284,10 @@ describe('routeAgentMessage return-path', () => { const bRows = readInbound(B, SB.id); const inboundId = bRows[0].id; - // Archive S1 — simulates session cleanup or channel disconnect. - updateSession(S1.id, { status: 'archived' }); + // Close S1 — simulates session cleanup or channel disconnect. + updateSession(S1.id, { status: 'closed' }); - // B replies. origin points to S1 (archived), should fall through to S2. + // B replies. origin points to S1 (closed), should fall through to S2. await routeAgentMessage( { id: 'msg-reply-stale', platform_id: A, content: JSON.stringify({ text: 'reply' }), in_reply_to: inboundId }, SB, From 405dd341486e0c12105c93ec5dd9ecaa50c12cd9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 12:30:04 +0000 Subject: [PATCH 54/55] chore: bump version to 2.0.45 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3a7e5c9..77afaaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.44", + "version": "2.0.45", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 9a649fadc5ad60a191ef10ed603121826d418c7a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 15:33:02 +0300 Subject: [PATCH 55/55] feat(setup): default to interactive Claude handoff on failure Failures now launch an interactive Claude session instead of the non-interactive assist (REASON/COMMAND parser). The user debugs with full terminal access and types /exit to return to setup. The original assist mode is available via --assist-mode flag or NANOCLAW_SETUP_ASSIST_MODE=1 env var. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 6 +- setup/lib/claude-assist.ts | 6 +- setup/lib/claude-handoff.ts | 116 +++++++++++++++++++++++++++++++++++ setup/lib/runner.ts | 4 +- setup/lib/setup-config.ts | 9 +++ setup/lib/windowed-runner.ts | 4 +- 6 files changed, 135 insertions(+), 10 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index bfe1ab4..5428d03 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; -import { offerClaudeAssist } from './lib/claude-assist.js'; +import { offerClaudeOnFailure } from './lib/claude-handoff.js'; import { applyToEnv, parseFlags, @@ -416,7 +416,7 @@ async function main(): Promise { } else { phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName: 'cli-agent', msg: ping === 'socket_error' @@ -528,7 +528,7 @@ async function main(): Promise { service_running: res.terminal?.fields.SERVICE === 'running', has_credentials: res.terminal?.fields.CREDENTIALS === 'configured', }); - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName: 'verify', msg: summary || 'Verification completed with unresolved issues.', hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`, diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 187377e..8c0910d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -43,7 +43,7 @@ export interface AssistContext { * rather than us stuffing contents into the prompt. Keys are step names as * they appear in fail() calls; values are repo-relative paths. */ -const STEP_FILES: Record = { +export const STEP_FILES: Record = { bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'], environment: ['setup/environment.ts'], container: [ @@ -81,7 +81,7 @@ const STEP_FILES: Record = { ], }; -const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts']; +export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts']; /** * Returns `true` if the user ran a Claude-suggested fix command; callers @@ -150,7 +150,7 @@ function isClaudeAuthenticated(): boolean { } } -async function ensureClaudeReady(projectRoot: string): Promise { +export async function ensureClaudeReady(projectRoot: string): Promise { if (!isClaudeInstalled()) { const install = ensureAnswer( await p.confirm({ diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 87023ef..892b397 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -23,10 +23,19 @@ * attempting to parse it as a real answer. */ import { execSync, spawn } from 'child_process'; +import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { + type AssistContext, + BIG_PICTURE_FILES, + ensureClaudeReady, + offerClaudeAssist, + STEP_FILES, +} from './claude-assist.js'; +import { ensureAnswer } from './runner.js'; import { brandBody, note } from './theme.js'; export interface HandoffContext { @@ -194,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string { return lines.join('\n'); } + +/** + * Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either + * the interactive failure handoff (default) or the non-interactive assist. + * + * Drop-in replacement for `offerClaudeAssist` at failure call sites. + */ +export async function offerClaudeOnFailure( + ctx: AssistContext, + projectRoot: string = process.cwd(), +): Promise { + if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') { + return offerClaudeAssist(ctx, projectRoot); + } + return offerFailureHandoff(ctx, projectRoot); +} + +/** + * Interactive Claude handoff for setup failures. Same role as + * `offerClaudeAssist` but spawns an interactive session instead of + * parsing a structured REASON/COMMAND response. + * + * Returns `true` if Claude was launched (the user may have fixed + * things during the session), `false` if skipped/declined/unavailable. + */ +async function offerFailureHandoff( + ctx: AssistContext, + projectRoot: string, +): Promise { + if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; + if (!(await ensureClaudeReady(projectRoot))) return false; + + const want = ensureAnswer( + await p.confirm({ + message: 'Want to debug this with Claude?', + initialValue: true, + }), + ); + if (!want) return false; + + const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot); + + note( + [ + "Launching Claude to help debug this failure.", + "It has the context of what went wrong.", + "", + k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."), + ].join('\n'), + 'Handing off to Claude', + ); + + return new Promise((resolve) => { + const child = spawn( + 'claude', + [ + '--append-system-prompt', + systemPrompt, + '--permission-mode', + 'acceptEdits', + ], + { stdio: 'inherit' }, + ); + child.on('close', () => { + p.log.success(brandBody("Back from Claude. Let's continue.")); + resolve(true); + }); + child.on('error', () => { + p.log.error("Couldn't launch Claude. Continuing without handoff."); + resolve(false); + }); + }); +} + +function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string { + const stepRefs = STEP_FILES[ctx.stepName] ?? []; + const references = [ + ...BIG_PICTURE_FILES, + ...stepRefs, + 'logs/setup.log', + ctx.rawLogPath + ? path.relative(projectRoot, ctx.rawLogPath) + : 'logs/setup-steps/', + ].filter((v, i, a) => a.indexOf(v) === i); + + const lines: string[] = [ + "The user is running NanoClaw's interactive setup flow and hit a failure.", + '', + `Failed step: ${ctx.stepName}`, + `Error: ${ctx.msg}`, + ]; + + if (ctx.hint) lines.push(`Hint: ${ctx.hint}`); + + lines.push( + '', + 'Your job: help them diagnose and fix this issue. Read the referenced files', + 'and logs to understand what went wrong, then help them fix it. You can read', + 'files, run commands, check logs, and explain what happened. Be concise.', + "When they're ready to resume setup, tell them to type /exit.", + '', + 'Relevant files (read as needed with the Read tool):', + ); + for (const f of references) lines.push(` - ${f}`); + + return lines.join('\n'); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 6ffffed..6adb02e 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -18,7 +18,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { offerClaudeAssist } from './claude-assist.js'; +import { offerClaudeOnFailure } from './claude-handoff.js'; import { emit as phEmit } from './diagnostics.js'; import { brandBody, fitToWidth, fmtDuration } from './theme.js'; @@ -367,7 +367,7 @@ export async function fail( if (hint) p.log.message(k.dim(hint)); p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); - const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath }); + const ranFix = await offerClaudeOnFailure({ stepName, msg, hint, rawLogPath }); // If the user just ran a Claude-suggested fix, offer to resume the flow // at the step that failed instead of aborting. We re-exec via spawnSync diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 1fa6ad4..b8eb654 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -123,6 +123,15 @@ export const CONFIG: Entry[] = [ surface: 'flag', type: 'string', }, + { + key: 'assistMode', + envVar: 'NANOCLAW_SETUP_ASSIST_MODE', + label: 'Assist mode', + help: 'Use non-interactive Claude assist on failure instead of interactive handoff.', + surface: 'flag', + type: 'boolean', + default: false, + }, ]; // ─── name derivation ─────────────────────────────────────────────────── diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 87c971e..f13dcd3 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -18,7 +18,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; -import { offerClaudeAssist } from './claude-assist.js'; +import { offerClaudeOnFailure } from './claude-handoff.js'; import { emit as phEmit } from './diagnostics.js'; import type { StepResult, SpinnerLabels } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; @@ -212,7 +212,7 @@ async function handleStall( // offerClaudeAssist runs its own spinner and may propose a fix command. // We don't attempt to restart the stalled build from here — if Claude // proposes a command the user accepts, they can retry setup afterwards. - await offerClaudeAssist({ + await offerClaudeOnFailure({ stepName, msg: `The ${stepName} step has produced no output for 60 seconds.`, hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',