diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index be78845..6b110d3 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -87,18 +87,17 @@ The script: 2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-/`. 3. Reuses or creates the DM `messaging_groups` 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`). -6. Writes a `kind: 'chat'`, `sender: 'system'` welcome message into `inbound.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. Show the script's output to the user. ## 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: -> 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. diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts new file mode 100644 index 0000000..ccd9387 --- /dev/null +++ b/scripts/init-cli-agent.ts @@ -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 { + 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); +}); diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 8468778..29ca6d4 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -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, - * etc.) + the CLI channel to the same agent, stages a welcome into the DM - * session so the agent greets the operator over that channel. - * - * 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. + * For the CLI-only scratch agent used during `/new-setup`, see + * `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run + * through here. * * Creates/reuses: user, owner grant (if none), agent group + filesystem, - * messaging group(s), wiring, session. Stages a system welcome message so - * the host sweep wakes the container and the agent sends the greeting via - * the normal delivery path. + * messaging group(s), wiring. * - * Runs alongside the service (WAL-mode sqlite) — does NOT initialize - * channel adapters, so there's no Gateway conflict. + * Runs alongside the service (WAL-mode sqlite + CLI socket IPC) — does NOT + * 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: - * # DM mode * pnpm exec tsx scripts/init-first-agent.ts \ * --channel discord \ * --user-id discord:1470183333427675709 \ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] - * - * # CLI-only mode - * pnpm exec tsx scripts/init-first-agent.ts --cli-only \ - * --display-name "Gavriel" \ - * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] + * [--welcome "System instruction: ..."] \ + * [--no-cli-bonus] * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. */ +import net from 'net'; import path from 'path'; 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 { 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, MessagingGroup } from '../src/types.js'; interface Args { - cliOnly: boolean; noCliBonus: boolean; channel: string; userId: string; @@ -73,17 +67,13 @@ const DEFAULT_WELCOME = const CLI_CHANNEL = 'cli'; const CLI_PLATFORM_ID = 'local'; -const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; function parseArgs(argv: string[]): Args { - const out: Partial = { cliOnly: false, noCliBonus: false }; + const out: Partial = { noCliBonus: false }; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; switch (key) { - case '--cli-only': - out.cliOnly = true; - break; case '--no-cli-bonus': out.noCliBonus = true; break; @@ -114,42 +104,23 @@ function parseArgs(argv: string[]): Args { } } - if (!out.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 required: (keyof Args)[] = ['channel', 'userId', 'platformId', 'displayName']; const missing = required.filter((k) => !out[k]); 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.'); process.exit(2); } return { - cliOnly: false, noCliBonus: out.noCliBonus ?? false, channel: out.channel!, userId: out.userId!, platformId: out.platformId!, - displayName: out.displayName, - agentName: out.agentName?.trim() || out.displayName, + displayName: out.displayName!, + agentName: out.agentName?.trim() || out.displayName!, welcome: out.welcome?.trim() || DEFAULT_WELCOME, }; } @@ -217,7 +188,6 @@ async function main(): Promise { const now = new Date().toISOString(); // 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); upsertUser({ id: userId, @@ -238,10 +208,8 @@ async function main(): Promise { promotedToOwner = true; } - // 2. Agent group + filesystem - const folder = args.cliOnly - ? `cli-with-${normalizeName(args.displayName)}` - : `dm-with-${normalizeName(args.displayName)}`; + // 2. Agent group + filesystem. + const folder = `dm-with-${normalizeName(args.displayName)}`; let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); if (!ag) { const agId = generateId('ag'); @@ -261,89 +229,115 @@ async function main(): Promise { instructions: `# ${args.agentName}\n\n` + `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. - // In DM mode: the DM messaging group is primary, CLI is wired as a bonus. - // In cli-only mode: the CLI messaging group is primary; no DM group. - const cliMg = ensureCliMessagingGroup(now); - - let primaryMg: MessagingGroup; - if (args.cliOnly) { - primaryMg = cliMg; + // 3. DM messaging group. + const platformId = namespacedPlatformId(args.channel, args.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 { - const platformId = namespacedPlatformId(args.channel, args.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; + console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`); } - // Wire primary (DM or CLI), auto-creates companion agent_destinations row. - wireIfMissing(primaryMg, ag, now, args.cliOnly ? 'cli' : 'dm'); - - // In DM mode also wire CLI so `pnpm run chat` works immediately. - // Skip the bonus when --no-cli-bonus is set — used by /new-setup-2 so the - // throwaway CLI-only agent from /new-setup still owns CLI routing cleanly. - if (!args.cliOnly && !args.noCliBonus) { + // 4. Wire DM (auto-creates companion agent_destinations row) and, + // unless suppressed, also wire the CLI channel so `pnpm run chat` works + // against the new agent immediately. `/new-setup-2` sets --no-cli-bonus + // so the scratch CLI agent from `/new-setup` keeps owning CLI routing. + wireIfMissing(dmMg, ag, now, 'dm'); + if (!args.noCliBonus) { + const cliMg = ensureCliMessagingGroup(now); wireIfMissing(cliMg, ag, now, 'cli-bonus'); } - // 4. Session + staged welcome (on the primary messaging group) - const { session, created } = resolveSession(ag.id, primaryMg.id, null, 'shared'); - console.log(`${created ? 'Created' : 'Reusing'} session: ${session.id}`); - - 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', - }), - }); + // 5. Welcome delivery over the CLI socket. Router picks up the line, + // writes the message into the DM session's inbound.db, and wakes the + // container synchronously — no sweep wait. + await sendWelcomeViaCliSocket(dmMg, args.welcome); console.log(''); console.log('Init complete.'); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); - if (args.cliOnly) { - console.log(` channel: cli/${CLI_PLATFORM_ID}`); - } else { - 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(` channel: ${args.channel} ${dmMg.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( - 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.', - ); + console.log('Welcome DM queued — the agent will greet you shortly.'); +} + +/** + * 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 { + const sockPath = path.join(DATA_DIR, 'cli.sock'); + + await new Promise((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) => { - console.error(err); + console.error(err instanceof Error ? err.message : err); process.exit(1); }); diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts index e5a901d..d9a90c5 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -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 - * status block so /new-setup SKILL.md can parse the result without having - * to read the script's plain stdout. + * Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so + * /new-setup SKILL.md can parse the result without having to read the + * script's plain stdout. * * Args: * --display-name (required) operator's display name * --agent-name (optional) agent persona name, defaults to display-name - * --welcome (optional) system welcome instruction */ import { execFileSync } from 'child_process'; import path from 'path'; @@ -19,11 +18,9 @@ import { emitStatus } from './status.js'; function parseArgs(args: string[]): { displayName: string; agentName?: string; - welcome?: string; } { let displayName: string | undefined; let agentName: string | undefined; - let welcome: string | undefined; for (let i = 0; i < args.length; i++) { const key = args[i]; @@ -37,10 +34,6 @@ function parseArgs(args: string[]): { agentName = val; i++; break; - case '--welcome': - welcome = val; - i++; - break; } } @@ -53,20 +46,19 @@ function parseArgs(args: string[]): { process.exit(2); } - return { displayName, agentName, welcome }; + return { displayName, agentName }; } export async function run(args: string[]): Promise { - const { displayName, agentName, welcome } = parseArgs(args); + const { displayName, agentName } = parseArgs(args); 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 (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 { execFileSync('pnpm', scriptArgs, { @@ -76,7 +68,7 @@ export async function run(args: string[]): Promise { }); } catch (err) { 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, stdout: e.stdout, stderr: e.stderr, diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 9343258..d8d8f9d 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -10,6 +10,14 @@ export interface ChannelSetup { /** Called when an inbound message arrives from the platform. */ onInbound(platformId: string, threadId: string | null, message: InboundMessage): void | Promise; + /** + * 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; + /** Called when the adapter discovers metadata about a conversation. */ onMetadata(platformId: string, name?: string, isGroup?: boolean): void; @@ -17,6 +25,41 @@ export interface ChannelSetup { 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. */ export interface InboundMessage { id: string; diff --git a/src/channels/channel-registry.test.ts b/src/channels/channel-registry.test.ts index 5121c64..27ee660 100644 --- a/src/channels/channel-registry.test.ts +++ b/src/channels/channel-registry.test.ts @@ -105,6 +105,7 @@ describe('channel registry', () => { await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); @@ -208,6 +209,7 @@ describe('channel + router integration', () => { await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); diff --git a/src/channels/cli.ts b/src/channels/cli.ts index c84952c..ad5e7e3 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -7,19 +7,31 @@ * 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. + * Wire format: one JSON object per line. + * + * Client → server: + * { "text": "user message" } # default — talk to cli/local + * { "text": "...", "to": {"channelType": "discord", + * "platformId": "discord:@me:149...", + * "threadId": null} } # route to a specific mg + * { "text": "...", "to": {...}, "reply_to": {...} } # + redirect replies + * Server → client: + * { "text": "agent reply" } + * + * The `to` and `reply_to` addressing is how admin transports (the bootstrap + * 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 net from 'net'; @@ -30,7 +42,8 @@ import { log } from '../log.js'; import type { ChannelAdapter, ChannelSetup, - InboundMessage, + DeliveryAddress, + InboundEvent, OutboundMessage, } from './adapter.js'; import { registerChannelAdapter } from './channel-registry.js'; @@ -129,16 +142,25 @@ function createAdapter(): ChannelAdapter { }; 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 + // Defer the chat-slot swap until we see the first line — if it turns out + // to be a routed (`to`-bearing) one-shot, we leave the existing chat + // client in place. Only plain chat connections participate in supersede. + let claimedChatSlot = false; + + 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; - log.info('CLI client connected'); + client = socket; + log.info('CLI client connected'); + }; let buffer = ''; socket.on('data', (chunk) => { @@ -148,13 +170,13 @@ function createAdapter(): ChannelAdapter { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line) continue; - void handleLine(line, config); + void handleLine(line, config, claimChatSlot); } }); socket.on('close', () => { if (client === socket) client = null; - log.info('CLI client disconnected'); + if (claimedChatSlot) log.info('CLI client disconnected'); }); socket.on('error', (err) => { @@ -162,8 +184,16 @@ function createAdapter(): ChannelAdapter { }); } - async function handleLine(line: string, config: ChannelSetup): Promise { - let payload: { text?: unknown }; + async function handleLine( + line: string, + config: ChannelSetup, + claimChatSlot: () => void, + ): Promise { + let payload: { + text?: unknown; + to?: unknown; + reply_to?: unknown; + }; try { payload = JSON.parse(line); } catch (err) { @@ -172,23 +202,73 @@ function createAdapter(): ChannelAdapter { } 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}`, - }, - }; + const to = parseAddress(payload.to); + const replyTo = parseAddress(payload.reply_to); + + if (to) { + // Routed message — admin transport. Build a full InboundEvent targeting + // `to`'s channel/platform, and let `reply_to` (if any) redirect replies. + // Does NOT claim the chat slot, so an active terminal chat isn't evicted. + 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 { - 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) { log.error('CLI: onInbound threw', { err }); } } + function parseAddress(raw: unknown): DeliveryAddress | null { + if (!raw || typeof raw !== 'object') return null; + const obj = raw as Record; + 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; } diff --git a/src/host-core.test.ts b/src/host-core.test.ts index da2fd37..9906c4b 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -25,7 +25,7 @@ import { outboundDbPath, } from './session-manager.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 vi.mock('./container-runner.js', () => ({ diff --git a/src/index.ts b/src/index.ts index 7c5ab24..1ec8619 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,15 @@ async function main(): Promise { 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) { log.info('Channel metadata discovered', { channelType: adapter.channelType, diff --git a/src/modules/approvals/picks.test.ts b/src/modules/approvals/picks.test.ts index 508aa35..c48f58d 100644 --- a/src/modules/approvals/picks.test.ts +++ b/src/modules/approvals/picks.test.ts @@ -57,6 +57,7 @@ async function mountMockAdapter( await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index 9c65f8e..caef815 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -41,7 +41,7 @@ import { getAllAgentGroups } from '../../db/agent-groups.js'; import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.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 { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index e2f100c..6913c72 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -27,8 +27,8 @@ import { setSenderResolver, setSenderScopeGate, type AccessGateResult, - type InboundEvent, } from '../../router.js'; +import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; diff --git a/src/modules/permissions/permissions.test.ts b/src/modules/permissions/permissions.test.ts index d76d0d6..c66e082 100644 --- a/src/modules/permissions/permissions.test.ts +++ b/src/modules/permissions/permissions.test.ts @@ -60,6 +60,7 @@ async function mountMockAdapter( await initChannelAdapters(() => ({ conversations: [], onInbound: () => {}, + onInboundEvent: () => {}, onMetadata: () => {}, onAction: () => {}, })); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index be60280..e08123a 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -30,7 +30,7 @@ import { normalizeOptions, type RawOption } from '../../channels/ask-question.js import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.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 { createPendingSenderApproval, hasInFlightSenderApproval } from './db/pending-sender-approvals.js'; diff --git a/src/router.ts b/src/router.ts index 4289f1f..c1e8881 100644 --- a/src/router.ts +++ b/src/router.ts @@ -32,32 +32,12 @@ import { resolveSession, writeSessionMessage } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; import { getSession } from './db/sessions.js'; import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js'; +import type { InboundEvent } from './channels/adapter.js'; function generateId(): string { 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. * @@ -408,13 +388,23 @@ async function deliverToAgent( 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, { id: messageIdForAgent(event.message.id, agent.agent_group_id), kind: event.message.kind, timestamp: event.message.timestamp, - platformId: event.platformId, - channelType: event.channelType, - threadId: event.threadId, + platformId: deliveryAddr.platformId, + channelType: deliveryAddr.channelType, + threadId: deliveryAddr.threadId, content: event.message.content, trigger: wake ? 1 : 0, });