refactor(modules): extract approvals + interactive as registry-based modules
Phase 2 / PR #3 of the module refactor. Moves the approval and interactive- question flows out of core and into src/modules/, wired through the response dispatcher and delivery action registries. New modules: - src/modules/interactive/ — registers a response handler that claims pending_questions rows, writes question_response to the session DB, wakes the container. createPendingQuestion call stays inline in delivery.ts (guarded by hasTable) per plan. - src/modules/approvals/ — registers 3 delivery actions (install_packages, request_rebuild, add_mcp_server), a response handler for pending_approvals (including OneCLI action fall-through), an adapter-ready hook that boots the OneCLI manual-approval handler, and a shutdown hook that stops it. OneCLI implementation (src/onecli-approvals.ts) moves into the module. Core lifecycle hooks added (narrow, not registries): - onDeliveryAdapterReady(cb) in delivery.ts — fires when setDeliveryAdapter runs (or immediately if already set). Used by approvals for OneCLI boot. - onShutdown(cb) in index.ts — fires on SIGTERM/SIGINT. Used by approvals for OneCLI teardown. - getDeliveryAdapter() getter in delivery.ts — for live-flow adapter access in registered delivery actions. Core shrinks: delivery.ts 911 → 665 lines, index.ts 405 → 224 lines. dispatchResponse now logs "Unclaimed response" instead of falling through to an inline handler — the inline fallback moved into the two modules. Migration files renamed to the module-<name>-<short>.ts convention: - 003-pending-approvals.ts → module-approvals-pending-approvals.ts - 007-pending-approvals-title-options.ts → module-approvals-title-options.ts Migration.name fields unchanged so existing DBs treat them as already-applied. Degradation verified: emptying the modules barrel builds clean and 137/137 tests pass. Actions would log "Unknown system action"; button clicks would log "Unclaimed response". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
src/modules/interactive/agent.md
Normal file
21
src/modules/interactive/agent.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## ask_user_question
|
||||
|
||||
Use `ask_user_question` when you need the user to pick from a small set of concrete options and you can't infer a reasonable default. This is a **blocking** call — your turn pauses until the user clicks or the timeout expires.
|
||||
|
||||
**When to use:**
|
||||
- Confirming a destructive action ("Delete these 3 files?")
|
||||
- Choosing between incompatible paths ("Keep their version or yours?")
|
||||
- Gathering a required parameter that must be one of a known set
|
||||
|
||||
**When NOT to use:**
|
||||
- Open-ended text input — just send a regular message asking.
|
||||
- Yes/no confirmations where "no" is the safe default — just proceed and let the user interrupt.
|
||||
- Anything you can work out from context.
|
||||
|
||||
**Arguments:**
|
||||
- `title` (string) — short card header, e.g. "Confirm deletion"
|
||||
- `question` (string) — the full question
|
||||
- `options` (array) — each is either a plain string or `{ label, selectedLabel?, value? }`. `selectedLabel` replaces the button text after click; `value` is what gets returned to you
|
||||
- `timeout` (number, seconds, default 300) — how long to wait before giving up
|
||||
|
||||
The response is the `value` (or label if no value set) of whichever option the user chose. On timeout you get an error and should proceed with a sensible default or tell the user you timed out.
|
||||
55
src/modules/interactive/index.ts
Normal file
55
src/modules/interactive/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Interactive module — generic ask_user_question flow.
|
||||
*
|
||||
* Container-side `ask_user_question` writes a chat-sdk card to outbound.db +
|
||||
* polls inbound.db for a `question_response` system message. On the host side
|
||||
* this module handles the button-click response: look up the pending_questions
|
||||
* row, write the response into the session's inbound.db, wake the container.
|
||||
*
|
||||
* The `createPendingQuestion` call in `deliverMessage` (delivery.ts) stays
|
||||
* inline in core — it's 15 lines guarded by `hasTable('pending_questions')`,
|
||||
* modularizing it adds more registry surface than it saves.
|
||||
*/
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { deletePendingQuestion, getPendingQuestion, getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { registerResponseHandler, type ResponsePayload } from '../../index.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
|
||||
async function handleInteractiveResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
if (!hasTable(getDb(), 'pending_questions')) return false;
|
||||
|
||||
const pq = getPendingQuestion(payload.questionId);
|
||||
if (!pq) return false;
|
||||
|
||||
const session = getSession(pq.session_id);
|
||||
if (!session) {
|
||||
log.warn('Session not found for pending question', { questionId: payload.questionId, sessionId: pq.session_id });
|
||||
deletePendingQuestion(payload.questionId);
|
||||
return true; // claimed — we owned this questionId even though the session is gone
|
||||
}
|
||||
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `qr-${payload.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: payload.questionId,
|
||||
selectedOption: payload.value,
|
||||
userId: payload.userId ?? '',
|
||||
}),
|
||||
});
|
||||
|
||||
deletePendingQuestion(payload.questionId);
|
||||
log.info('Question response routed', { questionId: payload.questionId, selectedOption: payload.value, sessionId: session.id });
|
||||
|
||||
await wakeContainer(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(handleInteractiveResponse);
|
||||
12
src/modules/interactive/project.md
Normal file
12
src/modules/interactive/project.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## Interactive module
|
||||
|
||||
Generic ask_user_question flow. Lives in `src/modules/interactive/`.
|
||||
|
||||
The container-side MCP tool `ask_user_question` writes a chat-sdk card to outbound.db and polls inbound.db for a `question_response` system message. The host side of this is split:
|
||||
|
||||
- **Inline in `src/delivery.ts`:** the `deliverMessage` path intercepts `content.type === 'ask_question'` messages and writes a row to `pending_questions`. Guarded by `hasTable(db, 'pending_questions')`.
|
||||
- **This module:** registers a `ResponseHandler` that runs when a button-click arrives via the channel adapter's `onAction`. It looks up the `pending_questions` row, writes a `question_response` system message into the session's inbound.db, wakes the container.
|
||||
|
||||
The `pending_questions` table is in the core `001-initial.ts` migration — the module doesn't own the schema, just the behavior. Removing the module disables the button-click response path only; cards are still delivered.
|
||||
|
||||
`getAskQuestionRender` in `src/db/sessions.ts` resolves card render metadata for `chat-sdk-bridge.ts`. It reads both `pending_questions` and `pending_approvals` and degrades via `hasTable`. Stays in core.
|
||||
Reference in New Issue
Block a user