diff --git a/bun.lock b/bun.lock index b468e6c7..db7cfbf1 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "cli": { "name": "hapi", - "version": "0.12.0-0", + "version": "0.12.0-1", "bin": { "hapi": "./bin/happy.mjs", "hapi-mcp": "./bin/happy-mcp.mjs", diff --git a/cli/src/agent/AgentRegistry.ts b/cli/src/agent/AgentRegistry.ts new file mode 100644 index 00000000..c95489f6 --- /dev/null +++ b/cli/src/agent/AgentRegistry.ts @@ -0,0 +1,24 @@ +import type { AgentBackend, AgentBackendFactory } from './types'; + +export class AgentRegistry { + private static readonly factories = new Map(); + + static register(agentType: string, factory: AgentBackendFactory): void { + if (!agentType || typeof agentType !== 'string') { + throw new Error('Agent type must be a non-empty string'); + } + this.factories.set(agentType, factory); + } + + static create(agentType: string): AgentBackend { + const factory = this.factories.get(agentType); + if (!factory) { + throw new Error(`Unknown agent type: ${agentType}`); + } + return factory(); + } + + static list(): string[] { + return Array.from(this.factories.keys()).sort(); + } +} diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts new file mode 100644 index 00000000..01e68c5d --- /dev/null +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -0,0 +1,146 @@ +import type { AgentMessage, PlanItem } from '@/agent/types'; +import { asString, deriveToolName, isObject } from '@/agent/utils'; +import { ACP_SESSION_UPDATE_TYPES } from './constants'; + +function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' | 'failed' { + if (status === 'in_progress' || status === 'completed' || status === 'failed') { + return status; + } + return 'pending'; +} + +function deriveToolNameFromUpdate(update: Record): string { + return deriveToolName({ + title: asString(update.title), + kind: asString(update.kind), + rawInput: update.rawInput + }); +} + +function extractTextContent(block: unknown): string | null { + if (!isObject(block)) return null; + if (block.type !== 'text') return null; + const text = block.text; + return typeof text === 'string' ? text : null; +} + +function normalizePlanEntries(entries: unknown): PlanItem[] { + if (!Array.isArray(entries)) return []; + + const items: PlanItem[] = []; + for (const entry of entries) { + if (!isObject(entry)) continue; + const content = asString(entry.content); + const priority = asString(entry.priority); + const status = asString(entry.status); + + if (!content) continue; + if (priority !== 'high' && priority !== 'medium' && priority !== 'low') continue; + if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') continue; + + items.push({ content, priority, status }); + } + + return items; +} + +export class AcpMessageHandler { + private readonly toolCalls = new Map(); + + constructor(private readonly onMessage: (message: AgentMessage) => void) {} + + handleUpdate(update: unknown): void { + if (!isObject(update)) return; + const updateType = asString(update.sessionUpdate); + if (!updateType) return; + + if (updateType === ACP_SESSION_UPDATE_TYPES.agentMessageChunk) { + const content = update.content; + const text = extractTextContent(content); + if (text) { + this.onMessage({ type: 'text', text }); + } + return; + } + + if (updateType === ACP_SESSION_UPDATE_TYPES.agentThoughtChunk) { + return; + } + + if (updateType === ACP_SESSION_UPDATE_TYPES.toolCall) { + this.handleToolCall(update); + return; + } + + if (updateType === ACP_SESSION_UPDATE_TYPES.toolCallUpdate) { + this.handleToolCallUpdate(update); + return; + } + + if (updateType === ACP_SESSION_UPDATE_TYPES.plan) { + const items = normalizePlanEntries(update.entries); + if (items.length > 0) { + this.onMessage({ type: 'plan', items }); + } + } + } + + private handleToolCall(update: Record): void { + const toolCallId = asString(update.toolCallId); + if (!toolCallId) return; + + const name = deriveToolNameFromUpdate(update); + const input = update.rawInput ?? null; + const status = normalizeStatus(update.status); + + this.toolCalls.set(toolCallId, { name, input }); + + this.onMessage({ + type: 'tool_call', + id: toolCallId, + name, + input, + status + }); + } + + private handleToolCallUpdate(update: Record): void { + const toolCallId = asString(update.toolCallId); + if (!toolCallId) return; + + const status = normalizeStatus(update.status); + const existing = this.toolCalls.get(toolCallId); + + if (update.rawInput !== undefined) { + const name = deriveToolNameFromUpdate(update); + const input = update.rawInput; + this.toolCalls.set(toolCallId, { name, input }); + this.onMessage({ + type: 'tool_call', + id: toolCallId, + name, + input, + status + }); + } else if (existing && (status === 'in_progress' || status === 'pending')) { + this.onMessage({ + type: 'tool_call', + id: toolCallId, + name: existing.name, + input: existing.input, + status + }); + } + + if (status === 'completed' || status === 'failed') { + const output = update.rawOutput ?? update.content; + const result = output ?? { status }; + this.onMessage({ + type: 'tool_result', + id: toolCallId, + output: result, + status: status === 'failed' ? 'failed' : 'completed' + }); + } + } +} diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts new file mode 100644 index 00000000..dc27dfd0 --- /dev/null +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -0,0 +1,204 @@ +import type { AgentBackend, AgentMessage, AgentSessionConfig, PermissionRequest, PermissionResponse, PromptContent } from '@/agent/types'; +import { asString, isObject } from '@/agent/utils'; +import { AcpStdioTransport } from './AcpStdioTransport'; +import { AcpMessageHandler } from './AcpMessageHandler'; +import { logger } from '@/ui/logger'; +import packageJson from '../../../../package.json'; + +type PendingPermission = { + resolve: (result: { outcome: { outcome: string; optionId?: string } }) => void; +}; + +export class AcpSdkBackend implements AgentBackend { + private transport: AcpStdioTransport | null = null; + private permissionHandler: ((request: PermissionRequest) => void) | null = null; + private readonly pendingPermissions = new Map(); + private messageHandler: AcpMessageHandler | null = null; + private activeSessionId: string | null = null; + + constructor(private readonly options: { command: string; args?: string[]; env?: Record }) {} + + async initialize(): Promise { + if (this.transport) return; + + this.transport = new AcpStdioTransport({ + command: this.options.command, + args: this.options.args, + env: this.options.env + }); + + this.transport.onNotification((method, params) => { + if (method === 'session/update') { + this.handleSessionUpdate(params); + } + }); + + this.transport.registerRequestHandler('session/request_permission', async (params, requestId) => { + return await this.handlePermissionRequest(params, requestId); + }); + + const response = await this.transport.sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false + }, + clientInfo: { + name: 'hapi', + version: packageJson.version + } + }); + + if (!isObject(response) || typeof response.protocolVersion !== 'number') { + throw new Error('Invalid initialize response from ACP agent'); + } + + logger.debug(`[ACP] Initialized with protocol version ${response.protocolVersion}`); + } + + async newSession(config: AgentSessionConfig): Promise { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + + const response = await this.transport.sendRequest('session/new', { + cwd: config.cwd, + mcpServers: config.mcpServers + }); + + const sessionId = isObject(response) ? asString(response.sessionId) : null; + if (!sessionId) { + throw new Error('Invalid session/new response from ACP agent'); + } + + this.activeSessionId = sessionId; + return sessionId; + } + + async prompt( + sessionId: string, + content: PromptContent[], + onUpdate: (msg: AgentMessage) => void + ): Promise { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + + this.activeSessionId = sessionId; + this.messageHandler = new AcpMessageHandler(onUpdate); + + try { + const response = await this.transport.sendRequest('session/prompt', { + sessionId, + prompt: content + }); + + const stopReason = isObject(response) ? asString(response.stopReason) : null; + if (stopReason) { + onUpdate({ type: 'turn_complete', stopReason }); + } + } finally { + this.messageHandler = null; + } + } + + async cancelPrompt(sessionId: string): Promise { + if (!this.transport) { + return; + } + + this.transport.sendNotification('session/cancel', { sessionId }); + } + + async respondToPermission( + _sessionId: string, + request: PermissionRequest, + response: PermissionResponse + ): Promise { + const pending = this.pendingPermissions.get(request.id); + if (!pending) { + logger.debug('[ACP] No pending permission request for id', request.id); + return; + } + + this.pendingPermissions.delete(request.id); + + if (response.outcome === 'cancelled') { + pending.resolve({ outcome: { outcome: 'cancelled' } }); + return; + } + + pending.resolve({ + outcome: { + outcome: 'selected', + optionId: response.optionId + } + }); + } + + onPermissionRequest(handler: (request: PermissionRequest) => void): void { + this.permissionHandler = handler; + } + + async disconnect(): Promise { + if (!this.transport) return; + await this.transport.close(); + this.transport = null; + } + + private handleSessionUpdate(params: unknown): void { + if (!isObject(params)) return; + const sessionId = asString(params.sessionId); + if (this.activeSessionId && sessionId && sessionId !== this.activeSessionId) { + return; + } + const update = params.update; + if (!this.messageHandler) return; + this.messageHandler.handleUpdate(update); + } + + private async handlePermissionRequest(params: unknown, requestId: string | number | null): Promise { + if (!isObject(params)) { + return { outcome: { outcome: 'cancelled' } }; + } + + const sessionId = asString(params.sessionId) ?? this.activeSessionId ?? 'unknown'; + const toolCall = isObject(params.toolCall) ? params.toolCall : {}; + const toolCallId = asString(toolCall.toolCallId) ?? `tool-${Date.now()}`; + const title = asString(toolCall.title) ?? undefined; + const kind = asString(toolCall.kind) ?? undefined; + const rawInput = 'rawInput' in toolCall ? toolCall.rawInput : undefined; + const rawOutput = 'rawOutput' in toolCall ? toolCall.rawOutput : undefined; + const options = Array.isArray(params.options) + ? params.options + .filter((option) => isObject(option)) + .map((option, index) => ({ + optionId: asString(option.optionId) ?? `option-${index + 1}`, + name: asString(option.name) ?? `Option ${index + 1}`, + kind: asString(option.kind) ?? 'allow_once' + })) + : []; + + const request: PermissionRequest = { + id: toolCallId, + sessionId, + toolCallId, + title, + kind, + rawInput, + rawOutput, + options + }; + + if (this.permissionHandler) { + this.permissionHandler(request); + } else { + logger.debug('[ACP] No permission handler registered; cancelling request'); + return { outcome: { outcome: 'cancelled' } }; + } + + return await new Promise((resolve) => { + this.pendingPermissions.set(toolCallId, { resolve }); + }); + } +} diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts new file mode 100644 index 00000000..9f93b876 --- /dev/null +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -0,0 +1,225 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { logger } from '@/ui/logger'; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: string | number | null; + method: string; + params?: unknown; +} + +interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number | null; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +type RequestHandler = (params: unknown, requestId: string | number | null) => Promise; + +export class AcpStdioTransport { + private readonly process: ChildProcessWithoutNullStreams; + private readonly pending = new Map void; + reject: (error: Error) => void; + }>(); + private readonly requestHandlers = new Map(); + private notificationHandler: ((method: string, params: unknown) => void) | null = null; + private buffer = ''; + private nextId = 1; + private protocolError: Error | null = null; + + constructor(options: { + command: string; + args?: string[]; + env?: Record; + }) { + this.process = spawn(options.command, options.args ?? [], { + env: options.env, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + this.process.stdout.setEncoding('utf8'); + this.process.stdout.on('data', (chunk) => this.handleStdout(chunk)); + + this.process.stderr.setEncoding('utf8'); + this.process.stderr.on('data', (chunk) => { + logger.debug(`[ACP][stderr] ${chunk.toString().trim()}`); + }); + + this.process.on('exit', (code, signal) => { + const message = `ACP process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`; + logger.debug(message); + this.rejectAllPending(new Error(message)); + }); + + this.process.on('error', (error) => { + logger.debug('[ACP] Process error', error); + this.rejectAllPending(error instanceof Error ? error : new Error(String(error))); + }); + } + + onNotification(handler: ((method: string, params: unknown) => void) | null): void { + this.notificationHandler = handler; + } + + registerRequestHandler(method: string, handler: RequestHandler): void { + this.requestHandlers.set(method, handler); + } + + async sendRequest(method: string, params?: unknown): Promise { + const id = this.nextId++; + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + id, + method, + params + }; + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.writePayload(payload); + }); + } + + sendNotification(method: string, params?: unknown): void { + const payload: JsonRpcNotification = { + jsonrpc: '2.0', + method, + params + }; + this.writePayload(payload); + } + + async close(): Promise { + this.process.stdin.end(); + this.process.kill(); + this.rejectAllPending(new Error('ACP transport closed')); + } + + private handleStdout(chunk: string): void { + this.buffer += chunk; + let newlineIndex = this.buffer.indexOf('\n'); + + while (newlineIndex >= 0) { + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + this.handleLine(line); + } + + newlineIndex = this.buffer.indexOf('\n'); + } + } + + private handleLine(line: string): void { + if (this.protocolError) { + return; + } + let message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification | null = null; + try { + message = JSON.parse(line) as JsonRpcRequest | JsonRpcResponse | JsonRpcNotification; + } catch (error) { + const protocolError = new Error('Failed to parse JSON-RPC from ACP agent'); + this.protocolError = protocolError; + logger.debug('[ACP] Failed to parse JSON-RPC line', { line, error }); + this.rejectAllPending(protocolError); + this.process.stdin.end(); + this.process.kill(); + return; + } + + if (message && 'method' in message) { + if ('id' in message && message.id !== undefined) { + this.handleIncomingRequest(message as JsonRpcRequest).catch((error) => { + logger.debug('[ACP] Error handling request', error); + }); + return; + } + this.notificationHandler?.(message.method, message.params ?? null); + return; + } + + if (message && 'id' in message) { + this.handleResponse(message as JsonRpcResponse); + } + } + + private async handleIncomingRequest(request: JsonRpcRequest): Promise { + const handler = this.requestHandlers.get(request.method); + if (!handler) { + this.writePayload({ + jsonrpc: '2.0', + id: request.id, + error: { + code: -32601, + message: `Method not found: ${request.method}` + } + } satisfies JsonRpcResponse); + return; + } + + try { + const result = await handler(request.params ?? null, request.id ?? null); + this.writePayload({ + jsonrpc: '2.0', + id: request.id, + result + } satisfies JsonRpcResponse); + } catch (error) { + this.writePayload({ + jsonrpc: '2.0', + id: request.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : 'Internal error' + } + } satisfies JsonRpcResponse); + } + } + + private handleResponse(response: JsonRpcResponse): void { + if (response.id === null || response.id === undefined) { + logger.debug('[ACP] Received response without id'); + return; + } + + const pending = this.pending.get(response.id); + if (!pending) { + logger.debug('[ACP] Received response with no pending request', response.id); + return; + } + + this.pending.delete(response.id); + + if (response.error) { + pending.reject(new Error(response.error.message)); + return; + } + + pending.resolve(response.result); + } + + private writePayload(payload: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void { + const serialized = JSON.stringify(payload); + this.process.stdin.write(`${serialized}\n`); + } + + private rejectAllPending(error: Error): void { + for (const { reject } of this.pending.values()) { + reject(error); + } + this.pending.clear(); + } +} diff --git a/cli/src/agent/backends/acp/constants.ts b/cli/src/agent/backends/acp/constants.ts new file mode 100644 index 00000000..2e4a7c71 --- /dev/null +++ b/cli/src/agent/backends/acp/constants.ts @@ -0,0 +1,7 @@ +export const ACP_SESSION_UPDATE_TYPES = { + agentMessageChunk: 'agent_message_chunk', + agentThoughtChunk: 'agent_thought_chunk', + toolCall: 'tool_call', + toolCallUpdate: 'tool_call_update', + plan: 'plan' +} as const; diff --git a/cli/src/agent/backends/acp/index.ts b/cli/src/agent/backends/acp/index.ts new file mode 100644 index 00000000..078ebd7b --- /dev/null +++ b/cli/src/agent/backends/acp/index.ts @@ -0,0 +1,3 @@ +export * from './AcpSdkBackend'; +export * from './AcpStdioTransport'; +export * from './AcpMessageHandler'; diff --git a/cli/src/agent/index.ts b/cli/src/agent/index.ts new file mode 100644 index 00000000..e0fbab26 --- /dev/null +++ b/cli/src/agent/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './AgentRegistry'; +export * from './messageConverter'; +export * from './permissionAdapter'; diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts new file mode 100644 index 00000000..d3b95a22 --- /dev/null +++ b/cli/src/agent/messageConverter.ts @@ -0,0 +1,41 @@ +import type { AgentMessage, PlanItem } from './types'; + +export type CodexMessage = + | { type: 'message'; message: string } + | { type: 'tool-call'; name: string; callId: string; input: unknown } + | { type: 'tool-call-result'; callId: string; output: unknown } + | { type: 'plan'; entries: PlanItem[] } + | { type: 'error'; message: string }; + +export function convertAgentMessage(message: AgentMessage): CodexMessage | null { + switch (message.type) { + case 'text': + return { type: 'message', message: message.text }; + case 'tool_call': + return { + type: 'tool-call', + name: message.name, + callId: message.id, + input: message.input + }; + case 'tool_result': + return { + type: 'tool-call-result', + callId: message.id, + output: message.output + }; + case 'plan': + return { + type: 'plan', + entries: message.items + }; + case 'error': + return { type: 'error', message: message.message }; + case 'turn_complete': + return null; + default: { + const _exhaustive: never = message; + return _exhaustive; + } + } +} diff --git a/cli/src/agent/permissionAdapter.ts b/cli/src/agent/permissionAdapter.ts new file mode 100644 index 00000000..9c62dda3 --- /dev/null +++ b/cli/src/agent/permissionAdapter.ts @@ -0,0 +1,172 @@ +import type { AgentBackend, PermissionRequest, PermissionResponse } from './types'; +import type { AgentState } from '@/api/types'; +import type { ApiSessionClient } from '@/api/apiSession'; +import { logger } from '@/ui/logger'; +import { deriveToolName } from '@/agent/utils'; + +interface PermissionResponseMessage { + id: string; + approved: boolean; + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; +} + +function deriveToolInput(request: PermissionRequest): unknown { + if (request.rawInput !== undefined) { + return request.rawInput; + } + return request.rawOutput; +} + +function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null { + for (const kind of preferredKinds) { + const match = request.options.find((option) => option.kind === kind); + if (match) return match.optionId; + } + return request.options.length > 0 ? request.options[0].optionId : null; +} + +export class PermissionAdapter { + private readonly pendingRequests = new Map(); + + constructor( + private readonly session: ApiSessionClient, + private readonly backend: AgentBackend + ) { + this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); + this.session.rpcHandlerManager.registerHandler( + 'permission', + async (response) => { + await this.handlePermissionResponse(response); + } + ); + } + + private handlePermissionRequest(request: PermissionRequest): void { + this.pendingRequests.set(request.id, request); + + const toolName = deriveToolName({ + title: request.title, + kind: request.kind, + rawInput: request.rawInput + }); + const input = deriveToolInput(request); + + this.session.updateAgentState((currentState) => ({ + ...currentState, + requests: { + ...currentState.requests, + [request.id]: { + tool: toolName, + arguments: input, + createdAt: Date.now() + } + } + })); + + logger.debug(`[ACP] Permission request queued: ${toolName} (${request.id})`); + } + + private async handlePermissionResponse(response: PermissionResponseMessage): Promise { + const pending = this.pendingRequests.get(response.id); + if (!pending) { + logger.debug('[ACP] Permission response received for unknown request', response.id); + return; + } + + this.pendingRequests.delete(response.id); + + const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); + const toolName = deriveToolName({ + title: pending.title, + kind: pending.kind, + rawInput: pending.rawInput + }); + const toolInput = deriveToolInput(pending); + + const outcome = this.mapDecisionToOutcome(pending, decision); + if (decision === 'abort') { + await this.backend.cancelPrompt(pending.sessionId); + await this.backend.respondToPermission(pending.sessionId, pending, { outcome: 'cancelled' }); + await this.cancelAll('User aborted'); + } else if (outcome) { + await this.backend.respondToPermission(pending.sessionId, pending, outcome); + } + + this.session.updateAgentState((currentState) => { + const requestEntry = currentState.requests?.[response.id]; + const { [response.id]: _, ...remaining } = currentState.requests ?? {}; + + const status = response.approved ? 'approved' : 'denied'; + + return { + ...currentState, + requests: remaining, + completedRequests: { + ...currentState.completedRequests, + [response.id]: { + tool: toolName, + arguments: toolInput, + createdAt: requestEntry?.createdAt ?? Date.now(), + completedAt: Date.now(), + status, + decision + } + } + } satisfies AgentState; + }); + + logger.debug(`[ACP] Permission ${response.approved ? 'approved' : 'denied'} for ${toolName}`); + } + + private mapDecisionToOutcome( + request: PermissionRequest, + decision: 'approved' | 'approved_for_session' | 'denied' | 'abort' + ): PermissionResponse | null { + if (decision === 'abort') { + return { outcome: 'cancelled' }; + } + + if (decision === 'approved_for_session') { + const optionId = pickOptionId(request, ['allow_always', 'allow_once']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + if (decision === 'approved') { + const optionId = pickOptionId(request, ['allow_once', 'allow_always']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + const optionId = pickOptionId(request, ['reject_once', 'reject_always']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + async cancelAll(reason: string): Promise { + const pending = Array.from(this.pendingRequests.values()); + this.pendingRequests.clear(); + + for (const request of pending) { + await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }); + } + + this.session.updateAgentState((currentState) => { + const pendingRequests = currentState.requests ?? {}; + const completedRequests = { ...currentState.completedRequests }; + + for (const [id, request] of Object.entries(pendingRequests)) { + completedRequests[id] = { + ...request, + completedAt: Date.now(), + status: 'canceled', + reason, + decision: 'abort' + }; + } + + return { + ...currentState, + requests: {}, + completedRequests + }; + }); + } +} diff --git a/cli/src/agent/runners/gemini.ts b/cli/src/agent/runners/gemini.ts new file mode 100644 index 00000000..3a9a552c --- /dev/null +++ b/cli/src/agent/runners/gemini.ts @@ -0,0 +1,32 @@ +import { AgentRegistry } from '@/agent/AgentRegistry'; +import { AcpSdkBackend } from '@/agent/backends/acp'; + +function parseArgs(value?: string): string[] | null { + if (!value) return null; + const parts = value.split(' ').map((part) => part.trim()).filter(Boolean); + return parts.length > 0 ? parts : null; +} + +function buildEnv(): Record { + return Object.keys(process.env).reduce((acc, key) => { + const value = process.env[key]; + if (typeof value === 'string') { + acc[key] = value; + } + return acc; + }, {} as Record); +} + +const command = process.env.HAPPY_GEMINI_COMMAND + || process.env.GEMINI_ACP_COMMAND + || 'gemini'; + +const args = parseArgs(process.env.HAPPY_GEMINI_ARGS) + ?? parseArgs(process.env.GEMINI_ACP_ARGS) + ?? ['--acp']; + +AgentRegistry.register('gemini', () => new AcpSdkBackend({ + command, + args, + env: buildEnv() +})); diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts new file mode 100644 index 00000000..dfca2c6e --- /dev/null +++ b/cli/src/agent/runners/runAgentSession.ts @@ -0,0 +1,215 @@ +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; + +import { ApiClient } from '@/api/api'; +import type { AgentState, Metadata } from '@/api/types'; +import { logger } from '@/ui/logger'; +import packageJson from '../../../package.json'; +import { readSettings } from '@/persistence'; +import { configuration } from '@/configuration'; +import { runtimePath } from '@/projectPath'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { AgentRegistry } from '@/agent/AgentRegistry'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import { PermissionAdapter } from '@/agent/permissionAdapter'; +import type { AgentBackend, PromptContent } from '@/agent/types'; +import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; +import { initialMachineMetadata } from '@/daemon/run'; +import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; + +function emitReadyIfIdle(props: { + queueSize: () => number; + shouldExit: boolean; + thinking: boolean; + sendReady: () => void; +}): void { + if (props.shouldExit) return; + if (props.thinking) return; + if (props.queueSize() > 0) return; + props.sendReady(); +} + +export async function runAgentSession(opts: { + agentType: string; + startedBy?: 'daemon' | 'terminal'; +}): Promise { + const sessionTag = randomUUID(); + const api = await ApiClient.create(); + + const settings = await readSettings(); + const machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings. Please report this issue on ${packageJson.bugs}`); + process.exit(1); + } + + await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata + }); + + let state: AgentState = { + controlledByUser: false + }; + + const metadata: Metadata = { + path: process.cwd(), + host: os.hostname(), + version: packageJson.version, + os: os.platform(), + machineId, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: runtimePath(), + happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), + startedFromDaemon: opts.startedBy === 'daemon', + hostPid: process.pid, + startedBy: opts.startedBy || 'terminal', + lifecycleState: 'running', + lifecycleStateSince: Date.now(), + flavor: opts.agentType + }; + + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + const session = api.sessionSyncClient(response); + + try { + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[START] Failed to report session to daemon: ${result.error}`); + } + } catch (error) { + logger.debug('[START] Failed to report session to daemon', error); + } + + session.updateAgentState((currentState) => ({ + ...currentState, + controlledByUser: false + })); + + const messageQueue = new MessageQueue2>(() => hashObject({})); + + session.onUserMessage((message) => { + messageQueue.push(message.content.text, {}); + }); + + const backend: AgentBackend = AgentRegistry.create(opts.agentType); + await backend.initialize(); + + const permissionAdapter = new PermissionAdapter(session, backend); + + const happyServer = await startHappyServer(session); + const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); + const mcpServers = [ + { + name: 'happy', + command: bridgeCommand.command, + args: bridgeCommand.args, + env: [] + } + ]; + + const agentSessionId = await backend.newSession({ + cwd: process.cwd(), + mcpServers + }); + + let thinking = false; + let shouldExit = false; + let waitAbortController: AbortController | null = null; + + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => { + session.keepAlive(thinking, 'remote'); + }, 2000); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + const handleAbort = async () => { + logger.debug('[ACP] Abort requested'); + await backend.cancelPrompt(agentSessionId); + await permissionAdapter.cancelAll('User aborted'); + thinking = false; + session.keepAlive(thinking, 'remote'); + sendReady(); + if (waitAbortController) { + waitAbortController.abort(); + } + }; + + session.rpcHandlerManager.registerHandler('abort', async () => { + await handleAbort(); + }); + + const handleKillSession = async () => { + if (shouldExit) return; + shouldExit = true; + await permissionAdapter.cancelAll('Session killed'); + if (waitAbortController) { + waitAbortController.abort(); + } + }; + + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + + try { + while (!shouldExit) { + waitAbortController = new AbortController(); + const batch = await messageQueue.waitForMessagesAndGetAsString(waitAbortController.signal); + waitAbortController = null; + if (!batch) { + if (shouldExit) { + break; + } + continue; + } + + const promptContent: PromptContent[] = [{ + type: 'text', + text: batch.message + }]; + + thinking = true; + session.keepAlive(thinking, 'remote'); + + try { + await backend.prompt(agentSessionId, promptContent, (message) => { + const converted = convertAgentMessage(message); + if (converted) { + session.sendCodexMessage(converted); + } + }); + } catch (error) { + logger.warn('[ACP] Prompt failed', error); + session.sendSessionEvent({ + type: 'message', + message: 'Agent prompt failed. Check logs for details.' + }); + } finally { + thinking = false; + session.keepAlive(thinking, 'remote'); + await permissionAdapter.cancelAll('Prompt finished'); + emitReadyIfIdle({ + queueSize: () => messageQueue.size(), + shouldExit, + thinking, + sendReady + }); + } + } + } finally { + clearInterval(keepAliveInterval); + await permissionAdapter.cancelAll('Session ended'); + session.sendSessionDeath(); + await session.flush(); + session.close(); + await backend.disconnect(); + happyServer.stop(); + } +} diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts new file mode 100644 index 00000000..1441e4c1 --- /dev/null +++ b/cli/src/agent/types.ts @@ -0,0 +1,68 @@ +export type McpEnvVar = { + name: string; + value: string; +}; + +export type McpServerStdio = { + name: string; + command: string; + args: string[]; + env: McpEnvVar[]; +}; + +export type AgentSessionConfig = { + cwd: string; + mcpServers: McpServerStdio[]; +}; + +export type PromptContent = { + type: 'text'; + text: string; +}; + +export type PlanItem = { + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; +}; + +export type AgentMessage = + | { type: 'text'; text: string } + | { type: 'tool_call'; id: string; name: string; input: unknown; status: 'pending' | 'in_progress' | 'completed' | 'failed' } + | { type: 'tool_result'; id: string; output: unknown; status: 'completed' | 'failed' } + | { type: 'plan'; items: PlanItem[] } + | { type: 'turn_complete'; stopReason: string } + | { type: 'error'; message: string }; + +export type PermissionOption = { + optionId: string; + name: string; + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always' | string; +}; + +export type PermissionRequest = { + id: string; + sessionId: string; + toolCallId: string; + title?: string; + kind?: string; + rawInput?: unknown; + rawOutput?: unknown; + options: PermissionOption[]; +}; + +export type PermissionResponse = + | { outcome: 'selected'; optionId: string } + | { outcome: 'cancelled' }; + +export interface AgentBackend { + initialize(): Promise; + newSession(config: AgentSessionConfig): Promise; + prompt(sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void): Promise; + cancelPrompt(sessionId: string): Promise; + respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise; + onPermissionRequest(handler: (request: PermissionRequest) => void): void; + disconnect(): Promise; +} + +export type AgentBackendFactory = () => AgentBackend; diff --git a/cli/src/agent/utils.ts b/cli/src/agent/utils.ts new file mode 100644 index 00000000..80f88c74 --- /dev/null +++ b/cli/src/agent/utils.ts @@ -0,0 +1,35 @@ +export function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} + +export function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +export function deriveToolName(input: { + title?: string | null; + kind?: string | null; + rawInput?: unknown; +}): string { + if (input.title && input.title.trim().length > 0) { + return input.title.trim(); + } + + if (isObject(input.rawInput)) { + const fromName = input.rawInput.name; + if (typeof fromName === 'string' && fromName.trim().length > 0) { + return fromName.trim(); + } + + const fromTool = input.rawInput.tool; + if (typeof fromTool === 'string' && fromTool.trim().length > 0) { + return fromTool.trim(); + } + } + + if (input.kind && input.kind.trim().length > 0) { + return input.kind.trim(); + } + + return 'Tool'; +} diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts index fa928e50..448d0320 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/daemon/run.ts @@ -241,7 +241,7 @@ export async function startDaemon(): Promise { extraEnv = { CODEX_HOME: codexHomeDir }; - } else { // Assuming claude + } else if (options.agent === 'claude' || !options.agent) { extraEnv = { CLAUDE_CODE_OAUTH_TOKEN: options.token }; @@ -249,8 +249,13 @@ export async function startDaemon(): Promise { } // Construct arguments for the CLI + const agentCommand = options.agent === 'codex' + ? 'codex' + : options.agent === 'gemini' + ? 'gemini' + : 'claude'; const args = [ - options.agent === 'claude' ? 'claude' : 'codex', + agentCommand, '--hapi-starting-mode', 'remote', '--started-by', 'daemon' ]; diff --git a/cli/src/index.ts b/cli/src/index.ts index 9584eb2a..d5086a05 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -146,6 +146,30 @@ import { withBunRuntimeEnv } from './utils/bunRuntime' process.exit(1) } return; + } else if (subcommand === 'gemini') { + // Handle gemini command + try { + await import('./agent/runners/gemini'); + const { runAgentSession } = await import('./agent/runners/runAgentSession'); + + let startedBy: 'daemon' | 'terminal' | undefined = undefined; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--started-by') { + startedBy = args[++i] as 'daemon' | 'terminal'; + } + } + + await initializeToken(); + await authAndSetupMachineIfNeeded(); + await runAgentSession({ agentType: 'gemini', startedBy }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + return; } else if (subcommand === 'logout') { // Keep for backward compatibility - redirect to auth logout console.log(chalk.yellow('Note: "hapi logout" is deprecated. Use "hapi auth logout" instead.\n')); @@ -333,6 +357,7 @@ ${chalk.bold('Usage:')} hapi [options] Start Claude with Telegram control (direct-connect) hapi auth Manage authentication hapi codex Start Codex mode + hapi gemini Start Gemini ACP mode hapi mcp Start MCP stdio bridge hapi connect (not available in direct-connect mode) hapi notify (not available in direct-connect mode) diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 50b738bc..4e733336 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -121,7 +121,7 @@ export interface SpawnSessionOptions { directory: string; sessionId?: string; approvedNewDirectoryCreation?: boolean; - agent?: 'claude' | 'codex'; + agent?: 'claude' | 'codex' | 'gemini'; token?: string; } diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 82012931..cbb25c18 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -644,7 +644,7 @@ export class SyncEngine { async spawnSession( machineId: string, directory: string, - agent: 'claude' | 'codex' = 'claude' + agent: 'claude' | 'codex' | 'gemini' = 'claude' ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc(machineId, 'spawn-happy-session', { type: 'spawn-in-directory', directory, agent }) diff --git a/server/src/sync/todos.ts b/server/src/sync/todos.ts index 11e1905c..8bad4ab2 100644 --- a/server/src/sync/todos.ts +++ b/server/src/sync/todos.ts @@ -88,6 +88,39 @@ function extractTodosFromCodexMessage(content: Record): TodoIte return parsed.success ? parsed.data : null } +function extractTodosFromAcpMessage(content: Record): TodoItem[] | null { + if (content.type !== 'codex') return null + + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'plan') return null + + const entries = data.entries + if (!Array.isArray(entries)) return null + + const todos: TodoItem[] = [] + entries.forEach((entry, index) => { + if (!isObject(entry)) return + const contentValue = typeof entry.content === 'string' ? entry.content : null + const priorityValue = typeof entry.priority === 'string' ? entry.priority : null + const statusValue = typeof entry.status === 'string' ? entry.status : null + if (!contentValue || !priorityValue || !statusValue) return + if (priorityValue !== 'high' && priorityValue !== 'medium' && priorityValue !== 'low') return + if (statusValue !== 'pending' && statusValue !== 'in_progress' && statusValue !== 'completed') return + + const idValue = typeof entry.id === 'string' ? entry.id : `plan-${index + 1}` + + todos.push({ + content: contentValue, + priority: priorityValue, + status: statusValue, + id: idValue + }) + }) + + const parsed = TodosSchema.safeParse(todos) + return parsed.success ? parsed.data : null +} + export function extractTodoWriteTodosFromMessageContent(messageContent: unknown): TodoItem[] | null { const record = unwrapRoleWrappedRecordEnvelope(messageContent) if (!record) return null @@ -96,6 +129,7 @@ export function extractTodoWriteTodosFromMessageContent(messageContent: unknown) if (!isObject(record.content) || typeof record.content.type !== 'string') return null - return extractTodosFromClaudeOutput(record.content) ?? extractTodosFromCodexMessage(record.content) + return extractTodosFromClaudeOutput(record.content) + ?? extractTodosFromCodexMessage(record.content) + ?? extractTodosFromAcpMessage(record.content) } - diff --git a/server/src/web/routes/machines.ts b/server/src/web/routes/machines.ts index e967a427..8ec71711 100644 --- a/server/src/web/routes/machines.ts +++ b/server/src/web/routes/machines.ts @@ -5,7 +5,7 @@ import type { WebAppEnv } from '../middleware/auth' const spawnBodySchema = z.object({ directory: z.string().min(1), - agent: z.enum(['claude', 'codex']).optional() + agent: z.enum(['claude', 'codex', 'gemini']).optional() }) export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Hono { @@ -45,4 +45,3 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho return app } - diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 10f57575..4689c927 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -175,7 +175,7 @@ export class ApiClient { return await this.request('/api/machines') } - async spawnSession(machineId: string, directory: string, agent?: 'claude' | 'codex'): Promise { + async spawnSession(machineId: string, directory: string, agent?: 'claude' | 'codex' | 'gemini'): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', body: JSON.stringify({ directory, agent }) diff --git a/web/src/components/ToolCard/PermissionFooter.tsx b/web/src/components/ToolCard/PermissionFooter.tsx index 53ee4dd3..2d369b5a 100644 --- a/web/src/components/ToolCard/PermissionFooter.tsx +++ b/web/src/components/ToolCard/PermissionFooter.tsx @@ -32,7 +32,10 @@ function isToolAllowedForSession(toolName: string, toolInput: unknown, allowedTo } function isCodexSession(metadata: SessionMetadataSummary | null, toolName: string): boolean { - return metadata?.flavor === 'codex' || toolName.startsWith('Codex') + return metadata?.flavor === 'codex' + || metadata?.flavor === 'gemini' + || toolName.startsWith('Codex') + || toolName.startsWith('Gemini') } function formatPermissionSummary(permission: ToolPermission, toolName: string, toolInput: unknown, codex: boolean): string { diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index c04c24fd..9c3da862 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -6,7 +6,7 @@ import { queryKeys } from '@/lib/query-keys' type SpawnInput = { machineId: string directory: string - agent?: 'claude' | 'codex' + agent?: 'claude' | 'codex' | 'gemini' } export function useSpawnSession(api: ApiClient | null): {