mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
131fc99700
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": <body>} 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 <message...>
- 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) <noreply@anthropic.com>
102 lines
2.8 KiB
TypeScript
102 lines
2.8 KiB
TypeScript
/**
|
|
* 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();
|