You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
;
// 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.
]]>