From 3a3d2ee644db223aaf3f1aec8421331993e09e5e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:16 +0300 Subject: [PATCH 001/105] feat(cli): scaffold `nc` CLI with `list-groups` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a transport-agnostic CLI control plane shared between three eventual callers (host shell, Claude in project, container agent) — though only the host-side socket transport is wired in this commit. Container DB transport and approval flow land alongside the first risky command. - src/cli/frame.ts: wire format (RequestFrame, ResponseFrame, CallerContext) - src/cli/registry.ts: command registry with RiskClass - src/cli/dispatch.ts: transport-agnostic dispatcher - src/cli/transport.ts: Transport interface - src/cli/socket-client.ts: SocketTransport against data/nc.sock - src/cli/socket-server.ts: host-side listener (chmod 0600, line-delimited JSON) - src/cli/format.ts: human table / --json output modes - src/cli/client.ts: `nc` argv -> frame -> transport -> stdout - src/cli/commands/list-groups.ts: first command (riskClass: safe) - bin/nc: bash launcher (resolves project root via symlink) - src/index.ts: start/stop server + import command barrel `data/nc.sock` is intentionally separate from `data/cli.sock` (which the existing chat-style channel adapter still owns). Verified end-to-end: `nc list-groups`, `nc list groups`, `--json`, unknown-command error, host-down ENOENT message with start instructions. typecheck clean; eslint reports only the same `no-catch-all` warnings the rest of the codebase has. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/nc | 27 +++++++ src/cli/client.ts | 131 ++++++++++++++++++++++++++++++++ src/cli/commands/index.ts | 4 + src/cli/commands/list-groups.ts | 17 +++++ src/cli/dispatch.ts | 52 +++++++++++++ src/cli/format.ts | 55 ++++++++++++++ src/cli/frame.ts | 44 +++++++++++ src/cli/registry.ts | 36 +++++++++ src/cli/socket-client.ts | 71 +++++++++++++++++ src/cli/socket-server.ts | 116 ++++++++++++++++++++++++++++ src/cli/transport.ts | 10 +++ src/index.ts | 9 +++ 12 files changed, 572 insertions(+) create mode 100755 bin/nc create mode 100644 src/cli/client.ts create mode 100644 src/cli/commands/index.ts create mode 100644 src/cli/commands/list-groups.ts create mode 100644 src/cli/dispatch.ts create mode 100644 src/cli/format.ts create mode 100644 src/cli/frame.ts create mode 100644 src/cli/registry.ts create mode 100644 src/cli/socket-client.ts create mode 100644 src/cli/socket-server.ts create mode 100644 src/cli/transport.ts diff --git a/bin/nc b/bin/nc new file mode 100755 index 0000000..caceb42 --- /dev/null +++ b/bin/nc @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# nc — NanoClaw CLI launcher. +# +# Resolves the project root from this script's location, cd's there so the +# host-resolved DATA_DIR matches the running host, and execs the TS entry +# via tsx. Symlink this file into a directory on your PATH (or alias `nc` +# to its full path) to invoke from anywhere: +# +# ln -s "$(pwd)/bin/nc" /usr/local/bin/nc +# # or +# alias nc="$(pwd)/bin/nc" + +set -euo pipefail + +SCRIPT="${BASH_SOURCE[0]}" +# Resolve symlinks so PROJECT_ROOT points at the real checkout. +while [ -h "$SCRIPT" ]; do + DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)" + SCRIPT="$(readlink "$SCRIPT")" + [[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT" +done +SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" +exec pnpm exec tsx src/cli/client.ts "$@" diff --git a/src/cli/client.ts b/src/cli/client.ts new file mode 100644 index 0000000..dab5ef3 --- /dev/null +++ b/src/cli/client.ts @@ -0,0 +1,131 @@ +/** + * `nc` binary entry point. + * + * Parses argv, builds a request frame, sends it via the picked transport, + * formats the response, exits non-zero on error. + * + * Usage: + * nc [--key value ...] [--json] + * + * Examples: + * nc list-groups + * nc list groups # space-separated form is auto-joined + * nc list-groups --json + */ +import { randomUUID } from 'crypto'; + +import { formatResponse } from './format.js'; +import type { RequestFrame } from './frame.js'; +import { SocketTransport } from './socket-client.js'; +import type { Transport } from './transport.js'; + +async function main(): Promise { + const argv = process.argv.slice(2); + + if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + printUsage(); + process.exit(0); + } + + const { command, args, json } = parseArgv(argv); + const req: RequestFrame = { id: randomUUID(), command, args }; + const transport: Transport = pickTransport(); + + let res; + try { + res = await transport.sendFrame(req); + } catch (e) { + process.stderr.write(formatTransportError(e)); + process.exit(2); + } + + process.stdout.write(formatResponse(res, json ? 'json' : 'human')); + process.exit(res.ok ? 0 : 1); +} + +function pickTransport(): Transport { + // Container DB transport will land alongside the agent-runner change. + // For now: host-only — the only callers are a shell user or Claude in + // the project. + return new SocketTransport(); +} + +function parseArgv(argv: string[]): { + command: string; + args: Record; + json: boolean; +} { + const positional: string[] = []; + const args: Record = {}; + let json = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') { + json = true; + continue; + } + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + continue; + } + positional.push(a); + } + + if (positional.length === 0) { + process.stderr.write('nc: missing command\n'); + printUsage(); + process.exit(2); + } + + // Allow `nc list groups` as well as `nc list-groups`. Server rejects + // unknowns, so the naive join is safe — at worst the user gets an + // unknown-command error. + const command = + positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + + return { command, args, json }; +} + +function printUsage(): void { + process.stdout.write( + [ + 'Usage: nc [--key value ...] [--json]', + '', + 'Commands:', + ' list-groups List all agent groups.', + '', + 'Run `nc --json` for machine-readable output.', + '', + ].join('\n'), + ); +} + +function formatTransportError(e: unknown): string { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) { + return [ + `nc: cannot reach NanoClaw host (${msg}).`, + `Is the host running? Start it with: pnpm run dev`, + `Or, if installed as a service:`, + ` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`, + ` Linux: systemctl --user restart nanoclaw`, + ``, + ].join('\n'); + } + return `nc: transport error: ${msg}\n`; +} + +main().catch((err) => { + process.stderr.write( + `nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(2); +}); diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts new file mode 100644 index 0000000..f37e5ca --- /dev/null +++ b/src/cli/commands/index.ts @@ -0,0 +1,4 @@ +// Side-effect imports — each command file calls register() at top level. +// Imported by src/index.ts on host startup so the registry is populated +// before the CLI server accepts connections. +import './list-groups.js'; diff --git a/src/cli/commands/list-groups.ts b/src/cli/commands/list-groups.ts new file mode 100644 index 0000000..98b87f1 --- /dev/null +++ b/src/cli/commands/list-groups.ts @@ -0,0 +1,17 @@ +import { getAllAgentGroups } from '../../db/agent-groups.js'; +import { register } from '../registry.js'; + +register({ + name: 'list-groups', + description: 'List all agent groups.', + riskClass: 'safe', + parseArgs: () => ({}), + handler: async () => + getAllAgentGroups().map((g) => ({ + id: g.id, + name: g.name, + folder: g.folder, + provider: g.agent_provider ?? 'claude', + created_at: g.created_at, + })), +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts new file mode 100644 index 0000000..62c001b --- /dev/null +++ b/src/cli/dispatch.ts @@ -0,0 +1,52 @@ +/** + * Transport-agnostic dispatcher. Both the socket server (host caller) and + * — once it lands — the per-session DB poller (container caller) call + * dispatch() with the same frame and a transport-supplied CallerContext. + * + * Approval gating for risky calls from the container is the only branch + * that differs by caller. Host callers and `safe` commands run inline. + */ +import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; +import { lookup } from './registry.js'; + +export async function dispatch( + req: RequestFrame, + ctx: CallerContext, +): Promise { + const cmd = lookup(req.command); + if (!cmd) { + return err(req.id, 'unknown-command', `no command "${req.command}"`); + } + + // Agent + risky → approval flow. Wired alongside the first risky command; + // until then, return a clear pending-shaped error so the contract is visible. + if (ctx.caller !== 'host' && cmd.riskClass !== 'safe') { + return err( + req.id, + 'approval-pending', + 'Approval flow not yet wired. (Will be added when the first risky command lands.)', + ); + } + + let parsed: unknown; + try { + parsed = cmd.parseArgs(req.args); + } catch (e) { + return err(req.id, 'invalid-args', errMsg(e)); + } + + try { + const data = await cmd.handler(parsed, ctx); + return { id: req.id, ok: true, data }; + } catch (e) { + return err(req.id, 'handler-error', errMsg(e)); + } +} + +function err(id: string, code: ErrorCode, message: string): ResponseFrame { + return { id, ok: false, error: { code, message } }; +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/src/cli/format.ts b/src/cli/format.ts new file mode 100644 index 0000000..c468143 --- /dev/null +++ b/src/cli/format.ts @@ -0,0 +1,55 @@ +/** + * Output formatting for the `nc` binary. Two modes: + * - human (default): a small auto-table for arrays of flat records, + * JSON.stringify for everything else, plain "error: ..." line for !ok. + * - json: the response frame, pretty-printed. + * + * The MCP / agent side will always pass --json so it parses the frame + * itself. The DB transport (when it lands) skips this layer entirely — + * the agent sees frames directly. + */ +import type { ResponseFrame } from './frame.js'; + +export type FormatMode = 'human' | 'json'; + +export function formatResponse(res: ResponseFrame, mode: FormatMode): string { + if (mode === 'json') return JSON.stringify(res, null, 2) + '\n'; + + if (!res.ok) { + return `error (${res.error.code}): ${res.error.message}\n`; + } + return formatHuman(res.data) + '\n'; +} + +function formatHuman(data: unknown): string { + if (data === null || data === undefined) return ''; + if (typeof data === 'string') return data; + if (Array.isArray(data) && data.every(isFlatRecord)) { + return renderTable(data as Record[]); + } + return JSON.stringify(data, null, 2); +} + +function isFlatRecord(x: unknown): x is Record { + if (!x || typeof x !== 'object') return false; + for (const v of Object.values(x as Record)) { + if (v !== null && typeof v === 'object') return false; + } + return true; +} + +function renderTable(rows: Record[]): string { + if (rows.length === 0) return '(no rows)'; + const cols = Object.keys(rows[0]); + const widths = cols.map((c) => + Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)), + ); + const fmtRow = (vals: string[]): string => + vals.map((v, i) => v.padEnd(widths[i])).join(' '); + const lines = [ + fmtRow(cols), + fmtRow(widths.map((w) => '─'.repeat(w))), + ...rows.map((r) => fmtRow(cols.map((c) => String(r[c] ?? '')))), + ]; + return lines.join('\n'); +} diff --git a/src/cli/frame.ts b/src/cli/frame.ts new file mode 100644 index 0000000..8e7604a --- /dev/null +++ b/src/cli/frame.ts @@ -0,0 +1,44 @@ +/** + * Wire format shared between the socket transport (host caller) and — when + * it lands — the DB transport (container agent caller). + * + * Same JSON whether it goes over a socket as a line or sits in a + * `frame_json TEXT` column on a session DB. Caller identity is NOT carried + * in the frame — it's filled in by whichever server-side adapter received + * the bytes (see CallerContext). + */ + +export type RequestFrame = { + /** Correlation key set by the client. */ + id: string; + /** Registry name, e.g. "list-groups". */ + command: string; + /** Command-specific. Each command's parseArgs validates. */ + args: Record; +}; + +export type ResponseFrame = + | { id: string; ok: true; data: unknown } + | { id: string; ok: false; error: { code: ErrorCode; message: string } }; + +export type ErrorCode = + | 'unknown-command' + | 'invalid-args' + | 'permission-denied' + | 'approval-pending' + | 'not-found' + | 'handler-error' + | 'transport-error'; + +/** + * Filled in by the transport adapter on the server side. Handlers read + * caller identity from here, never from the frame. + */ +export type CallerContext = + | { caller: 'host' } + | { + caller: 'agent'; + sessionId: string; + agentGroupId: string; + messagingGroupId: string; + }; diff --git a/src/cli/registry.ts b/src/cli/registry.ts new file mode 100644 index 0000000..bd224c9 --- /dev/null +++ b/src/cli/registry.ts @@ -0,0 +1,36 @@ +/** + * Command registry — single source of truth for what `nc` can do. + * + * Each command file under `commands/` calls `register()` at top level, + * and `commands/index.ts` imports them all for side effects so the + * registry is populated before the host's CLI server accepts connections. + */ +import type { CallerContext } from './frame.js'; + +export type RiskClass = 'safe' | 'requires-admin' | 'requires-owner'; + +export type CommandDef = { + name: string; + description: string; + riskClass: RiskClass; + /** Validates `frame.args` and produces the typed handler input. Throws on invalid. */ + parseArgs: (raw: Record) => TArgs; + handler: (args: TArgs, ctx: CallerContext) => Promise; +}; + +const registry = new Map(); + +export function register(def: CommandDef): void { + if (registry.has(def.name)) { + throw new Error(`CLI command "${def.name}" already registered`); + } + registry.set(def.name, def as CommandDef); +} + +export function lookup(name: string): CommandDef | undefined { + return registry.get(name); +} + +export function listCommands(): CommandDef[] { + return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts new file mode 100644 index 0000000..1931cb0 --- /dev/null +++ b/src/cli/socket-client.ts @@ -0,0 +1,71 @@ +/** + * SocketTransport — client side. Used by the `nc` binary when running on + * the host (i.e. invoked from a shell or by Claude in the project). + * + * Wire format: line-delimited JSON. One request per connection; the server + * writes one response and closes. + */ +import net from 'net'; +import path from 'path'; + +import { DATA_DIR } from '../config.js'; +import type { RequestFrame, ResponseFrame } from './frame.js'; +import type { Transport } from './transport.js'; + +export const DEFAULT_SOCKET_PATH = path.join(DATA_DIR, 'nc.sock'); + +export class SocketTransport implements Transport { + constructor(private readonly socketPath: string = DEFAULT_SOCKET_PATH) {} + + async sendFrame(req: RequestFrame): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection(this.socketPath); + let buffer = ''; + let settled = false; + + const settle = ( + action: 'resolve' | 'reject', + valueOrErr: ResponseFrame | Error, + ): void => { + if (settled) return; + settled = true; + try { + client.end(); + } catch (_e) { + // best-effort + } + if (action === 'resolve') resolve(valueOrErr as ResponseFrame); + else reject(valueOrErr as Error); + }; + + client.on('connect', () => { + client.write(JSON.stringify(req) + '\n'); + }); + + client.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + const idx = buffer.indexOf('\n'); + if (idx < 0) return; + const line = buffer.slice(0, idx); + try { + const frame = JSON.parse(line) as ResponseFrame; + settle('resolve', frame); + } catch (e) { + settle( + 'reject', + new Error( + `malformed response from host: ${e instanceof Error ? e.message : String(e)}`, + ), + ); + } + }); + + client.on('error', (err) => settle('reject', err)); + client.on('close', () => { + if (!settled) { + settle('reject', new Error('host closed connection before sending response')); + } + }); + }); + } +} diff --git a/src/cli/socket-server.ts b/src/cli/socket-server.ts new file mode 100644 index 0000000..d77ce25 --- /dev/null +++ b/src/cli/socket-server.ts @@ -0,0 +1,116 @@ +/** + * Host-side socket listener. Started from src/index.ts, accepts one frame + * per connection, calls dispatch() with caller='host', writes the response + * frame, closes. + * + * Lives at data/nc.sock (separate from data/cli.sock, which the existing + * chat-style CLI channel adapter owns). Socket file is chmod 0600 — only + * the user that started the host can connect. + */ +import fs from 'fs'; +import net from 'net'; + +import { log } from '../log.js'; +import { dispatch } from './dispatch.js'; +import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js'; +import { DEFAULT_SOCKET_PATH } from './socket-client.js'; + +let server: net.Server | null = null; + +export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): Promise { + // Stale-socket cleanup — a previous run that crashed may have left the + // file behind, and net.createServer refuses to bind to an existing path. + try { + fs.unlinkSync(socketPath); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code !== 'ENOENT') { + log.warn('Failed to unlink stale nc socket (will try to bind anyway)', { socketPath, err }); + } + } + + const s = net.createServer((conn) => handleConnection(conn)); + server = s; + await new Promise((resolve, reject) => { + s.once('error', reject); + s.listen(socketPath, () => { + try { + fs.chmodSync(socketPath, 0o600); + } catch (err) { + log.warn('Failed to chmod nc socket (continuing)', { socketPath, err }); + } + log.info('nc CLI server listening', { socketPath }); + resolve(); + }); + }); +} + +export async function stopCliServer(): Promise { + if (!server) return; + const s = server; + server = null; + await new Promise((resolve) => s.close(() => resolve())); +} + +function handleConnection(conn: net.Socket): void { + let buffer = ''; + conn.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + void handleFrame(conn, line); + } + }); + conn.on('error', (err) => { + log.warn('nc CLI server connection error', { err }); + }); +} + +async function handleFrame(conn: net.Socket, line: string): Promise { + let req: RequestFrame; + try { + const parsed: unknown = JSON.parse(line); + if (!isRequestFrame(parsed)) throw new Error('bad request shape'); + req = parsed; + } catch (e) { + write(conn, { + id: 'unknown', + ok: false, + error: { + code: 'transport-error', + message: `bad frame: ${e instanceof Error ? e.message : String(e)}`, + }, + }); + return; + } + + // Host caller — connecting to data/nc.sock requires file-system access + // to a 0600 socket owned by the host user, so we treat the socket path + // itself as the auth boundary. + const ctx: CallerContext = { caller: 'host' }; + const res = await dispatch(req, ctx); + write(conn, res); +} + +function write(conn: net.Socket, frame: ResponseFrame): void { + try { + conn.write(JSON.stringify(frame) + '\n'); + conn.end(); + } catch (err) { + log.warn('Failed to write nc CLI response', { err }); + } +} + +function isRequestFrame(x: unknown): x is RequestFrame { + if (!x || typeof x !== 'object') return false; + const o = x as Record; + return ( + typeof o.id === 'string' && + typeof o.command === 'string' && + typeof o.args === 'object' && + o.args !== null + ); +} diff --git a/src/cli/transport.ts b/src/cli/transport.ts new file mode 100644 index 0000000..b263102 --- /dev/null +++ b/src/cli/transport.ts @@ -0,0 +1,10 @@ +/** + * Client-side transport interface. The `nc` binary picks one of these and + * calls sendFrame; the caller doesn't know whether bytes traveled over a + * Unix socket (host) or through outbound.db / inbound.db rows (container). + */ +import type { RequestFrame, ResponseFrame } from './frame.js'; + +export interface Transport { + sendFrame(req: RequestFrame): Promise; +} diff --git a/src/index.ts b/src/index.ts index ea9fba6..b2f6b61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,11 @@ import './channels/index.js'; // append registry-based modules. Imported for side effects (registrations). import './modules/index.js'; +// CLI command barrel — populates the `nc` registry before the CLI server +// accepts connections. +import './cli/commands/index.js'; +import { startCliServer, stopCliServer } from './cli/socket-server.js'; + import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; @@ -159,6 +164,9 @@ async function main(): Promise { startHostSweep(); log.info('Host sweep started'); + // 7. Start the `nc` CLI socket server (data/nc.sock). + await startCliServer(); + log.info('NanoClaw running'); } @@ -174,6 +182,7 @@ async function shutdown(signal: string): Promise { } stopDeliveryPolls(); stopHostSweep(); + await stopCliServer(); await teardownChannelAdapters(); process.exit(0); } From 594d1b4055c3486a203e339174afa81443b37835 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:47 +0300 Subject: [PATCH 002/105] style(cli): apply prettier formatting Pre-commit hook ran prettier on the prior commit but left the reformats unstaged. Folding them in here so the branch is clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/client.ts | 7 ++----- src/cli/dispatch.ts | 5 +---- src/cli/format.ts | 7 ++----- src/cli/socket-client.ts | 12 ++---------- src/cli/socket-server.ts | 7 +------ 5 files changed, 8 insertions(+), 30 deletions(-) diff --git a/src/cli/client.ts b/src/cli/client.ts index dab5ef3..db1e30a 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -88,8 +88,7 @@ function parseArgv(argv: string[]): { // Allow `nc list groups` as well as `nc list-groups`. Server rejects // unknowns, so the naive join is safe — at worst the user gets an // unknown-command error. - const command = - positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; return { command, args, json }; } @@ -124,8 +123,6 @@ function formatTransportError(e: unknown): string { } main().catch((err) => { - process.stderr.write( - `nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`, - ); + process.stderr.write(`nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(2); }); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 62c001b..6593750 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -9,10 +9,7 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { lookup } from './registry.js'; -export async function dispatch( - req: RequestFrame, - ctx: CallerContext, -): Promise { +export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise { const cmd = lookup(req.command); if (!cmd) { return err(req.id, 'unknown-command', `no command "${req.command}"`); diff --git a/src/cli/format.ts b/src/cli/format.ts index c468143..5ce67c0 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -41,11 +41,8 @@ function isFlatRecord(x: unknown): x is Record { function renderTable(rows: Record[]): string { if (rows.length === 0) return '(no rows)'; const cols = Object.keys(rows[0]); - const widths = cols.map((c) => - Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)), - ); - const fmtRow = (vals: string[]): string => - vals.map((v, i) => v.padEnd(widths[i])).join(' '); + const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length))); + const fmtRow = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(' '); const lines = [ fmtRow(cols), fmtRow(widths.map((w) => '─'.repeat(w))), diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts index 1931cb0..c94a4dc 100644 --- a/src/cli/socket-client.ts +++ b/src/cli/socket-client.ts @@ -23,10 +23,7 @@ export class SocketTransport implements Transport { let buffer = ''; let settled = false; - const settle = ( - action: 'resolve' | 'reject', - valueOrErr: ResponseFrame | Error, - ): void => { + const settle = (action: 'resolve' | 'reject', valueOrErr: ResponseFrame | Error): void => { if (settled) return; settled = true; try { @@ -51,12 +48,7 @@ export class SocketTransport implements Transport { const frame = JSON.parse(line) as ResponseFrame; settle('resolve', frame); } catch (e) { - settle( - 'reject', - new Error( - `malformed response from host: ${e instanceof Error ? e.message : String(e)}`, - ), - ); + settle('reject', new Error(`malformed response from host: ${e instanceof Error ? e.message : String(e)}`)); } }); diff --git a/src/cli/socket-server.ts b/src/cli/socket-server.ts index d77ce25..7ed2683 100644 --- a/src/cli/socket-server.ts +++ b/src/cli/socket-server.ts @@ -107,10 +107,5 @@ function write(conn: net.Socket, frame: ResponseFrame): void { function isRequestFrame(x: unknown): x is RequestFrame { if (!x || typeof x !== 'object') return false; const o = x as Record; - return ( - typeof o.id === 'string' && - typeof o.command === 'string' && - typeof o.args === 'object' && - o.args !== null - ); + return typeof o.id === 'string' && typeof o.command === 'string' && typeof o.args === 'object' && o.args !== null; } From bc19b716bfc227d544712c07b0b0318dbf30b020 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 5 May 2026 23:48:39 +0300 Subject: [PATCH 003/105] feat(cli): wire nc CLI commands into container agent Add delivery action handler (cli_request) so the host dispatches CLI commands arriving from container agents via outbound.db and writes responses back to inbound.db. Add nc MCP tool in the agent-runner following the ask_user_question blocking pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/messages-in.ts | 24 +++++ container/agent-runner/src/mcp-tools/cli.ts | 97 +++++++++++++++++++ container/agent-runner/src/mcp-tools/index.ts | 1 + src/cli/delivery-action.ts | 59 +++++++++++ src/index.ts | 1 + 5 files changed, 182 insertions(+) create mode 100644 container/agent-runner/src/mcp-tools/cli.ts create mode 100644 src/cli/delivery-action.ts diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 88906ed..3fcb226 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -124,6 +124,30 @@ export function getMessageIn(id: string): MessageInRow | undefined { } } +/** + * Find a pending CLI response (by requestId in content). + * Reads from inbound.db, checks processing_ack to skip already-handled responses. + */ +export function findCliResponse(requestId: string): MessageInRow | undefined { + const inbound = openInboundDb(); + const outbound = getOutboundDb(); + + try { + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"requestId":"${requestId}"%`) as MessageInRow | undefined; + + if (!response) return undefined; + + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; + + return response; + } finally { + inbound.close(); + } +} + /** * Find a pending response to a question (by questionId in content). * Reads from inbound.db, checks processing_ack to skip already-handled responses. diff --git a/container/agent-runner/src/mcp-tools/cli.ts b/container/agent-runner/src/mcp-tools/cli.ts new file mode 100644 index 0000000..868eee3 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/cli.ts @@ -0,0 +1,97 @@ +/** + * CLI MCP tool — lets the container agent invoke host CLI commands. + * + * Follows the ask_user_question blocking pattern: writes a system message + * to outbound.db, polls inbound.db for the response. + */ +import { findCliResponse, markCompleted } from '../db/messages-in.js'; +import { writeMessageOut } from '../db/messages-out.js'; +import { registerTools } from './server.js'; +import type { McpToolDefinition } from './types.js'; + +function log(msg: string): void { + console.error(`[mcp-tools] ${msg}`); +} + +function generateId(): string { + return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const ncCommand: McpToolDefinition = { + tool: { + name: 'nc', + description: + 'Run a NanoClaw CLI command on the host. Returns the command result as JSON. Use `nc list-groups` to see available agent groups. Run with command "help" to list all available commands.', + inputSchema: { + type: 'object' as const, + properties: { + command: { type: 'string', description: 'Command name (e.g. "list-groups")' }, + args: { + type: 'object', + description: 'Command arguments (command-specific)', + additionalProperties: true, + }, + timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' }, + }, + required: ['command'], + }, + }, + async handler(args) { + const command = args.command as string; + const commandArgs = (args.args as Record) ?? {}; + const timeout = ((args.timeout as number) || 30) * 1000; + + if (!command) { + return { content: [{ type: 'text' as const, text: 'Error: command is required' }], isError: true }; + } + + const requestId = generateId(); + + writeMessageOut({ + id: requestId, + kind: 'system', + content: JSON.stringify({ + action: 'cli_request', + requestId, + command, + args: commandArgs, + }), + }); + + log(`nc: ${requestId} → ${command} ${JSON.stringify(commandArgs)}`); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const response = findCliResponse(requestId); + if (response) { + markCompleted([response.id]); + const parsed = JSON.parse(response.content); + const frame = parsed.frame; + + if (frame.ok) { + log(`nc response: ${requestId} → ok`); + return { content: [{ type: 'text' as const, text: JSON.stringify(frame.data, null, 2) }] }; + } else { + log(`nc response: ${requestId} → error: ${frame.error.message}`); + return { + content: [{ type: 'text' as const, text: `Error (${frame.error.code}): ${frame.error.message}` }], + isError: true, + }; + } + } + await sleep(500); + } + + log(`nc timeout: ${requestId}`); + return { + content: [{ type: 'text' as const, text: `CLI command timed out after ${timeout / 1000}s` }], + isError: true, + }; + }, +}; + +registerTools([ncCommand]); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index bdaef5c..672a637 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -10,6 +10,7 @@ import './scheduling.js'; import './interactive.js'; import './agents.js'; import './self-mod.js'; +import './cli.js'; import { startMcpServer } from './server.js'; function log(msg: string): void { diff --git a/src/cli/delivery-action.ts b/src/cli/delivery-action.ts new file mode 100644 index 0000000..5c693be --- /dev/null +++ b/src/cli/delivery-action.ts @@ -0,0 +1,59 @@ +/** + * Delivery action handler for CLI requests from container agents. + * + * When an agent writes a `cli_request` system message to outbound.db, + * the delivery poll picks it up and calls this handler. We dispatch + * the command and write the response back to inbound.db. + */ +import type Database from 'better-sqlite3'; + +import { registerDeliveryAction } from '../delivery.js'; +import { insertMessage } from '../db/session-db.js'; +import { log } from '../log.js'; +import { dispatch } from './dispatch.js'; +import type { RequestFrame } from './frame.js'; +import type { Session } from '../types.js'; + +registerDeliveryAction('cli_request', async (content, session, inDb) => { + const requestId = content.requestId as string; + const command = content.command as string; + const args = (content.args as Record) ?? {}; + + if (!requestId || !command) { + log.warn('cli_request missing requestId or command', { sessionId: session.id }); + return; + } + + const req: RequestFrame = { id: requestId, command, args }; + const ctx = { + caller: 'agent' as const, + sessionId: session.id, + agentGroupId: session.agent_group_id, + messagingGroupId: session.messaging_group_id ?? '', + }; + + log.info('CLI request from agent', { requestId, command, sessionId: session.id }); + + const response = await dispatch(req, ctx); + + // Write response to inbound.db so the container can read it. + // trigger=0: don't wake the agent — this is an inline response to a tool call. + insertMessage(inDb, { + id: `cli-resp-${requestId}`, + kind: 'system', + timestamp: new Date().toISOString(), + platformId: null, + channelType: null, + threadId: null, + content: JSON.stringify({ + type: 'cli_response', + requestId, + frame: response, + }), + processAfter: null, + recurrence: null, + trigger: 0, + }); + + log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id }); +}); diff --git a/src/index.ts b/src/index.ts index c13ef11..3d39dd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ import './modules/index.js'; // CLI command barrel — populates the `nc` registry before the CLI server // accepts connections. import './cli/commands/index.js'; +import './cli/delivery-action.js'; import { startCliServer, stopCliServer } from './cli/socket-server.js'; import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js'; From 5e2bf1cb54bfecbbd5b9a7643a32e0f173cafbdf Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:07:37 +0300 Subject: [PATCH 004/105] feat(cli): replace MCP tool with standalone nc client in container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the nc MCP tool in favor of a standalone Bun CLI script at container/agent-runner/src/cli/nc.ts. Same interface as host-side bin/nc — all three callers (operator, Claude on host, agent in container) now use the same nc CLI. Container transport: writes cli_request to outbound.db (BEGIN IMMEDIATE for seq safety), polls inbound.db for response, acks via processing_ack. Dockerfile adds a /usr/local/bin/nc wrapper that execs the mounted source. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/Dockerfile | 5 + container/agent-runner/src/cli/nc.ts | 253 ++++++++++++++++++ container/agent-runner/src/db/messages-in.ts | 24 -- container/agent-runner/src/mcp-tools/cli.ts | 97 ------- container/agent-runner/src/mcp-tools/index.ts | 1 - 5 files changed, 258 insertions(+), 122 deletions(-) create mode 100644 container/agent-runner/src/cli/nc.ts delete mode 100644 container/agent-runner/src/mcp-tools/cli.ts diff --git a/container/Dockerfile b/container/Dockerfile index efa58b6..d6b654a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -104,6 +104,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +# ---- nc CLI wrapper ---------------------------------------------------------- +# Actual script lives in the mounted source at /app/src/cli/nc.ts. +RUN printf '#!/bin/sh\nexec bun /app/src/cli/nc.ts "$@"\n' > /usr/local/bin/nc && \ + chmod +x /usr/local/bin/nc + # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/nc.ts new file mode 100644 index 0000000..319c63a --- /dev/null +++ b/container/agent-runner/src/cli/nc.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun +/** + * nc — NanoClaw CLI client (container edition). + * + * Same interface as the host-side `bin/nc`. Detects that it's inside a + * container (the session DBs exist at /workspace/) and uses a DB transport + * instead of the Unix socket transport. + * + * Writes a cli_request system message to outbound.db, polls inbound.db + * for the response. Self-contained — no imports from agent-runner. + */ +import { Database } from 'bun:sqlite'; + +// --------------------------------------------------------------------------- +// Frame types (mirrors src/cli/frame.ts on the host) +// --------------------------------------------------------------------------- + +type RequestFrame = { + id: string; + command: string; + args: Record; +}; + +type ResponseFrame = + | { id: string; ok: true; data: unknown } + | { id: string; ok: false; error: { code: string; message: string } }; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const INBOUND_DB = '/workspace/inbound.db'; +const OUTBOUND_DB = '/workspace/outbound.db'; + +// --------------------------------------------------------------------------- +// DB transport +// --------------------------------------------------------------------------- + +function generateId(): string { + return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Write a cli_request to outbound.db. + * + * Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq), + * preventing seq collisions with concurrent agent-runner writes. + */ +function writeRequest(req: RequestFrame): void { + const db = new Database(OUTBOUND_DB); + db.exec('PRAGMA journal_mode = DELETE'); + db.exec('PRAGMA busy_timeout = 5000'); + + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + + try { + db.exec('BEGIN IMMEDIATE'); + const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m; + const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m; + const max = Math.max(maxOut, maxIn); + const nextSeq = max % 2 === 0 ? max + 1 : max + 2; + + db.prepare( + `INSERT INTO messages_out (id, seq, timestamp, kind, content) + VALUES ($id, $seq, datetime('now'), 'system', $content)`, + ).run({ + $id: req.id, + $seq: nextSeq, + $content: JSON.stringify({ + action: 'cli_request', + requestId: req.id, + command: req.command, + args: req.args, + }), + }); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } finally { + inDb.close(); + db.close(); + } +} + +/** + * Poll inbound.db for a cli_response matching our requestId. + * Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility. + */ +function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const inDb = new Database(INBOUND_DB, { readonly: true }); + inDb.exec('PRAGMA busy_timeout = 5000'); + inDb.exec('PRAGMA mmap_size = 0'); + + try { + const row = inDb + .prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null; + + if (row) { + inDb.close(); + + // Mark as completed via processing_ack so agent-runner skips it + const outDb = new Database(OUTBOUND_DB); + outDb.exec('PRAGMA journal_mode = DELETE'); + outDb.exec('PRAGMA busy_timeout = 5000'); + outDb + .prepare( + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + ) + .run(row.id); + outDb.close(); + + const parsed = JSON.parse(row.content); + return parsed.frame as ResponseFrame; + } + } finally { + try { inDb.close(); } catch {} + } + + Bun.sleepSync(500); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Arg parsing (mirrors host-side client.ts) +// --------------------------------------------------------------------------- + +function parseArgv(argv: string[]): { + command: string; + args: Record; + json: boolean; +} { + const positional: string[] = []; + const args: Record = {}; + let json = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') { + json = true; + continue; + } + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + continue; + } + positional.push(a); + } + + if (positional.length === 0) { + process.stderr.write('nc: missing command\n'); + printUsage(); + process.exit(2); + } + + const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + return { command, args, json }; +} + +function printUsage(): void { + process.stdout.write( + ['Usage: nc [--key value ...] [--json]', '', 'Run `nc help` to list available commands.', ''].join('\n'), + ); +} + +// --------------------------------------------------------------------------- +// Formatting (mirrors src/cli/format.ts on the host) +// --------------------------------------------------------------------------- + +function formatHuman(resp: ResponseFrame): string { + if (!resp.ok) { + return `error (${resp.error.code}): ${resp.error.message}\n`; + } + + const data = resp.data; + if (!Array.isArray(data) || data.length === 0) { + return JSON.stringify(data, null, 2) + '\n'; + } + + const isFlat = data.every( + (r) => + typeof r === 'object' && + r !== null && + !Array.isArray(r) && + Object.values(r as Record).every((v) => typeof v !== 'object' || v === null), + ); + + if (!isFlat) return JSON.stringify(data, null, 2) + '\n'; + + const keys = Object.keys(data[0] as Record); + const widths = keys.map((k) => + Math.max(k.length, ...data.map((r) => String((r as Record)[k] ?? '').length)), + ); + + const header = keys.map((k, i) => k.padEnd(widths[i])).join(' '); + const sep = widths.map((w) => '-'.repeat(w)).join(' '); + const rows = data.map((r) => + keys + .map((k, i) => String((r as Record)[k] ?? '').padEnd(widths[i])) + .join(' '), + ); + + return [header, sep, ...rows, ''].join('\n'); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const argv = process.argv.slice(2); + +if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + printUsage(); + process.exit(0); +} + +const { command, args, json } = parseArgv(argv); +const requestId = generateId(); +const req: RequestFrame = { id: requestId, command, args }; + +writeRequest(req); + +const resp = pollResponse(requestId, 30_000); + +if (!resp) { + process.stderr.write('nc: command timed out after 30s\n'); + process.exit(2); +} + +if (json) { + process.stdout.write(JSON.stringify(resp, null, 2) + '\n'); +} else { + const output = formatHuman(resp); + if (!resp.ok) { + process.stderr.write(output); + process.exit(1); + } + process.stdout.write(output); +} diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 3fcb226..88906ed 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -124,30 +124,6 @@ export function getMessageIn(id: string): MessageInRow | undefined { } } -/** - * Find a pending CLI response (by requestId in content). - * Reads from inbound.db, checks processing_ack to skip already-handled responses. - */ -export function findCliResponse(requestId: string): MessageInRow | undefined { - const inbound = openInboundDb(); - const outbound = getOutboundDb(); - - try { - const response = inbound - .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") - .get(`%"requestId":"${requestId}"%`) as MessageInRow | undefined; - - if (!response) return undefined; - - const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); - if (acked) return undefined; - - return response; - } finally { - inbound.close(); - } -} - /** * Find a pending response to a question (by questionId in content). * Reads from inbound.db, checks processing_ack to skip already-handled responses. diff --git a/container/agent-runner/src/mcp-tools/cli.ts b/container/agent-runner/src/mcp-tools/cli.ts deleted file mode 100644 index 868eee3..0000000 --- a/container/agent-runner/src/mcp-tools/cli.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * CLI MCP tool — lets the container agent invoke host CLI commands. - * - * Follows the ask_user_question blocking pattern: writes a system message - * to outbound.db, polls inbound.db for the response. - */ -import { findCliResponse, markCompleted } from '../db/messages-in.js'; -import { writeMessageOut } from '../db/messages-out.js'; -import { registerTools } from './server.js'; -import type { McpToolDefinition } from './types.js'; - -function log(msg: string): void { - console.error(`[mcp-tools] ${msg}`); -} - -function generateId(): string { - return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export const ncCommand: McpToolDefinition = { - tool: { - name: 'nc', - description: - 'Run a NanoClaw CLI command on the host. Returns the command result as JSON. Use `nc list-groups` to see available agent groups. Run with command "help" to list all available commands.', - inputSchema: { - type: 'object' as const, - properties: { - command: { type: 'string', description: 'Command name (e.g. "list-groups")' }, - args: { - type: 'object', - description: 'Command arguments (command-specific)', - additionalProperties: true, - }, - timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' }, - }, - required: ['command'], - }, - }, - async handler(args) { - const command = args.command as string; - const commandArgs = (args.args as Record) ?? {}; - const timeout = ((args.timeout as number) || 30) * 1000; - - if (!command) { - return { content: [{ type: 'text' as const, text: 'Error: command is required' }], isError: true }; - } - - const requestId = generateId(); - - writeMessageOut({ - id: requestId, - kind: 'system', - content: JSON.stringify({ - action: 'cli_request', - requestId, - command, - args: commandArgs, - }), - }); - - log(`nc: ${requestId} → ${command} ${JSON.stringify(commandArgs)}`); - - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const response = findCliResponse(requestId); - if (response) { - markCompleted([response.id]); - const parsed = JSON.parse(response.content); - const frame = parsed.frame; - - if (frame.ok) { - log(`nc response: ${requestId} → ok`); - return { content: [{ type: 'text' as const, text: JSON.stringify(frame.data, null, 2) }] }; - } else { - log(`nc response: ${requestId} → error: ${frame.error.message}`); - return { - content: [{ type: 'text' as const, text: `Error (${frame.error.code}): ${frame.error.message}` }], - isError: true, - }; - } - } - await sleep(500); - } - - log(`nc timeout: ${requestId}`); - return { - content: [{ type: 'text' as const, text: `CLI command timed out after ${timeout / 1000}s` }], - isError: true, - }; - }, -}; - -registerTools([ncCommand]); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index 672a637..bdaef5c 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -10,7 +10,6 @@ import './scheduling.js'; import './interactive.js'; import './agents.js'; import './self-mod.js'; -import './cli.js'; import { startMcpServer } from './server.js'; function log(msg: string): void { From 68658111479da6255a5b3701cee2bdf3030b1eeb Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:33:10 +0300 Subject: [PATCH 005/105] feat(cli): add CRUD helper, resource definitions, and help command Resource-first CLI: `nc groups list`, `nc wirings get `, etc. Seven resources defined (groups, messaging-groups, wirings, users, roles, members, sessions) with full column documentation that serves as the single source of truth for help output and arg validation. - CRUD helper auto-registers list/get/create/update/delete from declarative resource definitions with generic SQL - Custom operations for composite-PK resources (roles grant/revoke, members add/remove) - Access model: open (reads) / approval (writes) / hidden - `nc help` lists resources; `nc help` shows fields - Positional target IDs: `nc groups get ` - Removed unused priority column from wirings Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/cli/nc.ts | 6 + src/cli/client.ts | 39 ++-- src/cli/commands/help.ts | 106 ++++++++++ src/cli/commands/index.ts | 14 +- src/cli/commands/list-groups.ts | 17 -- src/cli/crud.ts | 280 ++++++++++++++++++++++++++ src/cli/dispatch.ts | 14 +- src/cli/registry.ts | 6 +- src/cli/resources/groups.ts | 36 ++++ src/cli/resources/index.ts | 11 + src/cli/resources/members.ts | 65 ++++++ src/cli/resources/messaging-groups.ts | 50 +++++ src/cli/resources/roles.ts | 66 ++++++ src/cli/resources/sessions.ts | 44 ++++ src/cli/resources/users.ts | 33 +++ src/cli/resources/wirings.ts | 69 +++++++ 16 files changed, 810 insertions(+), 46 deletions(-) create mode 100644 src/cli/commands/help.ts delete mode 100644 src/cli/commands/list-groups.ts create mode 100644 src/cli/crud.ts create mode 100644 src/cli/resources/groups.ts create mode 100644 src/cli/resources/index.ts create mode 100644 src/cli/resources/members.ts create mode 100644 src/cli/resources/messaging-groups.ts create mode 100644 src/cli/resources/roles.ts create mode 100644 src/cli/resources/sessions.ts create mode 100644 src/cli/resources/users.ts create mode 100644 src/cli/resources/wirings.ts diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/nc.ts index 319c63a..cc3883e 100644 --- a/container/agent-runner/src/cli/nc.ts +++ b/container/agent-runner/src/cli/nc.ts @@ -168,6 +168,12 @@ function parseArgv(argv: string[]): { } const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + + // Third positional is the target ID + if (positional.length >= 3) { + args.id = positional[2]; + } + return { command, args, json }; } diff --git a/src/cli/client.ts b/src/cli/client.ts index db1e30a..36197ad 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -5,12 +5,15 @@ * formats the response, exits non-zero on error. * * Usage: - * nc [--key value ...] [--json] + * nc [target] [--key value ...] [--json] * * Examples: - * nc list-groups - * nc list groups # space-separated form is auto-joined - * nc list-groups --json + * nc groups list + * nc groups get abc123 + * nc groups create --name foo --folder bar + * nc groups update abc123 --name baz + * nc help + * nc groups help */ import { randomUUID } from 'crypto'; @@ -44,9 +47,6 @@ async function main(): Promise { } function pickTransport(): Transport { - // Container DB transport will land alongside the agent-runner change. - // For now: host-only — the only callers are a shell user or Claude in - // the project. return new SocketTransport(); } @@ -85,10 +85,20 @@ function parseArgv(argv: string[]): { process.exit(2); } - // Allow `nc list groups` as well as `nc list-groups`. Server rejects - // unknowns, so the naive join is safe — at worst the user gets an - // unknown-command error. - const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; + // Single word: `nc help` + // Two words: `nc groups list`, `nc groups help` + // Three words: `nc groups get abc123` + let command: string; + if (positional.length === 1) { + command = positional[0]; + } else { + command = `${positional[0]}-${positional[1]}`; + } + + // Third positional is the target ID + if (positional.length >= 3) { + args.id = positional[2]; + } return { command, args, json }; } @@ -96,12 +106,9 @@ function parseArgv(argv: string[]): { function printUsage(): void { process.stdout.write( [ - 'Usage: nc [--key value ...] [--json]', + 'Usage: nc [target] [--key value ...] [--json]', '', - 'Commands:', - ' list-groups List all agent groups.', - '', - 'Run `nc --json` for machine-readable output.', + 'Run `nc help` to list available resources and commands.', '', ].join('\n'), ); diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts new file mode 100644 index 0000000..9219b70 --- /dev/null +++ b/src/cli/commands/help.ts @@ -0,0 +1,106 @@ +/** + * Built-in help command. Introspects the resource and command registries. + * + * nc help — list all resources and commands + * nc groups help — show group resource details (verbs, columns, enums) + */ +import { getResource, getResources } from '../crud.js'; +import { listCommands, register } from '../registry.js'; + +register({ + name: 'help', + description: 'List available resources and commands.', + access: 'open', + parseArgs: () => ({}), + handler: async () => { + const resources = getResources(); + const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource); + + const lines: string[] = []; + if (resources.length > 0) { + lines.push('Resources:'); + for (const r of resources) { + const ops: string[] = []; + if (r.operations.list) ops.push('list'); + if (r.operations.get) ops.push('get'); + if (r.operations.create) ops.push('create'); + if (r.operations.update) ops.push('update'); + if (r.operations.delete) ops.push('delete'); + if (r.customOperations) ops.push(...Object.keys(r.customOperations)); + lines.push(` ${r.plural.padEnd(20)} ${r.description}`); + lines.push(` ${''.padEnd(20)} verbs: ${ops.join(', ')}`); + } + } + + if (commands.length > 0) { + if (lines.length > 0) lines.push(''); + lines.push('Commands:'); + for (const c of commands) { + lines.push(` ${c.name.padEnd(20)} ${c.description}`); + } + } + + lines.push(''); + lines.push('Run `nc help` for detailed field information.'); + return lines.join('\n'); + }, +}); + +// Register per-resource help commands. These are registered dynamically +// after the resources barrel has been imported. +// We use a lazy approach: register a catch-all pattern isn't possible with +// the flat registry, so we register `-help` for each resource +// in a post-import hook. +export function registerResourceHelpCommands(): void { + for (const res of getResources()) { + // Skip if already registered (e.g. from a previous call) + try { + register({ + name: `${res.plural}-help`, + description: `Show ${res.name} resource details.`, + access: 'open', + resource: res.plural, + parseArgs: () => ({}), + handler: async () => { + const lines: string[] = []; + lines.push(`${res.plural}: ${res.description}`); + lines.push(''); + + // Verbs + const verbs: string[] = []; + if (res.operations.list) verbs.push(`list [open]`); + if (res.operations.get) verbs.push(`get [open]`); + if (res.operations.create) verbs.push(`create [approval]`); + if (res.operations.update) verbs.push(`update [approval]`); + if (res.operations.delete) verbs.push(`delete [approval]`); + if (res.customOperations) { + for (const [verb, op] of Object.entries(res.customOperations)) { + verbs.push(`${verb} [${op.access}] — ${op.description}`); + } + } + lines.push('Verbs:'); + for (const v of verbs) lines.push(` ${v}`); + lines.push(''); + + // Columns + lines.push('Fields:'); + for (const col of res.columns) { + const tags: string[] = []; + if (col.generated) tags.push('auto'); + if (col.required) tags.push('required'); + if (col.updatable) tags.push('updatable'); + if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`); + if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`); + + const flag = `--${col.name.replace(/_/g, '-')}`; + const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : ''; + lines.push(` ${flag.padEnd(28)} ${col.description}${tagStr}`); + } + return lines.join('\n'); + }, + }); + } catch { + // Already registered — skip + } + } +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index f37e5ca..5b05345 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,4 +1,10 @@ -// Side-effect imports — each command file calls register() at top level. -// Imported by src/index.ts on host startup so the registry is populated -// before the CLI server accepts connections. -import './list-groups.js'; +/** + * Command barrel — populates the registry before the CLI server starts. + * + * Resource definitions register their CRUD commands on import. + * Help commands are registered after resources are loaded. + */ +import '../resources/index.js'; +import { registerResourceHelpCommands } from './help.js'; + +registerResourceHelpCommands(); diff --git a/src/cli/commands/list-groups.ts b/src/cli/commands/list-groups.ts deleted file mode 100644 index 98b87f1..0000000 --- a/src/cli/commands/list-groups.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getAllAgentGroups } from '../../db/agent-groups.js'; -import { register } from '../registry.js'; - -register({ - name: 'list-groups', - description: 'List all agent groups.', - riskClass: 'safe', - parseArgs: () => ({}), - handler: async () => - getAllAgentGroups().map((g) => ({ - id: g.id, - name: g.name, - folder: g.folder, - provider: g.agent_provider ?? 'claude', - created_at: g.created_at, - })), -}); diff --git a/src/cli/crud.ts b/src/cli/crud.ts new file mode 100644 index 0000000..370b9ad --- /dev/null +++ b/src/cli/crud.ts @@ -0,0 +1,280 @@ +/** + * CRUD registration helper. + * + * Takes a declarative resource definition (table, columns, access levels) + * and auto-registers list/get/create/update/delete commands in the CLI + * registry. Column metadata doubles as documentation — `nc help` + * is generated from the same definitions. + */ +import { randomUUID } from 'crypto'; + +import { getDb } from '../db/connection.js'; +import { register } from './registry.js'; +import type { CallerContext } from './frame.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Access = 'open' | 'approval' | 'hidden'; + +export interface ColumnDef { + name: string; + type: 'string' | 'number' | 'boolean' | 'json'; + description: string; + /** Auto-set on create — not user-provided. */ + generated?: boolean; + /** Must be provided on create (ignored if generated). */ + required?: boolean; + /** Can be changed via update. */ + updatable?: boolean; + /** Default value on create when not provided. */ + default?: unknown; + /** Allowed values (shown in help). */ + enum?: string[]; +} + +export interface CustomOperation { + access: Access; + description: string; + args?: ColumnDef[]; + handler: (args: Record, ctx: CallerContext) => Promise; +} + +export interface ResourceDef { + /** Singular name: 'group'. */ + name: string; + /** Plural name: 'groups'. Used in command names. */ + plural: string; + /** DB table name. */ + table: string; + /** One-line description shown in help. */ + description: string; + /** Primary key column name. */ + idColumn: string; + columns: ColumnDef[]; + /** Which standard CRUD operations are enabled. */ + operations: { + list?: Access; + get?: Access; + create?: Access; + update?: Access; + delete?: Access; + }; + /** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */ + customOperations?: Record; +} + +// --------------------------------------------------------------------------- +// Resource registry (for help introspection) +// --------------------------------------------------------------------------- + +const resources = new Map(); + +export function getResources(): ResourceDef[] { + return [...resources.values()].sort((a, b) => a.plural.localeCompare(b.plural)); +} + +export function getResource(plural: string): ResourceDef | undefined { + return resources.get(plural); +} + +// --------------------------------------------------------------------------- +// Generic SQL handlers +// --------------------------------------------------------------------------- + +function visibleColumns(def: ResourceDef): string[] { + return def.columns.map((c) => c.name); +} + +function genericList(def: ResourceDef) { + const cols = visibleColumns(def).join(', '); + return async () => { + return getDb().prepare(`SELECT ${cols} FROM ${def.table}`).all(); + }; +} + +function genericGet(def: ResourceDef) { + const cols = visibleColumns(def).join(', '); + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + const row = getDb() + .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) + .get(id); + if (!row) throw new Error(`${def.name} not found: ${id}`); + return row; + }; +} + +function genericCreate(def: ResourceDef) { + return async (args: Record) => { + const values: Record = {}; + + for (const col of def.columns) { + if (col.generated) { + if (col.name === def.idColumn) { + values[col.name] = randomUUID(); + } else if (col.name.endsWith('_at')) { + values[col.name] = new Date().toISOString(); + } + continue; + } + + const v = args[col.name]; + if (v !== undefined) { + if (col.enum && !col.enum.includes(String(v))) { + throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`); + } + values[col.name] = col.type === 'number' ? Number(v) : v; + } else if (col.required) { + throw new Error(`--${col.name.replace(/_/g, '-')} is required`); + } else if (col.default !== undefined) { + values[col.name] = col.default; + } + } + + const colNames = Object.keys(values); + const placeholders = colNames.map((c) => `@${c}`); + getDb() + .prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`) + .run(values); + return values; + }; +} + +function genericUpdate(def: ResourceDef) { + const updatableCols = def.columns.filter((c) => c.updatable); + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + + const updates: Record = {}; + for (const col of updatableCols) { + const v = args[col.name]; + if (v !== undefined) { + if (col.enum && !col.enum.includes(String(v))) { + throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`); + } + updates[col.name] = col.type === 'number' ? Number(v) : v; + } + } + if (Object.keys(updates).length === 0) { + throw new Error(`nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`); + } + + const setClause = Object.keys(updates) + .map((k) => `${k} = @${k}`) + .join(', '); + const result = getDb() + .prepare(`UPDATE ${def.table} SET ${setClause} WHERE ${def.idColumn} = @_id`) + .run({ ...updates, _id: id }); + if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); + + const cols = visibleColumns(def).join(', '); + return getDb() + .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) + .get(id); + }; +} + +function genericDelete(def: ResourceDef) { + return async (args: Record) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + const result = getDb() + .prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`) + .run(id); + if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); + return { deleted: id }; + }; +} + +// --------------------------------------------------------------------------- +// parseArgs helper: normalizes --hyphen-keys to underscore_keys +// --------------------------------------------------------------------------- + +function normalizeArgs(raw: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + out[k.replace(/-/g, '_')] = v; + } + return out; +} + +// --------------------------------------------------------------------------- +// registerResource +// --------------------------------------------------------------------------- + +export function registerResource(def: ResourceDef): void { + resources.set(def.plural, def); + + if (def.operations.list) { + register({ + name: `${def.plural}-list`, + description: `List all ${def.plural}.`, + access: def.operations.list, + resource: def.plural, + parseArgs: () => ({}), + handler: genericList(def), + }); + } + + if (def.operations.get) { + register({ + name: `${def.plural}-get`, + description: `Get a ${def.name} by ID.`, + access: def.operations.get, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericGet(def), + }); + } + + if (def.operations.create) { + register({ + name: `${def.plural}-create`, + description: `Create a new ${def.name}.`, + access: def.operations.create, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericCreate(def), + }); + } + + if (def.operations.update) { + register({ + name: `${def.plural}-update`, + description: `Update a ${def.name}.`, + access: def.operations.update, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericUpdate(def), + }); + } + + if (def.operations.delete) { + register({ + name: `${def.plural}-delete`, + description: `Delete a ${def.name}.`, + access: def.operations.delete, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: genericDelete(def), + }); + } + + // Custom operations + if (def.customOperations) { + for (const [verb, op] of Object.entries(def.customOperations)) { + register({ + name: `${def.plural}-${verb}`, + description: op.description, + access: op.access, + resource: def.plural, + parseArgs: (raw) => normalizeArgs(raw), + handler: async (args, ctx) => op.handler(args as Record, ctx), + }); + } + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6593750..a9943c4 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,10 +1,10 @@ /** * Transport-agnostic dispatcher. Both the socket server (host caller) and - * — once it lands — the per-session DB poller (container caller) call - * dispatch() with the same frame and a transport-supplied CallerContext. + * the per-session DB poller (container caller) call dispatch() with the + * same frame and a transport-supplied CallerContext. * * Approval gating for risky calls from the container is the only branch - * that differs by caller. Host callers and `safe` commands run inline. + * that differs by caller. Host callers and `open` commands run inline. */ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { lookup } from './registry.js'; @@ -15,13 +15,13 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise = { name: string; description: string; - riskClass: RiskClass; + access: Access; + /** Resource this command belongs to (for help grouping). */ + resource?: string; /** Validates `frame.args` and produces the typed handler input. Throws on invalid. */ parseArgs: (raw: Record) => TArgs; handler: (args: TArgs, ctx: CallerContext) => Promise; diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts new file mode 100644 index 0000000..5181bee --- /dev/null +++ b/src/cli/resources/groups.ts @@ -0,0 +1,36 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'group', + plural: 'groups', + table: 'agent_groups', + description: + 'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'name', + type: 'string', + description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.', + required: true, + updatable: true, + }, + { + name: 'folder', + type: 'string', + description: + 'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.', + required: true, + }, + { + name: 'agent_provider', + type: 'string', + description: 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', + updatable: true, + default: null, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); diff --git a/src/cli/resources/index.ts b/src/cli/resources/index.ts new file mode 100644 index 0000000..42155e7 --- /dev/null +++ b/src/cli/resources/index.ts @@ -0,0 +1,11 @@ +/** + * Resource barrel — imports each resource module for its side-effect + * `registerResource(...)` call. + */ +import './groups.js'; +import './messaging-groups.js'; +import './wirings.js'; +import './users.js'; +import './roles.js'; +import './members.js'; +import './sessions.js'; diff --git a/src/cli/resources/members.ts b/src/cli/resources/members.ts new file mode 100644 index 0000000..ac529be --- /dev/null +++ b/src/cli/resources/members.ts @@ -0,0 +1,65 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'member', + plural: 'members', + table: 'agent_group_members', + description: + 'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".', + idColumn: 'user_id', + columns: [ + { + name: 'user_id', + type: 'string', + description: 'The user to grant membership. Must reference an existing user (users.id).', + }, + { + name: 'agent_group_id', + type: 'string', + description: 'The agent group to grant access to. Must reference an existing agent group (agent_groups.id).', + }, + { + name: 'added_by', + type: 'string', + description: 'User ID of whoever added this member. Informational — not enforced.', + }, + { name: 'added_at', type: 'string', description: 'ISO 8601 timestamp of when the membership was granted.' }, + ], + operations: { list: 'open' }, + customOperations: { + add: { + access: 'approval', + description: 'Add a user as a member of an agent group. Use --user and --group.', + handler: async (args) => { + const userId = args.user as string; + const groupId = args.group as string; + const addedBy = (args.added_by as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!groupId) throw new Error('--group is required'); + getDb() + .prepare( + `INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) + VALUES (?, ?, ?, datetime('now'))`, + ) + .run(userId, groupId, addedBy); + return { user_id: userId, agent_group_id: groupId }; + }, + }, + remove: { + access: 'approval', + description: 'Remove a user from an agent group. Use --user and --group.', + handler: async (args) => { + const userId = args.user as string; + const groupId = args.group as string; + if (!userId) throw new Error('--user is required'); + if (!groupId) throw new Error('--group is required'); + const result = getDb() + .prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?') + .run(userId, groupId); + if (result.changes === 0) throw new Error('member not found'); + return { removed: { user_id: userId, agent_group_id: groupId } }; + }, + }, + }, +}); diff --git a/src/cli/resources/messaging-groups.ts b/src/cli/resources/messaging-groups.ts new file mode 100644 index 0000000..edccfc0 --- /dev/null +++ b/src/cli/resources/messaging-groups.ts @@ -0,0 +1,50 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'messaging-group', + plural: 'messaging-groups', + table: 'messaging_groups', + description: + 'Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'channel_type', + type: 'string', + description: 'Channel adapter type — matches the adapter registered by /add- (e.g. telegram, discord, slack, whatsapp).', + required: true, + }, + { + name: 'platform_id', + type: 'string', + description: + 'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.', + required: true, + }, + { + name: 'name', + type: 'string', + description: 'Display name. Often auto-populated by the channel adapter.', + updatable: true, + }, + { + name: 'is_group', + type: 'number', + description: 'Multi-user group chat (1) or direct message (0). Affects session scoping.', + default: 0, + updatable: true, + }, + { + name: 'unknown_sender_policy', + type: 'string', + description: + 'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.', + enum: ['strict', 'request_approval', 'public'], + default: 'strict', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); diff --git a/src/cli/resources/roles.ts b/src/cli/resources/roles.ts new file mode 100644 index 0000000..4e0a20b --- /dev/null +++ b/src/cli/resources/roles.ts @@ -0,0 +1,66 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'role', + plural: 'roles', + table: 'user_roles', + description: + 'User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).', + idColumn: 'user_id', + columns: [ + { name: 'user_id', type: 'string', description: 'User receiving the role. Must exist in users table.' }, + { + name: 'role', + type: 'string', + description: '"owner" has full control, always global. "admin" can manage groups and approve actions.', + enum: ['owner', 'admin'], + }, + { + name: 'agent_group_id', + type: 'string', + description: 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.', + }, + { name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' }, + { name: 'granted_at', type: 'string', description: 'Auto-set.' }, + ], + operations: { list: 'open' }, + customOperations: { + grant: { + access: 'approval', + description: 'Grant a role. Use --user, --role, and optionally --group for scoped admin.', + handler: async (args) => { + const userId = args.user as string; + const role = args.role as string; + const groupId = (args.group as string) ?? null; + const grantedBy = (args.granted_by as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!role || !['owner', 'admin'].includes(role)) throw new Error('--role must be owner or admin'); + if (role === 'owner' && groupId) throw new Error('owner role is always global (do not pass --group)'); + getDb() + .prepare( + `INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) + VALUES (?, ?, ?, ?, datetime('now'))`, + ) + .run(userId, role, groupId, grantedBy); + return { user_id: userId, role, agent_group_id: groupId }; + }, + }, + revoke: { + access: 'approval', + description: 'Revoke a role. Use --user, --role, and --group if scoped.', + handler: async (args) => { + const userId = args.user as string; + const role = args.role as string; + const groupId = (args.group as string) ?? null; + if (!userId) throw new Error('--user is required'); + if (!role) throw new Error('--role is required'); + const result = getDb() + .prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS ?') + .run(userId, role, groupId); + if (result.changes === 0) throw new Error('role not found'); + return { revoked: { user_id: userId, role, agent_group_id: groupId } }; + }, + }, + }, +}); diff --git a/src/cli/resources/sessions.ts b/src/cli/resources/sessions.ts new file mode 100644 index 0000000..1a3bd24 --- /dev/null +++ b/src/cli/resources/sessions.ts @@ -0,0 +1,44 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'session', + plural: 'sessions', + table: 'sessions', + description: + 'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' }, + { + name: 'messaging_group_id', + type: 'string', + description: 'Messaging group this session serves. Null for agent-shared sessions.', + }, + { + name: 'thread_id', + type: 'string', + description: 'Thread ID. Only set for per-thread session mode.', + }, + { + name: 'agent_provider', + type: 'string', + description: 'Provider override. Null means inherit from agent group.', + }, + { + name: 'status', + type: 'string', + description: '"active" receives messages. "closed" is archived.', + enum: ['active', 'closed'], + }, + { + name: 'container_status', + type: 'string', + description: '"running" — container alive. "idle" — exited, restarts on next message. "stopped" — needs explicit wake.', + enum: ['running', 'idle', 'stopped'], + }, + { name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open' }, +}); diff --git a/src/cli/resources/users.ts b/src/cli/resources/users.ts new file mode 100644 index 0000000..5cd003e --- /dev/null +++ b/src/cli/resources/users.ts @@ -0,0 +1,33 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'user', + plural: 'users', + table: 'users', + description: + 'User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).', + idColumn: 'id', + columns: [ + { + name: 'id', + type: 'string', + description: 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.', + required: true, + }, + { + name: 'kind', + type: 'string', + description: + 'Channel type identifier (e.g. "telegram", "discord"). Used as a fallback for DM resolution when the id prefix doesn\'t match a registered adapter.', + required: true, + }, + { + name: 'display_name', + type: 'string', + description: 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' }, +}); diff --git a/src/cli/resources/wirings.ts b/src/cli/resources/wirings.ts new file mode 100644 index 0000000..f04102f --- /dev/null +++ b/src/cli/resources/wirings.ts @@ -0,0 +1,69 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'wiring', + plural: 'wirings', + table: 'messaging_group_agents', + description: + 'Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.', + idColumn: 'id', + columns: [ + { name: 'id', type: 'string', description: 'UUID.', generated: true }, + { + name: 'messaging_group_id', + type: 'string', + description: 'The chat/channel to route from. References messaging_groups.id.', + required: true, + }, + { + name: 'agent_group_id', + type: 'string', + description: 'The agent that handles messages. References agent_groups.id.', + required: true, + }, + { + name: 'engage_mode', + type: 'string', + description: + 'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.', + enum: ['pattern', 'mention', 'mention-sticky'], + default: 'mention', + updatable: true, + }, + { + name: 'engage_pattern', + type: 'string', + description: + 'Regex for engage_mode=pattern. Required when mode is pattern. Use "." to match every message (always-on). Ignored for mention modes.', + updatable: true, + }, + { + name: 'sender_scope', + type: 'string', + description: '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.', + enum: ['all', 'known'], + default: 'all', + updatable: true, + }, + { + name: 'ignored_message_policy', + type: 'string', + description: + 'What happens to messages that don\'t trigger engagement. "drop" — agent never sees them. "accumulate" — stored as background context (trigger=0) so the agent has prior context when eventually triggered.', + enum: ['drop', 'accumulate'], + default: 'drop', + updatable: true, + }, + { + name: 'session_mode', + type: 'string', + description: + '"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent.', + enum: ['shared', 'per-thread', 'agent-shared'], + default: 'shared', + updatable: true, + }, + { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, + ], + operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, +}); From a597b42648c3a50792652fade46b7f0d4576fcdc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:40:15 +0300 Subject: [PATCH 006/105] feat(cli): add remaining resources, fix descriptions from code review New read-only resources: - destinations (agent-to-agent ACL + routing map) - user-dms (DM channel cache) - dropped-messages (audit trail for dropped messages) - approvals (in-flight approval cards) Description fixes from reading source: - messaging-groups: add denied_at column (router checks it) - sessions: fix container_status (idle is unused, stopped is auto-restarted by sweep) - wirings: add note that threaded adapters force per-thread Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/resources/approvals.ts | 36 +++++++++++++ src/cli/resources/destinations.ts | 77 +++++++++++++++++++++++++++ src/cli/resources/dropped-messages.ts | 28 ++++++++++ src/cli/resources/index.ts | 4 ++ src/cli/resources/messaging-groups.ts | 10 +++- src/cli/resources/sessions.ts | 3 +- src/cli/resources/user-dms.ts | 21 ++++++++ src/cli/resources/wirings.ts | 5 +- 8 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 src/cli/resources/approvals.ts create mode 100644 src/cli/resources/destinations.ts create mode 100644 src/cli/resources/dropped-messages.ts create mode 100644 src/cli/resources/user-dms.ts diff --git a/src/cli/resources/approvals.ts b/src/cli/resources/approvals.ts new file mode 100644 index 0000000..a5310a4 --- /dev/null +++ b/src/cli/resources/approvals.ts @@ -0,0 +1,36 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'approval', + plural: 'approvals', + table: 'pending_approvals', + description: + 'Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.', + idColumn: 'approval_id', + columns: [ + { name: 'approval_id', type: 'string', description: 'Unique approval identifier (also used as the card questionId).' }, + { name: 'session_id', type: 'string', description: 'Session that requested the approval. Null for OneCLI credential approvals.' }, + { name: 'request_id', type: 'string', description: 'Original request identifier (OneCLI request UUID or same as approval_id).' }, + { + name: 'action', + type: 'string', + description: 'Action type — matches the registered approval handler (e.g. install_packages, add_mcp_server, onecli_credential).', + }, + { name: 'payload', type: 'json', description: 'JSON payload carried through to the approval handler.' }, + { name: 'created_at', type: 'string', description: 'Auto-set.' }, + { name: 'agent_group_id', type: 'string', description: 'Originating agent group.' }, + { name: 'channel_type', type: 'string', description: 'Channel the approval card was delivered on.' }, + { name: 'platform_id', type: 'string', description: 'Platform chat ID the card was delivered to.' }, + { name: 'platform_message_id', type: 'string', description: 'Platform message ID of the delivered card (for editing on expiry).' }, + { name: 'expires_at', type: 'string', description: 'When this approval expires (OneCLI gateway TTL).' }, + { + name: 'status', + type: 'string', + description: 'Current status.', + enum: ['pending', 'approved', 'rejected', 'expired'], + }, + { name: 'title', type: 'string', description: 'Card title shown to the admin.' }, + { name: 'options_json', type: 'json', description: 'Card button options as JSON array.' }, + ], + operations: { list: 'open', get: 'open' }, +}); diff --git a/src/cli/resources/destinations.ts b/src/cli/resources/destinations.ts new file mode 100644 index 0000000..ea67035 --- /dev/null +++ b/src/cli/resources/destinations.ts @@ -0,0 +1,77 @@ +import { getDb } from '../../db/connection.js'; +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'destination', + plural: 'destinations', + table: 'agent_destinations', + description: + 'Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.', + idColumn: 'agent_group_id', + columns: [ + { + name: 'agent_group_id', + type: 'string', + description: 'The agent that owns this destination. References agent_groups.id.', + }, + { + name: 'local_name', + type: 'string', + description: + 'Name the agent uses to address this target (e.g. ). Unique per agent. Lowercase, dash-separated.', + }, + { + name: 'target_type', + type: 'string', + description: '"channel" for messaging group targets, "agent" for agent-to-agent targets.', + enum: ['channel', 'agent'], + }, + { + name: 'target_id', + type: 'string', + description: 'The target\'s ID — messaging_groups.id for channels, agent_groups.id for agents.', + }, + { name: 'created_at', type: 'string', description: 'Auto-set.' }, + ], + operations: { list: 'open' }, + customOperations: { + add: { + access: 'approval', + description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.', + handler: async (args) => { + const agentGroupId = args.agent_group_id as string; + const localName = args.local_name as string; + const targetType = args.target_type as string; + const targetId = args.target_id as string; + if (!agentGroupId) throw new Error('--agent-group-id is required'); + if (!localName) throw new Error('--local-name is required'); + if (!targetType || !['channel', 'agent'].includes(targetType)) { + throw new Error('--target-type must be channel or agent'); + } + if (!targetId) throw new Error('--target-id is required'); + getDb() + .prepare( + `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES (?, ?, ?, ?, datetime('now'))`, + ) + .run(agentGroupId, localName, targetType, targetId); + return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId }; + }, + }, + remove: { + access: 'approval', + description: 'Remove a destination from an agent. Use --agent-group-id and --local-name.', + handler: async (args) => { + const agentGroupId = args.agent_group_id as string; + const localName = args.local_name as string; + if (!agentGroupId) throw new Error('--agent-group-id is required'); + if (!localName) throw new Error('--local-name is required'); + const result = getDb() + .prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?') + .run(agentGroupId, localName); + if (result.changes === 0) throw new Error('destination not found'); + return { removed: { agent_group_id: agentGroupId, local_name: localName } }; + }, + }, + }, +}); diff --git a/src/cli/resources/dropped-messages.ts b/src/cli/resources/dropped-messages.ts new file mode 100644 index 0000000..3404fc2 --- /dev/null +++ b/src/cli/resources/dropped-messages.ts @@ -0,0 +1,28 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'dropped-message', + plural: 'dropped-messages', + table: 'unregistered_senders', + description: + 'Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn\'t fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).', + idColumn: 'channel_type', + columns: [ + { name: 'channel_type', type: 'string', description: 'Channel adapter type of the dropped message.' }, + { name: 'platform_id', type: 'string', description: 'Platform chat ID where the message was dropped.' }, + { name: 'user_id', type: 'string', description: 'Sender user ID if resolved, null otherwise.' }, + { name: 'sender_name', type: 'string', description: 'Sender display name if available.' }, + { + name: 'reason', + type: 'string', + description: 'Why the message was dropped.', + enum: ['no_agent_wired', 'no_agent_engaged', 'unknown_sender_strict', 'unknown_sender_request_approval'], + }, + { name: 'messaging_group_id', type: 'string', description: 'Messaging group ID if resolved.' }, + { name: 'agent_group_id', type: 'string', description: 'Target agent group ID if resolved.' }, + { name: 'message_count', type: 'number', description: 'Number of dropped messages from this sender on this chat.' }, + { name: 'first_seen', type: 'string', description: 'First drop timestamp.' }, + { name: 'last_seen', type: 'string', description: 'Most recent drop timestamp.' }, + ], + operations: { list: 'open' }, +}); diff --git a/src/cli/resources/index.ts b/src/cli/resources/index.ts index 42155e7..816b32f 100644 --- a/src/cli/resources/index.ts +++ b/src/cli/resources/index.ts @@ -8,4 +8,8 @@ import './wirings.js'; import './users.js'; import './roles.js'; import './members.js'; +import './destinations.js'; +import './user-dms.js'; +import './dropped-messages.js'; +import './approvals.js'; import './sessions.js'; diff --git a/src/cli/resources/messaging-groups.ts b/src/cli/resources/messaging-groups.ts index edccfc0..0cda1c8 100644 --- a/src/cli/resources/messaging-groups.ts +++ b/src/cli/resources/messaging-groups.ts @@ -12,7 +12,8 @@ registerResource({ { name: 'channel_type', type: 'string', - description: 'Channel adapter type — matches the adapter registered by /add- (e.g. telegram, discord, slack, whatsapp).', + description: + 'Channel adapter type — matches the adapter registered by /add- (e.g. telegram, discord, slack, whatsapp).', required: true, }, { @@ -44,6 +45,13 @@ registerResource({ default: 'strict', updatable: true, }, + { + name: 'denied_at', + type: 'string', + description: + 'Set when the owner explicitly denies registering this channel. While set, the router drops all messages silently without re-escalating. Cleared by any explicit wiring mutation.', + updatable: true, + }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, diff --git a/src/cli/resources/sessions.ts b/src/cli/resources/sessions.ts index 1a3bd24..f60fccc 100644 --- a/src/cli/resources/sessions.ts +++ b/src/cli/resources/sessions.ts @@ -34,7 +34,8 @@ registerResource({ { name: 'container_status', type: 'string', - description: '"running" — container alive. "idle" — exited, restarts on next message. "stopped" — needs explicit wake.', + description: + '"running" — container alive and polling. "stopped" — container exited; the sweep will restart it automatically when due messages arrive. "idle" — reserved, currently unused.', enum: ['running', 'idle', 'stopped'], }, { name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' }, diff --git a/src/cli/resources/user-dms.ts b/src/cli/resources/user-dms.ts new file mode 100644 index 0000000..8b7b1cd --- /dev/null +++ b/src/cli/resources/user-dms.ts @@ -0,0 +1,21 @@ +import { registerResource } from '../crud.js'; + +registerResource({ + name: 'user-dm', + plural: 'user-dms', + table: 'user_dms', + description: + 'User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter\'s openDM resolves it.', + idColumn: 'user_id', + columns: [ + { name: 'user_id', type: 'string', description: 'User this DM route is for.' }, + { name: 'channel_type', type: 'string', description: 'Channel adapter type.' }, + { + name: 'messaging_group_id', + type: 'string', + description: 'The messaging group used to deliver DMs to this user on this channel.', + }, + { name: 'resolved_at', type: 'string', description: 'When this DM route was last resolved.' }, + ], + operations: { list: 'open' }, +}); diff --git a/src/cli/resources/wirings.ts b/src/cli/resources/wirings.ts index f04102f..d52f8b1 100644 --- a/src/cli/resources/wirings.ts +++ b/src/cli/resources/wirings.ts @@ -40,7 +40,8 @@ registerResource({ { name: 'sender_scope', type: 'string', - description: '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.', + description: + '"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.', enum: ['all', 'known'], default: 'all', updatable: true, @@ -58,7 +59,7 @@ registerResource({ name: 'session_mode', type: 'string', description: - '"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent.', + '"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent. Note: threaded adapters in group chats force per-thread regardless of this setting.', enum: ['shared', 'per-thread', 'agent-shared'], default: 'shared', updatable: true, From 8771e259a801d5c4608b79044fffaa1cdcdf8ada Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 00:42:33 +0300 Subject: [PATCH 007/105] style(cli): apply prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/crud.ts | 16 ++++++---------- src/cli/dispatch.ts | 6 +----- src/cli/resources/approvals.ts | 27 ++++++++++++++++++++++----- src/cli/resources/destinations.ts | 2 +- src/cli/resources/dropped-messages.ts | 2 +- src/cli/resources/groups.ts | 3 ++- src/cli/resources/roles.ts | 3 ++- src/cli/resources/user-dms.ts | 2 +- src/cli/resources/users.ts | 6 ++++-- 9 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 370b9ad..af1371f 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -99,9 +99,7 @@ function genericGet(def: ResourceDef) { return async (args: Record) => { const id = args.id as string; if (!id) throw new Error(`${def.name} id is required`); - const row = getDb() - .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) - .get(id); + const row = getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id); if (!row) throw new Error(`${def.name} not found: ${id}`); return row; }; @@ -160,7 +158,9 @@ function genericUpdate(def: ResourceDef) { } } if (Object.keys(updates).length === 0) { - throw new Error(`nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`); + throw new Error( + `nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`, + ); } const setClause = Object.keys(updates) @@ -172,9 +172,7 @@ function genericUpdate(def: ResourceDef) { if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); const cols = visibleColumns(def).join(', '); - return getDb() - .prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`) - .get(id); + return getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id); }; } @@ -182,9 +180,7 @@ function genericDelete(def: ResourceDef) { return async (args: Record) => { const id = args.id as string; if (!id) throw new Error(`${def.name} id is required`); - const result = getDb() - .prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`) - .run(id); + const result = getDb().prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`).run(id); if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`); return { deleted: id }; }; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index a9943c4..f4c8987 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -18,11 +18,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise.', + description: + 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', updatable: true, default: null, }, diff --git a/src/cli/resources/roles.ts b/src/cli/resources/roles.ts index 4e0a20b..9d51815 100644 --- a/src/cli/resources/roles.ts +++ b/src/cli/resources/roles.ts @@ -19,7 +19,8 @@ registerResource({ { name: 'agent_group_id', type: 'string', - description: 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.', + description: + 'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.', }, { name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' }, { name: 'granted_at', type: 'string', description: 'Auto-set.' }, diff --git a/src/cli/resources/user-dms.ts b/src/cli/resources/user-dms.ts index 8b7b1cd..50b2763 100644 --- a/src/cli/resources/user-dms.ts +++ b/src/cli/resources/user-dms.ts @@ -5,7 +5,7 @@ registerResource({ plural: 'user-dms', table: 'user_dms', description: - 'User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter\'s openDM resolves it.', + "User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.", idColumn: 'user_id', columns: [ { name: 'user_id', type: 'string', description: 'User this DM route is for.' }, diff --git a/src/cli/resources/users.ts b/src/cli/resources/users.ts index 5cd003e..0c4fd56 100644 --- a/src/cli/resources/users.ts +++ b/src/cli/resources/users.ts @@ -11,7 +11,8 @@ registerResource({ { name: 'id', type: 'string', - description: 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.', + description: + 'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.', required: true, }, { @@ -24,7 +25,8 @@ registerResource({ { name: 'display_name', type: 'string', - description: 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.', + description: + 'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.', updatable: true, }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, From a36acd3413b53d1670d478aaadb7b6afb20c72d8 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 6 May 2026 09:27:09 +0000 Subject: [PATCH 008/105] 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 5213c985066eced2846d3cb95893116e695887ad Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 6 May 2026 11:13:23 +0000 Subject: [PATCH 009/105] setup: correct Slack member-ID card directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack's profile button is in the bottom-left of the desktop sidebar (not the top-right), and the "More" overflow icon next to "Copy member ID" is the vertical kebab `⋮`, not the horizontal `⋯`. Match what users actually see in Slack. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/slack.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0918075..f2c9a82 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -308,9 +308,9 @@ async function collectSlackUserId(): Promise { [ "To get your Slack member ID:", '', - ' 1. In Slack, click your profile picture (top right)', + ' 1. In Slack, click your profile picture (bottom left)', ' 2. Click "Profile"', - ' 3. Click the three dots (⋯) → "Copy member ID"', + ' 3. Click the three dots (⋮) → "Copy member ID"', ].join('\n'), 'Find your Slack user ID', ); From 0d7458c6f371c13e9041b2b551466d20f8a4d76a Mon Sep 17 00:00:00 2001 From: NanoClaw bot user Date: Wed, 6 May 2026 19:38:33 +0200 Subject: [PATCH 010/105] 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 011/105] 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 012/105] 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 013/105] =?UTF-8?q?setup:=20add=20back-to-channels=20exit?= =?UTF-8?q?=20to=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 014/105] 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 015/105] =?UTF-8?q?feat(skills):=20add=20/add-mnemon=20ski?= =?UTF-8?q?ll=20=E2=80=94=20persistent=20semantic=20memory=20for=20agent?= =?UTF-8?q?=20groups?= 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 016/105] 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 017/105] 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 018/105] =?UTF-8?q?fix(add-karpathy-llm-wiki):=20update=20?= =?UTF-8?q?for=20v2=20=E2=80=94=20schedule=5Ftask=20MCP=20+=20no=20build?= =?UTF-8?q?=20step?= 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 019/105] =?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 020/105] 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 021/105] 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 022/105] 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 023/105] 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 024/105] 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 025/105] 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 026/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?42k=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 027/105] 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 028/105] 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 029/105] 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 030/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?43k=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 031/105] 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 032/105] 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 033/105] 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 034/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?44k=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 035/105] 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 036/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?45k=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 037/105] 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 038/105] 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 039/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?47k=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 040/105] 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 041/105] 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 042/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?47k=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 043/105] 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 044/105] 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 045/105] 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 046/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?49k=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 047/105] 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 9090c33e7e8d3c8e33741f54f87b8c5b71ff1ef1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:48:57 +0300 Subject: [PATCH 048/105] docs(cli): add agent instructions for nc CLI Auto-discovered by composeGroupClaudeMd() as module-cli.md fragment, included in every agent group's composed CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mcp-tools/cli.instructions.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 container/agent-runner/src/mcp-tools/cli.instructions.md diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md new file mode 100644 index 0000000..5bec167 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -0,0 +1,60 @@ +## Admin CLI (`nc`) + +The `nc` command is available at `/usr/local/bin/nc`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. + +### Usage + +``` +nc [] [--flags] +nc help +nc help +``` + +### Resources + +| Resource | Verbs | What it is | +|----------|-------|------------| +| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | +| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | +| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | +| users | list, get, create, update | Platform identities (`:`) | +| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) | +| members | list, add, remove | Unprivileged access gate for an agent group | +| destinations | list, add, remove | Where an agent group can send messages | +| sessions | list, get | Active sessions (read-only) | +| user-dms | list | Cold-DM cache (read-only) | +| dropped-messages | list | Messages from unregistered senders (read-only) | +| approvals | list, get | Pending approval requests (read-only) | + +### When to use + +- **Looking up your own config** — `nc groups get ` to see your agent group settings. +- **Finding who you're wired to** — `nc wirings list` to see which messaging groups route to which agent groups. +- **Checking user roles** — `nc roles list` to see who is an owner/admin. +- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `nc` rather than guessing. + +### Access rules + +Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it. + +### Examples + +```bash +# List all agent groups +nc groups list + +# Get details for a specific group +nc groups get abc123 + +# See field definitions for a resource +nc wirings help + +# List all wirings for a specific messaging group +nc wirings list --messaging-group-id mg_xyz +``` + +### Tips + +- Use `nc help` to see all available fields, types, enums, and which fields are required or updatable. +- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. +- For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. From 6ea49898dde37b7cdd7f40f8f15399b88a546d0d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 00:50:08 +0300 Subject: [PATCH 049/105] 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 050/105] 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 051/105] 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 052/105] 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 053/105] 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 054/105] 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 055/105] 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 056/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?50k=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 057/105] 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 058/105] 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 059/105] 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.', From 0855369b79b9a516afb7cb79edfb720ca0bd35e7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 15:56:09 +0300 Subject: [PATCH 060/105] refactor(cli): rename nc to ncl Rename the CLI binary, socket path, container wrapper, error prefixes, and all references from `nc` to `ncl`. Add ~/.local/bin symlink during setup and pnpm script alias. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/{nc => ncl} | 8 ++--- container/Dockerfile | 8 ++--- .../agent-runner/src/cli/{nc.ts => ncl.ts} | 10 +++--- package.json | 4 +++ scripts/chat.ts | 4 +-- setup/service.ts | 35 +++++++++++++++++++ src/cli/client.ts | 34 +++++++++--------- src/cli/commands/help.ts | 6 ++-- src/cli/crud.ts | 2 +- src/cli/format.ts | 2 +- src/cli/registry.ts | 2 +- src/cli/socket-client.ts | 4 +-- src/cli/socket-server.ts | 14 ++++---- src/cli/transport.ts | 2 +- src/index.ts | 4 +-- 15 files changed, 89 insertions(+), 50 deletions(-) rename bin/{nc => ncl} (85%) rename container/agent-runner/src/cli/{nc.ts => ncl.ts} (95%) diff --git a/bin/nc b/bin/ncl similarity index 85% rename from bin/nc rename to bin/ncl index caceb42..27cc09a 100755 --- a/bin/nc +++ b/bin/ncl @@ -1,15 +1,15 @@ #!/usr/bin/env bash # -# nc — NanoClaw CLI launcher. +# ncl — NanoClaw CLI launcher. # # Resolves the project root from this script's location, cd's there so the # host-resolved DATA_DIR matches the running host, and execs the TS entry -# via tsx. Symlink this file into a directory on your PATH (or alias `nc` +# via tsx. Symlink this file into a directory on your PATH (or alias `ncl` # to its full path) to invoke from anywhere: # -# ln -s "$(pwd)/bin/nc" /usr/local/bin/nc +# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl # # or -# alias nc="$(pwd)/bin/nc" +# alias ncl="$(pwd)/bin/ncl" set -euo pipefail diff --git a/container/Dockerfile b/container/Dockerfile index 1dd2f88..89f834a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -110,10 +110,10 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \ RUN --mount=type=cache,target=/root/.cache/pnpm \ pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" -# ---- nc CLI wrapper ---------------------------------------------------------- -# Actual script lives in the mounted source at /app/src/cli/nc.ts. -RUN printf '#!/bin/sh\nexec bun /app/src/cli/nc.ts "$@"\n' > /usr/local/bin/nc && \ - chmod +x /usr/local/bin/nc +# ---- ncl CLI wrapper ---------------------------------------------------------- +# Actual script lives in the mounted source at /app/src/cli/ncl.ts. +RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \ + chmod +x /usr/local/bin/ncl # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh diff --git a/container/agent-runner/src/cli/nc.ts b/container/agent-runner/src/cli/ncl.ts similarity index 95% rename from container/agent-runner/src/cli/nc.ts rename to container/agent-runner/src/cli/ncl.ts index cc3883e..83bd666 100644 --- a/container/agent-runner/src/cli/nc.ts +++ b/container/agent-runner/src/cli/ncl.ts @@ -1,8 +1,8 @@ #!/usr/bin/env bun /** - * nc — NanoClaw CLI client (container edition). + * ncl — NanoClaw CLI client (container edition). * - * Same interface as the host-side `bin/nc`. Detects that it's inside a + * Same interface as the host-side `bin/ncl`. Detects that it's inside a * container (the session DBs exist at /workspace/) and uses a DB transport * instead of the Unix socket transport. * @@ -162,7 +162,7 @@ function parseArgv(argv: string[]): { } if (positional.length === 0) { - process.stderr.write('nc: missing command\n'); + process.stderr.write('ncl: missing command\n'); printUsage(); process.exit(2); } @@ -179,7 +179,7 @@ function parseArgv(argv: string[]): { function printUsage(): void { process.stdout.write( - ['Usage: nc [--key value ...] [--json]', '', 'Run `nc help` to list available commands.', ''].join('\n'), + ['Usage: ncl [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'), ); } @@ -243,7 +243,7 @@ writeRequest(req); const resp = pollResponse(requestId, 30_000); if (!resp) { - process.stderr.write('nc: command timed out after 30s\n'); + process.stderr.write('ncl: command timed out after 30s\n'); process.exit(2); } diff --git a/package.json b/package.json index 77afaaf..6bddd32 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "packageManager": "pnpm@10.33.0", "main": "dist/index.js", + "bin": { + "ncl": "bin/ncl" + }, "scripts": { "build": "tsc", "start": "node dist/index.js", @@ -16,6 +19,7 @@ "prepare": "husky", "setup": "tsx setup/index.ts", "setup:auto": "tsx setup/auto.ts", + "ncl": "tsx src/cli/client.ts", "chat": "tsx scripts/chat.ts", "auth": "tsx src/whatsapp-auth.ts", "lint": "eslint src/", diff --git a/scripts/chat.ts b/scripts/chat.ts index 20194fb..e32fcee 100644 --- a/scripts/chat.ts +++ b/scripts/chat.ts @@ -1,5 +1,5 @@ /** - * nc — chat with your NanoClaw agent from the terminal. + * ncl — chat with your NanoClaw agent from the terminal. * * Usage: * pnpm run chat @@ -36,7 +36,7 @@ function main(): void { const e = err as NodeJS.ErrnoException; if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') { console.error(`NanoClaw daemon not reachable at ${socketPath()}.`); - console.error('Start the service (launchctl/systemd) before running nc.'); + console.error('Start the service (launchctl/systemd) before running ncl.'); } else { console.error('CLI socket error:', err); } diff --git a/setup/service.ts b/setup/service.ts index 777c0c5..a866a92 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -82,6 +82,41 @@ export async function run(_args: string[]): Promise { }); process.exit(1); } + + installCliSymlink(projectRoot, homeDir); +} + +/** + * Symlink bin/ncl into ~/.local/bin so `ncl` is available from anywhere. + * Idempotent — overwrites an existing symlink but won't clobber a real file. + */ +function installCliSymlink(projectRoot: string, homeDir: string): void { + const source = path.join(projectRoot, 'bin', 'ncl'); + const targetDir = path.join(homeDir, '.local', 'bin'); + const target = path.join(targetDir, 'ncl'); + + try { + fs.mkdirSync(targetDir, { recursive: true }); + + // Remove existing symlink (but not a real file) + try { + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink()) { + fs.unlinkSync(target); + } else { + log.warn('~/.local/bin/ncl exists and is not a symlink — skipping', { target }); + return; + } + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') throw err; + } + + fs.symlinkSync(source, target); + log.info('Installed ncl CLI symlink', { target, source }); + } catch (err) { + log.warn('Could not install ncl CLI symlink (non-fatal)', { err }); + } } function setupLaunchd( diff --git a/src/cli/client.ts b/src/cli/client.ts index 36197ad..98527ed 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -1,19 +1,19 @@ /** - * `nc` binary entry point. + * `ncl` binary entry point. * * Parses argv, builds a request frame, sends it via the picked transport, * formats the response, exits non-zero on error. * * Usage: - * nc [target] [--key value ...] [--json] + * ncl [target] [--key value ...] [--json] * * Examples: - * nc groups list - * nc groups get abc123 - * nc groups create --name foo --folder bar - * nc groups update abc123 --name baz - * nc help - * nc groups help + * ncl groups list + * ncl groups get abc123 + * ncl groups create --name foo --folder bar + * ncl groups update abc123 --name baz + * ncl help + * ncl groups help */ import { randomUUID } from 'crypto'; @@ -80,14 +80,14 @@ function parseArgv(argv: string[]): { } if (positional.length === 0) { - process.stderr.write('nc: missing command\n'); + process.stderr.write('ncl: missing command\n'); printUsage(); process.exit(2); } - // Single word: `nc help` - // Two words: `nc groups list`, `nc groups help` - // Three words: `nc groups get abc123` + // Single word: `ncl help` + // Two words: `ncl groups list`, `ncl groups help` + // Three words: `ncl groups get abc123` let command: string; if (positional.length === 1) { command = positional[0]; @@ -106,9 +106,9 @@ function parseArgv(argv: string[]): { function printUsage(): void { process.stdout.write( [ - 'Usage: nc [target] [--key value ...] [--json]', + 'Usage: ncl [target] [--key value ...] [--json]', '', - 'Run `nc help` to list available resources and commands.', + 'Run `ncl help` to list available resources and commands.', '', ].join('\n'), ); @@ -118,7 +118,7 @@ function formatTransportError(e: unknown): string { const msg = e instanceof Error ? e.message : String(e); if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) { return [ - `nc: cannot reach NanoClaw host (${msg}).`, + `ncl: cannot reach NanoClaw host (${msg}).`, `Is the host running? Start it with: pnpm run dev`, `Or, if installed as a service:`, ` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`, @@ -126,10 +126,10 @@ function formatTransportError(e: unknown): string { ``, ].join('\n'); } - return `nc: transport error: ${msg}\n`; + return `ncl: transport error: ${msg}\n`; } main().catch((err) => { - process.stderr.write(`nc: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); + process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(2); }); diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index 9219b70..d50eaef 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -1,8 +1,8 @@ /** * Built-in help command. Introspects the resource and command registries. * - * nc help — list all resources and commands - * nc groups help — show group resource details (verbs, columns, enums) + * ncl help — list all resources and commands + * ncl groups help — show group resource details (verbs, columns, enums) */ import { getResource, getResources } from '../crud.js'; import { listCommands, register } from '../registry.js'; @@ -41,7 +41,7 @@ register({ } lines.push(''); - lines.push('Run `nc help` for detailed field information.'); + lines.push('Run `ncl help` for detailed field information.'); return lines.join('\n'); }, }); diff --git a/src/cli/crud.ts b/src/cli/crud.ts index af1371f..98c9989 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -3,7 +3,7 @@ * * Takes a declarative resource definition (table, columns, access levels) * and auto-registers list/get/create/update/delete commands in the CLI - * registry. Column metadata doubles as documentation — `nc help` + * registry. Column metadata doubles as documentation — `ncl help` * is generated from the same definitions. */ import { randomUUID } from 'crypto'; diff --git a/src/cli/format.ts b/src/cli/format.ts index 5ce67c0..9b54599 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -1,5 +1,5 @@ /** - * Output formatting for the `nc` binary. Two modes: + * Output formatting for the `ncl` binary. Two modes: * - human (default): a small auto-table for arrays of flat records, * JSON.stringify for everything else, plain "error: ..." line for !ok. * - json: the response frame, pretty-printed. diff --git a/src/cli/registry.ts b/src/cli/registry.ts index bd75782..a60e74a 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -1,5 +1,5 @@ /** - * Command registry — single source of truth for what `nc` can do. + * Command registry — single source of truth for what `ncl` can do. * * Each command file under `commands/` calls `register()` at top level, * and `commands/index.ts` imports them all for side effects so the diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts index c94a4dc..4c80c5d 100644 --- a/src/cli/socket-client.ts +++ b/src/cli/socket-client.ts @@ -1,5 +1,5 @@ /** - * SocketTransport — client side. Used by the `nc` binary when running on + * SocketTransport — client side. Used by the `ncl` binary when running on * the host (i.e. invoked from a shell or by Claude in the project). * * Wire format: line-delimited JSON. One request per connection; the server @@ -12,7 +12,7 @@ import { DATA_DIR } from '../config.js'; import type { RequestFrame, ResponseFrame } from './frame.js'; import type { Transport } from './transport.js'; -export const DEFAULT_SOCKET_PATH = path.join(DATA_DIR, 'nc.sock'); +export const DEFAULT_SOCKET_PATH = path.join(DATA_DIR, 'ncl.sock'); export class SocketTransport implements Transport { constructor(private readonly socketPath: string = DEFAULT_SOCKET_PATH) {} diff --git a/src/cli/socket-server.ts b/src/cli/socket-server.ts index 7ed2683..9027848 100644 --- a/src/cli/socket-server.ts +++ b/src/cli/socket-server.ts @@ -3,7 +3,7 @@ * per connection, calls dispatch() with caller='host', writes the response * frame, closes. * - * Lives at data/nc.sock (separate from data/cli.sock, which the existing + * Lives at data/ncl.sock (separate from data/cli.sock, which the existing * chat-style CLI channel adapter owns). Socket file is chmod 0600 — only * the user that started the host can connect. */ @@ -25,7 +25,7 @@ export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): } catch (err) { const e = err as NodeJS.ErrnoException; if (e.code !== 'ENOENT') { - log.warn('Failed to unlink stale nc socket (will try to bind anyway)', { socketPath, err }); + log.warn('Failed to unlink stale ncl socket (will try to bind anyway)', { socketPath, err }); } } @@ -37,9 +37,9 @@ export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): try { fs.chmodSync(socketPath, 0o600); } catch (err) { - log.warn('Failed to chmod nc socket (continuing)', { socketPath, err }); + log.warn('Failed to chmod ncl socket (continuing)', { socketPath, err }); } - log.info('nc CLI server listening', { socketPath }); + log.info('ncl CLI server listening', { socketPath }); resolve(); }); }); @@ -65,7 +65,7 @@ function handleConnection(conn: net.Socket): void { } }); conn.on('error', (err) => { - log.warn('nc CLI server connection error', { err }); + log.warn('ncl CLI server connection error', { err }); }); } @@ -87,7 +87,7 @@ async function handleFrame(conn: net.Socket, line: string): Promise { return; } - // Host caller — connecting to data/nc.sock requires file-system access + // Host caller — connecting to data/ncl.sock requires file-system access // to a 0600 socket owned by the host user, so we treat the socket path // itself as the auth boundary. const ctx: CallerContext = { caller: 'host' }; @@ -100,7 +100,7 @@ function write(conn: net.Socket, frame: ResponseFrame): void { conn.write(JSON.stringify(frame) + '\n'); conn.end(); } catch (err) { - log.warn('Failed to write nc CLI response', { err }); + log.warn('Failed to write ncl CLI response', { err }); } } diff --git a/src/cli/transport.ts b/src/cli/transport.ts index b263102..14285ec 100644 --- a/src/cli/transport.ts +++ b/src/cli/transport.ts @@ -1,5 +1,5 @@ /** - * Client-side transport interface. The `nc` binary picks one of these and + * Client-side transport interface. The `ncl` binary picks one of these and * calls sendFrame; the caller doesn't know whether bytes traveled over a * Unix socket (host) or through outbound.db / inbound.db rows (container). */ diff --git a/src/index.ts b/src/index.ts index 3d39dd8..f16992a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,7 +53,7 @@ import './channels/index.js'; // append registry-based modules. Imported for side effects (registrations). import './modules/index.js'; -// CLI command barrel — populates the `nc` registry before the CLI server +// CLI command barrel — populates the `ncl` registry before the CLI server // accepts connections. import './cli/commands/index.js'; import './cli/delivery-action.js'; @@ -169,7 +169,7 @@ async function main(): Promise { startHostSweep(); log.info('Host sweep started'); - // 7. Start the `nc` CLI socket server (data/nc.sock). + // 7. Start the `ncl` CLI socket server (data/ncl.sock). await startCliServer(); log.info('NanoClaw running'); From 046b99c7457bf47e03eba02fe9333c3fe609ed8f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 16:31:30 +0300 Subject: [PATCH 061/105] feat(cli): wire approval flow for agent CLI commands When a container agent calls an approval-gated ncl command, dispatch now sends an approval card to an admin instead of returning a stub error. On approve, the handler re-dispatches the original command and notifies the agent with the result. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/dispatch.ts | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index f4c8987..7b247eb 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -6,6 +6,9 @@ * Approval gating for risky calls from the container is the only branch * that differs by caller. Host callers and `open` commands run inline. */ +import { getAgentGroup } from '../db/agent-groups.js'; +import { getSession } from '../db/sessions.js'; +import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js'; import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; import { lookup } from './registry.js'; @@ -15,10 +18,28 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise `--${k} ${v}`) + .join(' '); + + await requestApproval({ + session, + agentName, + action: 'cli_command', + payload: { frame: { id: req.id, command: req.command, args: req.args } }, + title: `CLI: ${req.command}`, + question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``, + }); + + return err(req.id, 'approval-pending', 'Approval request sent to admin. You will be notified of the result.'); } let parsed: unknown; @@ -36,6 +57,18 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise { + const frame = payload.frame as RequestFrame; + const response = await dispatch(frame, { caller: 'host' }); + + if (response.ok) { + const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); + notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`); + } else { + notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`); + } +}); + function err(id: string, code: ErrorCode, message: string): ResponseFrame { return { id, ok: false, error: { code, message } }; } From ed571d1f66719a1f971bf2a324309649f5b6e1e0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 20:45:18 +0300 Subject: [PATCH 062/105] =?UTF-8?q?docs(cli):=20add=20write=20examples,=20?= =?UTF-8?q?approval=20flow,=20and=20nc=E2=86=92ncl=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add approval flow section explaining the request→notify→result mechanics - Add write command examples (groups create, roles grant, members add, etc.) - Rename stale `nc` references to `ncl` in container instructions - Add CLI reference section to host CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 26 +++++++++ .../src/mcp-tools/cli.instructions.md | 57 ++++++++++++------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92824fb..e941490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,32 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `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). | +## Admin CLI (`ncl`) + +`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`). + +``` +ncl [] [--flags] +ncl help +ncl help +``` + +| Resource | Verbs | What it is | +|----------|-------|------------| +| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | +| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | +| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | +| users | list, get, create, update | Platform identities (`:`) | +| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) | +| members | list, add, remove | Unprivileged access gate for an agent group | +| destinations | list, add, remove | Where an agent group can send messages | +| sessions | list, get | Active sessions (read-only) | +| user-dms | list | Cold-DM cache (read-only) | +| dropped-messages | list | Messages from unregistered senders (read-only) | +| approvals | list, get | Pending approval requests (read-only) | + +Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions). + ## Channels and Providers (skill-installed) Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills: diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index 5bec167..f9965f5 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -1,13 +1,13 @@ -## Admin CLI (`nc`) +## Admin CLI (`ncl`) -The `nc` command is available at `/usr/local/bin/nc`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. +The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. ### Usage ``` -nc [] [--flags] -nc help -nc help +ncl [] [--flags] +ncl help +ncl help ``` ### Resources @@ -28,33 +28,50 @@ nc help ### When to use -- **Looking up your own config** — `nc groups get ` to see your agent group settings. -- **Finding who you're wired to** — `nc wirings list` to see which messaging groups route to which agent groups. -- **Checking user roles** — `nc roles list` to see who is an owner/admin. -- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `nc` rather than guessing. +- **Looking up your own config** — `ncl groups get ` to see your agent group settings. +- **Finding who you're wired to** — `ncl wirings list` to see which messaging groups route to which agent groups. +- **Checking user roles** — `ncl roles list` to see who is an owner/admin. +- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `ncl` rather than guessing. ### Access rules Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it. +### Approval flow + +Write commands (create, update, delete, grant, revoke, add, remove) require admin approval. Here's what happens: + +1. You run the command (e.g. `ncl groups create --name "Research" --folder research`). +2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet. +3. An admin or owner gets a notification (on the same channel when possible) showing exactly what you requested, with approve/reject options. +4. Once the admin responds: + - **Approved:** the command executes and the result is delivered back to you as a system message in this conversation. + - **Rejected:** you get a system message saying the request was rejected. + +You don't need to poll or retry — the result arrives automatically. + ### Examples ```bash -# List all agent groups -nc groups list +# Read commands (no approval needed) +ncl groups list +ncl groups get abc123 +ncl wirings list --messaging-group-id mg_xyz +ncl roles list +ncl wirings help -# Get details for a specific group -nc groups get abc123 - -# See field definitions for a resource -nc wirings help - -# List all wirings for a specific messaging group -nc wirings list --messaging-group-id mg_xyz +# Write commands (approval required) +ncl groups create --name "Research" --folder research +ncl groups update abc123 --name "Research v2" +ncl roles grant --user telegram:jane --role admin +ncl roles grant --user discord:bob --role admin --group abc123 +ncl members add --user-id telegram:jane --agent-group-id abc123 +ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz ``` ### Tips -- Use `nc help` to see all available fields, types, enums, and which fields are required or updatable. +- Use `ncl help` to see all available fields, types, enums, and which fields are required or updatable. - Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. - For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. +- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result. From 6caad0757ad8407b2005fabe317aa0b159dabb7c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 21:02:23 +0300 Subject: [PATCH 063/105] fix(cli): add list filtering/pagination, fix double-close in container ncl - genericList now accepts column filters (--flag value) and LIMIT (default 200) - Remove early inDb.close() in container pollResponse to avoid double-close - Document filtering and --limit in cli.instructions.md Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/cli/ncl.ts | 4 +--- .../src/mcp-tools/cli.instructions.md | 1 + src/cli/crud.ts | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts index 83bd666..d86c601 100644 --- a/container/agent-runner/src/cli/ncl.ts +++ b/container/agent-runner/src/cli/ncl.ts @@ -102,8 +102,6 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul .get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null; if (row) { - inDb.close(); - // Mark as completed via processing_ack so agent-runner skips it const outDb = new Database(OUTBOUND_DB); outDb.exec('PRAGMA journal_mode = DELETE'); @@ -119,7 +117,7 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul return parsed.frame as ResponseFrame; } } finally { - try { inDb.close(); } catch {} + inDb.close(); } Bun.sleepSync(500); diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index f9965f5..9dee60f 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -73,5 +73,6 @@ ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz - Use `ncl help` to see all available fields, types, enums, and which fields are required or updatable. - Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. +- `list` supports filtering by any non-auto column (e.g. `ncl wirings list --messaging-group-id mg_xyz`). Default limit is 200 rows; override with `--limit N`. - For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. - Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result. diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 98c9989..7721f67 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -89,8 +89,21 @@ function visibleColumns(def: ResourceDef): string[] { function genericList(def: ResourceDef) { const cols = visibleColumns(def).join(', '); - return async () => { - return getDb().prepare(`SELECT ${cols} FROM ${def.table}`).all(); + const filterableNames = new Set(def.columns.filter((c) => !c.generated).map((c) => c.name)); + return async (args: Record) => { + const limit = args.limit !== undefined ? Math.max(1, Number(args.limit)) : 200; + const filters: string[] = []; + const params: unknown[] = []; + for (const [k, v] of Object.entries(args)) { + if (k === 'id' || k === 'limit') continue; + if (filterableNames.has(k)) { + filters.push(`${k} = ?`); + params.push(v); + } + } + const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : ''; + params.push(limit); + return getDb().prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`).all(...params); }; } @@ -211,7 +224,7 @@ export function registerResource(def: ResourceDef): void { description: `List all ${def.plural}.`, access: def.operations.list, resource: def.plural, - parseArgs: () => ({}), + parseArgs: (raw) => normalizeArgs(raw), handler: genericList(def), }); } From 01eac7b225c87f956813415125cbab937fed1835 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 21:04:07 +0300 Subject: [PATCH 064/105] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/crud.ts | 4 +++- src/delivery.test.ts | 17 +++++++++++++---- src/host-core.test.ts | 8 ++++++-- src/modules/agent-to-agent/agent-route.test.ts | 14 ++++++++++++-- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 7721f67..928aeed 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -103,7 +103,9 @@ function genericList(def: ResourceDef) { } const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : ''; params.push(limit); - return getDb().prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`).all(...params); + return getDb() + .prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`) + .all(...params); }; } diff --git a/src/delivery.test.ts b/src/delivery.test.ts index 5d23536..aadfde8 100644 --- a/src/delivery.test.ts +++ b/src/delivery.test.ts @@ -26,7 +26,14 @@ vi.mock('./config.js', async () => { const TEST_DIR = '/tmp/nanoclaw-test-delivery'; -import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.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'; @@ -233,10 +240,12 @@ describe('deliverSessionMessages — permission check', () => { // 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) + 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' })); + ) + .run('out-unauth', JSON.stringify({ text: 'sneaky' })); outDb.close(); const calls: string[] = []; diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 1225b76..e6b9153 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -838,7 +838,6 @@ describe('agent-shared session resolution', () => { const { session } = resolveSession('ag-1', null, null, 'agent-shared'); expect(session.messaging_group_id).toBeNull(); }); - }); describe('agent-to-agent routing', () => { @@ -885,7 +884,12 @@ 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' }), in_reply_to: null }, + { + id: 'out-a2a-1', + platform_id: 'ag-researcher', + content: JSON.stringify({ text: 'research this' }), + in_reply_to: null, + }, paSlackSession, ); diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts index fca0d4b..b0bc66e 100644 --- a/src/modules/agent-to-agent/agent-route.test.ts +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -328,7 +328,12 @@ describe('routeAgentMessage return-path', () => { // 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 }, + { + id: 'msg-reply-tamper', + platform_id: A, + content: JSON.stringify({ text: 'misdirected' }), + in_reply_to: cInboundId, + }, SB, ); @@ -353,7 +358,12 @@ describe('routeAgentMessage return-path', () => { // 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' }, + { + id: 'msg-reply-channel', + platform_id: A, + content: JSON.stringify({ text: 'response' }), + in_reply_to: 'channel-msg-1', + }, SB, ); From 49909942042b3c47e70312e016c704e159a57411 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 18:05:53 +0000 Subject: [PATCH 065/105] chore: bump version to 2.0.46 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6bddd32..7aae3df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.45", + "version": "2.0.46", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 0e11eaf186577ed1d185350b3b3aca3eff793696 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 18:05:56 +0000 Subject: [PATCH 066/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?66k=20tokens=20=C2=B7=2083%=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 941546a..61f3913 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 150k tokens, 75% of context window + + 166k tokens, 83% of context window @@ -15,8 +15,8 @@ tokens - - 150k + + 166k From e6d470d8313979e3d1aef3155cba77558d5c4e06 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 21:14:02 +0300 Subject: [PATCH 067/105] docs: add ncl CLI to changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec9fc3..cba3557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ For detailed release notes, see the [full changelog on the documentation site](h ## [Unreleased] +- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage. - **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). - **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default. From 0060c6b84acc20f90996aade9611db643c6cf05e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 21:14:37 +0300 Subject: [PATCH 068/105] docs: add v2.0.45 changelog entry Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba3557..7043f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ For detailed release notes, see the [full changelog on the documentation site](h ## [Unreleased] +## [2.0.45] - 2026-05-08 + - **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage. - **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). - **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default. From ef43cbb3d9505514a2c82427088ad1b105a957ee Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 21:18:02 +0300 Subject: [PATCH 069/105] docs: remove migration fixes from changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7043f0f..4a4b1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ For detailed release notes, see the [full changelog on the documentation site](h - **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage. - **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md). -- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default. ## [2.0.0] - 2026-04-22 From 31ccc61b27a96248f55f63a9a8c5b079d4999d8a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 22:18:16 +0300 Subject: [PATCH 070/105] feat(db): move container config from filesystem to DB Source of truth for container runtime config moves from groups//container.json to a new container_configs table. The file becomes a materialized view written at spawn time. - New container_configs table with scalar columns (provider, model, effort, image_tag, assistant_name, max_messages_per_prompt) and JSON columns (mcp_servers, packages_apt, packages_npm, skills, additional_mounts) - Startup backfill seeds DB from existing container.json files - materializeContainerJson() replaces readContainerConfig + ensureRuntimeFields - Self-mod handlers (install_packages, add_mcp_server) write to DB - Provider cascade simplified: session -> container_configs -> 'claude' - ncl groups config-{get,update,add-mcp-server,remove-mcp-server, add-package,remove-package} custom operations - restartAgentGroupContainers() helper for config change propagation - Container side unchanged (still reads /workspace/agent/container.json) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backfill-container-configs.ts | 77 +++++++++ src/claude-md-compose.ts | 10 +- src/cli/resources/groups.ts | 190 ++++++++++++++++++++- src/container-config.ts | 125 +++++--------- src/container-restart.ts | 44 +++++ src/container-runner.test.ts | 23 +-- src/container-runner.ts | 66 ++----- src/db/container-configs.ts | 84 +++++++++ src/db/index.ts | 9 + src/db/migrations/014-container-configs.ts | 26 +++ src/db/migrations/index.ts | 2 + src/group-init.ts | 12 +- src/index.ts | 7 +- src/modules/self-mod/apply.ts | 59 ++++--- src/types.ts | 19 +++ 15 files changed, 573 insertions(+), 180 deletions(-) create mode 100644 src/backfill-container-configs.ts create mode 100644 src/container-restart.ts create mode 100644 src/db/container-configs.ts create mode 100644 src/db/migrations/014-container-configs.ts diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts new file mode 100644 index 0000000..b046c3c --- /dev/null +++ b/src/backfill-container-configs.ts @@ -0,0 +1,77 @@ +/** + * One-time backfill: seed `container_configs` rows from existing + * `groups//container.json` files and `agent_groups.agent_provider`. + * + * Runs after migrations, before channel adapters start. Idempotent — skips + * groups that already have a config row. + */ +import fs from 'fs'; +import path from 'path'; + +import { GROUPS_DIR } from './config.js'; +import type { McpServerConfig, AdditionalMountConfig } from './container-config.js'; +import { getAllAgentGroups } from './db/agent-groups.js'; +import { getContainerConfig, createContainerConfig } from './db/container-configs.js'; +import { log } from './log.js'; +import type { ContainerConfigRow } from './types.js'; + +interface LegacyContainerJson { + mcpServers?: Record; + packages?: { apt?: string[]; npm?: string[] }; + imageTag?: string; + additionalMounts?: AdditionalMountConfig[]; + skills?: string[] | 'all'; + provider?: string; + assistantName?: string; + maxMessagesPerPrompt?: number; +} + +export function backfillContainerConfigs(): void { + const groups = getAllAgentGroups(); + let backfilled = 0; + + for (const group of groups) { + // Skip if already has a config row + if (getContainerConfig(group.id)) continue; + + // Read legacy container.json from disk + const filePath = path.join(GROUPS_DIR, group.folder, 'container.json'); + let legacy: LegacyContainerJson = {}; + if (fs.existsSync(filePath)) { + try { + legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson; + } catch (err) { + log.warn('Backfill: failed to parse container.json, using defaults', { + folder: group.folder, + err: String(err), + }); + } + } + + // DB agent_provider wins over file provider (matches old cascade) + const provider = group.agent_provider || legacy.provider || null; + + const row: ContainerConfigRow = { + agent_group_id: group.id, + provider, + model: null, + effort: null, + image_tag: legacy.imageTag ?? null, + assistant_name: legacy.assistantName ?? null, + max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null, + skills: JSON.stringify(legacy.skills ?? 'all'), + mcp_servers: JSON.stringify(legacy.mcpServers ?? {}), + packages_apt: JSON.stringify(legacy.packages?.apt ?? []), + packages_npm: JSON.stringify(legacy.packages?.npm ?? []), + additional_mounts: JSON.stringify(legacy.additionalMounts ?? []), + updated_at: new Date().toISOString(), + }; + + createContainerConfig(row); + backfilled++; + } + + if (backfilled > 0) { + log.info('Backfilled container_configs from disk', { count: backfilled }); + } +} diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts index c0519e4..64ad799 100644 --- a/src/claude-md-compose.ts +++ b/src/claude-md-compose.ts @@ -18,7 +18,8 @@ import fs from 'fs'; import path from 'path'; import { GROUPS_DIR } from './config.js'; -import { readContainerConfig } from './container-config.js'; +import type { McpServerConfig } from './container-config.js'; +import { getContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; @@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void { } // Desired fragment set. - const config = readContainerConfig(group.folder); + const configRow = getContainerConfig(group.id); + const mcpServers: Record = configRow + ? (JSON.parse(configRow.mcp_servers) as Record) + : {}; const desired = new Map(); // Skill fragments — every skill that ships an `instructions.md`. @@ -91,7 +95,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void { // MCP server fragments — inline instructions from container.json for // user-added external MCP servers. - for (const [name, mcp] of Object.entries(config.mcpServers)) { + for (const [name, mcp] of Object.entries(mcpServers)) { if (mcp.instructions) { desired.set(`mcp-${name}.md`, { type: 'inline', diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index e334fc1..3b721d9 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -1,5 +1,32 @@ +import type { McpServerConfig } from '../../container-config.js'; +import { restartAgentGroupContainers } from '../../container-restart.js'; +import { + getContainerConfig, + updateContainerConfigScalars, + updateContainerConfigJson, +} from '../../db/container-configs.js'; +import type { ContainerConfigRow } from '../../types.js'; import { registerResource } from '../crud.js'; +/** Deserialize JSON columns for display. */ +function presentConfig(row: ContainerConfigRow): Record { + return { + agent_group_id: row.agent_group_id, + provider: row.provider, + model: row.model, + effort: row.effort, + image_tag: row.image_tag, + assistant_name: row.assistant_name, + max_messages_per_prompt: row.max_messages_per_prompt, + skills: JSON.parse(row.skills), + mcp_servers: JSON.parse(row.mcp_servers), + packages_apt: JSON.parse(row.packages_apt), + packages_npm: JSON.parse(row.packages_npm), + additional_mounts: JSON.parse(row.additional_mounts), + updated_at: row.updated_at, + }; +} + registerResource({ name: 'group', plural: 'groups', @@ -26,12 +53,169 @@ registerResource({ { name: 'agent_provider', type: 'string', - description: - 'LLM provider. Null means the default (claude). Skill-installed providers (e.g. opencode) register via /add-.', - updatable: true, + description: 'Deprecated — use `ncl groups config-update --provider`. Kept for backwards compat.', + updatable: false, default: null, }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, + customOperations: { + 'config-get': { + access: 'open', + description: 'Show the container config for a group. Use --id .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + return presentConfig(row); + }, + }, + 'config-update': { + access: 'approval', + description: + 'Update container config scalar fields. Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt.', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const updates: Partial< + Pick< + ContainerConfigRow, + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' + > + > = {}; + if (args.provider !== undefined) updates.provider = args.provider as string; + if (args.model !== undefined) updates.model = args.model as string; + if (args.effort !== undefined) updates.effort = args.effort as string; + if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string; + if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string; + if (args.max_messages_per_prompt !== undefined) + updates.max_messages_per_prompt = Number(args.max_messages_per_prompt); + + if (Object.keys(updates).length === 0) { + throw new Error( + 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt', + ); + } + + updateContainerConfigScalars(id, updates); + restartAgentGroupContainers(id, 'config updated via ncl'); + + const updated = getContainerConfig(id)!; + return presentConfig(updated); + }, + }, + 'config-add-mcp-server': { + access: 'approval', + description: + 'Add an MCP server to a group. Use --id --name --command [--args ] [--env ].', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const name = args.name as string; + if (!name) throw new Error('--name is required'); + const command = args.command as string; + if (!command) throw new Error('--command is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const servers = JSON.parse(row.mcp_servers) as Record; + servers[name] = { + command, + args: args.args ? (JSON.parse(args.args as string) as string[]) : [], + env: args.env ? (JSON.parse(args.env as string) as Record) : {}, + }; + updateContainerConfigJson(id, 'mcp_servers', servers); + restartAgentGroupContainers(id, `mcp server "${name}" added via ncl`); + + return { added: name, servers }; + }, + }, + 'config-remove-mcp-server': { + access: 'approval', + description: 'Remove an MCP server from a group. Use --id --name .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + const name = args.name as string; + if (!name) throw new Error('--name is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const servers = JSON.parse(row.mcp_servers) as Record; + if (!servers[name]) throw new Error(`MCP server "${name}" not found`); + delete servers[name]; + updateContainerConfigJson(id, 'mcp_servers', servers); + restartAgentGroupContainers(id, `mcp server "${name}" removed via ncl`); + + return { removed: name }; + }, + }, + 'config-add-package': { + access: 'approval', + description: 'Add a package to a group. Use --id and --apt or --npm .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const apt = args.apt as string | undefined; + const npm = args.npm as string | undefined; + if (!apt && !npm) throw new Error('Provide --apt or --npm '); + + if (apt) { + const existing = JSON.parse(row.packages_apt) as string[]; + if (!existing.includes(apt)) { + existing.push(apt); + updateContainerConfigJson(id, 'packages_apt', existing); + } + } + if (npm) { + const existing = JSON.parse(row.packages_npm) as string[]; + if (!existing.includes(npm)) { + existing.push(npm); + updateContainerConfigJson(id, 'packages_npm', existing); + } + } + + return { added: { apt: apt || null, npm: npm || null } }; + }, + }, + 'config-remove-package': { + access: 'approval', + description: 'Remove a package from a group. Use --id and --apt or --npm .', + handler: async (args) => { + const id = args.id as string; + if (!id) throw new Error('--id is required'); + + const row = getContainerConfig(id); + if (!row) throw new Error(`No container config for group: ${id}`); + + const apt = args.apt as string | undefined; + const npm = args.npm as string | undefined; + if (!apt && !npm) throw new Error('Provide --apt or --npm '); + + if (apt) { + const existing = JSON.parse(row.packages_apt) as string[]; + const filtered = existing.filter((p) => p !== apt); + updateContainerConfigJson(id, 'packages_apt', filtered); + } + if (npm) { + const existing = JSON.parse(row.packages_npm) as string[]; + const filtered = existing.filter((p) => p !== npm); + updateContainerConfigJson(id, 'packages_npm', filtered); + } + + return { removed: { apt: apt || null, npm: npm || null } }; + }, + }, + }, }); diff --git a/src/container-config.ts b/src/container-config.ts index d972842..597ca92 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -1,26 +1,25 @@ /** - * Per-group container config, stored as a plain JSON file at - * `groups//container.json`. Mounted read-only inside the container - * at `/workspace/agent/container.json` — the runner reads it at startup but - * cannot modify it. Config changes go through the self-mod approval flow. + * Container config types and materialization. * - * All fields are optional — a missing file or a partial file both resolve - * to sensible defaults. Writes are atomic-enough (write-then-rename is not - * worth the ceremony here since there's only one writer in practice: the - * host, from the delivery thread that processes approved system actions). + * Source of truth is the `container_configs` table in the central DB. + * This module provides: + * - Type definitions for the file shape (read by the container runner) + * - `materializeContainerJson()` — writes `groups//container.json` + * from the DB at spawn time + * - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group */ import fs from 'fs'; import path from 'path'; import { GROUPS_DIR } from './config.js'; +import { getContainerConfig } from './db/container-configs.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import type { AgentGroup, ContainerConfigRow } from './types.js'; export interface McpServerConfig { command: string; args?: string[]; env?: Record; - // Optional always-in-context guidance. When set, the host writes the - // content to `.claude-fragments/mcp-.md` at spawn and imports it - // into the composed CLAUDE.md. instructions?: string; } @@ -30,101 +29,61 @@ export interface AdditionalMountConfig { readonly?: boolean; } +/** Shape of the materialized `container.json` file read by the container runner. */ export interface ContainerConfig { mcpServers: Record; packages: { apt: string[]; npm: string[] }; imageTag?: string; additionalMounts: AdditionalMountConfig[]; - /** Which skills to enable — array of skill names or "all" (default). */ skills: string[] | 'all'; - /** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */ provider?: string; - /** Agent group display name (used in transcript archiving). */ groupName?: string; - /** Assistant display name (used in system prompt / responses). */ assistantName?: string; - /** Agent group ID — set by the host, read by the runner. */ agentGroupId?: string; - /** Max messages per prompt. Falls back to code default if unset. */ maxMessagesPerPrompt?: number; + model?: string; + effort?: string; } -function emptyConfig(): ContainerConfig { +/** Build a `ContainerConfig` from a DB row + agent group identity. */ +export function configFromDb(row: ContainerConfigRow, group: AgentGroup): ContainerConfig { return { - mcpServers: {}, - packages: { apt: [], npm: [] }, - additionalMounts: [], - skills: 'all', + mcpServers: JSON.parse(row.mcp_servers) as Record, + packages: { + apt: JSON.parse(row.packages_apt) as string[], + npm: JSON.parse(row.packages_npm) as string[], + }, + imageTag: row.image_tag ?? undefined, + additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[], + skills: JSON.parse(row.skills) as string[] | 'all', + provider: row.provider ?? undefined, + groupName: group.name, + assistantName: row.assistant_name ?? group.name, + agentGroupId: group.id, + maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined, + model: row.model ?? undefined, + effort: row.effort ?? undefined, }; } -function configPath(folder: string): string { - return path.join(GROUPS_DIR, folder, 'container.json'); -} - /** - * Read the container config for a group, returning sensible defaults for - * any missing fields (or an entirely empty config if the file is absent). - * Never throws for missing / malformed files — corruption logs a warning - * via console.error and falls back to empty. + * Materialize `container.json` from the DB. Called at spawn time so the + * container always sees fresh config. Returns the `ContainerConfig` for + * use by the caller (buildMounts, buildContainerArgs, etc.). */ -export function readContainerConfig(folder: string): ContainerConfig { - const p = configPath(folder); - if (!fs.existsSync(p)) return emptyConfig(); - try { - const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial; - return { - mcpServers: raw.mcpServers ?? {}, - packages: { - apt: raw.packages?.apt ?? [], - npm: raw.packages?.npm ?? [], - }, - imageTag: raw.imageTag, - additionalMounts: raw.additionalMounts ?? [], - skills: raw.skills ?? 'all', - provider: raw.provider, - groupName: raw.groupName, - assistantName: raw.assistantName, - agentGroupId: raw.agentGroupId, - maxMessagesPerPrompt: raw.maxMessagesPerPrompt, - }; - } catch (err) { - console.error(`[container-config] failed to parse ${p}: ${String(err)}`); - return emptyConfig(); - } -} +export function materializeContainerJson(agentGroupId: string): ContainerConfig { + const group = getAgentGroup(agentGroupId); + if (!group) throw new Error(`Agent group not found: ${agentGroupId}`); -/** - * Write the container config for a group, creating the groups// - * directory if necessary. Pretty-printed JSON so diffs in the activation - * flow are reviewable. - */ -export function writeContainerConfig(folder: string, config: ContainerConfig): void { - const p = configPath(folder); + const row = getContainerConfig(agentGroupId); + if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`); + + const config = configFromDb(row, group); + + const p = path.join(GROUPS_DIR, group.folder, 'container.json'); const dir = path.dirname(p); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n'); -} -/** - * Apply a mutator function to a group's container config and persist the - * result. Convenient for append-style changes like `install_packages` and - * `add_mcp_server` handlers. - */ -export function updateContainerConfig(folder: string, mutate: (config: ContainerConfig) => void): ContainerConfig { - const config = readContainerConfig(folder); - mutate(config); - writeContainerConfig(folder, config); return config; } - -/** - * Initialize an empty container.json for a group if one doesn't already - * exist. Idempotent — used from `group-init.ts`. - */ -export function initContainerConfig(folder: string): boolean { - const p = configPath(folder); - if (fs.existsSync(p)) return false; - writeContainerConfig(folder, emptyConfig()); - return true; -} diff --git a/src/container-restart.ts b/src/container-restart.ts new file mode 100644 index 0000000..ff6ff51 --- /dev/null +++ b/src/container-restart.ts @@ -0,0 +1,44 @@ +/** + * Helper to restart all running containers for an agent group. + * + * Used by: + * - self-mod approval handlers (after config change) + * - ncl config-update (after CLI config change) + */ +import { killContainer } from './container-runner.js'; +import { getSessionsByAgentGroup } from './db/sessions.js'; +import { log } from './log.js'; +import { writeSessionMessage } from './session-manager.js'; + +/** + * Kill all running containers for an agent group and schedule wake messages + * so the host sweep respawns them with fresh config. + */ +export function restartAgentGroupContainers(agentGroupId: string, reason: string): void { + const sessions = getSessionsByAgentGroup(agentGroupId).filter((s) => s.status === 'active'); + + for (const session of sessions) { + killContainer(session.id, reason); + writeSessionMessage(agentGroupId, session.id, { + id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: agentGroupId, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: `Container restarted: ${reason}. Resuming.`, + sender: 'system', + senderId: 'system', + }), + processAfter: new Date(Date.now() + 5000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ''), + }); + } + + if (sessions.length > 0) { + log.info('Restarted agent group containers', { agentGroupId, reason, count: sessions.length }); + } +} diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index cd18a72..3c188f9 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest'; import { resolveProviderName } from './container-runner.js'; describe('resolveProviderName', () => { - it('prefers session over group and container.json', () => { - expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex'); + it('prefers session over container config', () => { + expect(resolveProviderName('codex', 'claude')).toBe('codex'); }); - it('falls back to group when session is null', () => { - expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex'); - }); - - it('falls back to container.json when session and group are null', () => { - expect(resolveProviderName(null, null, 'opencode')).toBe('opencode'); + it('falls back to container config when session is null', () => { + expect(resolveProviderName(null, 'opencode')).toBe('opencode'); }); it('defaults to claude when nothing is set', () => { - expect(resolveProviderName(null, null, undefined)).toBe('claude'); + expect(resolveProviderName(null, undefined)).toBe('claude'); }); it('lowercases the resolved name', () => { - expect(resolveProviderName('CODEX', null, null)).toBe('codex'); - expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode'); - expect(resolveProviderName(null, null, 'Claude')).toBe('claude'); + expect(resolveProviderName('CODEX', null)).toBe('codex'); + expect(resolveProviderName(null, 'Claude')).toBe('claude'); }); it('treats empty string as unset (falls through)', () => { - expect(resolveProviderName('', 'codex', null)).toBe('codex'); - expect(resolveProviderName(null, '', 'opencode')).toBe('opencode'); + expect(resolveProviderName('', 'opencode')).toBe('opencode'); + expect(resolveProviderName(null, '')).toBe('claude'); }); }); diff --git a/src/container-runner.ts b/src/container-runner.ts index 27b0f5c..cdf93f2 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -19,7 +19,9 @@ import { ONECLI_URL, TIMEZONE, } from './config.js'; -import { readContainerConfig, writeContainerConfig } from './container-config.js'; +import { materializeContainerJson } from './container-config.js'; +import { getContainerConfig } from './db/container-configs.js'; +import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { composeGroupClaudeMd } from './claude-md-compose.js'; import { getAgentGroup } from './db/agent-groups.js'; @@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise { } writeSessionRouting(agentGroup.id, session.id); - // Read container config once — threaded through provider resolution, - // buildMounts, and buildContainerArgs so we don't re-read the file. - const containerConfig = readContainerConfig(agentGroup.folder); - - // Ensure container.json has the agent group identity fields the runner needs. - // Written at spawn time so the runner can read them from the RO mount. - ensureRuntimeFields(containerConfig, agentGroup); + // Materialize container.json from DB — writes fresh file and returns + // the config object, threaded through provider resolution, buildMounts, + // and buildContainerArgs so we don't re-read. + const containerConfig = materializeContainerJson(agentGroup.id); // Resolve the effective provider + any host-side contribution it declares // (extra mounts, env passthrough). Computed once and threaded through both @@ -204,22 +203,19 @@ export function killContainer(sessionId: string, reason: string): void { } /** - * Resolve the provider name for a session using the precedence documented in - * the provider-install skills: + * Resolve the provider name for a session: * * sessions.agent_provider - * → agent_groups.agent_provider - * → container.json `provider` + * → container_configs.provider * → 'claude' * * Pure so the precedence can be unit-tested without a DB or filesystem. */ export function resolveProviderName( sessionProvider: string | null | undefined, - agentGroupProvider: string | null | undefined, containerConfigProvider: string | null | undefined, ): string { - return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase(); + return (sessionProvider || containerConfigProvider || 'claude').toLowerCase(); } function resolveProviderContribution( @@ -227,7 +223,7 @@ function resolveProviderContribution( agentGroup: AgentGroup, containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider); + const provider = resolveProviderName(session.agent_provider, containerConfig.provider); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ @@ -396,34 +392,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain } } -/** - * Ensure container.json has the runtime identity fields the runner needs. - * Written at spawn time so they're always current even if the DB values - * change (e.g. group rename). Only writes if values differ to avoid - * unnecessary file churn. - */ -function ensureRuntimeFields( - containerConfig: import('./container-config.js').ContainerConfig, - agentGroup: AgentGroup, -): void { - let dirty = false; - if (containerConfig.agentGroupId !== agentGroup.id) { - containerConfig.agentGroupId = agentGroup.id; - dirty = true; - } - if (containerConfig.groupName !== agentGroup.name) { - containerConfig.groupName = agentGroup.name; - dirty = true; - } - if (containerConfig.assistantName !== agentGroup.name) { - containerConfig.assistantName = agentGroup.name; - dirty = true; - } - if (dirty) { - writeContainerConfig(agentGroup.folder, containerConfig); - } -} - async function buildContainerArgs( mounts: VolumeMount[], containerName: string, @@ -497,9 +465,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise const agentGroup = getAgentGroup(agentGroupId); if (!agentGroup) throw new Error('Agent group not found'); - const containerConfig = readContainerConfig(agentGroup.folder); - const aptPackages = containerConfig.packages.apt; - const npmPackages = containerConfig.packages.npm; + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) throw new Error('Container config not found'); + const aptPackages = JSON.parse(configRow.packages_apt) as string[]; + const npmPackages = JSON.parse(configRow.packages_npm) as string[]; if (aptPackages.length === 0 && npmPackages.length === 0) { throw new Error('No packages to install. Use install_packages first.'); @@ -536,9 +505,8 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise fs.unlinkSync(tmpDockerfile); } - // Store the image tag in groups//container.json - containerConfig.imageTag = imageTag; - writeContainerConfig(agentGroup.folder, containerConfig); + // Store the image tag in the DB + updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag }); log.info('Per-agent-group image built', { agentGroupId, imageTag }); } diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts new file mode 100644 index 0000000..2e1ce9e --- /dev/null +++ b/src/db/container-configs.ts @@ -0,0 +1,84 @@ +import type { ContainerConfigRow } from '../types.js'; +import { getDb } from './connection.js'; + +export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined { + return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as + | ContainerConfigRow + | undefined; +} + +export function getAllContainerConfigs(): ContainerConfigRow[] { + return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[]; +} + +/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */ +export function createContainerConfig(config: ContainerConfigRow): void { + getDb() + .prepare( + `INSERT INTO container_configs ( + agent_group_id, provider, model, effort, image_tag, assistant_name, + max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm, + additional_mounts, updated_at + ) VALUES ( + @agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name, + @max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm, + @additional_mounts, @updated_at + )`, + ) + .run(config); +} + +/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */ +export function ensureContainerConfig(agentGroupId: string): void { + getDb() + .prepare( + `INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at) + VALUES (?, ?)`, + ) + .run(agentGroupId, new Date().toISOString()); +} + +/** Update scalar fields on a config row. Only touches fields present in `updates`. */ +export function updateContainerConfigScalars( + agentGroupId: string, + updates: Partial< + Pick< + ContainerConfigRow, + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' + > + >, +): void { + const fields: string[] = []; + const values: Record = { agent_group_id: agentGroupId }; + + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + fields.push(`${key} = @${key}`); + values[key] = value; + } + } + if (fields.length === 0) return; + + fields.push('updated_at = @updated_at'); + values.updated_at = new Date().toISOString(); + + getDb() + .prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`) + .run(values); +} + +/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */ +export function updateContainerConfigJson( + agentGroupId: string, + column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts', + value: unknown, +): void { + const now = new Date().toISOString(); + getDb() + .prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`) + .run(JSON.stringify(value), now, agentGroupId); +} + +export function deleteContainerConfig(agentGroupId: string): void { + getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId); +} diff --git a/src/db/index.ts b/src/db/index.ts index 0e4285a..57a1013 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -42,3 +42,12 @@ export { deletePendingApproval, getPendingApprovalsByAction, } from './sessions.js'; +export { + getContainerConfig, + getAllContainerConfigs, + createContainerConfig, + ensureContainerConfig, + updateContainerConfigScalars, + updateContainerConfigJson, + deleteContainerConfig, +} from './container-configs.js'; diff --git a/src/db/migrations/014-container-configs.ts b/src/db/migrations/014-container-configs.ts new file mode 100644 index 0000000..b9e3968 --- /dev/null +++ b/src/db/migrations/014-container-configs.ts @@ -0,0 +1,26 @@ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration014: Migration = { + version: 14, + name: 'container-configs', + up(db: Database.Database) { + db.exec(` + CREATE TABLE container_configs ( + agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE, + provider TEXT, + model TEXT, + effort TEXT, + image_tag TEXT, + assistant_name TEXT, + max_messages_per_prompt INTEGER, + skills TEXT NOT NULL DEFAULT '"all"', + mcp_servers TEXT NOT NULL DEFAULT '{}', + packages_apt TEXT NOT NULL DEFAULT '[]', + packages_npm TEXT NOT NULL DEFAULT '[]', + additional_mounts TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL + ); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index b46e678..a181cb3 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -10,6 +10,7 @@ import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; import { migration013 } from './013-approval-render-metadata.js'; +import { migration014 } from './014-container-configs.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -31,6 +32,7 @@ const migrations: Migration[] = [ migration011, migration012, migration013, + migration014, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/group-init.ts b/src/group-init.ts index b325150..e6d919b 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { DATA_DIR, GROUPS_DIR } from './config.js'; -import { initContainerConfig } from './container-config.js'; +import { ensureContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; @@ -65,12 +65,10 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('CLAUDE.local.md'); } - // groups//container.json — empty container config, replaces the - // former agent_groups.container_config DB column. Self-modification flows - // read and write this file directly. - if (initContainerConfig(group.folder)) { - initialized.push('container.json'); - } + // Ensure container_configs row exists in the DB. Idempotent — no-op if + // the row already exists (e.g. created by backfill or group creation). + ensureContainerConfig(group.id); + initialized.push('container_configs'); // 2. data/v2-sessions//.claude-shared/ — Claude state + per-group skills const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared'); diff --git a/src/index.ts b/src/index.ts index f16992a..6af9b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ */ import path from 'path'; +import { backfillContainerConfigs } from './backfill-container-configs.js'; import { DATA_DIR } from './config.js'; import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; @@ -74,7 +75,11 @@ async function main(): Promise { runMigrations(db); log.info('Central DB ready', { path: dbPath }); - // 1b. One-time filesystem cutover — idempotent, no-op after first run. + // 1b. Backfill container_configs from legacy container.json files. + // Idempotent — skips groups that already have a config row. + backfillContainerConfigs(); + + // 1c. One-time filesystem cutover — idempotent, no-op after first run. migrateGroupsToClaudeLocal(); // 2. Container runtime diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index 5291937..da5b356 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -3,17 +3,16 @@ * * The approvals module calls these when an admin clicks Approve on a * pending_approvals row whose action matches. Each handler mutates the - * container config, rebuilds/kills the container as needed, and lets the - * host sweep respawn it on the new image on the next message. + * container config in the DB, rebuilds/kills the container as needed, + * and lets the host sweep respawn it on the next message. * - * install_packages: rebuild image + kill container (apt/npm global installs - * must be baked into the image layer). - * add_mcp_server: kill container only — bun runs TS directly, so a pure - * MCP wiring change needs nothing more than a process restart. + * install_packages: update DB + rebuild image + kill container. + * add_mcp_server: update DB + kill container only. */ -import { updateContainerConfig } from '../../container-config.js'; import { buildAgentGroupImage, killContainer } from '../../container-runner.js'; import { getAgentGroup } from '../../db/agent-groups.js'; +import { getContainerConfig, updateContainerConfigJson } from '../../db/container-configs.js'; +import type { McpServerConfig } from '../../container-config.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; import type { ApprovalHandler } from '../approvals/index.js'; @@ -24,10 +23,24 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, notify('install_packages approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[])); - if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[])); - }); + + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('install_packages approved but container config missing.'); + return; + } + + // Append new packages to existing lists in the DB + if (payload.apt) { + const existing = JSON.parse(configRow.packages_apt) as string[]; + existing.push(...(payload.apt as string[])); + updateContainerConfigJson(agentGroup.id, 'packages_apt', existing); + } + if (payload.npm) { + const existing = JSON.parse(configRow.packages_npm) as string[]; + existing.push(...(payload.npm as string[])); + updateContainerConfigJson(agentGroup.id, 'packages_npm', existing); + } const pkgs = [ ...((payload.apt as string[] | undefined) || []), @@ -37,8 +50,6 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, try { await buildAgentGroupImage(session.agent_group_id); killContainer(session.id, 'rebuild applied'); - // Schedule a follow-up prompt a few seconds after kill so the host sweep - // respawns the container on the new image and the agent verifies + reports. writeSessionMessage(session.agent_group_id, session.id, { id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', @@ -71,13 +82,21 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use notify('add_mcp_server approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - cfg.mcpServers[payload.name as string] = { - command: payload.command as string, - args: (payload.args as string[]) || [], - env: (payload.env as Record) || {}, - }; - }); + + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('add_mcp_server approved but container config missing.'); + return; + } + + // Add the new MCP server to the existing map in the DB + const servers = JSON.parse(configRow.mcp_servers) as Record; + servers[payload.name as string] = { + command: payload.command as string, + args: (payload.args as string[]) || [], + env: (payload.env as Record) || {}, + }; + updateContainerConfigJson(agentGroup.id, 'mcp_servers', servers); killContainer(session.id, 'mcp server added'); notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); diff --git a/src/types.ts b/src/types.ts index b3e2470..ece7b76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,10 +4,29 @@ export interface AgentGroup { id: string; name: string; folder: string; + /** @deprecated Use container_configs.provider instead. */ agent_provider: string | null; created_at: string; } +/** Per-agent-group container runtime config. Source of truth in the DB; + * materialized to `groups//container.json` at spawn time. */ +export interface ContainerConfigRow { + agent_group_id: string; + provider: string | null; + model: string | null; + effort: string | null; + image_tag: string | null; + assistant_name: string | null; + max_messages_per_prompt: number | null; + skills: string; // JSON: '"all"' | '["skill1","skill2"]' + mcp_servers: string; // JSON: Record + packages_apt: string; // JSON: string[] + packages_npm: string; // JSON: string[] + additional_mounts: string; // JSON: AdditionalMountConfig[] + updated_at: string; +} + export type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public'; export interface MessagingGroup { From 7eebcf74c2f7ea5d4507222efbdd47e50bb0b3fb Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 22:33:42 +0300 Subject: [PATCH 071/105] fix: harden container config DB layer - config-add/remove-package now rebuild image + restart containers - Deduplicate packages in self-mod install_packages handler - Add runtime whitelist guards for SQL column interpolation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/resources/groups.ts | 7 +++++++ src/db/container-configs.ts | 5 +++++ src/modules/self-mod/apply.ts | 10 +++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 3b721d9..73ced9c 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -1,4 +1,5 @@ import type { McpServerConfig } from '../../container-config.js'; +import { buildAgentGroupImage } from '../../container-runner.js'; import { restartAgentGroupContainers } from '../../container-restart.js'; import { getContainerConfig, @@ -186,6 +187,9 @@ registerResource({ } } + await buildAgentGroupImage(id); + restartAgentGroupContainers(id, 'package added via ncl'); + return { added: { apt: apt || null, npm: npm || null } }; }, }, @@ -214,6 +218,9 @@ registerResource({ updateContainerConfigJson(id, 'packages_npm', filtered); } + await buildAgentGroupImage(id); + restartAgentGroupContainers(id, 'package removed via ncl'); + return { removed: { apt: apt || null, npm: npm || null } }; }, }, diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts index 2e1ce9e..207c02b 100644 --- a/src/db/container-configs.ts +++ b/src/db/container-configs.ts @@ -38,6 +38,9 @@ export function ensureContainerConfig(agentGroupId: string): void { .run(agentGroupId, new Date().toISOString()); } +const SCALAR_COLUMNS = new Set(['provider', 'model', 'effort', 'image_tag', 'assistant_name', 'max_messages_per_prompt']); +const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']); + /** Update scalar fields on a config row. Only touches fields present in `updates`. */ export function updateContainerConfigScalars( agentGroupId: string, @@ -53,6 +56,7 @@ export function updateContainerConfigScalars( for (const [key, value] of Object.entries(updates)) { if (value !== undefined) { + if (!SCALAR_COLUMNS.has(key)) throw new Error(`Invalid scalar column: ${key}`); fields.push(`${key} = @${key}`); values[key] = value; } @@ -73,6 +77,7 @@ export function updateContainerConfigJson( column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts', value: unknown, ): void { + if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`); const now = new Date().toISOString(); getDb() .prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`) diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index da5b356..b9753ab 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -30,15 +30,19 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, return; } - // Append new packages to existing lists in the DB + // Append new packages to existing lists in the DB (deduplicated) if (payload.apt) { const existing = JSON.parse(configRow.packages_apt) as string[]; - existing.push(...(payload.apt as string[])); + for (const pkg of payload.apt as string[]) { + if (!existing.includes(pkg)) existing.push(pkg); + } updateContainerConfigJson(agentGroup.id, 'packages_apt', existing); } if (payload.npm) { const existing = JSON.parse(configRow.packages_npm) as string[]; - existing.push(...(payload.npm as string[])); + for (const pkg of payload.npm as string[]) { + if (!existing.includes(pkg)) existing.push(pkg); + } updateContainerConfigJson(agentGroup.id, 'packages_npm', existing); } From 4c83a8193be9e8ee3a90fd670ff9e693bd87103e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 8 May 2026 22:36:58 +0300 Subject: [PATCH 072/105] style: move column whitelist consts to module top Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/container-configs.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts index 207c02b..a401544 100644 --- a/src/db/container-configs.ts +++ b/src/db/container-configs.ts @@ -1,6 +1,16 @@ import type { ContainerConfigRow } from '../types.js'; import { getDb } from './connection.js'; +const SCALAR_COLUMNS = new Set([ + 'provider', + 'model', + 'effort', + 'image_tag', + 'assistant_name', + 'max_messages_per_prompt', +]); +const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']); + export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined { return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as | ContainerConfigRow @@ -38,9 +48,6 @@ export function ensureContainerConfig(agentGroupId: string): void { .run(agentGroupId, new Date().toISOString()); } -const SCALAR_COLUMNS = new Set(['provider', 'model', 'effort', 'image_tag', 'assistant_name', 'max_messages_per_prompt']); -const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']); - /** Update scalar fields on a config row. Only touches fields present in `updates`. */ export function updateContainerConfigScalars( agentGroupId: string, From 78cf2433a33f90455df9b70c64c733b4e178473e Mon Sep 17 00:00:00 2001 From: MoBot Date: Fri, 8 May 2026 16:10:59 -0400 Subject: [PATCH 073/105] fix(container-runner): raise install_packages build timeout to 15min The 5-minute timeout in buildAgentGroupImage was tight for first-time apt + pnpm global installs on slow networks (the exact scenario install_packages triggers, since the image hasn't pre-installed the requested packages). Hit ETIMEDOUT on a real install with apt + npm packages. 900_000ms gives realistic headroom without masking genuinely hung builds. --- src/container-runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 27b0f5c..3ebef1a 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -530,7 +530,7 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, { cwd: DATA_DIR, stdio: 'pipe', - timeout: 300_000, + timeout: 900_000, }); } finally { fs.unlinkSync(tmpDockerfile); From 1efe28ccdc65ffdd20069869e00be0b1e23937ea Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 12:04:45 +0300 Subject: [PATCH 074/105] feat(cli): support space-separated multi-word verbs `ncl groups config get` now works alongside `ncl groups config-get`. Parser joins all positionals with dashes; dispatcher falls back by trimming the last segment as a target ID (`ncl groups get abc123`). Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/cli/ncl.ts | 9 +++------ src/cli/client.ts | 19 +++++-------------- src/cli/dispatch.ts | 20 +++++++++++++++++++- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts index d86c601..c835368 100644 --- a/container/agent-runner/src/cli/ncl.ts +++ b/container/agent-runner/src/cli/ncl.ts @@ -165,12 +165,9 @@ function parseArgv(argv: string[]): { process.exit(2); } - const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0]; - - // Third positional is the target ID - if (positional.length >= 3) { - args.id = positional[2]; - } + // Join all positionals with dashes. The dispatcher trims the last + // segment as a target ID if the full name isn't a registered command. + const command = positional.join('-'); return { command, args, json }; } diff --git a/src/cli/client.ts b/src/cli/client.ts index 98527ed..93ed500 100644 --- a/src/cli/client.ts +++ b/src/cli/client.ts @@ -85,20 +85,11 @@ function parseArgv(argv: string[]): { process.exit(2); } - // Single word: `ncl help` - // Two words: `ncl groups list`, `ncl groups help` - // Three words: `ncl groups get abc123` - let command: string; - if (positional.length === 1) { - command = positional[0]; - } else { - command = `${positional[0]}-${positional[1]}`; - } - - // Third positional is the target ID - if (positional.length >= 3) { - args.id = positional[2]; - } + // Join all positionals with dashes to form the command name. + // If the full name isn't a command, the dispatcher will try trimming + // the last segment and using it as the target ID (e.g. `groups get abc` + // → command "groups-get", id "abc"). + const command = positional.join('-'); return { command, args, json }; } diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 7b247eb..268e4d2 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -13,7 +13,25 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr import { lookup } from './registry.js'; export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise { - const cmd = lookup(req.command); + let cmd = lookup(req.command); + + // Fallback: if the full command isn't registered, trim the last + // dash-segment and treat it as the target ID. This lets clients join + // all positional args with dashes (e.g. `ncl groups get abc123` + // → command "groups-get-abc123" → trim → "groups-get" + id "abc123"). + if (!cmd) { + const idx = req.command.lastIndexOf('-'); + if (idx > 0) { + const shortened = req.command.slice(0, idx); + const tail = req.command.slice(idx + 1); + const fallback = lookup(shortened); + if (fallback) { + cmd = fallback; + req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } }; + } + } + } + if (!cmd) { return err(req.id, 'unknown-command', `no command "${req.command}"`); } From 37b54968ce891eaefef03be469839239d0c8df3e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 12:06:40 +0300 Subject: [PATCH 075/105] refactor(cli): use spaces in custom operation keys Operation keys like 'config get' read naturally and crud.ts normalizes spaces to dashes for the registry name. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/crud.ts | 2 +- src/cli/resources/groups.ts | 14 +++++++------- src/container-restart.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli/crud.ts b/src/cli/crud.ts index 928aeed..9c7ed99 100644 --- a/src/cli/crud.ts +++ b/src/cli/crud.ts @@ -279,7 +279,7 @@ export function registerResource(def: ResourceDef): void { if (def.customOperations) { for (const [verb, op] of Object.entries(def.customOperations)) { register({ - name: `${def.plural}-${verb}`, + name: `${def.plural}-${verb.replace(/ /g, '-')}`, description: op.description, access: op.access, resource: def.plural, diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 73ced9c..dc79e81 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -54,7 +54,7 @@ registerResource({ { name: 'agent_provider', type: 'string', - description: 'Deprecated — use `ncl groups config-update --provider`. Kept for backwards compat.', + description: 'Deprecated — use `ncl groups config update --provider`. Kept for backwards compat.', updatable: false, default: null, }, @@ -62,7 +62,7 @@ registerResource({ ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, customOperations: { - 'config-get': { + 'config get': { access: 'open', description: 'Show the container config for a group. Use --id .', handler: async (args) => { @@ -73,7 +73,7 @@ registerResource({ return presentConfig(row); }, }, - 'config-update': { + 'config update': { access: 'approval', description: 'Update container config scalar fields. Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt.', @@ -110,7 +110,7 @@ registerResource({ return presentConfig(updated); }, }, - 'config-add-mcp-server': { + 'config add-mcp-server': { access: 'approval', description: 'Add an MCP server to a group. Use --id --name --command [--args ] [--env ].', @@ -137,7 +137,7 @@ registerResource({ return { added: name, servers }; }, }, - 'config-remove-mcp-server': { + 'config remove-mcp-server': { access: 'approval', description: 'Remove an MCP server from a group. Use --id --name .', handler: async (args) => { @@ -158,7 +158,7 @@ registerResource({ return { removed: name }; }, }, - 'config-add-package': { + 'config add-package': { access: 'approval', description: 'Add a package to a group. Use --id and --apt or --npm .', handler: async (args) => { @@ -193,7 +193,7 @@ registerResource({ return { added: { apt: apt || null, npm: npm || null } }; }, }, - 'config-remove-package': { + 'config remove-package': { access: 'approval', description: 'Remove a package from a group. Use --id and --apt or --npm .', handler: async (args) => { diff --git a/src/container-restart.ts b/src/container-restart.ts index ff6ff51..74d7d4f 100644 --- a/src/container-restart.ts +++ b/src/container-restart.ts @@ -3,7 +3,7 @@ * * Used by: * - self-mod approval handlers (after config change) - * - ncl config-update (after CLI config change) + * - ncl groups config update (after CLI config change) */ import { killContainer } from './container-runner.js'; import { getSessionsByAgentGroup } from './db/sessions.js'; From 9ce82588d91d20f497d6cb89ffcbf6b52d778d6c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 12:08:18 +0300 Subject: [PATCH 076/105] refactor(cli): remove deprecated agent_provider from groups columns Provider is now managed via `ncl groups config update --provider`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/resources/groups.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index dc79e81..2127e36 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -51,13 +51,6 @@ registerResource({ 'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.', required: true, }, - { - name: 'agent_provider', - type: 'string', - description: 'Deprecated — use `ncl groups config update --provider`. Kept for backwards compat.', - updatable: false, - default: null, - }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, From 08698da0d2e38c95fc769fdd6dd5442ac57c080a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 12:09:11 +0300 Subject: [PATCH 077/105] fix(cli): decouple package commands from docker build config add/remove-package should only update the DB and restart. Image rebuild is handled by the self-mod approval flow or manually. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/resources/groups.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 2127e36..68d1b53 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -1,5 +1,4 @@ import type { McpServerConfig } from '../../container-config.js'; -import { buildAgentGroupImage } from '../../container-runner.js'; import { restartAgentGroupContainers } from '../../container-restart.js'; import { getContainerConfig, @@ -180,10 +179,9 @@ registerResource({ } } - await buildAgentGroupImage(id); restartAgentGroupContainers(id, 'package added via ncl'); - return { added: { apt: apt || null, npm: npm || null } }; + return { added: { apt: apt || null, npm: npm || null }, note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.' }; }, }, 'config remove-package': { @@ -211,10 +209,9 @@ registerResource({ updateContainerConfigJson(id, 'packages_npm', filtered); } - await buildAgentGroupImage(id); restartAgentGroupContainers(id, 'package removed via ncl'); - return { removed: { apt: apt || null, npm: npm || null } }; + return { removed: { apt: apt || null, npm: npm || null }, note: 'Image rebuild required for package changes to take effect.' }; }, }, }, From a84327573eea628b30c9643a934cdff07fd3cf18 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 13:28:07 +0000 Subject: [PATCH 078/105] chore: bump version to 2.0.47 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7aae3df..c098cfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.46", + "version": "2.0.47", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From be3a8a97c6a2b13287b094e07e9c520b9789adb8 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 19:02:15 +0300 Subject: [PATCH 079/105] feat: race-free on-wake messages and explicit restart CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple container restart from config updates — config CLI ops now only write to the DB; restart is a separate `ncl groups restart` command with --rebuild and --message flags. Add on_wake column to messages_in so wake messages are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace window. killContainer accepts an onExit callback for race-free respawn. Agent- called restart auto-scopes to the calling session. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 3 +- container/agent-runner/src/db/messages-in.ts | 7 +- container/agent-runner/src/poll-loop.test.ts | 65 +++++++- container/agent-runner/src/poll-loop.ts | 4 +- src/cli/resources/groups.ts | 61 +++++-- src/container-restart.test.ts | 161 +++++++++++++++++++ src/container-restart.ts | 69 ++++---- src/container-runner.ts | 6 +- src/db/schema.ts | 5 +- src/db/session-db.ts | 15 +- src/modules/self-mod/apply.ts | 40 +++-- src/session-manager.ts | 6 + 12 files changed, 381 insertions(+), 61 deletions(-) create mode 100644 src/container-restart.test.ts diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 871e43a..51a82d7 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -196,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { platform_id TEXT, channel_type TEXT, thread_id TEXT, - content TEXT NOT NULL + content TEXT NOT NULL, + on_wake INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE delivered ( message_out_id TEXT PRIMARY KEY, diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 88906ed..d3a1a33 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -49,7 +49,7 @@ function getMaxMessagesPerPrompt(): number { * sees the prior context it missed. Host's countDueMessages gates waking on * trigger=1 separately (see src/db/session-db.ts). */ -export function getPendingMessages(): MessageInRow[] { +export function getPendingMessages(isFirstPoll = false): MessageInRow[] { const inbound = openInboundDb(); const outbound = getOutboundDb(); @@ -59,10 +59,11 @@ export function getPendingMessages(): MessageInRow[] { `SELECT * FROM messages_in WHERE status = 'pending' AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) + AND (on_wake = 0 OR ?1 = 1) ORDER BY seq DESC - LIMIT ?`, + LIMIT ?2`, ) - .all(getMaxMessagesPerPrompt()) as MessageInRow[]; + .all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[]; if (pending.length === 0) return []; diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 82f9f75..29b769b 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -14,13 +14,18 @@ afterEach(() => { closeSessionDb(); }); -function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) { +function insertMessage( + id: string, + kind: string, + content: object, + opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 }, +) { getInboundDb() .prepare( - `INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content) - VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`, + `INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content) + VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`, ) - .run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content)); + .run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content)); } describe('formatter', () => { @@ -131,6 +136,58 @@ describe('accumulate gate (trigger column)', () => { }); }); +describe('on_wake filtering', () => { + it('first poll returns on_wake=1 messages', () => { + insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(true); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('m1'); + }); + + it('subsequent polls skip on_wake=1 messages', () => { + insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(false); + expect(messages).toHaveLength(0); + }); + + it('normal messages returned regardless of isFirstPoll', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'hello' }); + expect(getPendingMessages(true)).toHaveLength(1); + + // Reset: mark completed so we can re-test with a fresh message + markCompleted(['m1']); + insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' }); + expect(getPendingMessages(false)).toHaveLength(1); + }); + + it('mixed batch: first poll returns both normal and on_wake messages', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' }); + insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(true); + expect(messages).toHaveLength(2); + expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']); + }); + + it('mixed batch: subsequent poll returns only normal messages', () => { + insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' }); + insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 }); + const messages = getPendingMessages(false); + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('m1'); + }); + + it('on_wake defaults to 0 for inserts without explicit value', () => { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, content) + VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`, + ) + .run(); + // Should be returned even on non-first poll (on_wake=0) + expect(getPendingMessages(false)).toHaveLength(1); + }); +}); + describe('routing', () => { it('should extract routing from messages', () => { getInboundDb() diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e0ac722..bbf45be 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -67,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearStaleProcessingAcks(); let pollCount = 0; + let isFirstPoll = true; while (true) { // Skip system messages — they're responses for MCP tools (e.g., ask_user_question) - const messages = getPendingMessages().filter((m) => m.kind !== 'system'); + const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system'); + isFirstPoll = false; pollCount++; // Periodic heartbeat so we know the loop is alive diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 68d1b53..8ea42b0 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -1,5 +1,8 @@ import type { McpServerConfig } from '../../container-config.js'; +import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; import { restartAgentGroupContainers } from '../../container-restart.js'; +import { getSession } from '../../db/sessions.js'; +import { writeSessionMessage } from '../../session-manager.js'; import { getContainerConfig, updateContainerConfigScalars, @@ -54,6 +57,47 @@ registerResource({ ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, customOperations: { + 'restart': { + access: 'approval', + description: + 'Restart containers for a group. Use --id [--rebuild] [--message ]. ' + + 'From inside a container, --id is auto-filled and only the calling session is restarted. ' + + '--rebuild rebuilds the container image first. --message sets an on-wake message for the fresh container; ' + + 'if omitted, containers come back on the next user message.', + handler: async (args, ctx) => { + const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined); + if (!id) throw new Error('--id is required'); + if (args.rebuild) { + await buildAgentGroupImage(id); + } + const message = args.message as string | undefined; + + // From an agent: scope to the calling session only + if (ctx.caller === 'agent') { + if (message) { + writeSessionMessage(id, ctx.sessionId, { + id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }), + onWake: 1, + }); + } + killContainer(ctx.sessionId, 'restarted via ncl', message ? () => { + const s = getSession(ctx.sessionId); + if (s) wakeContainer(s); + } : undefined); + return { restarted: 1, rebuilt: !!args.rebuild }; + } + + // From the host: restart all running containers in the group + const count = restartAgentGroupContainers(id, 'restarted via ncl', message); + return { restarted: count, rebuilt: !!args.rebuild }; + }, + }, 'config get': { access: 'open', description: 'Show the container config for a group. Use --id .', @@ -96,7 +140,6 @@ registerResource({ } updateContainerConfigScalars(id, updates); - restartAgentGroupContainers(id, 'config updated via ncl'); const updated = getContainerConfig(id)!; return presentConfig(updated); @@ -124,7 +167,6 @@ registerResource({ env: args.env ? (JSON.parse(args.env as string) as Record) : {}, }; updateContainerConfigJson(id, 'mcp_servers', servers); - restartAgentGroupContainers(id, `mcp server "${name}" added via ncl`); return { added: name, servers }; }, @@ -145,7 +187,6 @@ registerResource({ if (!servers[name]) throw new Error(`MCP server "${name}" not found`); delete servers[name]; updateContainerConfigJson(id, 'mcp_servers', servers); - restartAgentGroupContainers(id, `mcp server "${name}" removed via ncl`); return { removed: name }; }, @@ -179,9 +220,10 @@ registerResource({ } } - restartAgentGroupContainers(id, 'package added via ncl'); - - return { added: { apt: apt || null, npm: npm || null }, note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.' }; + return { + added: { apt: apt || null, npm: npm || null }, + note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.', + }; }, }, 'config remove-package': { @@ -209,9 +251,10 @@ registerResource({ updateContainerConfigJson(id, 'packages_npm', filtered); } - restartAgentGroupContainers(id, 'package removed via ncl'); - - return { removed: { apt: apt || null, npm: npm || null }, note: 'Image rebuild required for package changes to take effect.' }; + return { + removed: { apt: apt || null, npm: npm || null }, + note: 'Image rebuild required for package changes to take effect.', + }; }, }, }, diff --git a/src/container-restart.test.ts b/src/container-restart.test.ts new file mode 100644 index 0000000..956df63 --- /dev/null +++ b/src/container-restart.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('./log.js', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const mockIsContainerRunning = vi.fn<(id: string) => boolean>(); +const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void) => void>(); +const mockWakeContainer = vi.fn(); +vi.mock('./container-runner.js', () => ({ + isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string), + killContainer: (...args: unknown[]) => mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined), + wakeContainer: (...args: unknown[]) => mockWakeContainer(...args), +})); + +const mockGetSessionsByAgentGroup = vi.fn(); +const mockGetSession = vi.fn(); +vi.mock('./db/sessions.js', () => ({ + getSessionsByAgentGroup: (...args: unknown[]) => mockGetSessionsByAgentGroup(...args), + getSession: (...args: unknown[]) => mockGetSession(...args), +})); + +const mockWriteSessionMessage = vi.fn(); +vi.mock('./session-manager.js', () => ({ + writeSessionMessage: (...args: unknown[]) => mockWriteSessionMessage(...args), +})); + +import { restartAgentGroupContainers } from './container-restart.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Helpers --- + +function makeSession(id: string, agentGroupId: string, status = 'active') { + return { id, agent_group_id: agentGroupId, status }; +} + +// --- Tests --- + +describe('restartAgentGroupContainers', () => { + it('skips sessions without a running container', () => { + mockGetSessionsByAgentGroup.mockReturnValue([ + makeSession('s1', 'g1'), + makeSession('s2', 'g1'), + ]); + mockIsContainerRunning.mockReturnValue(false); + + const count = restartAgentGroupContainers('g1', 'test'); + + expect(count).toBe(0); + expect(mockKillContainer).not.toHaveBeenCalled(); + expect(mockWriteSessionMessage).not.toHaveBeenCalled(); + }); + + it('skips non-active sessions', () => { + mockGetSessionsByAgentGroup.mockReturnValue([ + makeSession('s1', 'g1', 'closed'), + ]); + mockIsContainerRunning.mockReturnValue(true); + + const count = restartAgentGroupContainers('g1', 'test'); + + expect(count).toBe(0); + expect(mockKillContainer).not.toHaveBeenCalled(); + }); + + it('kills running containers and returns count', () => { + mockGetSessionsByAgentGroup.mockReturnValue([ + makeSession('s1', 'g1'), + makeSession('s2', 'g1'), + ]); + mockIsContainerRunning.mockImplementation((id) => id === 's1'); + + const count = restartAgentGroupContainers('g1', 'test'); + + expect(count).toBe(1); + expect(mockKillContainer).toHaveBeenCalledTimes(1); + expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined); + }); + + it('does not write wake message when wakeMessage is omitted', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + + restartAgentGroupContainers('g1', 'test'); + + expect(mockWriteSessionMessage).not.toHaveBeenCalled(); + expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined); + }); + + it('writes on_wake message and passes onExit callback when wakeMessage is provided', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + + restartAgentGroupContainers('g1', 'test', 'Resuming.'); + + // Should write an on-wake message + expect(mockWriteSessionMessage).toHaveBeenCalledTimes(1); + const [agentGroupId, sessionId, msg] = mockWriteSessionMessage.mock.calls[0]; + expect(agentGroupId).toBe('g1'); + expect(sessionId).toBe('s1'); + expect(msg.onWake).toBe(1); + expect(JSON.parse(msg.content).text).toBe('Resuming.'); + + // Should pass an onExit callback to killContainer + expect(mockKillContainer).toHaveBeenCalledTimes(1); + const onExit = mockKillContainer.mock.calls[0][2]; + expect(typeof onExit).toBe('function'); + }); + + it('onExit callback calls wakeContainer with refreshed session', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + const freshSession = makeSession('s1', 'g1'); + mockGetSession.mockReturnValue(freshSession); + + restartAgentGroupContainers('g1', 'test', 'Resuming.'); + + // Simulate container exit by calling the onExit callback + const onExit = mockKillContainer.mock.calls[0][2] as () => void; + onExit(); + + expect(mockGetSession).toHaveBeenCalledWith('s1'); + expect(mockWakeContainer).toHaveBeenCalledWith(freshSession); + }); + + it('onExit callback does not wake if session no longer exists', () => { + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]); + mockIsContainerRunning.mockReturnValue(true); + mockGetSession.mockReturnValue(undefined); + + restartAgentGroupContainers('g1', 'test', 'Resuming.'); + + const onExit = mockKillContainer.mock.calls[0][2] as () => void; + onExit(); + + expect(mockWakeContainer).not.toHaveBeenCalled(); + }); + + it('handles multiple running sessions with wake message', () => { + mockGetSessionsByAgentGroup.mockReturnValue([ + makeSession('s1', 'g1'), + makeSession('s2', 'g1'), + ]); + mockIsContainerRunning.mockReturnValue(true); + + const count = restartAgentGroupContainers('g1', 'test', 'Config updated.'); + + expect(count).toBe(2); + expect(mockKillContainer).toHaveBeenCalledTimes(2); + expect(mockWriteSessionMessage).toHaveBeenCalledTimes(2); + + // Each session gets its own on-wake message + expect(mockWriteSessionMessage.mock.calls[0][1]).toBe('s1'); + expect(mockWriteSessionMessage.mock.calls[1][1]).toBe('s2'); + }); +}); diff --git a/src/container-restart.ts b/src/container-restart.ts index 74d7d4f..6f83531 100644 --- a/src/container-restart.ts +++ b/src/container-restart.ts @@ -1,44 +1,57 @@ /** * Helper to restart all running containers for an agent group. * - * Used by: - * - self-mod approval handlers (after config change) - * - ncl groups config update (after CLI config change) + * Writes an on_wake message to each session, kills the container, then + * wakes a fresh container via the onExit callback — race-free. */ -import { killContainer } from './container-runner.js'; -import { getSessionsByAgentGroup } from './db/sessions.js'; +import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; +import { getSession, getSessionsByAgentGroup } from './db/sessions.js'; import { log } from './log.js'; import { writeSessionMessage } from './session-manager.js'; /** - * Kill all running containers for an agent group and schedule wake messages - * so the host sweep respawns them with fresh config. + * Kill all running containers for an agent group and respawn them. + * + * Only targets sessions that actually have a running container. + * If `wakeMessage` is provided, each session gets an on_wake message + * (picked up only by the fresh container's first poll) and a + * wakeContainer call on exit. Without it, containers are killed and + * only come back on the next real user message. */ -export function restartAgentGroupContainers(agentGroupId: string, reason: string): void { - const sessions = getSessionsByAgentGroup(agentGroupId).filter((s) => s.status === 'active'); +export function restartAgentGroupContainers( + agentGroupId: string, + reason: string, + wakeMessage?: string, +): number { + const sessions = getSessionsByAgentGroup(agentGroupId).filter( + (s) => s.status === 'active' && isContainerRunning(s.id), + ); for (const session of sessions) { - killContainer(session.id, reason); - writeSessionMessage(agentGroupId, session.id, { - id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - kind: 'chat', - timestamp: new Date().toISOString(), - platformId: agentGroupId, - channelType: 'agent', - threadId: null, - content: JSON.stringify({ - text: `Container restarted: ${reason}. Resuming.`, - sender: 'system', - senderId: 'system', - }), - processAfter: new Date(Date.now() + 5000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ''), - }); + if (wakeMessage) { + writeSessionMessage(agentGroupId, session.id, { + id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: agentGroupId, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: wakeMessage, + sender: 'system', + senderId: 'system', + }), + onWake: 1, + }); + } + killContainer(session.id, reason, wakeMessage ? () => { + const s = getSession(session.id); + if (s) wakeContainer(s); + } : undefined); } if (sessions.length > 0) { - log.info('Restarted agent group containers', { agentGroupId, reason, count: sessions.length }); + log.info('Restarting agent group containers', { agentGroupId, reason, count: sessions.length }); } + return sessions.length; } diff --git a/src/container-runner.ts b/src/container-runner.ts index cdf93f2..903bd8b 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -190,10 +190,14 @@ async function spawnContainer(session: Session): Promise { } /** Kill a container for a session. */ -export function killContainer(sessionId: string, reason: string): void { +export function killContainer(sessionId: string, reason: string, onExit?: () => void): void { const entry = activeContainers.get(sessionId); if (!entry) return; + if (onExit) { + entry.process.once('close', onExit); + } + log.info('Killing container', { sessionId, reason, containerName: entry.containerName }); try { stopContainer(entry.containerName); diff --git a/src/db/schema.ts b/src/db/schema.ts index 48d9ce3..533ec51 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -177,7 +177,10 @@ CREATE TABLE IF NOT EXISTS messages_in ( -- 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 + source_session_id TEXT, + on_wake INTEGER NOT NULL DEFAULT 0 + -- 1 = only deliver on the container's first poll (fresh start). + -- Dying containers (past first poll) skip these rows. ); CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id); diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 6713702..15ba0e4 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -114,14 +114,20 @@ export function insertMessage( * path for the target's reply. NULL on channel-side inbound. */ sourceSessionId?: string | null; + /** + * 1 = only deliver on the container's first poll (fresh start). + * Dying containers (past first poll) skip these rows. + */ + onWake?: 0 | 1; }, ): 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, source_session_id) - VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId)`, + `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, on_wake) + VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId, @onWake)`, ).run({ ...message, trigger: message.trigger ?? 1, + onWake: message.onWake ?? 0, sourceSessionId: message.sourceSessionId ?? null, seq: nextEvenSeq(db), }); @@ -318,6 +324,11 @@ export function migrateMessagesInTable(db: Database.Database): void { // their replies fall back to the legacy "newest active session" lookup. db.prepare('ALTER TABLE messages_in ADD COLUMN source_session_id TEXT').run(); } + if (!cols.has('on_wake')) { + // 1 = only deliver on the container's first poll (fresh start). + // All existing rows are normal messages, so default 0. + db.prepare('ALTER TABLE messages_in ADD COLUMN on_wake INTEGER NOT NULL DEFAULT 0').run(); + } } /** diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index b9753ab..c5318ff 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -4,14 +4,16 @@ * The approvals module calls these when an admin clicks Approve on a * pending_approvals row whose action matches. Each handler mutates the * container config in the DB, rebuilds/kills the container as needed, - * and lets the host sweep respawn it on the next message. + * and writes an on_wake message so the fresh container picks up where + * the old one left off. * - * install_packages: update DB + rebuild image + kill container. - * add_mcp_server: update DB + kill container only. + * install_packages: update DB + rebuild image + kill container + on_wake. + * add_mcp_server: update DB + kill container + on_wake. */ -import { buildAgentGroupImage, killContainer } from '../../container-runner.js'; +import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; import { getAgentGroup } from '../../db/agent-groups.js'; import { getContainerConfig, updateContainerConfigJson } from '../../db/container-configs.js'; +import { getSession } from '../../db/sessions.js'; import type { McpServerConfig } from '../../container-config.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; @@ -53,7 +55,6 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, log.info('Package install approved', { agentGroupId: session.agent_group_id, userId }); try { await buildAgentGroupImage(session.agent_group_id); - killContainer(session.id, 'rebuild applied'); writeSessionMessage(session.agent_group_id, session.id, { id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', @@ -66,10 +67,11 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, sender: 'system', senderId: 'system', }), - processAfter: new Date(Date.now() + 5000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ''), + onWake: 1, + }); + killContainer(session.id, 'rebuild applied', () => { + const s = getSession(session.id); + if (s) wakeContainer(s); }); log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id }); } catch (e) { @@ -102,7 +104,23 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use }; updateContainerConfigJson(agentGroup.id, 'mcp_servers', servers); - killContainer(session.id, 'mcp server added'); - notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); + writeSessionMessage(session.agent_group_id, session.id, { + id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: `MCP server "${payload.name}" added. Verify it's available (e.g. list your tools) and report the result to the user.`, + sender: 'system', + senderId: 'system', + }), + onWake: 1, + }); + killContainer(session.id, 'mcp server added', () => { + const s = getSession(session.id); + if (s) wakeContainer(s); + }); log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId }); }; diff --git a/src/session-manager.ts b/src/session-manager.ts index 5c423ea..38c77f2 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -216,6 +216,11 @@ export function writeSessionMessage( * path so the target's reply routes back to that exact session. */ sourceSessionId?: string | null; + /** + * 1 = only deliver on the container's first poll (fresh start). + * Dying containers (past first poll) skip these rows. + */ + onWake?: 0 | 1; }, ): void { // Extract base64 attachment data, save to inbox, replace with file paths @@ -235,6 +240,7 @@ export function writeSessionMessage( recurrence: message.recurrence ?? null, trigger: message.trigger ?? 1, sourceSessionId: message.sourceSessionId ?? null, + onWake: message.onWake ?? 0, }); } finally { db.close(); From aebcffe1807a9e5c0813fa784b3757b8428878c7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:02:31 +0300 Subject: [PATCH 080/105] feat: per-group CLI scope (disabled/group/global) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cli_scope column to container_configs with three levels: - disabled: agent never learns about ncl (instructions excluded from CLAUDE.md) and host dispatch rejects any cli_request - group (default): agent can only access groups, sessions, destinations, and members resources, scoped to its own agent group with auto-filled --id/--agent_group_id/--group args. Help output reflects the scope. - global: unrestricted access (current behavior) Enforcement is host-side only — no image rebuild or env var needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backfill-container-configs.ts | 1 + src/claude-md-compose.ts | 4 +- src/cli/commands/help.ts | 45 ++++- src/cli/dispatch.test.ts | 304 +++++++++++++++++++++++++++++ src/cli/dispatch.ts | 41 ++++ src/cli/frame.ts | 1 + src/cli/resources/groups.ts | 30 ++- src/container-restart.test.ts | 22 +-- src/container-restart.ts | 20 +- src/container-runner.ts | 1 - src/db/container-configs.ts | 3 +- src/db/migrations/015-cli-scope.ts | 10 + src/db/migrations/index.ts | 2 + src/types.ts | 1 + 14 files changed, 443 insertions(+), 42 deletions(-) create mode 100644 src/cli/dispatch.test.ts create mode 100644 src/db/migrations/015-cli-scope.ts diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts index b046c3c..5551c90 100644 --- a/src/backfill-container-configs.ts +++ b/src/backfill-container-configs.ts @@ -64,6 +64,7 @@ export function backfillContainerConfigs(): void { packages_apt: JSON.stringify(legacy.packages?.apt ?? []), packages_npm: JSON.stringify(legacy.packages?.npm ?? []), additional_mounts: JSON.stringify(legacy.additionalMounts ?? []), + cli_scope: 'group', updated_at: new Date().toISOString(), }; diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts index 64ad799..285f79a 100644 --- a/src/claude-md-compose.ts +++ b/src/claude-md-compose.ts @@ -79,13 +79,15 @@ export function composeGroupClaudeMd(group: AgentGroup): void { // Built-in module fragments — every MCP tool source file that ships a // sibling `.instructions.md`. These describe how the agent should // use that module's MCP tools (schedule_task, install_packages, etc.). - // Always included — these are built-in, not toggleable. + // Skip cli.instructions.md when cli_scope is disabled. + const cliDisabled = configRow?.cli_scope === 'disabled'; const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH); if (fs.existsSync(mcpToolsHostDir)) { for (const entry of fs.readdirSync(mcpToolsHostDir)) { const match = entry.match(/^(.+)\.instructions\.md$/); if (!match) continue; const moduleName = match[1]; + if (moduleName === 'cli' && cliDisabled) continue; desired.set(`module-${moduleName}.md`, { type: 'symlink', content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`, diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index d50eaef..138f05f 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -4,19 +4,38 @@ * ncl help — list all resources and commands * ncl groups help — show group resource details (verbs, columns, enums) */ +import { getContainerConfig } from '../../db/container-configs.js'; import { getResource, getResources } from '../crud.js'; +import type { CallerContext } from '../frame.js'; import { listCommands, register } from '../registry.js'; +const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']); + +function getCliScope(ctx: CallerContext): string | undefined { + if (ctx.caller !== 'agent') return undefined; + return getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group'; +} + register({ name: 'help', description: 'List available resources and commands.', access: 'open', parseArgs: () => ({}), - handler: async () => { - const resources = getResources(); + handler: async (_args, ctx) => { + const cliScope = getCliScope(ctx); + let resources = getResources(); + if (cliScope === 'group') { + resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural)); + } const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource); const lines: string[] = []; + + if (cliScope === 'group') { + lines.push('CLI scope: group (--id and group args are auto-filled to your agent group)'); + lines.push(''); + } + if (resources.length > 0) { lines.push('Resources:'); for (const r of resources) { @@ -61,18 +80,28 @@ export function registerResourceHelpCommands(): void { access: 'open', resource: res.plural, parseArgs: () => ({}), - handler: async () => { + handler: async (_args, ctx) => { + const cliScope = getCliScope(ctx); const lines: string[] = []; lines.push(`${res.plural}: ${res.description}`); + + if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) { + lines.push(''); + lines.push('Note: --id and group args are auto-filled to your agent group. You do not need to pass them.'); + } + lines.push(''); // Verbs + const idAutoFilled = + cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations'); + const idHint = idAutoFilled ? '' : ' '; const verbs: string[] = []; if (res.operations.list) verbs.push(`list [open]`); - if (res.operations.get) verbs.push(`get [open]`); + if (res.operations.get) verbs.push(`get${idHint} [open]`); if (res.operations.create) verbs.push(`create [approval]`); - if (res.operations.update) verbs.push(`update [approval]`); - if (res.operations.delete) verbs.push(`delete [approval]`); + if (res.operations.update) verbs.push(`update${idHint} [approval]`); + if (res.operations.delete) verbs.push(`delete${idHint} [approval]`); if (res.customOperations) { for (const [verb, op] of Object.entries(res.customOperations)) { verbs.push(`${verb} [${op.access}] — ${op.description}`); @@ -83,9 +112,13 @@ export function registerResourceHelpCommands(): void { lines.push(''); // Columns + const autoFilledFields = cliScope === 'group' + ? new Set(['id', 'agent_group_id', 'group']) + : new Set(); lines.push('Fields:'); for (const col of res.columns) { const tags: string[] = []; + if (autoFilledFields.has(col.name)) tags.push('auto-filled'); if (col.generated) tags.push('auto'); if (col.required) tags.push('required'); if (col.updatable) tags.push('updatable'); diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts new file mode 100644 index 0000000..9eb63af --- /dev/null +++ b/src/cli/dispatch.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('../log.js', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const mockGetContainerConfig = vi.fn(); +vi.mock('../db/container-configs.js', () => ({ + getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args), +})); + +const mockGetAgentGroup = vi.fn(); +vi.mock('../db/agent-groups.js', () => ({ + getAgentGroup: (...args: unknown[]) => mockGetAgentGroup(...args), +})); + +const mockGetSession = vi.fn(); +vi.mock('../db/sessions.js', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})); + +vi.mock('../modules/approvals/index.js', () => ({ + registerApprovalHandler: vi.fn(), + requestApproval: vi.fn(), +})); + +// Register a test command so dispatch has something to find +import { register } from './registry.js'; + +register({ + name: 'test-cmd', + description: 'test command (non-group resource)', + resource: 'test', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'groups-test', + description: 'test command (groups resource)', + resource: 'groups', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'general-cmd', + description: 'test command (no resource, like help)', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'sessions-list', + description: 'test command (sessions resource)', + resource: 'sessions', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'destinations-list', + description: 'test command (destinations resource)', + resource: 'destinations', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'members-add', + description: 'test command (members resource)', + resource: 'members', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +register({ + name: 'wirings-list', + description: 'test command (wirings resource — not allowed)', + resource: 'wirings', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ echo: args }), +}); + +import { dispatch } from './dispatch.js'; +import type { CallerContext } from './frame.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Helpers --- + +function agentCtx(overrides?: Partial>): CallerContext { + return { + caller: 'agent', + sessionId: 's1', + agentGroupId: 'g1', + messagingGroupId: 'mg1', + ...overrides, + }; +} + +// --- Tests --- + +describe('CLI scope enforcement', () => { + it('disabled: rejects all CLI requests from agent', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('disabled'); + } + }); + + it('group: auto-fills --id with caller agent group', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.id).toBe('g1'); + } + }); + + it('group: rejects cross-group access', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'other-group' } }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('scoped'); + } + }); + + it('group: allows same-group id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'g1' } }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('group: blocks non-group resources', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('test'); + } + }); + + it('group: allows general commands with no resource (e.g. help)', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'general-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('group: allows sessions, auto-fills --agent_group_id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'sessions-list', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.agent_group_id).toBe('g1'); + // --id should NOT be auto-filled for sessions (it's session UUID, not group) + expect(data.echo.id).toBeUndefined(); + } + }); + + it('group: allows destinations, auto-fills --id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'destinations-list', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.id).toBe('g1'); + } + }); + + it('group: allows members, auto-fills --group', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'members-add', args: { user: 'u1' } }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.group).toBe('g1'); + } + }); + + it('group: blocks non-whitelisted resources (wirings)', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('wirings'); + } + }); + + it('group: rejects cross-group --agent_group_id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'sessions-list', args: { agent_group_id: 'other-group' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + + it('group: rejects cross-group --group', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'members-add', args: { user: 'u1', group: 'other-group' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + + it('global: allows cross-group access', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'other-group' } }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('global: allows non-group resources', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + }); + + it('global: does not auto-fill --id', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: { foo: 'bar' } }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as { echo: Record }; + expect(data.echo.id).toBeUndefined(); + } + }); + + it('defaults to group when cli_scope is missing', async () => { + mockGetContainerConfig.mockReturnValue({}); + + const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx()); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + + it('host caller bypasses CLI scope enforcement', async () => { + // No config check should happen for host callers + const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'any-group' } }, { caller: 'host' }); + + expect(resp.ok).toBe(true); + expect(mockGetContainerConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 268e4d2..5ec6843 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -6,6 +6,7 @@ * Approval gating for risky calls from the container is the only branch * that differs by caller. Host callers and `open` commands run inline. */ +import { getContainerConfig } from '../db/container-configs.js'; import { getAgentGroup } from '../db/agent-groups.js'; import { getSession } from '../db/sessions.js'; import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js'; @@ -36,6 +37,46 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise = { + agent_group_id: req.args.agent_group_id ?? ctx.agentGroupId, + group: req.args.group ?? ctx.agentGroupId, + }; + // Only auto-fill --id for resources where it IS the agent group ID + // (groups, destinations). For sessions/members --id is a different key. + if (cmd.resource === 'groups' || cmd.resource === 'destinations') { + fill.id = req.args.id ?? ctx.agentGroupId; + } + req = { ...req, args: { ...req.args, ...fill } }; + } + } + if (ctx.caller !== 'host' && cmd.access === 'approval') { const session = getSession(ctx.sessionId); if (!session) { diff --git a/src/cli/frame.ts b/src/cli/frame.ts index 8e7604a..67cd61c 100644 --- a/src/cli/frame.ts +++ b/src/cli/frame.ts @@ -25,6 +25,7 @@ export type ErrorCode = | 'unknown-command' | 'invalid-args' | 'permission-denied' + | 'forbidden' | 'approval-pending' | 'not-found' | 'handler-error' diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 8ea42b0..c6a19ec 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -26,6 +26,7 @@ function presentConfig(row: ContainerConfigRow): Record { packages_apt: JSON.parse(row.packages_apt), packages_npm: JSON.parse(row.packages_npm), additional_mounts: JSON.parse(row.additional_mounts), + cli_scope: row.cli_scope, updated_at: row.updated_at, }; } @@ -57,7 +58,7 @@ registerResource({ ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, customOperations: { - 'restart': { + restart: { access: 'approval', description: 'Restart containers for a group. Use --id [--rebuild] [--message ]. ' + @@ -86,10 +87,16 @@ registerResource({ onWake: 1, }); } - killContainer(ctx.sessionId, 'restarted via ncl', message ? () => { - const s = getSession(ctx.sessionId); - if (s) wakeContainer(s); - } : undefined); + killContainer( + ctx.sessionId, + 'restarted via ncl', + message + ? () => { + const s = getSession(ctx.sessionId); + if (s) wakeContainer(s); + } + : undefined, + ); return { restarted: 1, rebuilt: !!args.rebuild }; } @@ -112,7 +119,7 @@ registerResource({ 'config update': { access: 'approval', description: - 'Update container config scalar fields. Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt.', + 'Update container config scalar fields. Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); @@ -122,7 +129,7 @@ registerResource({ const updates: Partial< Pick< ContainerConfigRow, - 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope' > > = {}; if (args.provider !== undefined) updates.provider = args.provider as string; @@ -132,10 +139,17 @@ registerResource({ if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string; if (args.max_messages_per_prompt !== undefined) updates.max_messages_per_prompt = Number(args.max_messages_per_prompt); + if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) { + const scope = (args['cli-scope'] ?? args.cli_scope) as string; + if (!['disabled', 'group', 'global'].includes(scope)) { + throw new Error('--cli-scope must be one of: disabled, group, global'); + } + updates.cli_scope = scope; + } if (Object.keys(updates).length === 0) { throw new Error( - 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt', + 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope', ); } diff --git a/src/container-restart.test.ts b/src/container-restart.test.ts index 956df63..d07d17f 100644 --- a/src/container-restart.test.ts +++ b/src/container-restart.test.ts @@ -11,7 +11,8 @@ const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void const mockWakeContainer = vi.fn(); vi.mock('./container-runner.js', () => ({ isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string), - killContainer: (...args: unknown[]) => mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined), + killContainer: (...args: unknown[]) => + mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined), wakeContainer: (...args: unknown[]) => mockWakeContainer(...args), })); @@ -43,10 +44,7 @@ function makeSession(id: string, agentGroupId: string, status = 'active') { describe('restartAgentGroupContainers', () => { it('skips sessions without a running container', () => { - mockGetSessionsByAgentGroup.mockReturnValue([ - makeSession('s1', 'g1'), - makeSession('s2', 'g1'), - ]); + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]); mockIsContainerRunning.mockReturnValue(false); const count = restartAgentGroupContainers('g1', 'test'); @@ -57,9 +55,7 @@ describe('restartAgentGroupContainers', () => { }); it('skips non-active sessions', () => { - mockGetSessionsByAgentGroup.mockReturnValue([ - makeSession('s1', 'g1', 'closed'), - ]); + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1', 'closed')]); mockIsContainerRunning.mockReturnValue(true); const count = restartAgentGroupContainers('g1', 'test'); @@ -69,10 +65,7 @@ describe('restartAgentGroupContainers', () => { }); it('kills running containers and returns count', () => { - mockGetSessionsByAgentGroup.mockReturnValue([ - makeSession('s1', 'g1'), - makeSession('s2', 'g1'), - ]); + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]); mockIsContainerRunning.mockImplementation((id) => id === 's1'); const count = restartAgentGroupContainers('g1', 'test'); @@ -142,10 +135,7 @@ describe('restartAgentGroupContainers', () => { }); it('handles multiple running sessions with wake message', () => { - mockGetSessionsByAgentGroup.mockReturnValue([ - makeSession('s1', 'g1'), - makeSession('s2', 'g1'), - ]); + mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]); mockIsContainerRunning.mockReturnValue(true); const count = restartAgentGroupContainers('g1', 'test', 'Config updated.'); diff --git a/src/container-restart.ts b/src/container-restart.ts index 6f83531..e09d6f3 100644 --- a/src/container-restart.ts +++ b/src/container-restart.ts @@ -18,11 +18,7 @@ import { writeSessionMessage } from './session-manager.js'; * wakeContainer call on exit. Without it, containers are killed and * only come back on the next real user message. */ -export function restartAgentGroupContainers( - agentGroupId: string, - reason: string, - wakeMessage?: string, -): number { +export function restartAgentGroupContainers(agentGroupId: string, reason: string, wakeMessage?: string): number { const sessions = getSessionsByAgentGroup(agentGroupId).filter( (s) => s.status === 'active' && isContainerRunning(s.id), ); @@ -44,10 +40,16 @@ export function restartAgentGroupContainers( onWake: 1, }); } - killContainer(session.id, reason, wakeMessage ? () => { - const s = getSession(session.id); - if (s) wakeContainer(s); - } : undefined); + killContainer( + session.id, + reason, + wakeMessage + ? () => { + const s = getSession(session.id); + if (s) wakeContainer(s); + } + : undefined, + ); } if (sessions.length > 0) { diff --git a/src/container-runner.ts b/src/container-runner.ts index 903bd8b..d529c9c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -473,7 +473,6 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise if (!configRow) throw new Error('Container config not found'); const aptPackages = JSON.parse(configRow.packages_apt) as string[]; const npmPackages = JSON.parse(configRow.packages_npm) as string[]; - if (aptPackages.length === 0 && npmPackages.length === 0) { throw new Error('No packages to install. Use install_packages first.'); } diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts index a401544..219c73f 100644 --- a/src/db/container-configs.ts +++ b/src/db/container-configs.ts @@ -8,6 +8,7 @@ const SCALAR_COLUMNS = new Set([ 'image_tag', 'assistant_name', 'max_messages_per_prompt', + 'cli_scope', ]); const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']); @@ -54,7 +55,7 @@ export function updateContainerConfigScalars( updates: Partial< Pick< ContainerConfigRow, - 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' + 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope' > >, ): void { diff --git a/src/db/migrations/015-cli-scope.ts b/src/db/migrations/015-cli-scope.ts new file mode 100644 index 0000000..6c0c7dd --- /dev/null +++ b/src/db/migrations/015-cli-scope.ts @@ -0,0 +1,10 @@ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration015: Migration = { + version: 15, + name: 'cli-scope', + up(db: Database.Database) { + db.prepare("ALTER TABLE container_configs ADD COLUMN cli_scope TEXT NOT NULL DEFAULT 'group'").run(); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index a181cb3..0cefb37 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -11,6 +11,7 @@ import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; import { migration013 } from './013-approval-render-metadata.js'; import { migration014 } from './014-container-configs.js'; +import { migration015 } from './015-cli-scope.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -33,6 +34,7 @@ const migrations: Migration[] = [ migration012, migration013, migration014, + migration015, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/types.ts b/src/types.ts index ece7b76..26a40f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,7 @@ export interface ContainerConfigRow { packages_apt: string; // JSON: string[] packages_npm: string; // JSON: string[] additional_mounts: string; // JSON: AdditionalMountConfig[] + cli_scope: string; // 'disabled' | 'group' | 'global' updated_at: string; } From 04e41fb0ef21e248cf3d45fcbbfde2d5fe1b9d58 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:09:05 +0300 Subject: [PATCH 081/105] feat: default owner agent group to global CLI scope When init-first-agent creates an agent group for an owner, set cli_scope to 'global' so the owner's personal agent has full ncl access. All other agent groups remain 'group'-scoped by default. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/init-first-agent.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 61a17d6..461e407 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -47,6 +47,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio import { addMember } from '../src/modules/permissions/db/agent-group-members.js'; import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; +import { updateContainerConfigScalars } from '../src/db/container-configs.js'; import { initGroupFilesystem } from '../src/group-init.js'; import { namespacedPlatformId } from '../src/platform-id.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; @@ -231,6 +232,8 @@ async function main(): Promise { granted_at: now, }); } + // Owner's agent group gets global CLI access + updateContainerConfigScalars(ag.id, { cli_scope: 'global' }); } else if (args.role === 'admin') { const alreadyAdmin = existingRoles.some( (r) => r.role === 'admin' && r.agent_group_id === ag.id, From faeeba198eb23f0bf3ef90de90fe9ae35a4710b9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:17:13 +0300 Subject: [PATCH 082/105] fix(security): block cli_scope escalation and cross-group data leaks Group-scoped agents could previously: - See all agent groups via `groups list` (generic list skips --id filter) - Look up any session by UUID via `sessions get` - Request cli_scope change to global via config update approval Fixed by: - Post-handler filtering: list results filtered, get results verified against caller's agent_group_id - Pre-handler --id check scoped to resources where id IS the group ID (groups, destinations) so session UUIDs aren't falsely rejected - cli_scope/cli-scope args blocked outright for group-scoped agents, before the approval gate Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/dispatch.test.ts | 107 +++++++++++++++++++++++++++++++++++++++ src/cli/dispatch.ts | 34 ++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts index 9eb63af..bb1afa0 100644 --- a/src/cli/dispatch.test.ts +++ b/src/cli/dispatch.test.ts @@ -91,6 +91,31 @@ register({ handler: async (args) => ({ echo: args }), }); +// Commands that return data shaped like real resources (for post-handler filtering tests) +register({ + name: 'groups-list-data', + description: 'returns mock group rows', + resource: 'groups', + access: 'open', + parseArgs: (raw) => raw, + handler: async () => [ + { id: 'g1', name: 'my-group' }, + { id: 'g2', name: 'other-group' }, + ], +}); + +register({ + name: 'sessions-get-data', + description: 'returns a mock session row', + resource: 'sessions', + access: 'open', + parseArgs: (raw) => raw, + handler: async (args) => ({ + id: args.id, + agent_group_id: (args as Record).belongs_to ?? 'g1', + }), +}); + import { dispatch } from './dispatch.js'; import type { CallerContext } from './frame.js'; @@ -157,6 +182,35 @@ describe('CLI scope enforcement', () => { expect(resp.ok).toBe(true); }); + it('group: blocks cli_scope escalation', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('cli_scope'); + } + }); + + it('group: blocks cli-scope escalation (hyphenated)', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + } + }); + it('group: blocks non-group resources', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); @@ -301,4 +355,57 @@ describe('CLI scope enforcement', () => { expect(resp.ok).toBe(true); expect(mockGetContainerConfig).not.toHaveBeenCalled(); }); + + // --- Post-handler filtering --- + + it('group: groups list filters out other groups', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as Array<{ id: string }>; + expect(data).toHaveLength(1); + expect(data[0].id).toBe('g1'); + } + }); + + it('group: sessions get rejects cross-group session', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'other-group' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(false); + if (!resp.ok) { + expect(resp.error.code).toBe('forbidden'); + expect(resp.error.message).toContain('different agent group'); + } + }); + + it('group: sessions get allows own-group session', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); + + const resp = await dispatch( + { id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'g1' } }, + agentCtx(), + ); + + expect(resp.ok).toBe(true); + }); + + it('global: no post-handler filtering', async () => { + mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); + + const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx()); + + expect(resp.ok).toBe(true); + if (resp.ok) { + const data = resp.data as Array<{ id: string }>; + expect(data).toHaveLength(2); // both groups returned + } + }); }); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 5ec6843..3098daa 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -55,12 +55,21 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise typeof row === 'object' && row !== null && (row as Record)[groupField] === ctx.agentGroupId, + ); + } else if (data && typeof data === 'object' && groupField in (data as Record)) { + if ((data as Record)[groupField] !== ctx.agentGroupId) { + return err(req.id, 'forbidden', 'Resource belongs to a different agent group.'); + } + } + } + } + return { id: req.id, ok: true, data }; } catch (e) { return err(req.id, 'handler-error', errMsg(e)); From 1c7623ca4120104fe2f32afbe93471a19bf35ac7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:23:44 +0300 Subject: [PATCH 083/105] docs: update for container config DB, on-wake, and CLI scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: new key files, updated groups verbs, rewritten self-mod section, new Container Config and Container Restart sections - db-central.md: container_configs table (§1.15), migrations 014+015 - db-session.md: messages_in schema with trigger, source_session_id, on_wake columns - schema.ts: comment no longer references disk-based config - cli.instructions.md: rewritten for scope-aware usage, auto-fill, restart/config ops, group-scoped examples Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 31 ++++++++- .../src/mcp-tools/cli.instructions.md | 69 ++++++++++--------- docs/db-central.md | 30 +++++++- docs/db-session.md | 29 ++++---- src/db/schema.ts | 3 +- 5 files changed, 109 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e941490..1cf7e6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,10 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t | `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge | | `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache | | `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) | -| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations | +| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) | +| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup | +| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers | +| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations | | `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch | | `src/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 | @@ -93,7 +96,7 @@ ncl help | Resource | Verbs | What it is | |----------|-------|------------| -| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | +| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) | | messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | | wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | | users | list, get, create, update | Platform identities (`:`) | @@ -120,10 +123,32 @@ Each `/add-` skill is idempotent: `git fetch origin ` → copy mod One tier of agent self-modification today: -1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`. +1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`. A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented. +## Container Config + +Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups//container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools. + +**`cli_scope`** — controls what the agent can do with `ncl` from inside the container: + +| Value | Behavior | +|-------|----------| +| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. | +| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. | +| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. | + +Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion). + +## Container Restart + +`ncl groups restart --id [--rebuild] [--message ]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted. + +The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns. + +Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`). + ## 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. 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`. diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index 9dee60f..6a4f72e 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -1,49 +1,51 @@ ## Admin CLI (`ncl`) -The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration — agent groups, messaging groups, wirings, users, roles, and more. +The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration. ### Usage ``` -ncl [] [--flags] +ncl [--flags] ncl help ncl help ``` +### Scope + +Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them. + ### Resources +Run `ncl help` for the full list. Common resources: + | Resource | Verbs | What it is | |----------|-------|------------| -| groups | list, get, create, update, delete | Agent groups (workspace, personality, container config) | -| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform | -| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) | -| users | list, get, create, update | Platform identities (`:`) | -| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) | -| members | list, add, remove | Unprivileged access gate for an agent group | -| destinations | list, add, remove | Where an agent group can send messages | +| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) | | sessions | list, get | Active sessions (read-only) | -| user-dms | list | Cold-DM cache (read-only) | -| dropped-messages | list | Messages from unregistered senders (read-only) | -| approvals | list, get | Pending approval requests (read-only) | +| destinations | list, add, remove | Where an agent group can send messages | +| members | list, add, remove | Unprivileged access gate for an agent group | + +Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals. ### When to use -- **Looking up your own config** — `ncl groups get ` to see your agent group settings. -- **Finding who you're wired to** — `ncl wirings list` to see which messaging groups route to which agent groups. -- **Checking user roles** — `ncl roles list` to see who is an owner/admin. -- **Answering questions about the system** — when the user asks about groups, channels, users, or configuration, query `ncl` rather than guessing. +- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config. +- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`). +- **Checking who's in your group** — `ncl members list`. +- **Seeing your destinations** — `ncl destinations list`. +- **Answering questions about the system** — query `ncl` rather than guessing. ### Access rules -Read commands (list, get) are open. Write commands (create, update, delete, grant, revoke, add, remove) require admin approval — the request is held until an admin approves it. +Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. ### Approval flow -Write commands (create, update, delete, grant, revoke, add, remove) require admin approval. Here's what happens: +Write commands require admin approval. Here's what happens: -1. You run the command (e.g. `ncl groups create --name "Research" --folder research`). +1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`). 2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet. -3. An admin or owner gets a notification (on the same channel when possible) showing exactly what you requested, with approve/reject options. +3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options. 4. Once the admin responds: - **Approved:** the command executes and the result is delivered back to you as a system message in this conversation. - **Rejected:** you get a system message saying the request was rejected. @@ -54,25 +56,24 @@ You don't need to poll or retry — the result arrives automatically. ```bash # Read commands (no approval needed) -ncl groups list -ncl groups get abc123 -ncl wirings list --messaging-group-id mg_xyz -ncl roles list -ncl wirings help +ncl groups get +ncl groups config get +ncl sessions list +ncl destinations list +ncl members list # Write commands (approval required) -ncl groups create --name "Research" --folder research -ncl groups update abc123 --name "Research v2" -ncl roles grant --user telegram:jane --role admin -ncl roles grant --user discord:bob --role admin --group abc123 -ncl members add --user-id telegram:jane --agent-group-id abc123 -ncl destinations add --agent-group-id abc123 --messaging-group-id mg_xyz +ncl groups restart +ncl groups restart --rebuild --message "Config updated." +ncl groups config update --model claude-sonnet-4-5-20250514 +ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]' +ncl groups config add-package --npm some-package +ncl members add --user telegram:jane ``` ### Tips -- Use `ncl help` to see all available fields, types, enums, and which fields are required or updatable. +- Use `ncl help` to see all available fields, types, enums, and which fields are auto-filled. - Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically. -- `list` supports filtering by any non-auto column (e.g. `ncl wirings list --messaging-group-id mg_xyz`). Default limit is 200 rows; override with `--limit N`. -- For composite-key resources (roles, members, destinations), use the custom verbs (grant/revoke, add/remove) instead of create/delete. +- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`. - Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result. diff --git a/docs/db-central.md b/docs/db-central.md index 8268acf..75c27f3 100644 --- a/docs/db-central.md +++ b/docs/db-central.md @@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com ### 1.1 `agent_groups` -Agent workspaces. Each maps 1:1 to a `groups//` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB. +Agent workspaces. Each maps 1:1 to a `groups//` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read. ```sql CREATE TABLE agent_groups ( @@ -294,6 +294,32 @@ CREATE TABLE schema_version ( ); ``` +### 1.15 `container_configs` + +Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups//container.json` at spawn time. + +```sql +CREATE TABLE container_configs ( + agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE, + provider TEXT, + model TEXT, + effort TEXT, + image_tag TEXT, + assistant_name TEXT, + max_messages_per_prompt INTEGER, + skills TEXT NOT NULL DEFAULT '"all"', + mcp_servers TEXT NOT NULL DEFAULT '{}', + packages_apt TEXT NOT NULL DEFAULT '[]', + packages_npm TEXT NOT NULL DEFAULT '[]', + additional_mounts TEXT NOT NULL DEFAULT '[]', + cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global + updated_at TEXT NOT NULL +); +``` + +- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` +- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts` + --- ## 2. Migration system @@ -313,6 +339,8 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig | 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) | | 008 | `008-dropped-messages.ts` | `unregistered_senders` | | 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table | +| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config | +| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` | Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development. diff --git a/docs/db-session.md b/docs/db-session.md index 9370d90..2b9fd23 100644 --- a/docs/db-session.md +++ b/docs/db-session.md @@ -33,19 +33,22 @@ Every message landing in the session: user chat, scheduled task, recurring task, ```sql CREATE TABLE messages_in ( - id TEXT PRIMARY KEY, - seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3 - kind TEXT NOT NULL, - timestamp TEXT NOT NULL, - status TEXT DEFAULT 'pending', -- pending|completed|failed|paused - process_after TEXT, - recurrence TEXT, -- cron expr for recurring - series_id TEXT, -- groups occurrences of a recurring task - tries INTEGER DEFAULT 0, - platform_id TEXT, - channel_type TEXT, - thread_id TEXT, - content TEXT NOT NULL -- JSON; shape depends on kind + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3 + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- pending|completed|failed|paused + process_after TEXT, + recurrence TEXT, -- cron expr for recurring + series_id TEXT, -- groups occurrences of a recurring task + tries INTEGER DEFAULT 0, + trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL, -- JSON; shape depends on kind + source_session_id TEXT, -- agent-to-agent return path + on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll ); CREATE INDEX idx_messages_in_series ON messages_in(series_id); ``` diff --git a/src/db/schema.ts b/src/db/schema.ts index 533ec51..56701e6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -7,8 +7,7 @@ export const SCHEMA = ` -- Agent workspaces: folder, skills, CLAUDE.md. -- All workspaces are equal; privilege lives on users, not groups. --- Container config (mcpServers, packages, imageTag, additionalMounts) lives --- in groups//container.json on disk, not in the DB. +-- Container config lives in the container_configs table (see migration 014). CREATE TABLE agent_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL, From f9d30e8b9c7990cecddf083692fc443b44237299 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:25:11 +0300 Subject: [PATCH 084/105] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/help.ts | 8 +++----- src/cli/dispatch.test.ts | 10 ++-------- src/cli/dispatch.ts | 11 +++++++++-- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts index 138f05f..1cdb5c5 100644 --- a/src/cli/commands/help.ts +++ b/src/cli/commands/help.ts @@ -93,8 +93,7 @@ export function registerResourceHelpCommands(): void { lines.push(''); // Verbs - const idAutoFilled = - cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations'); + const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations'); const idHint = idAutoFilled ? '' : ' '; const verbs: string[] = []; if (res.operations.list) verbs.push(`list [open]`); @@ -112,9 +111,8 @@ export function registerResourceHelpCommands(): void { lines.push(''); // Columns - const autoFilledFields = cliScope === 'group' - ? new Set(['id', 'agent_group_id', 'group']) - : new Set(); + const autoFilledFields = + cliScope === 'group' ? new Set(['id', 'agent_group_id', 'group']) : new Set(); lines.push('Fields:'); for (const col of res.columns) { const tags: string[] = []; diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts index bb1afa0..b63d712 100644 --- a/src/cli/dispatch.test.ts +++ b/src/cli/dispatch.test.ts @@ -185,10 +185,7 @@ describe('CLI scope enforcement', () => { it('group: blocks cli_scope escalation', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); - const resp = await dispatch( - { id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, - agentCtx(), - ); + const resp = await dispatch({ id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, agentCtx()); expect(resp.ok).toBe(false); if (!resp.ok) { @@ -200,10 +197,7 @@ describe('CLI scope enforcement', () => { it('group: blocks cli-scope escalation (hyphenated)', async () => { mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' }); - const resp = await dispatch( - { id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, - agentCtx(), - ); + const resp = await dispatch({ id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, agentCtx()); expect(resp.ok).toBe(false); if (!resp.ok) { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 3098daa..68db969 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -62,7 +62,11 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise typeof row === 'object' && row !== null && (row as Record)[groupField] === ctx.agentGroupId, + (row) => + typeof row === 'object' && + row !== null && + (row as Record)[groupField] === ctx.agentGroupId, ); } else if (data && typeof data === 'object' && groupField in (data as Record)) { if ((data as Record)[groupField] !== ctx.agentGroupId) { From 661da3969e2494793e2b2225208dfe3904afcf97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 17:26:30 +0000 Subject: [PATCH 085/105] chore: bump version to 2.0.48 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c098cfb..d304cae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.47", + "version": "2.0.48", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From f33f2d89ce8e77bb6e8f1a55780dd1c0c1632eb0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 17:26:33 +0000 Subject: [PATCH 086/105] =?UTF-8?q?docs:=20update=20token=20count=20to=201?= =?UTF-8?q?74k=20tokens=20=C2=B7=2087%=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 61f3913..a21355d 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 166k tokens, 83% of context window + + 174k tokens, 87% of context window @@ -15,8 +15,8 @@ tokens - - 166k + + 174k From 25a5b81c59c0e2ce4182dad21aea92d79c31c82b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:26:48 +0300 Subject: [PATCH 087/105] fix: re-stage prettier-formatted files in pre-commit hook The hook ran format:fix but didn't re-stage the modified files, so commits went through with unformatted code and CI caught the diff. Co-Authored-By: Claude Opus 4.6 (1M context) --- .husky/pre-commit | 1 + 1 file changed, 1 insertion(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 799cd8f..3bcac61 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ pnpm run format:fix +git diff --name-only -- 'src/**/*.ts' | xargs git add 2>/dev/null || true From bd50ef7e387a423e19aef8e096b3d60bf0a78e80 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:30:36 +0300 Subject: [PATCH 088/105] fix: only re-stage previously staged files in pre-commit hook Capture staged file list before prettier runs, then re-add only those files. Prevents pulling in unrelated unstaged changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .husky/pre-commit | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 3bcac61..379c43c 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,5 @@ +staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts') pnpm run format:fix -git diff --name-only -- 'src/**/*.ts' | xargs git add 2>/dev/null || true +if [ -n "$staged" ]; then + echo "$staged" | xargs git add +fi From 9312d467bd13a3520704c3b92c93f276e9d50e51 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:31:32 +0300 Subject: [PATCH 089/105] docs: add changelog entries for container config DB, on-wake, CLI scope Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4b1f9..c6241ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ For detailed release notes, see the [full changelog on the documentation site](h ## [Unreleased] +- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups//container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`. +- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism. +- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents. + ## [2.0.45] - 2026-05-08 - **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage. From f7a8df0e8e56917d0925e40ad2983e2b95024d6b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:32:41 +0300 Subject: [PATCH 090/105] docs: move changelog entries to 2.0.48 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6241ba..fb4e412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ For detailed release notes, see the [full changelog on the documentation site](h ## [Unreleased] +## [2.0.48] - 2026-05-09 + - **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups//container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`. - **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism. - **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents. From 5ba9d23ea8725d144eeb23bfc5eb98382d170486 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:32:55 +0300 Subject: [PATCH 091/105] docs: remove empty Unreleased section from changelog --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4e412..a2f9bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,6 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). -## [Unreleased] - ## [2.0.48] - 2026-05-09 - **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups//container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`. From 6539c0286a5e1e53bb403b886fd96e1d2ed28da1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:39:24 +0300 Subject: [PATCH 092/105] docs: explain that CLI config changes require restart Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mcp-tools/cli.instructions.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index 6a4f72e..63a8674 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -71,6 +71,27 @@ ncl groups config add-package --npm some-package ncl members add --user telegram:jane ``` +### Config changes require a restart + +Changes made via `ncl groups config update` (model, provider, effort, etc.) are saved to the DB but do **not** take effect on the running container. You must restart for them to apply: + +```bash +ncl groups config update --model claude-sonnet-4-5-20250514 +# After approval: config is saved but container still runs the old model +ncl groups restart --message "Applying config update." +# After approval: container restarts with the new config +``` + +Package and MCP server changes (`config add-package`, `config add-mcp-server`) also require a restart. For packages, use `--rebuild` since they're baked into the image: + +```bash +ncl groups config add-package --npm some-package +# After approval: +ncl groups restart --rebuild --message "Installing new package." +``` + +Without `--message`, the container is killed but only comes back on the next user message. + ### Tips - Use `ncl help` to see all available fields, types, enums, and which fields are auto-filled. From 05906e4b6a0ff0286abae973aac553eb5a433eb9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 17:39:43 +0000 Subject: [PATCH 093/105] chore: bump version to 2.0.49 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d304cae..96000a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.48", + "version": "2.0.49", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 0287d71595c995bd5971725c7e80e7a7d63d4ece Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:40:54 +0300 Subject: [PATCH 094/105] docs: move restart guidance into config help descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-liner in cli.instructions.md pointing to `ncl groups config help`. Each config operation's description now says whether restart or rebuild is needed — agent discovers it via progressive disclosure. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mcp-tools/cli.instructions.md | 21 ++----------------- src/cli/resources/groups.ts | 15 ++++++++----- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index 63a8674..b6f3173 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -71,26 +71,9 @@ ncl groups config add-package --npm some-package ncl members add --user telegram:jane ``` -### Config changes require a restart +### Important -Changes made via `ncl groups config update` (model, provider, effort, etc.) are saved to the DB but do **not** take effect on the running container. You must restart for them to apply: - -```bash -ncl groups config update --model claude-sonnet-4-5-20250514 -# After approval: config is saved but container still runs the old model -ncl groups restart --message "Applying config update." -# After approval: container restarts with the new config -``` - -Package and MCP server changes (`config add-package`, `config add-mcp-server`) also require a restart. For packages, use `--rebuild` since they're baked into the image: - -```bash -ncl groups config add-package --npm some-package -# After approval: -ncl groups restart --rebuild --message "Installing new package." -``` - -Without `--message`, the container is killed but only comes back on the next user message. +Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details. ### Tips diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index c6a19ec..848b35a 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -119,7 +119,8 @@ registerResource({ 'config update': { access: 'approval', description: - 'Update container config scalar fields. Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.', + 'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' + + 'Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); @@ -162,7 +163,8 @@ registerResource({ 'config add-mcp-server': { access: 'approval', description: - 'Add an MCP server to a group. Use --id --name --command [--args ] [--env ].', + 'Add an MCP server to a group. Requires `ncl groups restart` to take effect. ' + + 'Use --id --name --command [--args ] [--env ].', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); @@ -187,7 +189,8 @@ registerResource({ }, 'config remove-mcp-server': { access: 'approval', - description: 'Remove an MCP server from a group. Use --id --name .', + description: + 'Remove an MCP server from a group. Requires `ncl groups restart` to take effect. Use --id --name .', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); @@ -207,7 +210,8 @@ registerResource({ }, 'config add-package': { access: 'approval', - description: 'Add a package to a group. Use --id and --apt or --npm .', + description: + 'Add a package to a group. Requires `ncl groups restart --rebuild` to take effect. Use --id and --apt or --npm .', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); @@ -242,7 +246,8 @@ registerResource({ }, 'config remove-package': { access: 'approval', - description: 'Remove a package from a group. Use --id and --apt or --npm .', + description: + 'Remove a package from a group. Requires `ncl groups restart --rebuild` to take effect. Use --id and --apt or --npm .', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); From d324419d7bfb15103ec87e5b86d44e789f9b2492 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 17:41:21 +0000 Subject: [PATCH 095/105] chore: bump version to 2.0.50 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 96000a9..39e788a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.49", + "version": "2.0.50", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From dc13300fb1724592269f7cee23d94fe635dd25e6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:43:41 +0300 Subject: [PATCH 096/105] docs: clarify --message flag on restart for config help Explain that --message sets an on-wake instruction so the fresh container can continue after restart (verify tools, notify user). Without it, the container only comes back on the next user message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/resources/groups.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 848b35a..980f5ef 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -63,8 +63,10 @@ registerResource({ description: 'Restart containers for a group. Use --id [--rebuild] [--message ]. ' + 'From inside a container, --id is auto-filled and only the calling session is restarted. ' + - '--rebuild rebuilds the container image first. --message sets an on-wake message for the fresh container; ' + - 'if omitted, containers come back on the next user message.', + '--rebuild rebuilds the container image first (required for package changes). ' + + '--message sets an on-wake instruction for the fresh container to act on when it starts — ' + + 'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' + + 'Without --message, the container is killed and only comes back on the next user message.', handler: async (args, ctx) => { const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined); if (!id) throw new Error('--id is required'); From eff13717f956845cb2a875eeb431252666a9c37e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 17:44:09 +0000 Subject: [PATCH 097/105] chore: bump version to 2.0.51 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39e788a..f4a9a8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.50", + "version": "2.0.51", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 4c57e4d69ba191bc68245fee733a372ef4b03d4b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 9 May 2026 20:44:50 +0300 Subject: [PATCH 098/105] docs: soften restart description wording --- src/cli/resources/groups.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts index 980f5ef..83344e0 100644 --- a/src/cli/resources/groups.ts +++ b/src/cli/resources/groups.ts @@ -66,7 +66,7 @@ registerResource({ '--rebuild rebuilds the container image first (required for package changes). ' + '--message sets an on-wake instruction for the fresh container to act on when it starts — ' + 'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' + - 'Without --message, the container is killed and only comes back on the next user message.', + 'Without --message, the container stops and only starts again on the next user message.', handler: async (args, ctx) => { const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined); if (!id) throw new Error('--id is required'); From 9267d52bdbcc0411cbb09341af431844a4362279 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 17:45:17 +0000 Subject: [PATCH 099/105] chore: bump version to 2.0.52 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4a9a8b..f21e82a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.51", + "version": "2.0.52", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From ad5d4d26642c7b23946d914af90b106cebd775ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petki=20Tam=C3=A1s?= Date: Sun, 3 May 2026 19:02:15 +0200 Subject: [PATCH 100/105] feat(container-config): add per-group model + effort overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow individual agent groups to opt into different models or effort levels without changing host-wide defaults. Useful when one group is high-stakes (opus, high effort) but most are routine (sonnet/haiku, low effort). container.json gains two optional fields: - model: alias ("sonnet" | "opus" | "haiku") or full model ID - effort: "low" | "medium" | "high" | "xhigh" | "max" Both omitted = SDK default (current behavior). The host plumbs them as NANOCLAW_MODEL / NANOCLAW_EFFORT env vars at container spawn time; the agent-runner reads them in providers/index.ts and threads through to the provider via ProviderOptions. The Claude provider passes them straight to sdkQuery options. `effort` is currently typed as `any` because the @anthropic-ai/claude- agent-sdk type doesn't surface it yet — passing it through still works at runtime via the SDK's loose option handling. Drop the cast once the SDK adds an `effort` field to its options type. --- container/agent-runner/src/config.ts | 4 ++++ container/agent-runner/src/index.ts | 2 ++ container/agent-runner/src/providers/claude.ts | 7 +++++++ container/agent-runner/src/providers/types.ts | 10 ++++++++++ 4 files changed, 23 insertions(+) diff --git a/container/agent-runner/src/config.ts b/container/agent-runner/src/config.ts index 3a022ab..1546011 100644 --- a/container/agent-runner/src/config.ts +++ b/container/agent-runner/src/config.ts @@ -16,6 +16,8 @@ export interface RunnerConfig { agentGroupId: string; maxMessagesPerPrompt: number; mcpServers: Record }>; + model?: string; + effort?: string; } const DEFAULT_MAX_MESSAGES = 10; @@ -43,6 +45,8 @@ export function loadConfig(): RunnerConfig { agentGroupId: (raw.agentGroupId as string) || '', maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES, mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {}, + model: (raw.model as string) || undefined, + effort: (raw.effort as string) || undefined, }; return _config; diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 90c690f..d579592 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -91,6 +91,8 @@ async function main(): Promise { mcpServers, env: { ...process.env }, additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + model: config.model, + effort: config.effort, }); await runPollLoop({ diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6850e51..d8e78dd 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -257,11 +257,15 @@ export class ClaudeProvider implements AgentProvider { private mcpServers: Record; private env: Record; private additionalDirectories?: string[]; + private model?: string; + private effort?: string; constructor(options: ProviderOptions = {}) { this.assistantName = options.assistantName; this.mcpServers = options.mcpServers ?? {}; this.additionalDirectories = options.additionalDirectories; + this.model = options.model; + this.effort = options.effort; this.env = { ...(options.env ?? {}), CLAUDE_CODE_AUTO_COMPACT_WINDOW, @@ -293,6 +297,9 @@ export class ClaudeProvider implements AgentProvider { ], disallowedTools: SDK_DISALLOWED_TOOLS, env: this.env, + model: this.model, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + effort: this.effort as any, permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: ['project', 'user'], diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index b4b1fc8..c679dbe 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -25,6 +25,16 @@ export interface ProviderOptions { mcpServers?: Record; env?: Record; additionalDirectories?: string[]; + /** + * Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through + * to the underlying SDK. If omitted, the SDK default is used. + */ + model?: string; + /** + * Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed + * through to the underlying SDK. If omitted, the SDK default is used. + */ + effort?: string; } export interface QueryInput { From 077466782678a79c7bfc574aad7acafe12a175e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 18:08:06 +0000 Subject: [PATCH 101/105] chore: bump version to 2.0.53 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f21e82a..017241c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.52", + "version": "2.0.53", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 9e1dbdf48cbeb9f3861fcccb77244a0e9bf9a9c8 Mon Sep 17 00:00:00 2001 From: Yaniv Golan Date: Sat, 9 May 2026 16:43:58 +0300 Subject: [PATCH 102/105] =?UTF-8?q?chore(container):=20bump=20claude-code?= =?UTF-8?q?=202.1.116=20=E2=86=92=202.1.128?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 patch versions ahead. The 2.1.120 binary baseline introduced a number of plugin and skill behaviors that have since landed in the public Claude Code docs: ${CLAUDE_EFFORT} substitution, settled `arguments` field in skill frontmatter, plugin `channels` field. No breaking changes for nanoclaw's runtime contract. Verified by running container/skills/{agent-browser,vercel-cli,slack-formatting} under the bumped image; all three load and execute as expected. SDK at ^0.2.116 (caret) remains compatible with claude-code 2.1.128. Bumping CLAUDE_CODE_VERSION invalidates the pnpm install layer in container/Dockerfile and triggers a full rebuild of the agent image. Co-Authored-By: Claude Opus 4.7 --- container/Dockerfile | 2 +- container/agent-runner/bun.lock | 20 ++++++++++---------- container/agent-runner/package.json | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/container/Dockerfile b/container/Dockerfile index 89f834a..2622d06 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false # Pin CLI versions for reproducibility. Bump deliberately — unpinned installs # mean every rebuild silently picks up the latest and can break in lockstep # across all users. -ARG CLAUDE_CODE_VERSION=2.1.116 +ARG CLAUDE_CODE_VERSION=2.1.128 ARG AGENT_BROWSER_VERSION=latest ARG VERCEL_VERSION=52.2.1 ARG BUN_VERSION=1.3.12 diff --git a/container/agent-runner/bun.lock b/container/agent-runner/bun.lock index 3c08828..ee57204 100644 --- a/container/agent-runner/bun.lock +++ b/container/agent-runner/bun.lock @@ -5,7 +5,7 @@ "": { "name": "nanoclaw-agent-runner", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.116", + "@anthropic-ai/claude-agent-sdk": "^0.2.128", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0", @@ -18,23 +18,23 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.138", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index e9af0b1..dcd4e45 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -9,7 +9,7 @@ "test": "bun test" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.116", + "@anthropic-ai/claude-agent-sdk": "^0.2.128", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" From 8d57bdfa3ddd83eaaf64508988b87ce772e2372f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 May 2026 18:16:05 +0000 Subject: [PATCH 103/105] chore: bump version to 2.0.54 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 017241c..967717d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.53", + "version": "2.0.54", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From d8e3f9f959e6062b7c0904b789af14836cef4cab Mon Sep 17 00:00:00 2001 From: glifocat Date: Sun, 10 May 2026 08:51:53 +0200 Subject: [PATCH 104/105] docs: add changelog entry for 2.0.54 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f9bd2..b571baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to NanoClaw will be documented in this file. For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). +## [2.0.54] - 2026-05-10 + +- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model --effort `. Defaults to the host-configured model when unset. +- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128. +- CLI help text improvements for `ncl groups config` and `ncl groups restart`. + ## [2.0.48] - 2026-05-09 - **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups//container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`. From f49de0fb0133e60f910086216eb0066efb415c29 Mon Sep 17 00:00:00 2001 From: johnnyfish Date: Sun, 10 May 2026 19:23:22 +0300 Subject: [PATCH 105/105] fix: teach agent to use OneCLI gateway credentials after MCP server install --- container/agent-runner/src/mcp-tools/self-mod.instructions.md | 2 +- container/skills/onecli-gateway/instructions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/container/agent-runner/src/mcp-tools/self-mod.instructions.md b/container/agent-runner/src/mcp-tools/self-mod.instructions.md index 15057e0..b2a4b3a 100644 --- a/container/agent-runner/src/mcp-tools/self-mod.instructions.md +++ b/container/agent-runner/src/mcp-tools/self-mod.instructions.md @@ -22,4 +22,4 @@ Use **`add_mcp_server`** to add an MCP server to your configuration. Browse avai add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) ``` -Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential. +Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery). diff --git a/container/skills/onecli-gateway/instructions.md b/container/skills/onecli-gateway/instructions.md index 26d347a..ad3fd95 100644 --- a/container/skills/onecli-gateway/instructions.md +++ b/container/skills/onecli-gateway/instructions.md @@ -4,4 +4,4 @@ Your HTTP requests go through the OneCLI proxy, which injects real credentials a 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. +If you get a `401`/`403`/`app_not_connected`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.