feat(v2): OneCLI 0.3.1 — approvals, credential collection, threaded routing
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>
This commit is contained in:
65
src/index.ts
65
src/index.ts
@@ -13,6 +13,19 @@ import { getMessagingGroupsByChannel, getMessagingGroupAgents } from './db/messa
|
||||
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,
|
||||
@@ -79,12 +92,35 @@ async function main(): Promise<void> {
|
||||
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
|
||||
setDeliveryAdapter({
|
||||
async deliver(channelType, platformId, threadId, kind, content, files) {
|
||||
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 });
|
||||
@@ -92,11 +128,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
|
||||
},
|
||||
async setTyping(channelType, platformId, threadId) {
|
||||
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();
|
||||
@@ -107,6 +145,9 @@ async function main(): Promise<void> {
|
||||
startHostSweep();
|
||||
log.info('Host sweep started');
|
||||
|
||||
// 7. Start OneCLI manual-approval handler
|
||||
startOneCLIApprovalHandler(deliveryAdapter);
|
||||
|
||||
log.info('NanoClaw v2 running');
|
||||
}
|
||||
|
||||
@@ -134,9 +175,20 @@ function buildConversationConfigs(channelType: string): ConversationConfig[] {
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -188,6 +240,10 @@ async function handleApprovalResponse(
|
||||
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);
|
||||
@@ -262,6 +318,7 @@ async function handleApprovalResponse(
|
||||
/** Graceful shutdown. */
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
log.info('Shutdown signal received', { signal });
|
||||
stopOneCLIApprovalHandler();
|
||||
stopDeliveryPolls();
|
||||
stopHostSweep();
|
||||
await teardownChannelAdapters();
|
||||
|
||||
Reference in New Issue
Block a user