Three features built on top of @onecli-sh/sdk 0.3.1, landed together because
they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK
bridge, channel adapter contract).
## OneCLI manual-approval handler
* `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's
`configureManualApproval`; on each request, delivers an `ask_question` card
to the admin agent group's first messaging group, persists a
`pending_approvals` row, and waits on an in-memory Promise resolved by the
admin's button click or an expiry timer. Expired cards are edited to
"Expired (...)" and a startup sweep flushes any rows left over from a
previous process.
* Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the
Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays
in the persisted payload for audit.
* Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware
columns from the start (`agent_group_id`, `channel_type`, `platform_id`,
`platform_message_id`, `expires_at`, `status`), `session_id` relaxed to
nullable so cross-session approvals fit.
* `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals
through `resolveOneCLIApproval` before falling back to the
session-bound approval path.
## Credential collection from chat
New `trigger_credential_collection` MCP tool — the agent researches a
third-party API, calls the tool with `{name, hostPattern, headerName,
valueFormat, description}`, and blocks until the host reports saved, rejected,
or failed. The credential value never enters the agent's context: the user
submits it into a Chat SDK Modal on the host side, the host writes it to
OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to
`onecli secrets create`, shape mirrors the SDK we expect upstream), and only
the status string flows back to the container via a system message.
* `src/credentials.ts` — host-side handler: delivers the card to the
conversation's own channel (not the admin channel — credential collection
is a user-facing flow, distinct from admin approval), persists a
`pending_credentials` row, drives the submit → `createSecret` → notify
pipeline. Falls back gracefully when the channel doesn't support modals.
* `src/db/credentials.ts` + migration 005: `pending_credentials` table.
* `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card,
handles the `nccr:` action prefix by opening a Modal with a TextInput,
registers an `onModalSubmit` handler for the `nccm:` callback prefix.
* `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP
tool, mirroring the `ask_user_question` polling pattern.
* `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse`
helper to pick up the system message the host writes back.
## Threaded adapter routing
The destination layer previously didn't carry thread context, so agent replies
to Discord always landed in the root channel regardless of which thread the
inbound came from.
* `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill
at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat,
Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix,
Resend, iMessage.
* `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads
collapse to channel-level sessions). Threaded adapters override the
wiring's `session_mode` to `'per-thread'` so each thread = a session
(except `agent-shared`, which is preserved as a cross-channel intent the
adapter can't know about).
* `session_routing` table in `inbound.db` — single-row default reply routing
written by the host on every container wake from
`session.messaging_group_id` + `session.thread_id`. Forward-compat
`CREATE TABLE IF NOT EXISTS` handles older session DBs lazily.
* `container/agent-runner/src/db/session-routing.ts` — container-side reader.
* `send_message` / `send_file` / `ask_user_question` / `send_card` /
scheduling tools all default their routing (channel, platform, **and**
thread) from the session when no explicit `to` is given. Explicit `to`
uses the destination's channel with `thread_id = null` (cross-destination
sends start a new conversation elsewhere).
* `poll-loop.ts::sendToDestination` (the final-text single-destination
shortcut) now inherits `thread_id` from `RoutingContext` too — this was
the root cause of Discord replies landing in the root channel even after
`send_message` was wired correctly.
## Related cleanups
* `src/container-runner.ts`: OneCLI agent identifier switched from the lossy
folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)`
a trivial reverse lookup for per-agent scoping.
* `wakeContainer` race fix via an in-flight promise map — concurrent wakes
during the async buildContainerArgs / OneCLI `applyContainerConfig` window
no longer double-spawn containers against the same session directory.
* `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema
version assertion — it had to be bumped on every migration addition.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
/**
|
|
* NanoClaw v2 — main entry point.
|
|
*
|
|
* Thin orchestrator: init DB, run migrations, start channel adapters,
|
|
* start delivery polls, start sweep, handle shutdown.
|
|
*/
|
|
import path from 'path';
|
|
|
|
import { DATA_DIR } from './config.js';
|
|
import { initDb } from './db/connection.js';
|
|
import { runMigrations } from './db/migrations/index.js';
|
|
import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messaging-groups.js';
|
|
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
|
|
import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
|
|
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
|
import {
|
|
ONECLI_ACTION,
|
|
resolveOneCLIApproval,
|
|
startOneCLIApprovalHandler,
|
|
stopOneCLIApprovalHandler,
|
|
} from './onecli-approvals.js';
|
|
import {
|
|
getCredentialForModal,
|
|
handleCredentialChannelUnsupported,
|
|
handleCredentialReject,
|
|
handleCredentialSubmit,
|
|
setCredentialDeliveryAdapter,
|
|
} from './credentials.js';
|
|
import { routeInbound } from './router.js';
|
|
import {
|
|
getPendingQuestion,
|
|
deletePendingQuestion,
|
|
getPendingApproval,
|
|
deletePendingApproval,
|
|
getSession,
|
|
} from './db/sessions.js';
|
|
import { getAgentGroup, updateAgentGroup } from './db/agent-groups.js';
|
|
import { writeSessionMessage } from './session-manager.js';
|
|
import { wakeContainer, buildAgentGroupImage, killContainer } from './container-runner.js';
|
|
import { log } from './log.js';
|
|
|
|
// Channel barrel — each enabled channel self-registers on import.
|
|
// Channel skills uncomment lines in channels/index.ts to enable them.
|
|
import './channels/index.js';
|
|
|
|
import type { ChannelAdapter, ChannelSetup, ConversationConfig } from './channels/adapter.js';
|
|
import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js';
|
|
|
|
async function main(): Promise<void> {
|
|
log.info('NanoClaw v2 starting');
|
|
|
|
// 1. Init central DB
|
|
const dbPath = path.join(DATA_DIR, 'v2.db');
|
|
const db = initDb(dbPath);
|
|
runMigrations(db);
|
|
log.info('Central DB ready', { path: dbPath });
|
|
|
|
// 2. Container runtime
|
|
ensureContainerRuntimeRunning();
|
|
cleanupOrphans();
|
|
|
|
// 3. Channel adapters
|
|
await initChannelAdapters((adapter: ChannelAdapter): ChannelSetup => {
|
|
const conversations = buildConversationConfigs(adapter.channelType);
|
|
return {
|
|
conversations,
|
|
onInbound(platformId, threadId, message) {
|
|
routeInbound({
|
|
channelType: adapter.channelType,
|
|
platformId,
|
|
threadId,
|
|
message: {
|
|
id: message.id,
|
|
kind: message.kind,
|
|
content: JSON.stringify(message.content),
|
|
timestamp: message.timestamp,
|
|
},
|
|
}).catch((err) => {
|
|
log.error('Failed to route inbound message', { channelType: adapter.channelType, err });
|
|
});
|
|
},
|
|
onMetadata(platformId, name, isGroup) {
|
|
log.info('Channel metadata discovered', {
|
|
channelType: adapter.channelType,
|
|
platformId,
|
|
name,
|
|
isGroup,
|
|
});
|
|
},
|
|
onAction(questionId, selectedOption, userId) {
|
|
handleQuestionResponse(questionId, selectedOption, userId).catch((err) => {
|
|
log.error('Failed to handle question response', { questionId, err });
|
|
});
|
|
},
|
|
getCredentialForModal,
|
|
onCredentialReject(credentialId) {
|
|
handleCredentialReject(credentialId).catch((err) =>
|
|
log.error('Failed to handle credential reject', { credentialId, err }),
|
|
);
|
|
},
|
|
onCredentialSubmit(credentialId, value) {
|
|
handleCredentialSubmit(credentialId, value).catch((err) =>
|
|
log.error('Failed to handle credential submit', { credentialId, err }),
|
|
);
|
|
},
|
|
onCredentialChannelUnsupported(credentialId) {
|
|
handleCredentialChannelUnsupported(credentialId).catch((err) =>
|
|
log.error('Failed to handle credential channel-unsupported', { credentialId, err }),
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
// 4. Delivery adapter bridge — dispatches to channel adapters
|
|
const deliveryAdapter = {
|
|
async deliver(
|
|
channelType: string,
|
|
platformId: string,
|
|
threadId: string | null,
|
|
kind: string,
|
|
content: string,
|
|
files?: import('./channels/adapter.js').OutboundFile[],
|
|
): Promise<string | undefined> {
|
|
const adapter = getChannelAdapter(channelType);
|
|
if (!adapter) {
|
|
log.warn('No adapter for channel type', { channelType });
|
|
return;
|
|
}
|
|
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
|
|
},
|
|
async setTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
|
|
const adapter = getChannelAdapter(channelType);
|
|
await adapter?.setTyping?.(platformId, threadId);
|
|
},
|
|
};
|
|
setDeliveryAdapter(deliveryAdapter);
|
|
setCredentialDeliveryAdapter(deliveryAdapter);
|
|
|
|
// 5. Start delivery polls
|
|
startActiveDeliveryPoll();
|
|
startSweepDeliveryPoll();
|
|
log.info('Delivery polls started');
|
|
|
|
// 6. Start host sweep
|
|
startHostSweep();
|
|
log.info('Host sweep started');
|
|
|
|
// 7. Start OneCLI manual-approval handler
|
|
startOneCLIApprovalHandler(deliveryAdapter);
|
|
|
|
log.info('NanoClaw v2 running');
|
|
}
|
|
|
|
/** Build ConversationConfig[] for a channel type from the central DB. */
|
|
function buildConversationConfigs(channelType: string): ConversationConfig[] {
|
|
const groups = getMessagingGroupsByChannel(channelType);
|
|
const configs: ConversationConfig[] = [];
|
|
|
|
for (const mg of groups) {
|
|
const agents = getMessagingGroupAgents(mg.id);
|
|
for (const agent of agents) {
|
|
const triggerRules = agent.trigger_rules ? JSON.parse(agent.trigger_rules) : null;
|
|
configs.push({
|
|
platformId: mg.platform_id,
|
|
agentGroupId: agent.agent_group_id,
|
|
triggerPattern: triggerRules?.pattern,
|
|
requiresTrigger: triggerRules?.requiresTrigger ?? false,
|
|
sessionMode: agent.session_mode,
|
|
});
|
|
}
|
|
}
|
|
|
|
return configs;
|
|
}
|
|
|
|
/** Handle a user's response to an ask_user_question card or an approval card. */
|
|
async function handleQuestionResponse(questionId: string, selectedOption: string, userId: string): Promise<void> {
|
|
// OneCLI credential approvals — resolved via in-memory Promise, not session DB
|
|
if (resolveOneCLIApproval(questionId, selectedOption)) {
|
|
return;
|
|
}
|
|
|
|
// Check if this is a pending approval (install_packages, request_rebuild)
|
|
const approval = getPendingApproval(questionId);
|
|
if (approval) {
|
|
if (approval.action === ONECLI_ACTION) {
|
|
// Row exists but the in-memory resolver is gone (timer fired or process
|
|
// was in a weird state). Nothing to do — just drop the row.
|
|
deletePendingApproval(questionId);
|
|
return;
|
|
}
|
|
await handleApprovalResponse(approval, selectedOption, userId);
|
|
return;
|
|
}
|
|
|
|
const pq = getPendingQuestion(questionId);
|
|
if (!pq) {
|
|
log.warn('Pending question not found (may have expired)', { questionId });
|
|
return;
|
|
}
|
|
|
|
const session = getSession(pq.session_id);
|
|
if (!session) {
|
|
log.warn('Session not found for pending question', { questionId, sessionId: pq.session_id });
|
|
deletePendingQuestion(questionId);
|
|
return;
|
|
}
|
|
|
|
// Write the response to the session DB as a system message
|
|
writeSessionMessage(session.agent_group_id, session.id, {
|
|
id: `qr-${questionId}-${Date.now()}`,
|
|
kind: 'system',
|
|
timestamp: new Date().toISOString(),
|
|
platformId: pq.platform_id,
|
|
channelType: pq.channel_type,
|
|
threadId: pq.thread_id,
|
|
content: JSON.stringify({
|
|
type: 'question_response',
|
|
questionId,
|
|
selectedOption,
|
|
userId,
|
|
}),
|
|
});
|
|
|
|
deletePendingQuestion(questionId);
|
|
log.info('Question response routed', { questionId, selectedOption, sessionId: session.id });
|
|
|
|
// Wake the container so the MCP tool's poll picks up the response
|
|
await wakeContainer(session);
|
|
}
|
|
|
|
/**
|
|
* Handle an admin's response to an approval card.
|
|
* Fire-and-forget model: the agent doesn't poll for this — we write a chat
|
|
* notification to its session DB, and optionally kill the container so the
|
|
* next wake picks up new config/images.
|
|
*/
|
|
async function handleApprovalResponse(
|
|
approval: import('./types.js').PendingApproval,
|
|
selectedOption: string,
|
|
userId: string,
|
|
): Promise<void> {
|
|
if (!approval.session_id) {
|
|
deletePendingApproval(approval.approval_id);
|
|
return;
|
|
}
|
|
const session = getSession(approval.session_id);
|
|
if (!session) {
|
|
deletePendingApproval(approval.approval_id);
|
|
return;
|
|
}
|
|
|
|
const notify = (text: string): void => {
|
|
writeSessionMessage(session.agent_group_id, session.id, {
|
|
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
kind: 'chat',
|
|
timestamp: new Date().toISOString(),
|
|
platformId: session.agent_group_id,
|
|
channelType: 'agent',
|
|
threadId: null,
|
|
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
|
});
|
|
};
|
|
|
|
if (selectedOption !== 'Approve') {
|
|
notify(`Your ${approval.action} request was rejected by admin.`);
|
|
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
|
deletePendingApproval(approval.approval_id);
|
|
await wakeContainer(session);
|
|
return;
|
|
}
|
|
|
|
const payload = JSON.parse(approval.payload);
|
|
|
|
if (approval.action === 'install_packages') {
|
|
const agentGroup = getAgentGroup(session.agent_group_id);
|
|
const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
|
|
if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] };
|
|
if (payload.apt) containerConfig.packages.apt.push(...payload.apt);
|
|
if (payload.npm) containerConfig.packages.npm.push(...payload.npm);
|
|
updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
|
|
|
|
const pkgs = [...(payload.apt || []), ...(payload.npm || [])].join(', ');
|
|
notify(`Packages approved (${pkgs}). Call request_rebuild to apply them.`);
|
|
log.info('Package install approved', { approvalId: approval.approval_id, userId });
|
|
} else if (approval.action === 'request_rebuild') {
|
|
try {
|
|
await buildAgentGroupImage(session.agent_group_id);
|
|
// Kill the container so the next wake uses the new image
|
|
killContainer(session.id, 'rebuild applied');
|
|
notify('Container image rebuilt. Your container will restart with the new image on the next message.');
|
|
log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId });
|
|
} catch (e) {
|
|
notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e });
|
|
}
|
|
} else if (approval.action === 'add_mcp_server') {
|
|
const agentGroup = getAgentGroup(session.agent_group_id);
|
|
const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
|
|
if (!containerConfig.mcpServers) containerConfig.mcpServers = {};
|
|
containerConfig.mcpServers[payload.name] = {
|
|
command: payload.command,
|
|
args: payload.args || [],
|
|
env: payload.env || {},
|
|
};
|
|
updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
|
|
|
|
// Kill the container so next wake loads the new MCP server config
|
|
killContainer(session.id, 'mcp server added');
|
|
notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
|
|
log.info('MCP server add approved', { approvalId: approval.approval_id, userId });
|
|
}
|
|
|
|
deletePendingApproval(approval.approval_id);
|
|
await wakeContainer(session);
|
|
}
|
|
|
|
/** Graceful shutdown. */
|
|
async function shutdown(signal: string): Promise<void> {
|
|
log.info('Shutdown signal received', { signal });
|
|
stopOneCLIApprovalHandler();
|
|
stopDeliveryPolls();
|
|
stopHostSweep();
|
|
await teardownChannelAdapters();
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
|
main().catch((err) => {
|
|
log.fatal('Startup failed', { err });
|
|
process.exit(1);
|
|
});
|