Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/agent/acp/AcpSdkBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,9 @@ export class AcpSdkBackend implements AgentBackend {
this.acpSessionId = sessionResponse.sessionId;
logger.debug(`[AcpSdkBackend] Session created: ${this.acpSessionId}`);

// Emit session-created event so the launcher can track the session ID
this.emit({ type: 'event', name: 'session-created', payload: { sessionId: this.acpSessionId } });

this.emit({ type: 'status', status: 'idle' });

// Send initial prompt if provided
Expand Down
20 changes: 15 additions & 5 deletions src/agent/acp/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,23 @@ import {
export interface GeminiBackendOptions extends AgentFactoryOptions {
/** API key for Gemini (defaults to GEMINI_API_KEY or GOOGLE_API_KEY env var) */
apiKey?: string;

/** OAuth token from Happy cloud (via 'happy connect gemini') - highest priority */
cloudToken?: string;

/** Model to use. If undefined, will use local config, env var, or default.
* If explicitly set to null, will use default (skip local config).
* (defaults to GEMINI_MODEL env var or 'gemini-2.5-pro') */
model?: string | null;

/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;

/** Optional permission handler for tool approval */
permissionHandler?: AcpPermissionHandler;

/** Session ID to resume (if available). Passed to Gemini CLI via --resume flag */
sessionId?: string | null;
}

/**
Expand Down Expand Up @@ -86,11 +89,18 @@ export function createGeminiBackend(options: GeminiBackendOptions): AgentBackend
// If options.model is explicitly null, skip local config and use env/default
const model = determineGeminiModel(options.model, localConfig);

// Build args - use only --experimental-acp flag
// Build args - start with --experimental-acp flag
// Model is passed via GEMINI_MODEL env var (gemini CLI reads it automatically)
// We don't use --model flag to avoid potential stdout conflicts with ACP protocol
const geminiArgs = ['--experimental-acp'];

// Add --resume flag if session ID is provided
// This allows the Gemini CLI to resume an existing session, maintaining conversation history
if (options.sessionId) {
geminiArgs.push('--resume', options.sessionId);
logger.debug(`[Gemini] Resuming session: ${options.sessionId}`);
}

const backendOptions: AcpSdkBackendOptions = {
agentName: 'gemini',
cwd: options.cwd,
Expand Down
139 changes: 136 additions & 3 deletions src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,26 +226,159 @@ export class ApiSessionClient extends EventEmitter {
role: 'agent',
content: {
type: 'codex',
data: body // This wraps the entire Claude message
data: body // This wraps the entire Codex message
},
meta: {
sentFrom: 'cli'
}
};
const encrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, content));

// Check if socket is connected before sending
if (!this.socket.connected) {
logger.debug('[API] Socket not connected, cannot send message. Message will be lost:', { type: body.type });
// TODO: Consider implementing message queue or HTTP fallback for reliability
}

this.socket.emit('message', {
sid: this.sessionId,
message: encrypted
});
}

/**
* Send Gemini message to mobile client
* Uses dedicated 'gemini' type instead of 'codex' for proper ACP alignment
*
* Configuration: GEMINI_DUAL_FORMAT
* - 'new': Send only gemini format
* - 'old': Send only legacy codex format (for rollback)
* - 'both': Send both formats for backward compatibility (default)
*/
sendGeminiMessage(body: any) {
const dualFormatMode = process.env.GEMINI_DUAL_FORMAT || 'both';

// Check if socket is connected before sending
if (!this.socket.connected) {
logger.debug('[API] Socket not connected, cannot send Gemini message. Message will be lost:', { type: body.type });
return;
}

// Send new gemini format
if (dualFormatMode === 'new' || dualFormatMode === 'both') {
let geminiContent = {
role: 'agent',
content: {
type: 'gemini', // Dedicated Gemini type
data: body
},
meta: {
sentFrom: 'cli',
format: 'gemini' // Marker for debugging
}
};
const geminiEncrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, geminiContent));

this.socket.emit('message', {
sid: this.sessionId,
message: geminiEncrypted
});

if (dualFormatMode === 'new') {
logger.debug('[API] Sent Gemini message (new format only):', { type: body.type });
}
}

// Send old codex format for backward compatibility
if (dualFormatMode === 'old' || dualFormatMode === 'both') {
// Convert new format to old codex format
let codexBody = this.convertGeminiToCodex(body);
if (codexBody) {
let codexContent = {
role: 'agent',
content: {
type: 'codex',
data: codexBody
},
meta: {
sentFrom: 'cli',
format: 'codex-compat' // Marker for debugging
}
};
const codexEncrypted = encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, codexContent));

this.socket.emit('message', {
sid: this.sessionId,
message: codexEncrypted
});

if (dualFormatMode === 'both') {
logger.debug('[API] Sent Gemini message (dual format - new + old):', { type: body.type });
}
}
}
}

/**
* Convert new Gemini message format to old Codex format for backward compatibility
*/
private convertGeminiToCodex(geminiBody: any): any {
const type = geminiBody.type;

switch (type) {
case 'model-output':
return {
type: 'message',
message: geminiBody.textDelta || '',
id: geminiBody.id
};

case 'thinking':
return {
type: 'reasoning',
message: geminiBody.text,
id: geminiBody.id
};

case 'message':
return {
type: 'message',
message: geminiBody.message,
id: geminiBody.id
};

case 'tool-call':
return {
type: 'tool-call',
callId: geminiBody.callId,
name: geminiBody.toolName,
input: geminiBody.args,
id: geminiBody.id
};

case 'tool-result':
return {
type: 'tool-call-result',
callId: geminiBody.callId,
output: geminiBody.result,
id: geminiBody.id
};

case 'status':
case 'token-count':
case 'file-edit':
case 'terminal-output':
case 'permission-request':
// These don't exist in old format, skip
logger.debug('[API] Skipping Gemini message type in codex conversion:', type);
return null;

default:
logger.debug('[API] Unknown Gemini message type for codex conversion:', type);
return null;
}
}

/**
* Send a generic agent message to the session.
* Works for any agent type (Gemini, Codex, Claude, etc.)
Expand Down
107 changes: 103 additions & 4 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,111 @@ export const UserMessageSchema = z.object({

export type UserMessage = z.infer<typeof UserMessageSchema>

/**
* Gemini message data schema - aligned with ACP protocol
* Used for dedicated Gemini message type (separate from Codex)
*/
export const GeminiMessageDataSchema = z.discriminatedUnion('type', [
// Text output from model
z.object({
type: z.literal('model-output'),
textDelta: z.string().optional(),
id: z.string()
}),

// Tool call
z.object({
type: z.literal('tool-call'),
toolName: z.string(),
args: z.any(),
callId: z.string(),
id: z.string()
}),

// Tool result
z.object({
type: z.literal('tool-result'),
toolName: z.string(),
result: z.any(),
callId: z.string(),
isError: z.boolean().optional(),
id: z.string()
}),

// Status update
z.object({
type: z.literal('status'),
status: z.enum(['starting', 'running', 'idle', 'stopped', 'error']),
id: z.string()
}),

// Token usage
z.object({
type: z.literal('token-count'),
inputTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number().optional(),
id: z.string()
}),

// Thinking/reasoning
z.object({
type: z.literal('thinking'),
text: z.string(),
id: z.string()
}),

// File edit
z.object({
type: z.literal('file-edit'),
description: z.string(),
diff: z.string(),
path: z.string().optional(),
id: z.string()
}),

// Terminal output
z.object({
type: z.literal('terminal-output'),
data: z.string(),
id: z.string()
}),

// Permission request
z.object({
type: z.literal('permission-request'),
permissionId: z.string(),
reason: z.string(),
payload: z.any().optional(),
id: z.string()
}),

// Generic message
z.object({
type: z.literal('message'),
message: z.string(),
id: z.string()
})
]);

export type GeminiMessageData = z.infer<typeof GeminiMessageDataSchema>

export const AgentMessageSchema = z.object({
role: z.literal('agent'),
content: z.object({
type: z.literal('output'),
data: z.any()
}),
content: z.discriminatedUnion('type', [
z.object({
type: z.literal('output'),
data: z.any() // Claude messages
}),
z.object({
type: z.literal('codex'),
data: z.any() // Codex/OpenAI messages
}),
z.object({
type: z.literal('gemini'), // Gemini messages
data: GeminiMessageDataSchema
})
]),
meta: MessageMetaSchema.optional()
})

Expand Down
26 changes: 23 additions & 3 deletions src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,14 +440,34 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
// Clean up permission handler
permissionHandler.reset();

// Reset Terminal
// Reset Terminal - IMPORTANT: Order matters to prevent stdin listener competition bug!
// Must unmount Ink BEFORE changing terminal modes to ensure useInput listeners are removed
if (inkInstance) {
inkInstance.unmount();
}

// Remove explicit abort listener
process.stdin.off('data', abort);

// Reset terminal mode after Ink cleanup
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
if (inkInstance) {
inkInstance.unmount();

// Additional cleanup: Ensure ALL stdin listeners are removed before switching to local mode
// This prevents parent and child from competing for stdin input (double character bug)
if (exitReason === 'switch') {
// Remove any remaining data listeners that might compete with the child process
const dataListeners = process.stdin.listeners('data');
const keypressListeners = process.stdin.listeners('keypress');

if (dataListeners.length > 0 || keypressListeners.length > 0) {
logger.debug(`[remote]: Cleaning up remaining stdin listeners before switch (data: ${dataListeners.length}, keypress: ${keypressListeners.length})`);
process.stdin.removeAllListeners('data');
process.stdin.removeAllListeners('keypress');
}
}

messageBuffer.clear();

// Resolve abort future
Expand Down
5 changes: 5 additions & 0 deletions src/claude/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export async function loop(opts: LoopOptions) {
if (opts.onModeChange) {
opts.onModeChange(mode);
}

// Small delay to ensure remote mode stdin cleanup fully completes
// before local mode spawns child (prevents stdin listener competition)
await new Promise(resolve => setTimeout(resolve, 50));

continue;
}
}
Expand Down
Loading