From 131fc997001f2464e39fd883390b690d8b1f4f5d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 18 Apr 2026 21:51:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(channels):=20add=20CLI=20channel=20?= =?UTF-8?q?=E2=80=94=20talk=20to=20your=20agent=20from=20the=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First default channel that ships with main. Unix-socket adapter + thin client; plugs into the running daemon rather than spawning its own host. ## src/channels/cli.ts - ChannelAdapter with channelType='cli', platformId='local'. - setup() unlinks any stale socket, listens on $DATA_DIR/cli.sock (mode 0600 so only the local user can connect). - On client connect: reads newline-delimited JSON ({"text": "..."}) and calls config.onInbound('local', null, {id, kind:'chat', content, ts}). - deliver() writes {"text": } back to the connected socket; silently no-ops when no client is attached (outbound row still persists). - Single-client policy: a second connection supersedes the first with a [superseded] notice. - teardown() closes the client, closes the server, removes the socket file. ## scripts/chat.ts + pnpm run chat One-shot client: - pnpm run chat - Connects to the socket, writes one JSON line with the message. - Reads replies; exits 2s after the first reply lands (hard timeout 120s). - ENOENT/ECONNREFUSED prints a hint to start the daemon. ## scripts/init-first-agent.ts - Fix stale imports after earlier module extractions (permissions + agent-to-agent moved their DB helpers into modules/). - After wiring the DM channel, also create cli/local messaging_group (unknown_sender_policy='public' — local socket perms handle auth) and wire it to the same agent. User can `pnpm run chat` immediately. ## package.json - Add "chat": "tsx scripts/chat.ts" script. ## Validation - pnpm run build clean. - pnpm test — 137 host tests pass. - bun test in container/agent-runner — 17 pass. - Service boot logs: "CLI channel listening" + "Channel adapter started channel=cli type=cli". Clean SIGTERM shutdown; socket file removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + scripts/chat.ts | 101 ++++++++++++++++++ scripts/init-first-agent.ts | 40 ++++++- src/channels/cli.ts | 204 ++++++++++++++++++++++++++++++++++++ src/channels/index.ts | 11 +- 5 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 scripts/chat.ts create mode 100644 src/channels/cli.ts diff --git a/package.json b/package.json index e9d2aca..e2af027 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check \"src/**/*.ts\"", "prepare": "husky", "setup": "tsx setup/index.ts", + "chat": "tsx scripts/chat.ts", "auth": "tsx src/whatsapp-auth.ts", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", diff --git a/scripts/chat.ts b/scripts/chat.ts new file mode 100644 index 0000000..20194fb --- /dev/null +++ b/scripts/chat.ts @@ -0,0 +1,101 @@ +/** + * nc — chat with your NanoClaw agent from the terminal. + * + * Usage: + * pnpm run chat + * + * Sends the message through the CLI channel (Unix socket) to the wired agent. + * Reads replies until the stream goes quiet, then exits. + * + * Preconditions: NanoClaw host service running, an agent group wired to + * `cli/local` via `/init-first-agent` or `/manage-channels`. + */ +import net from 'net'; +import path from 'path'; + +import { DATA_DIR } from '../src/config.js'; + +const SILENCE_MS = 2000; // exit after this much quiet time following the first reply +const TOTAL_TIMEOUT_MS = 120_000; // hard stop + +function socketPath(): string { + return path.join(DATA_DIR, 'cli.sock'); +} + +function main(): void { + const words = process.argv.slice(2); + if (words.length === 0) { + console.error('usage: pnpm run chat '); + process.exit(1); + } + const text = words.join(' '); + + const socket = net.connect(socketPath()); + + socket.on('error', (err) => { + 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.'); + } else { + console.error('CLI socket error:', err); + } + process.exit(2); + }); + + let firstReplySeen = false; + let silenceTimer: NodeJS.Timeout | null = null; + let hardTimer: NodeJS.Timeout | null = null; + + function scheduleExit(): void { + if (silenceTimer) clearTimeout(silenceTimer); + silenceTimer = setTimeout(() => { + socket.end(); + process.exit(0); + }, SILENCE_MS); + } + + socket.on('connect', () => { + socket.write(JSON.stringify({ text }) + '\n'); + hardTimer = setTimeout(() => { + if (!firstReplySeen) { + console.error(`timeout: no reply in ${TOTAL_TIMEOUT_MS}ms`); + socket.end(); + process.exit(3); + } + }, TOTAL_TIMEOUT_MS); + }); + + let buffer = ''; + socket.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; + try { + const msg = JSON.parse(line); + if (typeof msg.text === 'string') { + process.stdout.write(msg.text + '\n'); + firstReplySeen = true; + if (hardTimer) { + clearTimeout(hardTimer); + hardTimer = null; + } + scheduleExit(); + } + } catch { + // Ignore non-JSON lines — forward compatibility. + } + } + }); + + socket.on('close', () => { + if (silenceTimer) clearTimeout(silenceTimer); + if (hardTimer) clearTimeout(hardTimer); + process.exit(firstReplySeen ? 0 : 3); + }); +} + +main(); diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 90543d7..efb3b6b 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -25,7 +25,6 @@ import path from 'path'; import { DATA_DIR } from '../src/config.js'; import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js'; -import { normalizeName } from '../src/db/agent-destinations.js'; import { initDb } from '../src/db/connection.js'; import { createMessagingGroup, @@ -34,8 +33,9 @@ import { getMessagingGroupByPlatform, } from '../src/db/messaging-groups.js'; import { runMigrations } from '../src/db/migrations/index.js'; -import { grantRole, hasAnyOwner } from '../src/db/user-roles.js'; -import { upsertUser } from '../src/db/users.js'; +import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; +import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; +import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; import { resolveSession, writeSessionMessage } from '../src/session-manager.js'; import type { AgentGroup } from '../src/types.js'; @@ -224,12 +224,46 @@ async function main(): Promise { }), }); + // 6. Wire the CLI channel to the same agent so the user can `pnpm run chat` + // immediately. CLI ships with main and is always available — separate + // messaging_group from the DM channel, so the two don't share a session. + const CLI_PLATFORM_ID = 'local'; + let cliMg = getMessagingGroupByPlatform('cli', CLI_PLATFORM_ID); + if (!cliMg) { + cliMg = { + id: generateId('mg'), + channel_type: 'cli', + platform_id: CLI_PLATFORM_ID, + name: 'Local CLI', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now, + }; + createMessagingGroup(cliMg); + console.log(`Created CLI messaging group: ${cliMg.id}`); + } + const existingCliMga = getMessagingGroupAgentByPair(cliMg.id, ag.id); + if (!existingCliMga) { + createMessagingGroupAgent({ + id: generateId('mga'), + messaging_group_id: cliMg.id, + agent_group_id: ag.id, + trigger_rules: null, + response_scope: 'all', + session_mode: 'shared', + priority: 0, + created_at: now, + }); + console.log(`Wired cli/${CLI_PLATFORM_ID} -> ${ag.id}`); + } + console.log(''); console.log('Init complete.'); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` channel: ${args.channel} ${platformId}`); console.log(` session: ${session.id}`); + console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); console.log(''); console.log('Host sweep (<=60s) will wake the container and the agent will send the welcome DM.'); } diff --git a/src/channels/cli.ts b/src/channels/cli.ts new file mode 100644 index 0000000..c84952c --- /dev/null +++ b/src/channels/cli.ts @@ -0,0 +1,204 @@ +/** + * CLI channel — talk to your agent from a local terminal via Unix socket. + * + * Always-on, zero-credentials channel that ships with main. The daemon + * listens on `data/cli.sock`; the `scripts/chat.ts` client connects, writes + * a JSON line per message, reads JSON lines back. The channel plumbs into + * the normal router/delivery path like any other adapter — `/clear` and + * other session-level commands work identically. + * + * MVP shape: + * - One hardcoded messaging_group: `cli/local`. Wired to one agent via + * the setup flow (see `scripts/init-first-agent.ts`). Multi-agent + * support can add per-agent messaging_groups later without breaking + * the wire protocol. + * - Single connected client at a time. A second connection closes the + * first with a "superseded" notice. + * - Wire format: one JSON object per line. + * Client → server: { "text": "user message" } + * Server → client: { "text": "agent reply" } + * - deliver() silently no-ops when no client is connected. The outbound + * row is already in outbound.db, so the message isn't lost — it just + * doesn't reach this run's terminal. Reconnect to see subsequent replies. + */ +import fs from 'fs'; +import net from 'net'; +import path from 'path'; + +import { DATA_DIR } from '../config.js'; +import { log } from '../log.js'; +import type { + ChannelAdapter, + ChannelSetup, + InboundMessage, + OutboundMessage, +} from './adapter.js'; +import { registerChannelAdapter } from './channel-registry.js'; + +const PLATFORM_ID = 'local'; + +function socketPath(): string { + return path.join(DATA_DIR, 'cli.sock'); +} + +function createAdapter(): ChannelAdapter { + let server: net.Server | null = null; + let client: net.Socket | null = null; + + const adapter: ChannelAdapter = { + name: 'cli', + channelType: 'cli', + supportsThreads: false, + + async setup(config: ChannelSetup): Promise { + const sock = socketPath(); + + // 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(sock); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code !== 'ENOENT') { + log.warn('Failed to unlink stale CLI socket (will try to bind anyway)', { sock, err }); + } + } + + server = net.createServer((socket) => handleConnection(socket, config)); + await new Promise((resolve, reject) => { + server!.once('error', reject); + server!.listen(sock, () => { + // Tighten perms so only the owner can connect. Unix socket files + // obey filesystem perms — 0700 on the socket means other local + // users can't send into this agent. + try { + fs.chmodSync(sock, 0o600); + } catch (err) { + log.warn('Failed to chmod CLI socket (continuing)', { sock, err }); + } + log.info('CLI channel listening', { sock }); + resolve(); + }); + }); + }, + + async teardown(): Promise { + if (client) { + try { + client.end(); + } catch { + // swallow — teardown is best-effort + } + client = null; + } + if (server) { + await new Promise((resolve) => { + server!.close(() => resolve()); + }); + server = null; + } + // Remove the socket file so a relaunch doesn't trip over it. + try { + fs.unlinkSync(socketPath()); + } catch { + // swallow + } + }, + + isConnected(): boolean { + return server !== null; + }, + + async deliver(platformId, _threadId, message: OutboundMessage): Promise { + if (platformId !== PLATFORM_ID) return undefined; + if (!client) { + // No live terminal — outbound row is already persisted, so this + // isn't a data loss. User will see it on the next connect cycle + // (or never, if we don't add scroll-back). Not worth throwing. + return undefined; + } + const text = extractText(message); + if (text === null) return undefined; + try { + client.write(JSON.stringify({ text }) + '\n'); + } catch (err) { + log.warn('Failed to write to CLI client', { err }); + } + return undefined; + }, + }; + + function handleConnection(socket: net.Socket, config: ChannelSetup): void { + if (client) { + try { + client.write(JSON.stringify({ text: '[superseded by a newer client]' }) + '\n'); + client.end(); + } catch { + // swallow + } + } + client = socket; + log.info('CLI client connected'); + + let buffer = ''; + socket.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 handleLine(line, config); + } + }); + + socket.on('close', () => { + if (client === socket) client = null; + log.info('CLI client disconnected'); + }); + + socket.on('error', (err) => { + log.warn('CLI client socket error', { err }); + }); + } + + async function handleLine(line: string, config: ChannelSetup): Promise { + let payload: { text?: unknown }; + try { + payload = JSON.parse(line); + } catch (err) { + log.warn('CLI: ignoring non-JSON line from client', { line }); + return; + } + if (typeof payload.text !== 'string' || payload.text.length === 0) return; + + const inbound: InboundMessage = { + id: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + content: { + text: payload.text, + sender: 'cli', + senderId: `cli:${PLATFORM_ID}`, + }, + }; + try { + await config.onInbound(PLATFORM_ID, null, inbound); + } catch (err) { + log.error('CLI: onInbound threw', { err }); + } + } + + return adapter; +} + +function extractText(message: OutboundMessage): string | null { + const content = message.content as Record | string | undefined; + if (typeof content === 'string') return content; + if (content && typeof content === 'object' && typeof content.text === 'string') { + return content.text; + } + return null; +} + +registerChannelAdapter('cli', { factory: createAdapter }); diff --git a/src/channels/index.ts b/src/channels/index.ts index 8d549c0..e9b3bd1 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -1,6 +1,9 @@ -// Channel self-registration barrel file. +// Channel self-registration barrel. // Each import triggers the channel module's registerChannelAdapter() call. // -// v2 ships with no channels baked in. Channel skills (e.g. /add-slack-v2, -// /add-discord-v2, /add-whatsapp-v2) copy the channel module from the -// `channels` branch and append a self-registration import below. +// Main ships with one default channel — `cli`, the always-on local-terminal +// channel. Other channel skills (/add-slack, /add-discord, /add-whatsapp, +// ...) copy their module from the `channels` branch and append a +// self-registration import below. + +import './cli.js';