Merge pull request #1853 from qwibitai/refactor/pr9-cli-channel
feat(channels): add CLI channel — talk to your agent from the terminal
This commit is contained in:
@@ -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",
|
||||
|
||||
101
scripts/chat.ts
Normal file
101
scripts/chat.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* nc — chat with your NanoClaw agent from the terminal.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm run chat <message...>
|
||||
*
|
||||
* 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 <message...>');
|
||||
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();
|
||||
@@ -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<void> {
|
||||
}),
|
||||
});
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
204
src/channels/cli.ts
Normal file
204
src/channels/cli.ts
Normal file
@@ -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<void> {
|
||||
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<void>((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<void> {
|
||||
if (client) {
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// swallow — teardown is best-effort
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
if (server) {
|
||||
await new Promise<void>((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<string | undefined> {
|
||||
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<void> {
|
||||
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, unknown> | 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 });
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user