Skip to content

Latest commit

 

History

History
337 lines (248 loc) · 10.6 KB

File metadata and controls

337 lines (248 loc) · 10.6 KB
; // Check if the CLI binary is installed and accessible. // Called at startup to detect available adapters. chat( message: string, sessionId: string | null, options: ChatOptions & { cwd: string } ): Promise; // Send a message to the AI and return the response. // - message: the user's text from Telegram // - sessionId: unique identifier for the conversation session (null for first message) // - options.model: optional model name override // - options.timeout: max seconds to wait (default: 120) // - options.cwd: project directory to run the CLI in // - options.allowedTools: optional comma-separated tool list override // - options.skipPermissions: optional flag to skip interactive permission prompts chatStream?( message: string, sessionId: string | null, options: StreamChatOptions & { cwd: string } ): Promise; // Optional streaming variant of chat(). Sends output chunks incrementally // via the onChunk callback as the CLI produces them. // - options.onChunk: callback invoked with each output chunk // - options.minChunkSize: minimum characters to buffer before calling onChunk // - options.inactivityTimeout: seconds of no output before killing the process } ``` Supporting types: ```typescript type AdapterName = 'claude' | 'gemini'; // Extend this union for new adapters interface ChatOptions { model?: string; timeout?: number; allowedTools?: string; // Comma-separated list of tools (e.g., "Read,Glob,Grep") skipPermissions?: boolean; // Skip interactive permission prompts in the CLI } interface ChatResult { text: string; sessionId: string | null; } interface StreamChatOptions extends ChatOptions { onChunk: (chunk: string) => void | Promise; minChunkSize?: number; inactivityTimeout?: number; } ``` ## Step-by-Step: Creating a New Adapter Let's create an adapter for a hypothetical "Codex CLI" tool. ### Step 1: Create the Adapter File Create `src/adapters/codex.ts`: ```typescript import { v4 as uuidv4 } from 'uuid'; import type { CLIAdapter, ChatOptions } from '../types.js'; import { spawnCLI } from '../utils/process.js'; import { logger } from '../utils/logger.js'; export class CodexAdapter implements CLIAdapter { name = 'codex'; async isAvailable(): Promise { // Check if the CLI binary exists by running a version check const result = await spawnCLI('codex', ['--version'], { cwd: process.cwd(), timeout: 10, }); return result.exitCode === 0; } async chat( message: string, sessionId: string, options: ChatOptions & { cwd: string } ): Promise { // Build the CLI arguments const args = [ '--prompt', message, '--session', sessionId, ]; if (options.model) { args.push('--model', options.model); } const timeout = options.timeout ?? 120; logger.debug('Codex CLI call', { sessionId }); const result = await spawnCLI('codex', args, { cwd: options.cwd, timeout, }); // Handle timeout if (result.timedOut) { throw new Error( `Timeout -- Codex took longer than ${timeout}s. Try again or /clear.` ); } // Handle errors if (result.exitCode !== 0) { const errorMsg = result.stderr || result.stdout || 'Unknown error'; logger.error('Codex CLI error', { exitCode: result.exitCode, stderr: result.stderr, }); throw new Error(`Processing error: ${errorMsg.slice(0, 200)}`); } return result.stdout || '(empty response)'; } newSession(_projectPath: string): string { const sessionId = uuidv4(); logger.info('New Codex session', { sessionId }); return sessionId; } clearSession(sessionId: string): void { logger.info('Codex session cleared', { sessionId }); } } ``` ### Step 2: Update the AdapterName Type In `src/types.ts`, add `'codex'` to the union: ```typescript export type AdapterName = 'claude' | 'gemini' | 'codex'; ``` ### Step 3: Register the Adapter In `src/adapters/index.ts`, import and register the adapter: ```typescript import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; import { CodexAdapter } from './codex.js'; export function createDefaultRegistry(): AdapterRegistry { const registry = new AdapterRegistry(); registry.register(new ClaudeAdapter()); registry.register(new GeminiAdapter()); registry.register(new CodexAdapter()); return registry; } ``` ### Step 4: Use It In your `devbridge.config.json`: ```json { "projects": { "my-app": { "path": "/path/to/project", "adapter": "codex" } } } ``` --- ## How the Adapter is Used Understanding the full lifecycle helps you build a robust adapter. ### 1. Startup Detection When DevBridge starts, it calls `isAvailable()` on every registered adapter. This is used to: - Warn if a configured project uses an unavailable adapter - Report available adapters in the startup log ``` [INFO] Available adapters: claude, gemini [WARN] Project "my-app" uses codex but it's not installed ``` ### 2. Session Creation When a user sends their first message to a project, `SessionManager.getOrCreate()` calls `adapter.newSession(projectPath)`. The returned session ID is stored and used for all subsequent messages. ### 3. Chat Flow For each user message: 1. The `router.ts` handler retrieves the session and adapter 2. A typing indicator is shown in Telegram 3. `adapter.chat(message, sessionId, options)` is called 4. The response is split into chunks and sent back to Telegram 5. The session's `messageCount` and `lastMessageAt` are updated ### 4. Session Clearing When the user runs `/clear` or a session expires: 1. `adapter.clearSession(sessionId)` is called for cleanup 2. The session is removed from the store --- ## The `spawnCLI` and `spawnCLIStreaming` Utilities DevBridge provides `src/utils/process.ts` with two functions for running CLI processes. ### `spawnCLI` -- Buffered execution Runs a CLI command and returns the full output after the process exits. Best for short-lived commands. - Process spawning with `spawn()` (no `shell: true`) - Timeout management (SIGTERM followed by SIGKILL) - Immediate stdin closure (prevents hanging) - stdout/stderr capture - Error handling (spawn failures, permission errors) ```typescript interface SpawnResult { stdout: string; stderr: string; exitCode: number; timedOut: boolean; } function spawnCLI( command: string, args: string[], options: { cwd: string; timeout: number } ): Promise; ``` ### `spawnCLIStreaming` -- Streaming execution Runs a CLI command and streams output chunks as they arrive. Designed for long-running AI responses where you want incremental feedback. - **Inactivity timeout**: If the process produces no stdout/stderr for `inactivityTimeout` seconds (default: 300), it is killed. This prevents hung processes from blocking indefinitely. - **Hard timeout**: A safety-net `streamTimeout` (default: 3600 seconds / 1 hour) kills the process regardless of activity. - Calls `onChunk` with each output chunk as it arrives Use `spawnCLI` for quick operations (version checks, short prompts) and `spawnCLIStreaming` for AI chat responses that may take minutes. --- ## The AdapterRegistry The `AdapterRegistry` class (`src/adapters/index.ts`) provides: ```typescript class AdapterRegistry { register(adapter: CLIAdapter): void; // Add an adapter to the registry get(name: string): CLIAdapter; // Get an adapter by name (throws if not found) getAvailable(): Promise; // Return all adapters where isAvailable() returns true getAll(): CLIAdapter[]; // Return all registered adapters regardless of availability } ``` --- ## Built-in Adapter Details ### ClaudeAdapter - **Binary**: `claude` - **Session management**: Uses `--session-id` and `--resume` flags for multi-turn conversations - **Output format**: `--output-format stream-json` (streaming mode) or `--output-format text` (non-streaming) - **Permission control**: Tools are configured via `--allowedTools` based on the project's `permission_level` (default: `Read,Glob,Grep`). When `skipPermissions` is enabled, passes `--dangerously-skip-permissions` to auto-approve tool usage - **Streaming**: Supports `chatStream()` via `spawnCLIStreaming` with inactivity timeout - **Session tracking**: An in-memory `Set` tracks active sessions to decide when to use `--resume` - **Error recovery**: Detects corrupted sessions from error messages and signals for automatic cleanup ### GeminiAdapter - **Binary**: `gemini` - **Session management**: Uses `-p` flag for prompts (session continuity depends on Gemini CLI capabilities) - **Output format**: Uses CLI defaults - **Permission control**: Tools are configured via `--allowed-tools` based on the project's `permission_level`. When `skipPermissions` is enabled, passes `--yolo` to auto-approve tool usage - **Streaming**: Supports `chatStream()` via `spawnCLIStreaming` with inactivity timeout --- ## Tips for Adapter Development 1. **Version check**: Use `--version` or `--help` in `isAvailable()` with a short timeout (10 seconds). 2. **Error messages**: Truncate error messages to ~200 characters before throwing. Telegram has message limits and long stack traces are not useful to the user. 3. **Session state**: If your CLI does not support session IDs natively, you may need to manage conversation history yourself (e.g., appending messages to a file or maintaining context in memory). 4. **Timeout handling**: Always respect the timeout option. Users expect to be able to recover from hung processes with `/clear`. 5. **Logging**: Use the shared `logger` for debug, info, and error messages. This ensures all adapter activity appears in the unified log file. 6. **stdin closure**: Close stdin immediately after spawning to prevent the CLI from waiting for input. The `spawnCLI` utility handles this automatically. 7. **Model passthrough**: Pass the `model` option to the CLI if supported. This allows users to configure different models per project. ]]>