Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to NanoClaw will be documented in this file.

## [Unreleased]

- **Per-group harness-capability toggles — new groups default lean, existing groups unchanged.** Claude Code ships built-in features NanoClaw already replaces its own way: **agent teams** (overlaps `create_agent` + destinations; multiplies separately-billed agents invisibly) and the **Workflow** tool (duplicates NanoClaw's orchestration; largest per-turn tool schema). Both are now **OFF by default for newly-created groups** (and `DesignSync` + `ReportFindings`, desktop/UI-only tools, are fixed-off) — cutting ~20% of per-turn context. **This is non-breaking:** a new `container_configs.harness_capabilities` column (migration 020) *grandfathers every existing group* to its prior behavior (teams + Workflow on), so upgrading changes nothing for current agents. Defaults live in `src/harness-capabilities.ts`; the host reconciles each group's settings.json at spawn from the resolved state. Toggle per group with `ncl groups config update --id <g> --harness-capabilities 'agent-teams=on,workflow=off'` (+ `ncl groups restart`); opt an existing group into the lean defaults the same way. Details: [docs/harness-capabilities.md](docs/harness-capabilities.md).
- **Claude tool allowlist reconciled with the pinned CLI, plus a tool-surface drift guard.** `TOOL_ALLOWLIST` in the agent runner named five tools that don't exist on claude-code 2.1.197 (`Task` — renamed `Agent` upstream — plus `TodoWrite`, `TeamCreate`, `TeamDelete`, `ToolSearch`); the phantom entries are removed and the comment corrected: `allowedTools` is a permission auto-approve list, **not an availability filter** — interleaved wire captures show no allowlist effect on the stable tool surface, and `bypassPermissions` moots its permission role. (The surface itself shows per-query variance in conditional tools on this CLI pin — the drift fixture records the flicker set separately so regeneration cannot flake on it — so neither `allowedTools` nor `disallowedTools` alone should be relied on to shape what the model sees — the runner's PreToolUse hook is the deterministic block.) No wire-visible behavior change. A new wire-captured fixture (`container/agent-runner/src/providers/sdk-tools-baseline.json`) and `claude.tools.test.ts` now fail on any future claude-code CLI **or SDK** pin bump until the fixture is regenerated with `dump-sdk-tools.ts` and the lists re-verified.
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/harness-capabilities.ts` | Harness-capability registry — `agent-teams`/`workflow` toggles (both default off), resolved into `container.json`; settings reconciler lives in `group-init.ts`. See [docs/harness-capabilities.md](docs/harness-capabilities.md) |
| `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 |
Expand Down Expand Up @@ -141,7 +142,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
| 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`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` and `harness_capabilities` 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).
Expand Down
18 changes: 18 additions & 0 deletions container/agent-runner/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'bun:test';

import { configFromRaw } from './config.js';

describe('configFromRaw harnessCapabilities', () => {
it('treats a missing field as a pre-capability host — legacy all-on, not all-off', () => {
// Update-window skew: the bind-mounted runner source is already
// capability-aware while the still-running old host wrote container.json
// without the field. Those groups ran with teams + Workflow on; defaulting
// to {} here would regress them to all-off until the host restarts.
expect(configFromRaw({}).harnessCapabilities).toEqual({ 'agent-teams': 'on', workflow: 'on' });
});

it('passes an explicit host-resolved map through untouched', () => {
const caps = { 'agent-teams': 'off', workflow: 'off' };
expect(configFromRaw({ harnessCapabilities: caps }).harnessCapabilities).toEqual(caps);
});
});
37 changes: 26 additions & 11 deletions container/agent-runner/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,37 @@ export interface RunnerConfig {
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
/** Resolved harness-capability map (host-resolved). Missing → legacy all-on (pre-capability host). */
harnessCapabilities: Record<string, string>;
}

const DEFAULT_MAX_MESSAGES = 10;

// Pre-capability behavior. A container.json without `harnessCapabilities` was
// written by an older host (capability-aware hosts always emit the full
// resolved map, never omit it) — and under that host every group ran with
// teams + Workflow on. Defaulting to {} here would regress those groups to
// all-off during the update window where the bind-mounted runner source is
// already new but the still-running old host wrote the config file.
const LEGACY_HARNESS_CAPABILITIES: Record<string, string> = { 'agent-teams': 'on', workflow: 'on' };

let _config: RunnerConfig | null = null;

/** Map raw container.json fields to a RunnerConfig, applying per-field defaults. */
export function configFromRaw(raw: Record<string, unknown>): RunnerConfig {
return {
provider: (raw.provider as string) || 'claude',
assistantName: (raw.assistantName as string) || '',
groupName: (raw.groupName as string) || '',
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,
harnessCapabilities: (raw.harnessCapabilities as Record<string, string>) ?? LEGACY_HARNESS_CAPABILITIES,
};
}

/**
* Load config from container.json. Called once at startup.
* Falls back to sensible defaults for any missing field.
Expand All @@ -38,17 +63,7 @@ export function loadConfig(): RunnerConfig {
console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`);
}

_config = {
provider: (raw.provider as string) || 'claude',
assistantName: (raw.assistantName as string) || '',
groupName: (raw.groupName as string) || '',
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,
};

_config = configFromRaw(raw);
return _config;
}

Expand Down
1 change: 1 addition & 0 deletions container/agent-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ async function main(): Promise<void> {
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
harnessCapabilities: config.harnessCapabilities,
});

// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent grou

Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"` → `sales-briefing-a25c`); without it ids are `t-<hex>`.

Note: your in-session task-list tools (TaskCreate/TaskUpdate/TaskList) are per-conversation scratch for organizing the current turn's work — they are unrelated to `ncl tasks` scheduled tasks and do not survive the session.

Common commands:

```bash
Expand Down
74 changes: 74 additions & 0 deletions container/agent-runner/src/providers/claude.harness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Harness-capability mapping in the Claude provider: the disallow list is the
* fixed set plus capability-driven entries (fail closed), and the PreToolUse
* hook blocks exactly that list. Pure — no SDK, no DB (the hook's
* container_state write is try/caught by design).
*/
import { describe, expect, it } from 'bun:test';

import { ClaudeProvider, SDK_DISALLOWED_TOOLS, buildDisallowedTools, createPreToolUseHook } from './claude.js';

type LooseHook = (input: unknown) => Promise<Record<string, unknown>>;

describe('buildDisallowedTools', () => {
it('fails closed: absent/empty/off/garbage all include Workflow plus the fixed set', () => {
for (const caps of [undefined, {}, { workflow: 'off' }, { workflow: 'garbage' }]) {
const list = buildDisallowedTools(caps);
for (const fixed of SDK_DISALLOWED_TOOLS) expect(list).toContain(fixed);
expect(list).toContain('Workflow');
expect(list).toContain('DesignSync');
expect(list).toContain('ReportFindings');
}
});

it('workflow=on removes only Workflow', () => {
const list = buildDisallowedTools({ workflow: 'on' });
expect(list).not.toContain('Workflow');
expect(list).toContain('DesignSync');
expect(list).toContain('CronCreate');
});

it('agent-teams has no runner mechanism and never changes the list', () => {
expect(buildDisallowedTools({ 'agent-teams': 'on' })).toEqual(buildDisallowedTools({ 'agent-teams': 'off' }));
});
});

describe('createPreToolUseHook', () => {
it('blocks a listed tool, with the redirect in the model-visible fields', async () => {
const hook = createPreToolUseHook(['Workflow']) as unknown as LooseHook;
const res = await hook({ tool_name: 'Workflow', tool_input: {} });
expect(res.decision).toBe('block');
// The CLI feeds `reason` / permissionDecisionReason back to the model on a
// deny — stopReason is only surfaced with continue:false (turn-ending).
expect(String(res.reason)).toContain('nanoclaw equivalent');
const specific = res.hookSpecificOutput as Record<string, unknown>;
expect(specific.permissionDecision).toBe('deny');
expect(String(specific.permissionDecisionReason)).toContain('nanoclaw equivalent');
});

it('passes an unlisted tool through', async () => {
const hook = createPreToolUseHook(['Workflow']) as unknown as LooseHook;
const res = await hook({ tool_name: 'Bash', tool_input: { timeout: 1000 } });
expect(res.continue).toBe(true);
});
});

describe('ClaudeProvider instance wiring', () => {
// Guards the seam the pure-helper tests can't see: the provider must
// actually BUILD its blocklist from the capability map it was constructed
// with (a revert to the static SDK_DISALLOWED_TOOLS constant at the query
// site would pass every other test in this file).
it('builds its disallow list and hook blocklist from the constructor capabilities', async () => {
const off = new ClaudeProvider({ harnessCapabilities: { workflow: 'off' } });
const on = new ClaudeProvider({ harnessCapabilities: { workflow: 'on' } });

expect(off['disallowedTools']).toContain('Workflow');
expect(on['disallowedTools']).not.toContain('Workflow');
expect(on['disallowedTools']).toContain('DesignSync');

const offHook = off['preToolUseHook'] as unknown as LooseHook;
const onHook = on['preToolUseHook'] as unknown as LooseHook;
expect((await offHook({ tool_name: 'Workflow', tool_input: {} })).decision).toBe('block');
expect((await onHook({ tool_name: 'Workflow', tool_input: {} })).continue).toBe(true);
});
});
10 changes: 10 additions & 0 deletions container/agent-runner/src/providers/claude.tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,14 @@ describe('sdk tool-surface drift guard', () => {
).toBe(false);
}
});

it('capability-managed tools exist on the surface', () => {
// Workflow (the `workflow` capability) and DesignSync (fixed-off) must be
// real tools for the disallow/settings mechanisms to be doing anything.
for (const name of ['Workflow', 'DesignSync']) {
expect(baselineTools.has(name), `'${name}' vanished from the surface — re-audit its capability mapping`).toBe(
true,
);
}
});
});
Loading