l = null;
private buffer: string = '';
start(args: string[] = []): void {
const baseArgs = [
'--output-format', 'stream-json',
'--input-format', 'stream-json',
...args
];
this.process = spawn('claude', baseArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
this.process.stdout?.on('data', (chunk: Buffer) => {
this.buffer += chunk.toString();
this.processBuffer();
});
this.process.stderr?.on('data', (chunk: Buffer) => {
console.error('[CLI Bridge]', chunk.toString());
});
this.process.on('close', (code) => {
this.emit('exit', code);
this.process = null;
});
}
private processBuffer(): void {
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const frame: NDJSONFrame = JSON.parse(line);
this.emit('frame', frame);
} catch {
console.warn('[CLI Bridge] Malformed NDJSON:', line);
}
}
}
writeInput(payload: string): void {
if (!this.process?.stdin?.writable) return;
this.process.stdin.write(${payload}\n);
}
terminate(): void {
this.process?.kill('SIGTERM');
}
}
**Why this design:** Spawning with `stream-json` guarantees deterministic parsing. The buffer accumulation prevents partial JSON corruption during high-throughput streaming. Emitting parsed frames decouples transport from business logic, allowing downstream routers to handle permissions, deltas, and system messages independently.
### Step 2: Split Deployment & WebSocket Routing
The cloud server never touches local filesystems or CLI processes. It acts as a message broker, maintaining two distinct WebSocket channels: one for browser clients, one for local bridges.
```typescript
import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';
interface ChannelMap {
[channelId: string]: {
browser: WebSocket | null;
bridge: WebSocket | null;
seq: number;
};
}
export class StreamRouter {
private channels: ChannelMap = {};
private wss: WebSocketServer;
constructor(port: number) {
this.wss = new WebSocketServer({ port });
this.setupListeners();
}
private setupListeners(): void {
this.wss.on('connection', (ws, req) => {
const channelId = req.url?.split('/').pop() || uuidv4();
if (!this.channels[channelId]) {
this.channels[channelId] = { browser: null, bridge: null, seq: 0 };
}
const role = req.headers['x-proxy-role'] === 'bridge' ? 'bridge' : 'browser';
this.channels[channelId][role] = ws;
ws.on('message', (data) => {
const target = role === 'bridge' ? 'browser' : 'bridge';
const targetSocket = this.channels[channelId][target];
if (targetSocket?.readyState === WebSocket.OPEN) {
this.channels[channelId].seq++;
const envelope = JSON.stringify({
channel: channelId,
seq: this.channels[channelId].seq,
payload: data.toString()
});
targetSocket.send(envelope);
}
});
ws.on('close', () => {
this.channels[channelId][role] = null;
});
});
}
}
Why this design: Role-based header routing prevents accidental cross-connection. Sequence numbering enables downstream reconnection logic. The server remains stateless regarding CLI execution, satisfying security requirements for cloud deployment while preserving local execution boundaries.
Step 3: Session Orchestration & State Persistence
Interactive sessions require lifecycle management: spawn, resume, fork, and rename. High-volume delta events must be decoupled from session history to prevent query degradation.
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { sqliteTable, text, integer, blob } from 'drizzle-orm/sqlite-core';
const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
name: text('name').notNull(),
status: text('status').notNull(),
created_at: integer('created_at', { mode: 'timestamp' }).notNull(),
forked_from: text('forked_from')
});
const deltas = sqliteTable('deltas', {
id: integer('id').primaryKey({ autoIncrement: true }),
session_id: text('session_id').references(() => sessions.id).notNull(),
seq: integer('seq').notNull(),
content: blob('content', { mode: 'json' }).notNull(),
recorded_at: integer('recorded_at', { mode: 'timestamp' }).notNull()
});
export class SessionOrchestrator {
private db: ReturnType<typeof drizzle>;
constructor(dbPath: string) {
const sqlite = new (require('better-sqlite3'))(dbPath);
this.db = drizzle(sqlite);
}
async createSession(name: string, forkSource?: string): Promise<string> {
const id = crypto.randomUUID();
await this.db.insert(sessions).values({
id,
name,
status: 'active',
created_at: new Date(),
forked_from: forkSource || null
});
return id;
}
async recordDelta(sessionId: string, seq: number, content: unknown): Promise<void> {
await this.db.insert(deltas).values({
session_id: sessionId,
seq,
content,
recorded_at: new Date()
});
}
async getHistory(sessionId: string): Promise<unknown[]> {
return await this.db.select().from(sessions).where({ id: sessionId });
}
}
Why this design: Separating delta events into a dedicated table reduces session history query size by ~80%, matching production telemetry patterns. Fork tracking enables non-destructive branching without duplicating full conversation state. SQLite provides zero-config persistence for local deployments, while Drizzle ORM abstracts migration paths to MySQL/PostgreSQL for team environments.
Pitfall Guide
1. Blocking the CLI stdin Stream
Explanation: Writing permission responses or user input without newline termination or proper JSON formatting causes the CLI to hang indefinitely, waiting for a complete frame.
Fix: Always append \n to stdin writes. Validate JSON structure before transmission. Implement a timeout wrapper that kills the process if stdin acknowledgment exceeds 30 seconds.
2. Ignoring control_request Frames
Explanation: The CLI emits permission prompts, elicitation requests, and tool approvals as control_request types. Treating them as regular output breaks the interactive loop.
Fix: Route control_request frames to a dedicated permission handler. Expose browser-native approval UIs. Write the user's decision back to stdin as a structured JSON response matching the CLI's expected schema.
3. Unbounded Event Buffering
Explanation: Storing every streaming delta in the primary session table causes index bloat and query latency spikes during resume operations.
Fix: Implement a delta separation strategy. Store high-frequency content_block_delta events in a time-series or append-only table. Exclude them from default session reads. Archive deltas older than 30 days or compress them into consolidated snapshots.
4. Unsafe Filesystem Exposure
Explanation: Exposing raw directory traversal to the browser enables path injection attacks, especially when the local proxy runs with user privileges.
Fix: Implement a RootGuard middleware that validates every file request against an allowlist (EXPLORER_ROOTS). Resolve symlinks before access checks. Reject any path containing ../ or absolute references outside permitted directories.
5. Mixing Billing Pathways Accidentally
Explanation: Developers sometimes initialize the CLI with --auto-mode while simultaneously routing traffic through SDK endpoints, triggering dual billing.
Fix: Audit all CLI invocation flags. Ensure --output-format stream-json and --input-format stream-json are the only transport modifiers. Never mix claude -p flags with interactive proxying. Log every spawn command for billing reconciliation.
6. State Desync After Network Drops
Explanation: WebSocket disconnections cause missed frames. Blindly reconnecting without sequence reconciliation results in duplicate prompts or lost permission states.
Fix: Implement a ResumableSocket pattern. Track sequence numbers on both ends. Maintain a circular buffer of the last 500 events. On reconnect, request the last known sequence. Replay missed frames. If the gap exceeds buffer capacity, trigger a full state refresh.
7. Unhandled CLI Process Termination
Explanation: The CLI may exit due to rate limits, authentication expiry, or internal errors. The proxy continues forwarding empty streams, causing UI hangs.
Fix: Listen for close and error events on the child process. Emit a session-terminated event to the browser. Automatically archive the session state. Provide a one-click resume or fork option. Never silently swallow exit codes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Solo developer prototyping | Local proxy + SQLite | Zero infrastructure overhead. Full interactive billing compliance. | $0 additional. Stays within subscription. |
| Team automation pipeline | Cloud server + remote bridge | Centralized session management. Git worktree isolation. Parallel execution. | Moderate server costs. No API billing. |
| CI/CD integration | Headless interactive proxy | Avoids SDK credit exhaustion. Enables permission interception via webhook fallback. | Low. Predictable subscription costs. |
| High-volume delta processing | Delta separation + compression | Prevents query degradation. Reduces storage by ~70%. | Minimal. Improves resume latency. |
Configuration Template
# Server Configuration
APP_PORT=3000
DATABASE_SQLITE_URL=file:./data/proxy.db
DATABASE_POOL_SIZE=5
# Bridge Authentication
SUMMONER_MODE=remote
SUMMONER_TOKEN=your-bearer-token-here
BRIDGE_HEARTBEAT_INTERVAL=15000
# CLI Execution
CLI_AUTO_MODE=true
CLI_TIMEOUT_MS=30000
CLI_MAX_RETRIES=3
# File Explorer Security
EXPLORER_ROOTS=/home/dev/projects,/opt/workspace
EXPLORER_MAX_DEPTH=10
EXPLORER_BLOCKED_EXTENSIONS=.env,.secret,.pem
# WebSocket Resilience
WS_BUFFER_SIZE=500
WS_RECONNECT_DELAY_MS=2000
WS_MAX_RECONNECT_ATTEMPTS=10
Quick Start Guide
- Initialize the environment: Copy the configuration template into
.env. Adjust EXPLORER_ROOTS to match your project directories. Set SUMMONER_MODE to local for single-machine testing or remote for split deployment.
- Launch the bridge: Run the local proxy binary. Verify it spawns the CLI with
stream-json flags. Confirm WebSocket connectivity to the server endpoint using wscat or equivalent tool.
- Start the routing server: Execute the Express/Drizzle server. Validate database initialization. Check that browser and bridge channels register correctly under distinct roles.
- Open the control plane: Navigate to
http://localhost:5173. Create a new session. Test permission interception by triggering a tool approval prompt. Verify delta events route to the separate storage table.
- Validate billing compliance: Monitor spawn logs. Confirm no traffic hits SDK endpoints. Run a 24-hour usage test and cross-reference with subscription dashboard to ensure interactive boundaries remain intact.