feat(router,cli): replyTo override + CLI admin-transport flows

- InboundEvent gains an optional replyTo; router stamps the row's address
  fields from it when set, so replies can route to a different channel than
  the one the inbound came in on.
- ChannelSetup adds onInboundEvent for admin-transport adapters that build
  the full event themselves.
- CLI wire format accepts {text, to, reply_to}. Routed messages go through
  onInboundEvent and do not evict an active chat client.
- init-first-agent hands the DM welcome to the running service via
  data/cli.sock — synchronous wake, no sweep wait. Fails loudly if the
  service is down; no silent fallback.
- Split the CLI scratch-agent bootstrap into scripts/init-cli-agent.ts;
  init-first-agent is DM-only.

Agents cannot set replyTo: it lives only on the inbound/router seam and is
consumed once when writing messages_in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 23:30:47 +03:00
parent dadf258136
commit 6c26c0413a
15 changed files with 503 additions and 213 deletions

View File

@@ -87,18 +87,17 @@ The script:
2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-<name>/`. 2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-<name>/`.
3. Reuses or creates the DM `messaging_groups` row. 3. Reuses or creates the DM `messaging_groups` row.
4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row). 4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row).
5. Resolves the session (creates `inbound.db` / `outbound.db`). 5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up.
6. Writes a `kind: 'chat'`, `sender: 'system'` welcome message into `inbound.db`.
Show the script's output to the user. Show the script's output to the user.
## 5. Verify ## 5. Verify
Host sweep runs every ~60s. Within one sweep window the container wakes, the agent processes the system message, and the reply flows through `outbound.db` to the channel. The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel.
Do not tail the log or poll in a sleep loop. Ask the user in plain text: Do not tail the log or poll in a sleep loop. Ask the user in plain text:
> The welcome DM should arrive within ~60 seconds. Let me know when you've received it (or if it doesn't arrive within two minutes). > The welcome DM should arrive shortly. Let me know when you've received it (or if it doesn't arrive within two minutes).
Wait for the user's reply. If they confirm receipt, the skill is done. Wait for the user's reply. If they confirm receipt, the skill is done.

179
scripts/init-cli-agent.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* Initialize the scratch CLI agent used during `/new-setup`.
*
* Creates the synthetic `cli:local` user, grants owner role if no owner
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
* to the CLI messaging group so `pnpm run chat` works immediately.
*
* No welcome is staged — the operator's first `pnpm run chat` is the
* natural wake, and the agent introduces itself on first contact per its
* CLAUDE.md.
*
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize
* channel adapters, so there's no Gateway conflict.
*
* Usage:
* pnpm exec tsx scripts/init-cli-agent.ts \
* --display-name "Gavriel" \
* [--agent-name "Andy"]
*/
import path from 'path';
import { DATA_DIR } from '../src/config.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import { initDb } from '../src/db/connection.js';
import {
createMessagingGroup,
createMessagingGroupAgent,
getMessagingGroupAgentByPair,
getMessagingGroupByPlatform,
} from '../src/db/messaging-groups.js';
import { runMigrations } from '../src/db/migrations/index.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 type { AgentGroup, MessagingGroup } from '../src/types.js';
const CLI_CHANNEL = 'cli';
const CLI_PLATFORM_ID = 'local';
const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
interface Args {
displayName: string;
agentName: string;
}
function parseArgs(argv: string[]): Args {
let displayName: string | undefined;
let agentName: string | undefined;
for (let i = 0; i < argv.length; i++) {
const key = argv[i];
const val = argv[i + 1];
if (key === '--display-name') {
displayName = val;
i++;
} else if (key === '--agent-name') {
agentName = val;
i++;
}
}
if (!displayName) {
console.error('Missing required arg: --display-name');
console.error('See scripts/init-cli-agent.ts header for usage.');
process.exit(2);
}
return {
displayName,
agentName: agentName?.trim() || displayName,
};
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
const now = new Date().toISOString();
// 1. Synthetic CLI user + owner grant if none exists.
upsertUser({
id: CLI_SYNTHETIC_USER_ID,
kind: CLI_CHANNEL,
display_name: args.displayName,
created_at: now,
});
let promotedToOwner = false;
if (!hasAnyOwner()) {
grantRole({
user_id: CLI_SYNTHETIC_USER_ID,
role: 'owner',
agent_group_id: null,
granted_by: null,
granted_at: now,
});
promotedToOwner = true;
}
// 2. Agent group + filesystem.
const folder = `cli-with-${normalizeName(args.displayName)}`;
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) {
const agId = generateId('ag');
createAgentGroup({
id: agId,
name: args.agentName,
folder,
agent_provider: null,
created_at: now,
});
ag = getAgentGroupByFolder(folder)!;
console.log(`Created agent group: ${ag.id} (${folder})`);
} else {
console.log(`Reusing agent group: ${ag.id} (${folder})`);
}
initGroupFilesystem(ag, {
instructions:
`# ${args.agentName}\n\n` +
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
});
// 3. CLI messaging group + wiring.
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
if (!cliMg) {
cliMg = {
id: generateId('mg'),
channel_type: CLI_CHANNEL,
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 existing = getMessagingGroupAgentByPair(cliMg.id, ag.id);
if (!existing) {
createMessagingGroupAgent({
id: generateId('mga'),
messaging_group_id: cliMg.id,
agent_group_id: ag.id,
engage_mode: 'pattern',
engage_pattern: '.',
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now,
});
console.log(`Wired cli: ${cliMg.id} -> ${ag.id}`);
} else {
console.log(`Wiring already exists: ${existing.id}`);
}
console.log('');
console.log('Init complete.');
console.log(
` owner: ${CLI_SYNTHETIC_USER_ID}${promotedToOwner ? ' (promoted on first owner)' : ''}`,
);
console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
console.log(` channel: cli/${CLI_PLATFORM_ID}`);
console.log('');
console.log('Run `pnpm run chat hi` to talk to your agent.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,43 +1,39 @@
/** /**
* Init the first (or Nth) NanoClaw v2 agent. * Init the first (or Nth) NanoClaw v2 agent for a DM channel.
* *
* Two modes: * Wires a real DM channel (discord, telegram, etc.) to a new agent group
* (and the local CLI channel as a convenience bonus), then hands a welcome
* message to the running service via its CLI socket. The service routes
* that message into the DM session, which wakes the container synchronously —
* the agent processes the welcome and DMs the operator through the normal
* delivery path.
* *
* 1. **DM channel mode** (default): wires a real DM channel (discord, telegram, * For the CLI-only scratch agent used during `/new-setup`, see
* etc.) + the CLI channel to the same agent, stages a welcome into the DM * `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run
* session so the agent greets the operator over that channel. * through here.
*
* 2. **CLI-only mode** (`--cli-only`): wires only the CLI channel. Used by
* `/new-setup` to get to a working 2-way CLI chat with the bare minimum.
* Owner grant uses a synthetic `cli:local` user so admin-gated flows work.
* *
* Creates/reuses: user, owner grant (if none), agent group + filesystem, * Creates/reuses: user, owner grant (if none), agent group + filesystem,
* messaging group(s), wiring, session. Stages a system welcome message so * messaging group(s), wiring.
* the host sweep wakes the container and the agent sends the greeting via
* the normal delivery path.
* *
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize * Runs alongside the service (WAL-mode sqlite + CLI socket IPC) — does NOT
* channel adapters, so there's no Gateway conflict. * initialize channel adapters, so there's no Gateway conflict. Requires
* the service to be running: the welcome hand-off goes over the CLI socket
* and fails loudly if the service isn't up.
* *
* Usage: * Usage:
* # DM mode
* pnpm exec tsx scripts/init-first-agent.ts \ * pnpm exec tsx scripts/init-first-agent.ts \
* --channel discord \ * --channel discord \
* --user-id discord:1470183333427675709 \ * --user-id discord:1470183333427675709 \
* --platform-id discord:@me:1491573333382523708 \ * --platform-id discord:@me:1491573333382523708 \
* --display-name "Gavriel" \ * --display-name "Gavriel" \
* [--agent-name "Andy"] \ * [--agent-name "Andy"] \
* [--welcome "System instruction: ..."] * [--welcome "System instruction: ..."] \
* * [--no-cli-bonus]
* # CLI-only mode
* pnpm exec tsx scripts/init-first-agent.ts --cli-only \
* --display-name "Gavriel" \
* [--agent-name "Andy"] \
* [--welcome "System instruction: ..."]
* *
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
* is typically the same as the handle in --user-id, with the channel prefix. * is typically the same as the handle in --user-id, with the channel prefix.
*/ */
import net from 'net';
import path from 'path'; import path from 'path';
import { DATA_DIR } from '../src/config.js'; import { DATA_DIR } from '../src/config.js';
@@ -54,11 +50,9 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js';
import { upsertUser } from '../src/modules/permissions/db/users.js'; import { upsertUser } from '../src/modules/permissions/db/users.js';
import { initGroupFilesystem } from '../src/group-init.js'; import { initGroupFilesystem } from '../src/group-init.js';
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js';
interface Args { interface Args {
cliOnly: boolean;
noCliBonus: boolean; noCliBonus: boolean;
channel: string; channel: string;
userId: string; userId: string;
@@ -73,17 +67,13 @@ const DEFAULT_WELCOME =
const CLI_CHANNEL = 'cli'; const CLI_CHANNEL = 'cli';
const CLI_PLATFORM_ID = 'local'; const CLI_PLATFORM_ID = 'local';
const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
const out: Partial<Args> = { cliOnly: false, noCliBonus: false }; const out: Partial<Args> = { noCliBonus: false };
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const key = argv[i]; const key = argv[i];
const val = argv[i + 1]; const val = argv[i + 1];
switch (key) { switch (key) {
case '--cli-only':
out.cliOnly = true;
break;
case '--no-cli-bonus': case '--no-cli-bonus':
out.noCliBonus = true; out.noCliBonus = true;
break; break;
@@ -114,42 +104,23 @@ function parseArgs(argv: string[]): Args {
} }
} }
if (!out.displayName) { const required: (keyof Args)[] = ['channel', 'userId', 'platformId', 'displayName'];
console.error('Missing required arg: --display-name');
console.error('See scripts/init-first-agent.ts header for usage.');
process.exit(2);
}
if (out.cliOnly) {
// CLI-only: channel/user/platform default to the synthetic local CLI identity.
return {
cliOnly: true,
noCliBonus: out.noCliBonus ?? false,
channel: CLI_CHANNEL,
userId: CLI_SYNTHETIC_USER_ID,
platformId: CLI_PLATFORM_ID,
displayName: out.displayName,
agentName: out.agentName?.trim() || out.displayName,
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
};
}
const required: (keyof Args)[] = ['channel', 'userId', 'platformId'];
const missing = required.filter((k) => !out[k]); const missing = required.filter((k) => !out[k]);
if (missing.length) { if (missing.length) {
console.error(`Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`); console.error(
`Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`,
);
console.error('See scripts/init-first-agent.ts header for usage.'); console.error('See scripts/init-first-agent.ts header for usage.');
process.exit(2); process.exit(2);
} }
return { return {
cliOnly: false,
noCliBonus: out.noCliBonus ?? false, noCliBonus: out.noCliBonus ?? false,
channel: out.channel!, channel: out.channel!,
userId: out.userId!, userId: out.userId!,
platformId: out.platformId!, platformId: out.platformId!,
displayName: out.displayName, displayName: out.displayName!,
agentName: out.agentName?.trim() || out.displayName, agentName: out.agentName?.trim() || out.displayName!,
welcome: out.welcome?.trim() || DEFAULT_WELCOME, welcome: out.welcome?.trim() || DEFAULT_WELCOME,
}; };
} }
@@ -217,7 +188,6 @@ async function main(): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
// 1. User + (conditional) owner grant. // 1. User + (conditional) owner grant.
// In cli-only mode, the synthetic `cli:local` user becomes the first owner.
const userId = namespacedUserId(args.channel, args.userId); const userId = namespacedUserId(args.channel, args.userId);
upsertUser({ upsertUser({
id: userId, id: userId,
@@ -238,10 +208,8 @@ async function main(): Promise<void> {
promotedToOwner = true; promotedToOwner = true;
} }
// 2. Agent group + filesystem // 2. Agent group + filesystem.
const folder = args.cliOnly const folder = `dm-with-${normalizeName(args.displayName)}`;
? `cli-with-${normalizeName(args.displayName)}`
: `dm-with-${normalizeName(args.displayName)}`;
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) { if (!ag) {
const agId = generateId('ag'); const agId = generateId('ag');
@@ -261,89 +229,115 @@ async function main(): Promise<void> {
instructions: instructions:
`# ${args.agentName}\n\n` + `# ${args.agentName}\n\n` +
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` + `You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
'When you receive a system welcome prompt, introduce yourself briefly and invite them to chat. Keep replies concise.', 'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.',
}); });
// 3. Primary messaging group + wiring + welcome session. // 3. DM messaging group.
// In DM mode: the DM messaging group is primary, CLI is wired as a bonus. const platformId = namespacedPlatformId(args.channel, args.platformId);
// In cli-only mode: the CLI messaging group is primary; no DM group. let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
const cliMg = ensureCliMessagingGroup(now); if (!dmMg) {
const mgId = generateId('mg');
let primaryMg: MessagingGroup; createMessagingGroup({
if (args.cliOnly) { id: mgId,
primaryMg = cliMg; channel_type: args.channel,
platform_id: platformId,
name: args.displayName,
is_group: 0,
unknown_sender_policy: 'strict',
created_at: now,
});
dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
console.log(`Created messaging group: ${dmMg.id} (${platformId})`);
} else { } else {
const platformId = namespacedPlatformId(args.channel, args.platformId); console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`);
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
if (!dmMg) {
const mgId = generateId('mg');
createMessagingGroup({
id: mgId,
channel_type: args.channel,
platform_id: platformId,
name: args.displayName,
is_group: 0,
unknown_sender_policy: 'strict',
created_at: now,
});
dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
console.log(`Created messaging group: ${dmMg.id} (${platformId})`);
} else {
console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`);
}
primaryMg = dmMg;
} }
// Wire primary (DM or CLI), auto-creates companion agent_destinations row. // 4. Wire DM (auto-creates companion agent_destinations row) and,
wireIfMissing(primaryMg, ag, now, args.cliOnly ? 'cli' : 'dm'); // unless suppressed, also wire the CLI channel so `pnpm run chat` works
// against the new agent immediately. `/new-setup-2` sets --no-cli-bonus
// In DM mode also wire CLI so `pnpm run chat` works immediately. // so the scratch CLI agent from `/new-setup` keeps owning CLI routing.
// Skip the bonus when --no-cli-bonus is set — used by /new-setup-2 so the wireIfMissing(dmMg, ag, now, 'dm');
// throwaway CLI-only agent from /new-setup still owns CLI routing cleanly. if (!args.noCliBonus) {
if (!args.cliOnly && !args.noCliBonus) { const cliMg = ensureCliMessagingGroup(now);
wireIfMissing(cliMg, ag, now, 'cli-bonus'); wireIfMissing(cliMg, ag, now, 'cli-bonus');
} }
// 4. Session + staged welcome (on the primary messaging group) // 5. Welcome delivery over the CLI socket. Router picks up the line,
const { session, created } = resolveSession(ag.id, primaryMg.id, null, 'shared'); // writes the message into the DM session's inbound.db, and wakes the
console.log(`${created ? 'Created' : 'Reusing'} session: ${session.id}`); // container synchronously — no sweep wait.
await sendWelcomeViaCliSocket(dmMg, args.welcome);
writeSessionMessage(ag.id, session.id, {
id: generateId('sys-welcome'),
kind: 'chat',
timestamp: now,
platformId: primaryMg.platform_id,
channelType: primaryMg.channel_type,
threadId: null,
content: JSON.stringify({
text: args.welcome,
sender: 'system',
senderId: 'system',
}),
});
console.log(''); console.log('');
console.log('Init complete.'); console.log('Init complete.');
console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`);
console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
if (args.cliOnly) { console.log(` channel: ${args.channel} ${dmMg.platform_id}`);
console.log(` channel: cli/${CLI_PLATFORM_ID}`); if (!args.noCliBonus) {
} else { console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``);
console.log(` channel: ${args.channel} ${primaryMg.platform_id}`);
if (!args.noCliBonus) {
console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``);
}
} }
console.log(` session: ${session.id}`);
console.log(''); console.log('');
console.log( console.log('Welcome DM queued — the agent will greet you shortly.');
args.cliOnly }
? 'Host sweep (<=60s) will wake the container. Try `pnpm run chat hi`.'
: 'Host sweep (<=60s) will wake the container and the agent will send the welcome DM.', /**
); * Hand the welcome to the running service via its CLI Unix socket. The
* service's CLI adapter receives `{text, to}`, builds an InboundEvent
* targeting the DM messaging group, and calls routeInbound(). Router writes
* the message into inbound.db and wakes the container synchronously.
*
* Throws if the socket isn't reachable — this script requires the service
* to be running.
*/
async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): Promise<void> {
const sockPath = path.join(DATA_DIR, 'cli.sock');
await new Promise<void>((resolve, reject) => {
const socket = net.connect(sockPath);
let settled = false;
const settle = (err: Error | null) => {
if (settled) return;
settled = true;
try {
socket.end();
} catch {
/* noop */
}
if (err) reject(err);
else resolve();
};
socket.once('error', (err) =>
settle(
new Error(
`CLI socket at ${sockPath} not reachable: ${err.message}. Is the NanoClaw service running?`,
),
),
);
socket.once('connect', () => {
const payload =
JSON.stringify({
text: welcome,
to: {
channelType: dmMg.channel_type,
platformId: dmMg.platform_id,
threadId: null,
},
}) + '\n';
socket.write(payload, (err) => {
if (err) {
settle(err);
return;
}
// Brief flush delay so the router picks up the line before we close.
// Router handles it synchronously once read, so 50ms is plenty.
setTimeout(() => settle(null), 50);
});
});
});
} }
main().catch((err) => { main().catch((err) => {
console.error(err); console.error(err instanceof Error ? err.message : err);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,14 +1,13 @@
/** /**
* Step: cli-agent — Create the first agent wired to the CLI channel. * Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
* *
* Thin wrapper around `scripts/init-first-agent.ts --cli-only`. Emits a * Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
* status block so /new-setup SKILL.md can parse the result without having * /new-setup SKILL.md can parse the result without having to read the
* to read the script's plain stdout. * script's plain stdout.
* *
* Args: * Args:
* --display-name <name> (required) operator's display name * --display-name <name> (required) operator's display name
* --agent-name <name> (optional) agent persona name, defaults to display-name * --agent-name <name> (optional) agent persona name, defaults to display-name
* --welcome <text> (optional) system welcome instruction
*/ */
import { execFileSync } from 'child_process'; import { execFileSync } from 'child_process';
import path from 'path'; import path from 'path';
@@ -19,11 +18,9 @@ import { emitStatus } from './status.js';
function parseArgs(args: string[]): { function parseArgs(args: string[]): {
displayName: string; displayName: string;
agentName?: string; agentName?: string;
welcome?: string;
} { } {
let displayName: string | undefined; let displayName: string | undefined;
let agentName: string | undefined; let agentName: string | undefined;
let welcome: string | undefined;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const key = args[i]; const key = args[i];
@@ -37,10 +34,6 @@ function parseArgs(args: string[]): {
agentName = val; agentName = val;
i++; i++;
break; break;
case '--welcome':
welcome = val;
i++;
break;
} }
} }
@@ -53,20 +46,19 @@ function parseArgs(args: string[]): {
process.exit(2); process.exit(2);
} }
return { displayName, agentName, welcome }; return { displayName, agentName };
} }
export async function run(args: string[]): Promise<void> { export async function run(args: string[]): Promise<void> {
const { displayName, agentName, welcome } = parseArgs(args); const { displayName, agentName } = parseArgs(args);
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const script = path.join(projectRoot, 'scripts', 'init-first-agent.ts'); const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
const scriptArgs = ['exec', 'tsx', script, '--cli-only', '--display-name', displayName]; const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
if (agentName) scriptArgs.push('--agent-name', agentName); if (agentName) scriptArgs.push('--agent-name', agentName);
if (welcome) scriptArgs.push('--welcome', welcome);
log.info('Invoking init-first-agent in cli-only mode', { displayName, agentName }); log.info('Invoking init-cli-agent', { displayName, agentName });
try { try {
execFileSync('pnpm', scriptArgs, { execFileSync('pnpm', scriptArgs, {
@@ -76,7 +68,7 @@ export async function run(args: string[]): Promise<void> {
}); });
} catch (err) { } catch (err) {
const e = err as { stdout?: string; stderr?: string; status?: number }; const e = err as { stdout?: string; stderr?: string; status?: number };
log.error('init-first-agent failed', { log.error('init-cli-agent failed', {
status: e.status, status: e.status,
stdout: e.stdout, stdout: e.stdout,
stderr: e.stderr, stderr: e.stderr,

View File

@@ -10,6 +10,14 @@ export interface ChannelSetup {
/** Called when an inbound message arrives from the platform. */ /** Called when an inbound message arrives from the platform. */
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise<void>; onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise<void>;
/**
* Called by admin-transport adapters (CLI) that want to route a message to
* an arbitrary channel/platform and optionally redirect replies elsewhere.
* Regular chat adapters should use `onInbound`; `onInboundEvent` skips the
* adapter-channel-type injection so the caller can target any wired mg.
*/
onInboundEvent(event: InboundEvent): void | Promise<void>;
/** Called when the adapter discovers metadata about a conversation. */ /** Called when the adapter discovers metadata about a conversation. */
onMetadata(platformId: string, name?: string, isGroup?: boolean): void; onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
@@ -17,6 +25,41 @@ export interface ChannelSetup {
onAction(questionId: string, selectedOption: string, userId: string): void; onAction(questionId: string, selectedOption: string, userId: string): void;
} }
/** Delivery address used for reply-to overrides and (normally) the inbound's own origin. */
export interface DeliveryAddress {
channelType: string;
platformId: string;
threadId: string | null;
}
/**
* Full inbound event handed to the router.
*
* `channelType` + `platformId` + `threadId` identify which messaging group /
* session receives the message. `replyTo`, when set, overrides where the
* agent's reply is delivered — used by the CLI admin transport when the
* operator wants a message routed to one channel but replies echoed back to
* their terminal. Agents cannot set `replyTo`; it is a router-layer concept
* set only by external adapters carrying operator intent.
*/
export interface InboundEvent {
channelType: string;
platformId: string;
threadId: string | null;
message: {
id: string;
kind: 'chat' | 'chat-sdk';
content: string; // JSON blob
timestamp: string;
/**
* Platform-confirmed bot-mention signal forwarded from the adapter.
* See InboundMessage.isMention for the full explanation.
*/
isMention?: boolean;
};
replyTo?: DeliveryAddress;
}
/** Inbound message from adapter to host. */ /** Inbound message from adapter to host. */
export interface InboundMessage { export interface InboundMessage {
id: string; id: string;

View File

@@ -105,6 +105,7 @@ describe('channel registry', () => {
await initChannelAdapters(() => ({ await initChannelAdapters(() => ({
conversations: [], conversations: [],
onInbound: () => {}, onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {}, onMetadata: () => {},
onAction: () => {}, onAction: () => {},
})); }));
@@ -208,6 +209,7 @@ describe('channel + router integration', () => {
await initChannelAdapters(() => ({ await initChannelAdapters(() => ({
conversations: [], conversations: [],
onInbound: () => {}, onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {}, onMetadata: () => {},
onAction: () => {}, onAction: () => {},
})); }));

View File

@@ -7,19 +7,31 @@
* the normal router/delivery path like any other adapter — `/clear` and * the normal router/delivery path like any other adapter — `/clear` and
* other session-level commands work identically. * other session-level commands work identically.
* *
* MVP shape: * Wire format: one JSON object per line.
* - One hardcoded messaging_group: `cli/local`. Wired to one agent via *
* the setup flow (see `scripts/init-first-agent.ts`). Multi-agent * Client → server:
* support can add per-agent messaging_groups later without breaking * { "text": "user message" } # default — talk to cli/local
* the wire protocol. * { "text": "...", "to": {"channelType": "discord",
* - Single connected client at a time. A second connection closes the * "platformId": "discord:@me:149...",
* first with a "superseded" notice. * "threadId": null} } # route to a specific mg
* - Wire format: one JSON object per line. * { "text": "...", "to": {...}, "reply_to": {...} } # + redirect replies
* Client → server: { "text": "user message" } * Server → client:
* Server → client: { "text": "agent reply" } * { "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 * The `to` and `reply_to` addressing is how admin transports (the bootstrap
* doesn't reach this run's terminal. Reconnect to see subsequent replies. * script) inject messages targeting any wired channel. `reply_to` is a
* router-layer concept — agents cannot set it; it is carried only on
* inbound events from CLI clients that hold operator privilege (the socket
* is chmod 0600, so "connected to this socket" ≈ "is the owner").
*
* Single-client chat semantics: one connected terminal at a time. A second
* "chat" connection closes the first with a "superseded" notice. Admin
* route-opcode connections (`to` set) are one-shot and do NOT evict an
* active chat client.
*
* 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 fs from 'fs';
import net from 'net'; import net from 'net';
@@ -30,7 +42,8 @@ import { log } from '../log.js';
import type { import type {
ChannelAdapter, ChannelAdapter,
ChannelSetup, ChannelSetup,
InboundMessage, DeliveryAddress,
InboundEvent,
OutboundMessage, OutboundMessage,
} from './adapter.js'; } from './adapter.js';
import { registerChannelAdapter } from './channel-registry.js'; import { registerChannelAdapter } from './channel-registry.js';
@@ -129,16 +142,25 @@ function createAdapter(): ChannelAdapter {
}; };
function handleConnection(socket: net.Socket, config: ChannelSetup): void { function handleConnection(socket: net.Socket, config: ChannelSetup): void {
if (client) { // Defer the chat-slot swap until we see the first line — if it turns out
try { // to be a routed (`to`-bearing) one-shot, we leave the existing chat
client.write(JSON.stringify({ text: '[superseded by a newer client]' }) + '\n'); // client in place. Only plain chat connections participate in supersede.
client.end(); let claimedChatSlot = false;
} catch {
// swallow const claimChatSlot = () => {
if (claimedChatSlot) return;
claimedChatSlot = true;
if (client && client !== socket) {
try {
client.write(JSON.stringify({ text: '[superseded by a newer client]' }) + '\n');
client.end();
} catch {
// swallow
}
} }
} client = socket;
client = socket; log.info('CLI client connected');
log.info('CLI client connected'); };
let buffer = ''; let buffer = '';
socket.on('data', (chunk) => { socket.on('data', (chunk) => {
@@ -148,13 +170,13 @@ function createAdapter(): ChannelAdapter {
const line = buffer.slice(0, idx).trim(); const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1); buffer = buffer.slice(idx + 1);
if (!line) continue; if (!line) continue;
void handleLine(line, config); void handleLine(line, config, claimChatSlot);
} }
}); });
socket.on('close', () => { socket.on('close', () => {
if (client === socket) client = null; if (client === socket) client = null;
log.info('CLI client disconnected'); if (claimedChatSlot) log.info('CLI client disconnected');
}); });
socket.on('error', (err) => { socket.on('error', (err) => {
@@ -162,8 +184,16 @@ function createAdapter(): ChannelAdapter {
}); });
} }
async function handleLine(line: string, config: ChannelSetup): Promise<void> { async function handleLine(
let payload: { text?: unknown }; line: string,
config: ChannelSetup,
claimChatSlot: () => void,
): Promise<void> {
let payload: {
text?: unknown;
to?: unknown;
reply_to?: unknown;
};
try { try {
payload = JSON.parse(line); payload = JSON.parse(line);
} catch (err) { } catch (err) {
@@ -172,23 +202,73 @@ function createAdapter(): ChannelAdapter {
} }
if (typeof payload.text !== 'string' || payload.text.length === 0) return; if (typeof payload.text !== 'string' || payload.text.length === 0) return;
const inbound: InboundMessage = { const to = parseAddress(payload.to);
id: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, const replyTo = parseAddress(payload.reply_to);
kind: 'chat',
timestamp: new Date().toISOString(), if (to) {
content: { // Routed message — admin transport. Build a full InboundEvent targeting
text: payload.text, // `to`'s channel/platform, and let `reply_to` (if any) redirect replies.
sender: 'cli', // Does NOT claim the chat slot, so an active terminal chat isn't evicted.
senderId: `cli:${PLATFORM_ID}`, const event: InboundEvent = {
}, channelType: to.channelType,
}; platformId: to.platformId,
threadId: to.threadId,
message: {
id: `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
content: JSON.stringify({
text: payload.text,
sender: 'cli',
senderId: `cli:${PLATFORM_ID}`,
}),
},
replyTo: replyTo ?? undefined,
};
try {
await config.onInboundEvent(event);
} catch (err) {
log.error('CLI: onInboundEvent threw', { err });
}
return;
}
// Plain chat — claim the slot (evicting any prior client) and route via
// the standard onInbound path (adapter injects its own channelType).
claimChatSlot();
try { try {
await config.onInbound(PLATFORM_ID, null, inbound); await config.onInbound(PLATFORM_ID, null, {
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}`,
},
});
} catch (err) { } catch (err) {
log.error('CLI: onInbound threw', { err }); log.error('CLI: onInbound threw', { err });
} }
} }
function parseAddress(raw: unknown): DeliveryAddress | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
if (typeof obj.channelType !== 'string' || typeof obj.platformId !== 'string') return null;
const threadId =
obj.threadId === null || obj.threadId === undefined
? null
: typeof obj.threadId === 'string'
? obj.threadId
: null;
return {
channelType: obj.channelType,
platformId: obj.platformId,
threadId,
};
}
return adapter; return adapter;
} }

View File

@@ -25,7 +25,7 @@ import {
outboundDbPath, outboundDbPath,
} from './session-manager.js'; } from './session-manager.js';
import { getSession, findSession } from './db/sessions.js'; import { getSession, findSession } from './db/sessions.js';
import type { InboundEvent } from './router.js'; import type { InboundEvent } from './channels/adapter.js';
// Mock container runner to prevent actual Docker spawning // Mock container runner to prevent actual Docker spawning
vi.mock('./container-runner.js', () => ({ vi.mock('./container-runner.js', () => ({

View File

@@ -86,6 +86,15 @@ async function main(): Promise<void> {
log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); log.error('Failed to route inbound message', { channelType: adapter.channelType, err });
}); });
}, },
onInboundEvent(event) {
routeInbound(event).catch((err) => {
log.error('Failed to route inbound event', {
sourceAdapter: adapter.channelType,
targetChannelType: event.channelType,
err,
});
});
},
onMetadata(platformId, name, isGroup) { onMetadata(platformId, name, isGroup) {
log.info('Channel metadata discovered', { log.info('Channel metadata discovered', {
channelType: adapter.channelType, channelType: adapter.channelType,

View File

@@ -57,6 +57,7 @@ async function mountMockAdapter(
await initChannelAdapters(() => ({ await initChannelAdapters(() => ({
conversations: [], conversations: [],
onInbound: () => {}, onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {}, onMetadata: () => {},
onAction: () => {}, onAction: () => {},
})); }));

View File

@@ -41,7 +41,7 @@ import { getAllAgentGroups } from '../../db/agent-groups.js';
import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js'; import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { InboundEvent } from '../../router.js'; import type { InboundEvent } from '../../channels/adapter.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';

View File

@@ -27,8 +27,8 @@ import {
setSenderResolver, setSenderResolver,
setSenderScopeGate, setSenderScopeGate,
type AccessGateResult, type AccessGateResult,
type InboundEvent,
} from '../../router.js'; } from '../../router.js';
import type { InboundEvent } from '../../channels/adapter.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';

View File

@@ -60,6 +60,7 @@ async function mountMockAdapter(
await initChannelAdapters(() => ({ await initChannelAdapters(() => ({
conversations: [], conversations: [],
onInbound: () => {}, onInbound: () => {},
onInboundEvent: () => {},
onMetadata: () => {}, onMetadata: () => {},
onAction: () => {}, onAction: () => {},
})); }));

View File

@@ -30,7 +30,7 @@ import { normalizeOptions, type RawOption } from '../../channels/ask-question.js
import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js'; import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { InboundEvent } from '../../router.js'; import type { InboundEvent } from '../../channels/adapter.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js'; import { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js';

View File

@@ -32,32 +32,12 @@ import { resolveSession, writeSessionMessage } from './session-manager.js';
import { wakeContainer } from './container-runner.js'; import { wakeContainer } from './container-runner.js';
import { getSession } from './db/sessions.js'; import { getSession } from './db/sessions.js';
import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js'; import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js';
import type { InboundEvent } from './channels/adapter.js';
function generateId(): string { function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
} }
export interface InboundEvent {
channelType: string;
platformId: string;
threadId: string | null;
message: {
id: string;
kind: 'chat' | 'chat-sdk';
content: string; // JSON blob
timestamp: string;
/**
* Platform-confirmed bot-mention signal forwarded from the adapter.
* When defined, it's authoritative — use this instead of text-matching
* agent_group_name, which breaks on platforms where the mention token
* is the bot's platform username (e.g. Telegram). undefined means the
* adapter doesn't provide the signal; evaluateEngage falls back to
* agent-name regex.
*/
isMention?: boolean;
};
}
/** /**
* Sender-resolver hook. Runs before agent resolution. * Sender-resolver hook. Runs before agent resolution.
* *
@@ -408,13 +388,23 @@ async function deliverToAgent(
const { session, created } = resolveSession(agent.agent_group_id, mg.id, event.threadId, effectiveSessionMode); const { session, created } = resolveSession(agent.agent_group_id, mg.id, event.threadId, effectiveSessionMode);
// The inbound row's (channel_type, platform_id, thread_id) is the address
// the agent's reply will be delivered to. Normally it mirrors the source
// (stamped from the event). When the caller supplied `replyTo` (CLI admin
// transport acting on operator intent), the reply is redirected there.
const deliveryAddr = event.replyTo ?? {
channelType: event.channelType,
platformId: event.platformId,
threadId: event.threadId,
};
writeSessionMessage(session.agent_group_id, session.id, { writeSessionMessage(session.agent_group_id, session.id, {
id: messageIdForAgent(event.message.id, agent.agent_group_id), id: messageIdForAgent(event.message.id, agent.agent_group_id),
kind: event.message.kind, kind: event.message.kind,
timestamp: event.message.timestamp, timestamp: event.message.timestamp,
platformId: event.platformId, platformId: deliveryAddr.platformId,
channelType: event.channelType, channelType: deliveryAddr.channelType,
threadId: event.threadId, threadId: deliveryAddr.threadId,
content: event.message.content, content: event.message.content,
trigger: wake ? 1 : 0, trigger: wake ? 1 : 0,
}); });