|
| 1 | +--- |
| 2 | +phase: design |
| 3 | +title: System Design & Architecture |
| 4 | +description: Define the technical architecture, components, and data models |
| 5 | +--- |
| 6 | + |
| 7 | +# System Design & Architecture |
| 8 | + |
| 9 | +## Architecture Overview |
| 10 | + |
| 11 | +```mermaid |
| 12 | +graph TD |
| 13 | + Agent[Claude Code Agent] -->|hook writes agent-request| Hook[(agent-request JSONL)] |
| 14 | + Hook -->|poll| Runner[channel-runner.ts] |
| 15 | + Runner -->|tryHandle| Service[AskUserQuestionService] |
| 16 | + Service -->|sendInlineKeyboard| Adapter[TelegramAdapter] |
| 17 | + Adapter -->|HTML + reply_markup| TG[Telegram Chat] |
| 18 | + TG -->|callback_query| Adapter |
| 19 | + Adapter -->|onCallback| Runner |
| 20 | + Runner -->|handleCallback| Service |
| 21 | + Service -->|sendKey digit| TTY[TtyWriter.sendKey] |
| 22 | + TTY -->|raw keystroke| Agent |
| 23 | +``` |
| 24 | + |
| 25 | +| Component | Responsibility | |
| 26 | +|---|---| |
| 27 | +| `channel-runner.ts` | Orchestrate. Route `AskUserQuestion` to the service; register adapter callback handler; inject `TtyWriter.sendKey` as the agent-write callback. | |
| 28 | +| `AskUserQuestionService` (new, in `ask-user-question.ts`) | Parse input, render the keyboard (numbered options + Skip for single-select; Skip-only for multi-select), track an in-memory session per `questionId`, resolve the digit or Esc on tap, write to the agent. | |
| 29 | +| `TelegramAdapter` | Adds 4 methods: `sendInlineKeyboard`, `editInlineKeyboard`, `answerCallback`, `onCallback`. | |
| 30 | +| `TtyWriter.sendKey` (new) | Send a single raw keystroke to the agent's terminal, bypassing bracketed paste and auto-Enter. Translates the Esc byte (`\x1b`) to the backend-native representation. | |
| 31 | + |
| 32 | +## Data Models |
| 33 | + |
| 34 | +```ts |
| 35 | +// ask-user-question.ts (internal) |
| 36 | + |
| 37 | +interface QuestionOption { |
| 38 | + label: string; |
| 39 | + description?: string; |
| 40 | +} |
| 41 | + |
| 42 | +interface QuestionSpec { |
| 43 | + question: string; |
| 44 | + header?: string; |
| 45 | + options: QuestionOption[]; |
| 46 | + multiSelect: boolean; |
| 47 | +} |
| 48 | + |
| 49 | +interface ActiveSession { |
| 50 | + questionId: string; // short base36 counter, never sent to agent |
| 51 | + chatId: string; |
| 52 | + spec: QuestionSpec; |
| 53 | + messageId: number | null; |
| 54 | +} |
| 55 | + |
| 56 | +// callback_data encoding (≤64 bytes): |
| 57 | +// q:<questionId>:o:<optionIdx> — option tap (single-select keyboards only) |
| 58 | +// q:<questionId>:skip — Skip tap (every keyboard) |
| 59 | +// questionId is a base36 counter (1-2 chars); optionIdx is decimal — both well under 64 bytes. |
| 60 | +``` |
| 61 | + |
| 62 | +```ts |
| 63 | +// channel-connector/src/types.ts (new exports) |
| 64 | + |
| 65 | +export interface InlineKeyboardButton { |
| 66 | + text: string; |
| 67 | + callbackData: string; |
| 68 | +} |
| 69 | +export type InlineKeyboard = InlineKeyboardButton[][]; // rows of buttons |
| 70 | + |
| 71 | +export interface IncomingCallback { |
| 72 | + channelType: string; |
| 73 | + chatId: string; |
| 74 | + userId: string; |
| 75 | + messageId: number; |
| 76 | + callbackData: string; |
| 77 | + callbackQueryId: string; |
| 78 | + timestamp: Date; |
| 79 | +} |
| 80 | +export type CallbackHandler = (callback: IncomingCallback) => Promise<void>; |
| 81 | +``` |
| 82 | + |
| 83 | +## API Design |
| 84 | + |
| 85 | +### TelegramAdapter additions |
| 86 | + |
| 87 | +```ts |
| 88 | +class TelegramAdapter implements ChannelAdapter { |
| 89 | + // existing: start, stop, sendMessage, onMessage, isHealthy |
| 90 | + |
| 91 | + /** Send a message with an inline keyboard. Returns the Telegram message_id. */ |
| 92 | + async sendInlineKeyboard(chatId: string, html: string, keyboard: InlineKeyboard): Promise<number>; |
| 93 | + |
| 94 | + /** Replace (or remove with `null`) the keyboard on an existing message. */ |
| 95 | + async editInlineKeyboard(chatId: string, messageId: number, keyboard: InlineKeyboard | null): Promise<void>; |
| 96 | + |
| 97 | + /** Ack a callback_query, optionally with a transient toast. */ |
| 98 | + async answerCallback(callbackQueryId: string, text?: string): Promise<void>; |
| 99 | + |
| 100 | + /** Register a handler for inline-keyboard taps. */ |
| 101 | + onCallback(handler: CallbackHandler): void; |
| 102 | +} |
| 103 | +``` |
| 104 | + |
| 105 | +`ChannelAdapter` (the abstract interface) stays minimal — keyboard methods live only on the concrete `TelegramAdapter`. Promote to the interface if a second platform needs them. |
| 106 | + |
| 107 | +### TtyWriter addition |
| 108 | + |
| 109 | +```ts |
| 110 | +class TtyWriter { |
| 111 | + static async send(location, message): Promise<void>; // existing: bracketed paste + Enter |
| 112 | + static async sendKey(location, key): Promise<void>; // NEW: raw single keystroke |
| 113 | +} |
| 114 | +``` |
| 115 | + |
| 116 | +- **tmux** path: `tmux send-keys -t <id> <key>` — direct, no paste buffer. |
| 117 | +- **iTerm2 / Terminal.app**: focus the target session/tab, then `tell application "System Events" to keystroke "<key>"`. Requires macOS Accessibility permission. |
| 118 | + |
| 119 | +### ask-user-question.ts service |
| 120 | + |
| 121 | +```ts |
| 122 | +export class AskUserQuestionService { |
| 123 | + constructor(telegram: KeyboardChannel, sendToAgent: SendToAgent); |
| 124 | + |
| 125 | + /** Returns true if it owned the request; false → caller falls back to plain text. */ |
| 126 | + tryHandle(toolInput: Record<string, unknown>, chatId: string): Promise<boolean>; |
| 127 | + |
| 128 | + /** Resolve an inline-keyboard tap, write the digit to the agent, clear the keyboard. */ |
| 129 | + handleCallback(cb: IncomingCallback): Promise<void>; |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +`KeyboardChannel` is a 3-method subset of `TelegramAdapter` (`sendInlineKeyboard`, `editInlineKeyboard`, `answerCallback`) — kept narrow so unit tests can supply a tiny stub. |
| 134 | + |
| 135 | +`SendToAgent = (key: string) => Promise<void>` — in production wired to `(key) => TtyWriter.sendKey(terminalLocation, key)`. |
| 136 | + |
| 137 | +## Component Breakdown |
| 138 | + |
| 139 | +- `packages/channel-connector/src/types.ts` — add `InlineKeyboardButton`, `InlineKeyboard`, `IncomingCallback`, `CallbackHandler`. |
| 140 | +- `packages/channel-connector/src/index.ts` — re-export new types. |
| 141 | +- `packages/channel-connector/src/adapters/TelegramAdapter.ts` — 4 new methods + `bot.on('callback_query', ...)` wiring. |
| 142 | +- `packages/agent-manager/src/terminal/TtyWriter.ts` — new `sendKey` static method (tmux + iTerm2 + Terminal.app paths). |
| 143 | +- `packages/cli/src/services/channel/ask-user-question.ts` (new) — service, parser, formatter, keyboard builder, escape helper. |
| 144 | +- `packages/cli/src/services/channel/channel-runner.ts`: |
| 145 | + - `startOutputPolling` accepts optional `askUserQuestionService`. |
| 146 | + - Polling loop offers `AskUserQuestion` payloads to the service; on `false` (multi-question / malformed) falls back to `formatPromptMessage`. |
| 147 | + - `runChannelBridge` constructs the service with `TtyWriter.sendKey` and registers `telegram.onCallback`. |
| 148 | + |
| 149 | +## Design Decisions |
| 150 | + |
| 151 | +- **Single-question scope.** Multi-question payloads require sequencing keystrokes across pickers with timing assumptions; we reject them at the parser and fall back to plain `[Question]` text where the user can free-text reply. |
| 152 | +- **Multi-select is supported as Skip + chat reply, not toggle/Submit.** Driving multi-select toggles through the picker is just as fragile as multi-question. Instead, render the question and numbered options as a read-only formatted message with a `Skip`-only keyboard, plus a hint asking the user to reply in chat. The existing `onMessage` → `TtyWriter.send` path delivers the typed reply. |
| 153 | +- **Skip on every keyboard.** A `Skip` button is appended to every keyboard. On tap it sends the Esc byte (`\x1b`) via `sendKey`, which translates per backend (tmux `Escape`, AppleScript `key code 53`, wezterm literal byte). |
| 154 | +- **Digit keystroke via `sendKey`, not text via `send`.** The host picker reads stdin in raw mode. `TtyWriter.send` wraps payloads in bracketed paste markers `\e[200~ ... \e[201~`, which the inner TUI routes to the focused text input ("Other"), not to the picker's digit hotkey. `sendKey` sends a raw keystroke that the picker captures and selects+confirms immediately. Free-text replies (for the multi-select chat-reply path) intentionally use `send` so they reach the picker's text-input field. |
| 155 | +- **Process-memory state.** Bridge restart drops in-flight questions; on tap of a stale button we ack with a "Question expired" toast. Acceptable per requirements; avoids file-IO complexity. |
| 156 | +- **`questionId` is a short base36 counter.** Keeps `callback_data` ≤64 bytes and contains no PII. |
| 157 | +- **HTML escaping at the body builder.** Telegraf does not auto-escape; all user-controlled fields go through `escapeHtml` before insertion. |
| 158 | +- **Defense-in-depth on `chatId`.** The adapter delivers all callbacks; the runner filters by `chatIdRef.value`; the service re-checks `session.chatId`. Three independent gates. |
| 159 | +- **Keep `ChannelAdapter` interface minimal.** Inline keyboards are Telegram-specific; the runner imports `TelegramAdapter` directly. Revisit if a second adapter needs them. |
| 160 | + |
| 161 | +## Non-Functional Requirements |
| 162 | + |
| 163 | +- **Performance.** Callbacks are human-rate. No batching needed. |
| 164 | +- **Security.** HTML-escape every user-controlled field; AppleScript-escape every `sendKey` payload; reject callbacks whose `chatId` doesn't match the authorized chat. |
| 165 | +- **Reliability.** Callback handler errors must not crash the bridge: every external call (`editInlineKeyboard`, `answerCallback`, `sendToAgent`) is wrapped in try/catch + `debug` logging. |
| 166 | +- **Backwards compatibility.** Non-AskUserQuestion tool prompts unchanged. The legacy plain-text `[Question] <text>` path remains as the fallback. |
0 commit comments