Compare commits

..

21 Commits

Author SHA1 Message Date
gavrielc 01eac7b225 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:04:07 +03:00
gavrielc 6caad0757a 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) <noreply@anthropic.com>
2026-05-08 21:02:23 +03:00
gavrielc ed571d1f66 docs(cli): add write examples, approval flow, and nc→ncl rename
- 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) <noreply@anthropic.com>
2026-05-08 20:45:18 +03:00
gavrielc 046b99c745 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) <noreply@anthropic.com>
2026-05-08 16:31:30 +03:00
gavrielc 0855369b79 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) <noreply@anthropic.com>
2026-05-08 15:56:09 +03:00
gavrielc 33cbf59dd8 Merge remote-tracking branch 'origin/main' into nc-cli 2026-05-08 15:35:03 +03:00
gavrielc 9a649fadc5 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) <noreply@anthropic.com>
2026-05-08 15:34:47 +03:00
github-actions[bot] 405dd34148 chore: bump version to 2.0.45 2026-05-08 12:30:04 +00:00
gavrielc 81cb13ec46 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) <noreply@anthropic.com>
2026-05-08 15:29:36 +03:00
github-actions[bot] 9629d1cc4a docs: update token count to 150k tokens · 75% of context window 2026-05-08 12:25:00 +00:00
gavrielc 85850874ab test: add delivery retry, permission check, and poll-loop error recovery coverage
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) <noreply@anthropic.com>
2026-05-08 15:24:42 +03:00
gavrielc 9090c33e7e 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) <noreply@anthropic.com>
2026-05-08 00:48:57 +03:00
gavrielc 3dc29bb674 Merge remote-tracking branch 'origin/main' into nc-cli 2026-05-06 00:46:53 +03:00
gavrielc 8771e259a8 style(cli): apply prettier formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:42:33 +03:00
gavrielc a597b42648 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) <noreply@anthropic.com>
2026-05-06 00:40:15 +03:00
gavrielc 6865811147 feat(cli): add CRUD helper, resource definitions, and help command
Resource-first CLI: `nc groups list`, `nc wirings get <id>`, 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 <resource> help` shows fields
- Positional target IDs: `nc groups get <id>`
- Removed unused priority column from wirings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 00:33:10 +03:00
gavrielc 5e2bf1cb54 feat(cli): replace MCP tool with standalone nc client in container
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) <noreply@anthropic.com>
2026-05-06 00:07:37 +03:00
gavrielc bc19b716bf 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) <noreply@anthropic.com>
2026-05-05 23:48:39 +03:00
gavrielc 13f6fc2093 merge: catch up nc-cli to main
Resolve conflict in src/index.ts shutdown sequence — keep both
stopCliServer() from nc-cli and try/finally + resetCircuitBreaker()
from main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 17:24:26 +03:00
gavrielc 594d1b4055 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) <noreply@anthropic.com>
2026-04-29 18:03:47 +03:00
gavrielc 3a3d2ee644 feat(cli): scaffold nc CLI with list-groups command
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) <noreply@anthropic.com>
2026-04-29 18:03:16 +03:00
43 changed files with 2434 additions and 24 deletions
+26
View File
@@ -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 <resource> <verb> [<id>] [--flags]
ncl <resource> 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 (`<channel>:<handle>`) |
| 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:
Executable
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
#
# 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 `ncl`
# to its full path) to invoke from anywhere:
#
# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl
# # or
# alias ncl="$(pwd)/bin/ncl"
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 "$@"
+5
View File
@@ -110,6 +110,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}"
# ---- 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
RUN chmod +x /app/entrypoint.sh
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bun
/**
* ncl — NanoClaw CLI client (container edition).
*
* 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.
*
* 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<string, unknown>;
};
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) {
// 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 {
inDb.close();
}
Bun.sleepSync(500);
}
return null;
}
// ---------------------------------------------------------------------------
// Arg parsing (mirrors host-side client.ts)
// ---------------------------------------------------------------------------
function parseArgv(argv: string[]): {
command: string;
args: Record<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
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('ncl: missing command\n');
printUsage();
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];
}
return { command, args, json };
}
function printUsage(): void {
process.stdout.write(
['Usage: ncl <command> [--key value ...] [--json]', '', 'Run `ncl 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<string, unknown>).every((v) => typeof v !== 'object' || v === null),
);
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
const keys = Object.keys(data[0] as Record<string, unknown>);
const widths = keys.map((k) =>
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[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<string, unknown>)[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('ncl: 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);
}
@@ -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<voi
function sleep(ms: number): Promise<void> {
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({}, () => '<message to="discord-test">should not run</message>');
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');
})(),
};
}
}
@@ -0,0 +1,78 @@
## 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.
### Usage
```
ncl <resource> <verb> [<id>] [--flags]
ncl <resource> help
ncl 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 (`<channel>:<handle>`) |
| 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** — `ncl groups get <your-group-id>` 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
# 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
# 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 `ncl <resource> 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.
+5 -1
View File
@@ -1,10 +1,13 @@
{
"name": "nanoclaw",
"version": "2.0.44",
"version": "2.0.45",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"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/",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="149k tokens, 75% of context window">
<title>149k tokens, 75% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="150k tokens, 75% of context window">
<title>150k tokens, 75% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">149k</text>
<text x="71" y="14">149k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">150k</text>
<text x="71" y="14">150k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -2
View File
@@ -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 <message...>
@@ -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);
}
+3 -3
View File
@@ -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<void> {
} 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<void> {
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 ?? {})}`,
+3 -3
View File
@@ -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<string, string[]> = {
export const STEP_FILES: Record<string, string[]> = {
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
environment: ['setup/environment.ts'],
container: [
@@ -81,7 +81,7 @@ const STEP_FILES: Record<string, string[]> = {
],
};
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<boolean> {
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
if (!isClaudeInstalled()) {
const install = ensureAnswer(
await p.confirm({
+116
View File
@@ -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<boolean> {
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<boolean> {
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<boolean>((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');
}
+2 -2
View File
@@ -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
+9
View File
@@ -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 ───────────────────────────────────────────────────
+2 -2
View File
@@ -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.',
+35
View File
@@ -82,6 +82,41 @@ export async function run(_args: string[]): Promise<void> {
});
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(
+135
View File
@@ -0,0 +1,135 @@
/**
* `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:
* ncl <resource> <verb> [target] [--key value ...] [--json]
*
* Examples:
* 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';
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<void> {
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 {
return new SocketTransport();
}
function parseArgv(argv: string[]): {
command: string;
args: Record<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
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('ncl: missing command\n');
printUsage();
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];
}
return { command, args, json };
}
function printUsage(): void {
process.stdout.write(
[
'Usage: ncl <resource> <verb> [target] [--key value ...] [--json]',
'',
'Run `ncl help` to list available resources and commands.',
'',
].join('\n'),
);
}
function formatTransportError(e: unknown): string {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
return [
`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`,
` Linux: systemctl --user restart nanoclaw`,
``,
].join('\n');
}
return `ncl: transport error: ${msg}\n`;
}
main().catch((err) => {
process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(2);
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Built-in help command. Introspects the resource and command registries.
*
* 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';
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 `ncl <resource> 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 `<plural>-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 <id> [open]`);
if (res.operations.create) verbs.push(`create [approval]`);
if (res.operations.update) verbs.push(`update <id> [approval]`);
if (res.operations.delete) verbs.push(`delete <id> [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
}
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 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();
+291
View File
@@ -0,0 +1,291 @@
/**
* 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 — `ncl <resource> 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<string, unknown>, ctx: CallerContext) => Promise<unknown>;
}
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<string, CustomOperation>;
}
// ---------------------------------------------------------------------------
// Resource registry (for help introspection)
// ---------------------------------------------------------------------------
const resources = new Map<string, ResourceDef>();
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(', ');
const filterableNames = new Set(def.columns.filter((c) => !c.generated).map((c) => c.name));
return async (args: Record<string, unknown>) => {
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);
};
}
function genericGet(def: ResourceDef) {
const cols = visibleColumns(def).join(', ');
return async (args: Record<string, unknown>) => {
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<string, unknown>) => {
const values: Record<string, unknown> = {};
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<string, unknown>) => {
const id = args.id as string;
if (!id) throw new Error(`${def.name} id is required`);
const updates: Record<string, unknown> = {};
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<string, unknown>) => {
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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: (raw) => normalizeArgs(raw),
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<string, unknown>, ctx),
});
}
}
}
+59
View File
@@ -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<string, unknown>) ?? {};
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 });
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Transport-agnostic dispatcher. Both the socket server (host caller) and
* 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 `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';
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
const cmd = lookup(req.command);
if (!cmd) {
return err(req.id, 'unknown-command', `no command "${req.command}"`);
}
if (ctx.caller !== 'host' && cmd.access === 'approval') {
const session = getSession(ctx.sessionId);
if (!session) {
return err(req.id, 'handler-error', 'Session not found.');
}
const agentGroup = getAgentGroup(ctx.agentGroupId);
const agentName = agentGroup?.name ?? ctx.agentGroupId;
const argSummary = Object.entries(req.args)
.map(([k, v]) => `--${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;
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));
}
}
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
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 } };
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+52
View File
@@ -0,0 +1,52 @@
/**
* 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.
*
* 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<string, unknown>[]);
}
return JSON.stringify(data, null, 2);
}
function isFlatRecord(x: unknown): x is Record<string, unknown> {
if (!x || typeof x !== 'object') return false;
for (const v of Object.values(x as Record<string, unknown>)) {
if (v !== null && typeof v === 'object') return false;
}
return true;
}
function renderTable(rows: Record<string, unknown>[]): 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');
}
+44
View File
@@ -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<string, unknown>;
};
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;
};
+38
View File
@@ -0,0 +1,38 @@
/**
* 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
* registry is populated before the host's CLI server accepts connections.
*/
import type { CallerContext } from './frame.js';
export type Access = 'open' | 'approval' | 'hidden';
export type CommandDef<TArgs = unknown, TData = unknown> = {
name: string;
description: string;
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<string, unknown>) => TArgs;
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
};
const registry = new Map<string, CommandDef>();
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): 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));
}
+53
View File
@@ -0,0 +1,53 @@
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' },
});
+77
View File
@@ -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. <message to="local_name">). 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 } };
},
},
},
});
+28
View File
@@ -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' },
});
+37
View File
@@ -0,0 +1,37 @@
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-<provider>.',
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' },
});
+15
View File
@@ -0,0 +1,15 @@
/**
* 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 './destinations.js';
import './user-dms.js';
import './dropped-messages.js';
import './approvals.js';
import './sessions.js';
+65
View File
@@ -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 } };
},
},
},
});
+58
View File
@@ -0,0 +1,58 @@
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-<channel> (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: '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' },
});
+67
View File
@@ -0,0 +1,67 @@
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 } };
},
},
},
});
+45
View File
@@ -0,0 +1,45 @@
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 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.' },
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open' },
});
+21
View File
@@ -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' },
});
+35
View File
@@ -0,0 +1,35 @@
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' },
});
+70
View File
@@ -0,0 +1,70 @@
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. Note: threaded adapters in group chats force per-thread regardless of this setting.',
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' },
});
+63
View File
@@ -0,0 +1,63 @@
/**
* 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
* 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, 'ncl.sock');
export class SocketTransport implements Transport {
constructor(private readonly socketPath: string = DEFAULT_SOCKET_PATH) {}
async sendFrame(req: RequestFrame): Promise<ResponseFrame> {
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'));
}
});
});
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* 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/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.
*/
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<void> {
// 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 ncl socket (will try to bind anyway)', { socketPath, err });
}
}
const s = net.createServer((conn) => handleConnection(conn));
server = s;
await new Promise<void>((resolve, reject) => {
s.once('error', reject);
s.listen(socketPath, () => {
try {
fs.chmodSync(socketPath, 0o600);
} catch (err) {
log.warn('Failed to chmod ncl socket (continuing)', { socketPath, err });
}
log.info('ncl CLI server listening', { socketPath });
resolve();
});
});
}
export async function stopCliServer(): Promise<void> {
if (!server) return;
const s = server;
server = null;
await new Promise<void>((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('ncl CLI server connection error', { err });
});
}
async function handleFrame(conn: net.Socket, line: string): Promise<void> {
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/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' };
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 ncl CLI response', { err });
}
}
function isRequestFrame(x: unknown): x is RequestFrame {
if (!x || typeof x !== 'object') return false;
const o = x as Record<string, unknown>;
return typeof o.id === 'string' && typeof o.command === 'string' && typeof o.args === 'object' && o.args !== null;
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 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).
*/
import type { RequestFrame, ResponseFrame } from './frame.js';
export interface Transport {
sendFrame(req: RequestFrame): Promise<ResponseFrame>;
}
+127 -2
View File
@@ -26,8 +26,16 @@ 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 +154,120 @@ 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);
});
});
+10
View File
@@ -53,6 +53,12 @@ import './channels/index.js';
// append registry-based modules. Imported for side effects (registrations).
import './modules/index.js';
// CLI command barrel — populates the `ncl` 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';
import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js';
@@ -163,6 +169,9 @@ async function main(): Promise<void> {
startHostSweep();
log.info('Host sweep started');
// 7. Start the `ncl` CLI socket server (data/ncl.sock).
await startCliServer();
log.info('NanoClaw running');
}
@@ -178,6 +187,7 @@ async function shutdown(signal: string): Promise<void> {
}
stopDeliveryPolls();
stopHostSweep();
await stopCliServer();
try {
await teardownChannelAdapters();
} finally {
+15 -5
View File
@@ -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.
// 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,
@@ -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,
);