Files
nanoclaw/src/types.ts
gavrielc e92b245399 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>
2026-04-11 17:18:21 +03:00

140 lines
3.5 KiB
TypeScript

// ── Central DB entities ──
export interface AgentGroup {
id: string;
name: string;
folder: string;
is_admin: number; // 0 | 1
agent_provider: string | null;
container_config: string | null; // JSON: { additionalMounts, timeout }
created_at: string;
}
export interface MessagingGroup {
id: string;
channel_type: string;
platform_id: string;
name: string | null;
is_group: number; // 0 | 1
admin_user_id: string | null;
created_at: string;
}
export interface MessagingGroupAgent {
id: string;
messaging_group_id: string;
agent_group_id: string;
trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
response_scope: 'all' | 'triggered' | 'allowlisted';
session_mode: 'shared' | 'per-thread' | 'agent-shared';
priority: number;
created_at: string;
}
export interface Session {
id: string;
agent_group_id: string;
messaging_group_id: string | null;
thread_id: string | null;
agent_provider: string | null;
status: 'active' | 'closed';
container_status: 'running' | 'idle' | 'stopped';
last_active: string | null;
created_at: string;
}
// ── Session DB entities ──
export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface MessageIn {
id: string;
kind: MessageInKind;
timestamp: string;
status: MessageInStatus;
status_changed: string | null;
process_after: string | null;
recurrence: string | null;
tries: number;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
export interface MessageOut {
id: string;
in_reply_to: string | null;
timestamp: string;
delivered: number; // 0 | 1
deliver_after: string | null;
recurrence: string | null;
kind: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
// ── Pending questions (central DB) ──
export interface PendingQuestion {
question_id: string;
session_id: string;
message_out_id: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
created_at: string;
}
// ── Pending approvals (central DB) ──
export interface PendingApproval {
approval_id: string;
session_id: string | null;
request_id: string;
action: string;
payload: string; // JSON
created_at: string;
agent_group_id: string | null;
channel_type: string | null;
platform_id: string | null;
platform_message_id: string | null;
expires_at: string | null;
status: 'pending' | 'approved' | 'rejected' | 'expired';
}
// ── Pending credentials (central DB) ──
export type PendingCredentialStatus = 'pending' | 'submitted' | 'saved' | 'rejected' | 'failed';
export interface PendingCredential {
id: string;
agent_group_id: string;
session_id: string | null;
name: string;
type: 'generic' | 'anthropic';
host_pattern: string;
path_pattern: string | null;
header_name: string | null;
value_format: string | null;
description: string | null;
channel_type: string;
platform_id: string;
platform_message_id: string | null;
status: PendingCredentialStatus;
created_at: string;
}
// ── Agent destinations (central DB) ──
export interface AgentDestination {
agent_group_id: string;
local_name: string;
target_type: 'channel' | 'agent';
target_id: string;
created_at: string;
}