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:
@@ -27,6 +27,12 @@ export interface ChannelSetup {
|
||||
|
||||
/** Called when a user clicks a button/action in a card (e.g., ask_user_question response). */
|
||||
onAction(questionId: string, selectedOption: string, userId: string): void;
|
||||
|
||||
/** Credential collection hooks — used by chat-sdk-bridge to route the modal flow. */
|
||||
getCredentialForModal?(credentialId: string): { name: string; description: string | null; hostPattern: string } | null;
|
||||
onCredentialReject?(credentialId: string): void;
|
||||
onCredentialSubmit?(credentialId: string, value: string): void;
|
||||
onCredentialChannelUnsupported?(credentialId: string): void;
|
||||
}
|
||||
|
||||
/** Inbound message from adapter to host. */
|
||||
@@ -62,6 +68,18 @@ export interface ChannelAdapter {
|
||||
name: string;
|
||||
channelType: string;
|
||||
|
||||
/**
|
||||
* Whether this adapter models conversations as threads.
|
||||
*
|
||||
* true — adapter's platform uses threads as the primary conversation unit
|
||||
* (Discord, Slack, Linear, GitHub). One thread = one session; the
|
||||
* agent replies into the originating thread.
|
||||
* false — adapter's platform treats the channel itself as the conversation
|
||||
* (Telegram, WhatsApp, iMessage). Thread ids are stripped at the
|
||||
* router; agent replies go to the channel.
|
||||
*/
|
||||
supportsThreads: boolean;
|
||||
|
||||
// Lifecycle
|
||||
setup(config: ChannelSetup): Promise<void>;
|
||||
teardown(): Promise<void>;
|
||||
|
||||
@@ -39,6 +39,7 @@ function createMockAdapter(
|
||||
return {
|
||||
name: channelType,
|
||||
channelType,
|
||||
supportsThreads: false,
|
||||
delivered,
|
||||
inbound,
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
CardText,
|
||||
Actions,
|
||||
Button,
|
||||
Modal,
|
||||
TextInput,
|
||||
type Adapter,
|
||||
type ConcurrencyStrategy,
|
||||
type Message as ChatMessage,
|
||||
@@ -47,6 +49,13 @@ export interface ChatSdkBridgeConfig {
|
||||
botToken?: string;
|
||||
/** Platform-specific reply context extraction. */
|
||||
extractReplyContext?: ReplyContextExtractor;
|
||||
/**
|
||||
* Whether this platform uses threads as the primary conversation unit.
|
||||
* See `ChannelAdapter.supportsThreads`. Declared by the calling channel
|
||||
* skill, not inferred, because some platforms (Discord) can be used either
|
||||
* way and the default depends on installation style.
|
||||
*/
|
||||
supportsThreads: boolean;
|
||||
}
|
||||
|
||||
export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter {
|
||||
@@ -116,6 +125,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
return {
|
||||
name: adapter.name,
|
||||
channelType: adapter.name,
|
||||
supportsThreads: config.supportsThreads,
|
||||
|
||||
async setup(hostConfig: ChannelSetup) {
|
||||
setupConfig = hostConfig;
|
||||
@@ -151,8 +161,75 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
await thread.subscribe();
|
||||
});
|
||||
|
||||
// Handle button clicks (ask_user_question responses)
|
||||
// Handle button clicks (ask_user_question, credential card)
|
||||
chat.onAction(async (event) => {
|
||||
// Credential card actions: nccr:<credentialId>:<enter|reject>
|
||||
if (event.actionId.startsWith('nccr:')) {
|
||||
const [, credentialId, subAction] = event.actionId.split(':');
|
||||
if (!credentialId || !subAction) return;
|
||||
|
||||
if (subAction === 'reject') {
|
||||
try {
|
||||
await adapter.editMessage(event.threadId, event.messageId, {
|
||||
markdown: `🔑 Credential request\n\n❌ Rejected`,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('Failed to update credential card after reject', { err });
|
||||
}
|
||||
setupConfig.onCredentialReject?.(credentialId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subAction === 'enter') {
|
||||
const pending = setupConfig.getCredentialForModal?.(credentialId);
|
||||
if (!pending) {
|
||||
log.warn('Credential card clicked but row not pending', { credentialId });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const modalChildren = [
|
||||
CardText(
|
||||
pending.description ??
|
||||
`Enter the value for ${pending.name} (host: ${pending.hostPattern}).`,
|
||||
),
|
||||
TextInput({
|
||||
id: 'value',
|
||||
label: pending.name,
|
||||
placeholder: 'Paste your credential value',
|
||||
}),
|
||||
];
|
||||
// Modal children include a text element for context; the SDK
|
||||
// accepts TextElement in ModalChild so this is valid.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const modal = Modal({
|
||||
callbackId: `nccm:${credentialId}`,
|
||||
title: 'Enter credential',
|
||||
submitLabel: 'Save',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children: modalChildren as any,
|
||||
});
|
||||
const result = await event.openModal(modal);
|
||||
if (!result) {
|
||||
log.warn('openModal returned undefined — channel unsupported', { credentialId });
|
||||
setupConfig.onCredentialChannelUnsupported?.(credentialId);
|
||||
try {
|
||||
await adapter.editMessage(event.threadId, event.messageId, {
|
||||
markdown: `🔑 Credential request\n\n⚠️ This channel does not support modals.`,
|
||||
});
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to open credential modal', { credentialId, err });
|
||||
setupConfig.onCredentialChannelUnsupported?.(credentialId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.actionId.startsWith('ncq:')) return;
|
||||
const parts = event.actionId.split(':');
|
||||
if (parts.length < 3) return;
|
||||
@@ -173,6 +250,18 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
setupConfig.onAction(questionId, selectedOption, userId);
|
||||
});
|
||||
|
||||
// Modal submissions for credential collection
|
||||
chat.onModalSubmit(async (event) => {
|
||||
if (!event.callbackId.startsWith('nccm:')) return;
|
||||
const credentialId = event.callbackId.slice('nccm:'.length);
|
||||
const value = event.values?.value ?? '';
|
||||
if (!value) {
|
||||
log.warn('Credential modal submitted with empty value', { credentialId });
|
||||
return;
|
||||
}
|
||||
setupConfig.onCredentialSubmit?.(credentialId, value);
|
||||
});
|
||||
|
||||
await chat.initialize();
|
||||
|
||||
// Start Gateway listener for adapters that support it (e.g., Discord)
|
||||
@@ -259,6 +348,26 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
return result?.id;
|
||||
}
|
||||
|
||||
// Credential request card — buttons open a modal for secure input
|
||||
if (content.type === 'credential_request' && content.credentialId) {
|
||||
const credentialId = content.credentialId as string;
|
||||
const card = Card({
|
||||
title: '🔑 Credential request',
|
||||
children: [
|
||||
CardText(content.question as string),
|
||||
Actions([
|
||||
Button({ id: `nccr:${credentialId}:enter`, label: 'Enter credential', value: 'enter' }),
|
||||
Button({ id: `nccr:${credentialId}:reject`, label: 'Reject', value: 'reject' }),
|
||||
]),
|
||||
],
|
||||
});
|
||||
const result = await adapter.postMessage(tid, {
|
||||
card,
|
||||
fallbackText: `Credential request — open in a channel that supports modals.`,
|
||||
});
|
||||
return result?.id;
|
||||
}
|
||||
|
||||
// Normal message
|
||||
const text = (content.markdown as string) || (content.text as string);
|
||||
if (text) {
|
||||
|
||||
@@ -32,6 +32,7 @@ registerChannelAdapter('discord', {
|
||||
concurrency: 'concurrent',
|
||||
botToken: env.DISCORD_BOT_TOKEN,
|
||||
extractReplyContext,
|
||||
supportsThreads: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,6 @@ registerChannelAdapter('gchat', {
|
||||
const gchatAdapter = createGoogleChatAdapter({
|
||||
credentials: JSON.parse(env.GCHAT_CREDENTIALS),
|
||||
});
|
||||
return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,6 @@ registerChannelAdapter('github', {
|
||||
token: env.GITHUB_TOKEN,
|
||||
webhookSecret: env.GITHUB_WEBHOOK_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue' });
|
||||
return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,6 @@ registerChannelAdapter('imessage', {
|
||||
const imessageAdapter = Object.assign(rawAdapter, {
|
||||
channelIdFromThreadId: (threadId: string) => threadId,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,6 @@ registerChannelAdapter('linear', {
|
||||
apiKey: env.LINEAR_API_KEY,
|
||||
webhookSecret: env.LINEAR_WEBHOOK_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue' });
|
||||
return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,6 @@ registerChannelAdapter('matrix', {
|
||||
if (env.MATRIX_USER_ID) process.env.MATRIX_USER_ID = env.MATRIX_USER_ID;
|
||||
if (env.MATRIX_BOT_USERNAME) process.env.MATRIX_BOT_USERNAME = env.MATRIX_BOT_USERNAME;
|
||||
const matrixAdapter = createMatrixAdapter();
|
||||
return createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,6 @@ registerChannelAdapter('resend', {
|
||||
fromName: env.RESEND_FROM_NAME,
|
||||
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue' });
|
||||
return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,6 @@ registerChannelAdapter('slack', {
|
||||
botToken: env.SLACK_BOT_TOKEN,
|
||||
signingSecret: env.SLACK_SIGNING_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,6 @@ registerChannelAdapter('teams', {
|
||||
appId: env.TEAMS_APP_ID,
|
||||
appPassword: env.TEAMS_APP_PASSWORD,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,6 +26,11 @@ registerChannelAdapter('telegram', {
|
||||
botToken: env.TELEGRAM_BOT_TOKEN,
|
||||
mode: 'polling',
|
||||
});
|
||||
return createChatSdkBridge({ adapter: telegramAdapter, concurrency: 'concurrent', extractReplyContext });
|
||||
return createChatSdkBridge({
|
||||
adapter: telegramAdapter,
|
||||
concurrency: 'concurrent',
|
||||
extractReplyContext,
|
||||
supportsThreads: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,6 @@ registerChannelAdapter('webex', {
|
||||
botToken: env.WEBEX_BOT_TOKEN,
|
||||
webhookSecret: env.WEBEX_WEBHOOK_SECRET,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent', supportsThreads: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,6 @@ registerChannelAdapter('whatsapp-cloud', {
|
||||
appSecret: env.WHATSAPP_APP_SECRET,
|
||||
verifyToken: env.WHATSAPP_VERIFY_TOKEN,
|
||||
});
|
||||
return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent' });
|
||||
return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent', supportsThreads: false });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user