Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 0 additions & 51 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,57 +77,6 @@ Switch from npm-based distribution to standalone binary distribution with curl i

---

### Consolidate Chat Handlers with Adapter Pattern

**Context:** `src/chat/handler.ts` (container) and `src/chat/host-handler.ts` (host) share ~95% identical code:
- `StreamMessage` interface duplicated (L18-40 vs L11-33)
- `processBuffer()` method identical
- `handleStreamMessage()` method ~95% identical
- `interrupt()` method identical
- Default model `'sonnet'` hardcoded in both

Same pattern exists for OpenCode handlers. When we add more agents, this will get worse.

**Task:** Refactor to adapter pattern:

1. Create `src/chat/base-chat-session.ts`:
- Move `StreamMessage` interface here
- Create abstract `BaseChatSession` class with:
- `protected buffer: string`
- `protected sessionId?: string`
- `protected model: string` (default from constant)
- `protected processBuffer(): void` - shared implementation
- `protected handleStreamMessage(msg: StreamMessage): void` - shared implementation
- `async interrupt(): Promise<void>` - shared implementation
- `abstract getSpawnCommand(): string[]` - what differs between container/host
- `abstract getSpawnOptions(): object` - cwd, env differences

2. Create `src/chat/adapters/`:
- `container-adapter.ts` - implements spawn for `docker exec` into container
- `host-adapter.ts` - implements spawn for direct `claude` execution

3. Refactor `ChatSession` and `HostChatSession` to extend `BaseChatSession` and use adapters

4. Do the same for OpenCode handlers (`opencode-handler.ts`, `host-opencode-handler.ts`)

5. Move default model `'sonnet'` to `src/shared/constants.ts` as `DEFAULT_CLAUDE_MODEL`

**Files to create:**
- `src/chat/base-chat-session.ts`
- `src/chat/adapters/container-adapter.ts`
- `src/chat/adapters/host-adapter.ts`

**Files to modify:**
- `src/chat/handler.ts` - extend base, use adapter
- `src/chat/host-handler.ts` - extend base, use adapter
- `src/chat/opencode-handler.ts` - same pattern
- `src/chat/host-opencode-handler.ts` - same pattern
- `src/shared/constants.ts` - add DEFAULT_CLAUDE_MODEL

**Verify:** Chat still works for both container and host workspaces. Run existing chat tests.

---

## Considerations

> Add items here to discuss with project owner before promoting to tasks.
Expand Down
206 changes: 206 additions & 0 deletions src/chat/base-claude-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import type { ChatProcess, ClaudeStreamMessage, MessageCallback, SpawnConfig } from './types';
import { DEFAULT_CLAUDE_MODEL } from '../shared/constants';

export abstract class BaseClaudeSession {
protected process: ChatProcess | null = null;
protected sessionId?: string;
protected model: string;
protected sessionModel: string;
protected onMessage: MessageCallback;
protected buffer: string = '';

constructor(
sessionId: string | undefined,
model: string | undefined,
onMessage: MessageCallback
) {
this.sessionId = sessionId;
this.model = model || DEFAULT_CLAUDE_MODEL;
this.sessionModel = this.model;
this.onMessage = onMessage;
}

protected abstract getSpawnConfig(userMessage: string): SpawnConfig;
protected abstract getLogPrefix(): string;

async sendMessage(userMessage: string): Promise<void> {
const logPrefix = this.getLogPrefix();
const { command, options } = this.getSpawnConfig(userMessage);

console.log(`[${logPrefix}] Running:`, command.join(' '));

this.onMessage({
type: 'system',
content: 'Processing your message...',
timestamp: new Date().toISOString(),
});

try {
const proc = Bun.spawn(command, {
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
...options,
});

this.process = proc;

if (!proc.stdout || !proc.stderr) {
throw new Error('Failed to get process streams');
}

console.log(`[${logPrefix}] Process spawned, waiting for output...`);

const stderrPromise = new Response(proc.stderr).text();

const decoder = new TextDecoder();
let receivedAnyOutput = false;

for await (const chunk of proc.stdout) {
const text = decoder.decode(chunk);
console.log(`[${logPrefix}] Received chunk:`, text.length, 'bytes');
receivedAnyOutput = true;
this.buffer += text;
this.processBuffer();
}

const exitCode = await proc.exited;
console.log(
`[${logPrefix}] Process exited with code:`,
exitCode,
'receivedOutput:',
receivedAnyOutput
);

const stderrText = await stderrPromise;
if (stderrText) {
console.error(`[${logPrefix}] stderr:`, stderrText);
}

if (exitCode !== 0) {
this.onMessage({
type: 'error',
content: stderrText || `Claude exited with code ${exitCode}`,
timestamp: new Date().toISOString(),
});
return;
}

if (!receivedAnyOutput) {
this.onMessage({
type: 'error',
content: this.getNoOutputErrorMessage(),
timestamp: new Date().toISOString(),
});
return;
}

this.onMessage({
type: 'done',
content: 'Response complete',
timestamp: new Date().toISOString(),
});
} catch (err) {
console.error(`[${logPrefix}] Error:`, err);
this.onMessage({
type: 'error',
content: (err as Error).message,
timestamp: new Date().toISOString(),
});
} finally {
this.process = null;
}
}

protected getNoOutputErrorMessage(): string {
return 'No response from Claude. Check if Claude is authenticated.';
}

protected processBuffer(): void {
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || '';

for (const line of lines) {
if (!line.trim()) continue;

try {
const msg: ClaudeStreamMessage = JSON.parse(line);
this.handleStreamMessage(msg);
} catch {
console.error(`[${this.getLogPrefix()}] Failed to parse:`, line);
}
}
}

protected handleStreamMessage(msg: ClaudeStreamMessage): void {
const timestamp = new Date().toISOString();

if (msg.type === 'system' && msg.subtype === 'init') {
this.sessionId = msg.session_id;
this.sessionModel = this.model;
this.onMessage({
type: 'system',
content: `Session started: ${msg.session_id?.slice(0, 8)}...`,
timestamp,
});
return;
}

if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_use') {
this.onMessage({
type: 'tool_use',
content: JSON.stringify(block.input, null, 2),
toolName: block.name,
toolId: block.id,
timestamp,
});
}
}
return;
}

if (msg.type === 'stream_event' && msg.event?.type === 'content_block_delta') {
const delta = msg.event?.delta;
if (delta?.type === 'text_delta' && delta?.text) {
this.onMessage({
type: 'assistant',
content: delta.text,
timestamp,
});
}
return;
}
}

async interrupt(): Promise<void> {
if (this.process) {
this.process.kill();
this.process = null;
this.onMessage({
type: 'system',
content: 'Chat interrupted',
timestamp: new Date().toISOString(),
});
}
}

setModel(model: string): void {
if (this.model !== model) {
this.model = model;
if (this.sessionModel !== model) {
this.sessionId = undefined;
this.onMessage({
type: 'system',
content: `Switching to model: ${model}`,
timestamp: new Date().toISOString(),
});
}
}
}

getSessionId(): string | undefined {
return this.sessionId;
}
}
Loading