This repository was archived by the owner on Feb 14, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 273
Expand file tree
/
Copy pathrunGemini.ts
More file actions
1103 lines (955 loc) · 44.5 KB
/
Copy pathrunGemini.ts
File metadata and controls
1103 lines (955 loc) · 44.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Gemini CLI Entry Point
*
* This module provides the main entry point for running the Gemini agent
* through Happy CLI. It manages the agent lifecycle, session state, and
* communication with the Happy server and mobile app.
*/
import { render } from 'ink';
import React from 'react';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
import { join, resolve } from 'node:path';
import { ApiClient } from '@/api/api';
import { logger } from '@/ui/logger';
import { Credentials, readSettings } from '@/persistence';
import { AgentState, Metadata } from '@/api/types';
import { initialMachineMetadata } from '@/daemon/run';
import { configuration } from '@/configuration';
import packageJson from '../../package.json';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
import { projectPath } from '@/projectPath';
import { startHappyServer } from '@/claude/utils/startHappyServer';
import { MessageBuffer } from '@/ui/ink/messageBuffer';
import { notifyDaemonSessionStarted } from '@/daemon/controlClient';
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
import { stopCaffeinate } from '@/utils/caffeinate';
import { formatErrorForUi } from '@/utils/formatErrorForUi';
import { createGeminiBackend } from '@/agent/acp/gemini';
import type { AgentBackend, AgentMessage } from '@/agent/AgentBackend';
import { GeminiDisplay } from '@/ui/ink/GeminiDisplay';
import { GeminiPermissionHandler } from '@/gemini/utils/permissionHandler';
import { GeminiReasoningProcessor } from '@/gemini/utils/reasoningProcessor';
import { GeminiDiffProcessor } from '@/gemini/utils/diffProcessor';
import type { PermissionMode, GeminiMode, CodexMessagePayload } from '@/gemini/types';
import { GEMINI_MODEL_ENV, DEFAULT_GEMINI_MODEL, CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants';
import {
readGeminiLocalConfig,
determineGeminiModel,
saveGeminiModelToConfig,
getInitialGeminiModel
} from '@/gemini/utils/config';
import {
parseOptionsFromText,
hasIncompleteOptions,
formatOptionsXml,
} from '@/gemini/utils/optionsParser';
/**
* Main entry point for the gemini command with ink UI
*/
export async function runGemini(opts: {
credentials: Credentials;
startedBy?: 'daemon' | 'terminal';
}): Promise<void> {
//
// Define session
//
const sessionTag = randomUUID();
const api = await ApiClient.create(opts.credentials);
//
// Machine
//
const settings = await readSettings();
const machineId = settings?.machineId;
if (!machineId) {
console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/slopus/happy-cli/issues`);
process.exit(1);
}
logger.debug(`Using machineId: ${machineId}`);
await api.getOrCreateMachine({
machineId,
metadata: initialMachineMetadata
});
//
// Fetch Gemini cloud token (from 'happy connect gemini')
//
let cloudToken: string | undefined = undefined;
try {
const vendorToken = await api.getVendorToken('gemini');
if (vendorToken?.oauth?.access_token) {
cloudToken = vendorToken.oauth.access_token;
logger.debug('[Gemini] Using OAuth token from Happy cloud');
}
} catch (error) {
logger.debug('[Gemini] Failed to fetch cloud token:', error);
}
//
// Create session
//
const state: AgentState = {
controlledByUser: false,
};
const metadata: Metadata = {
path: process.cwd(),
host: os.hostname(),
version: packageJson.version,
os: os.platform(),
machineId: machineId,
homeDir: os.homedir(),
happyHomeDir: configuration.happyHomeDir,
happyLibDir: projectPath(),
happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'),
startedFromDaemon: opts.startedBy === 'daemon',
hostPid: process.pid,
startedBy: opts.startedBy || 'terminal',
lifecycleState: 'running',
lifecycleStateSince: Date.now(),
flavor: 'gemini'
};
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
const session = api.sessionSyncClient(response);
// Report to daemon
try {
logger.debug(`[START] Reporting session ${response.id} to daemon`);
const result = await notifyDaemonSessionStarted(response.id, metadata);
if (result.error) {
logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error);
} else {
logger.debug(`[START] Reported session ${response.id} to daemon`);
}
} catch (error) {
logger.debug('[START] Failed to report to daemon (may not be running):', error);
}
const messageQueue = new MessageQueue2<GeminiMode>((mode) => hashObject({
permissionMode: mode.permissionMode,
model: mode.model,
}));
// Track current overrides to apply per message
let currentPermissionMode: PermissionMode | undefined = undefined;
let currentModel: string | undefined = undefined;
session.onUserMessage((message) => {
// Resolve permission mode (validate) - same as Codex
let messagePermissionMode = currentPermissionMode;
if (message.meta?.permissionMode) {
const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo'];
if (validModes.includes(message.meta.permissionMode as PermissionMode)) {
messagePermissionMode = message.meta.permissionMode as PermissionMode;
currentPermissionMode = messagePermissionMode;
// Update permission handler with new mode
updatePermissionMode(messagePermissionMode);
logger.debug(`[Gemini] Permission mode updated from user message to: ${currentPermissionMode}`);
} else {
logger.debug(`[Gemini] Invalid permission mode received: ${message.meta.permissionMode}`);
}
} else {
logger.debug(`[Gemini] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`);
}
// Initialize permission mode if not set yet
if (currentPermissionMode === undefined) {
currentPermissionMode = 'default';
updatePermissionMode('default');
}
// Resolve model; explicit null resets to default (undefined)
let messageModel = currentModel;
if (message.meta?.hasOwnProperty('model')) {
// If model is explicitly null, reset internal state but don't update displayed model
// If model is provided, use it and update displayed model
// Otherwise keep current model
if (message.meta.model === null) {
messageModel = undefined; // Explicitly reset - will use default/env/config
currentModel = undefined;
// Don't call updateDisplayedModel here - keep current displayed model
// The backend will use the correct model from env/config/default
} else if (message.meta.model) {
messageModel = message.meta.model;
currentModel = messageModel;
// Save model to config file so it persists across sessions
updateDisplayedModel(messageModel, true); // Update UI and save to config
// Show model change message in UI (this will trigger UI re-render)
messageBuffer.addMessage(`Model changed to: ${messageModel}`, 'system');
}
// If message.meta.model is undefined, keep currentModel
}
// Build the full prompt with appendSystemPrompt if provided
// Only include system prompt for the first message to avoid forcing tool usage on every message
const originalUserMessage = message.content.text;
let fullPrompt = originalUserMessage;
if (isFirstMessage && message.meta?.appendSystemPrompt) {
// Prepend system prompt to user message only for first message
// Also add change_title instruction (like Codex does)
// Use EXACT same format as Codex: add instruction AFTER user message
// This matches Codex's approach exactly - instruction comes after user message
// Codex format: system prompt + user message + change_title instruction
fullPrompt = message.meta.appendSystemPrompt + '\n\n' + originalUserMessage + '\n\n' + CHANGE_TITLE_INSTRUCTION;
isFirstMessage = false;
}
const mode: GeminiMode = {
permissionMode: messagePermissionMode || 'default',
model: messageModel,
originalUserMessage, // Store original message separately
};
messageQueue.push(fullPrompt, mode);
});
let thinking = false;
session.keepAlive(thinking, 'remote');
const keepAliveInterval = setInterval(() => {
session.keepAlive(thinking, 'remote');
}, 2000);
// Track if this is the first message to include system prompt only once
let isFirstMessage = true;
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
try {
api.push().sendToAllDevices(
"It's ready!",
'Gemini is waiting for your command',
{ sessionId: session.sessionId }
);
} catch (pushError) {
logger.debug('[Gemini] Failed to send ready push', pushError);
}
};
/**
* Check if we can emit ready event
* * Returns true when ready event was emitted
*/
const emitReadyIfIdle = (): boolean => {
if (shouldExit) {
return false;
}
if (thinking) {
return false;
}
if (isResponseInProgress) {
return false;
}
if (messageQueue.size() > 0) {
return false;
}
sendReady();
return true;
};
//
// Abort handling
//
let abortController = new AbortController();
let shouldExit = false;
let geminiBackend: AgentBackend | null = null;
let acpSessionId: string | null = null;
let wasSessionCreated = false;
async function handleAbort() {
logger.debug('[Gemini] Abort requested - stopping current task');
// Send turn_aborted event (like Codex) when abort is requested
session.sendCodexMessage({
type: 'turn_aborted',
id: randomUUID(),
});
// Abort reasoning processor and reset diff processor
reasoningProcessor.abort();
diffProcessor.reset();
try {
abortController.abort();
messageQueue.reset();
if (geminiBackend && acpSessionId) {
await geminiBackend.cancel(acpSessionId);
}
logger.debug('[Gemini] Abort completed - session remains active');
} catch (error) {
logger.debug('[Gemini] Error during abort:', error);
} finally {
abortController = new AbortController();
}
}
const handleKillSession = async () => {
logger.debug('[Gemini] Kill session requested - terminating process');
await handleAbort();
logger.debug('[Gemini] Abort completed, proceeding with termination');
try {
if (session) {
session.updateMetadata((currentMetadata) => ({
...currentMetadata,
lifecycleState: 'archived',
lifecycleStateSince: Date.now(),
archivedBy: 'cli',
archiveReason: 'User terminated'
}));
session.sendSessionDeath();
await session.flush();
await session.close();
}
stopCaffeinate();
happyServer.stop();
if (geminiBackend) {
await geminiBackend.dispose();
}
logger.debug('[Gemini] Session termination complete, exiting');
process.exit(0);
} catch (error) {
logger.debug('[Gemini] Error during session termination:', error);
process.exit(1);
}
};
session.rpcHandlerManager.registerHandler('abort', handleAbort);
registerKillSessionHandler(session.rpcHandlerManager, handleKillSession);
//
// Initialize Ink UI
//
const messageBuffer = new MessageBuffer();
const hasTTY = process.stdout.isTTY && process.stdin.isTTY;
let inkInstance: ReturnType<typeof render> | null = null;
// Track current model for UI display
// Initialize with env var or default to show correct model from start
let displayedModel: string | undefined = getInitialGeminiModel();
// Log initial values
const localConfig = readGeminiLocalConfig();
logger.debug(`[gemini] Initial model setup: env[GEMINI_MODEL_ENV]=${process.env[GEMINI_MODEL_ENV] || 'not set'}, localConfig=${localConfig.model || 'not set'}, displayedModel=${displayedModel}`);
// Function to update displayed model and notify UI
const updateDisplayedModel = (model: string | undefined, saveToConfig: boolean = false) => {
// Only update if model is actually provided (not undefined)
if (model === undefined) {
logger.debug(`[gemini] updateDisplayedModel called with undefined, skipping update`);
return;
}
const oldModel = displayedModel;
displayedModel = model;
logger.debug(`[gemini] updateDisplayedModel called: oldModel=${oldModel}, newModel=${model}, saveToConfig=${saveToConfig}`);
// Save to config file if requested (when user changes model via mobile app)
if (saveToConfig) {
saveGeminiModelToConfig(model);
}
// Trigger UI update by adding a system message with model info
// The message will be parsed by UI to extract model name
if (hasTTY && oldModel !== model) {
// Add a system message that includes model info - UI will parse it
// Format: [MODEL:gemini-2.5-pro] to make it easy to extract
logger.debug(`[gemini] Adding model update message to buffer: [MODEL:${model}]`);
messageBuffer.addMessage(`[MODEL:${model}]`, 'system');
} else if (hasTTY) {
logger.debug(`[gemini] Model unchanged, skipping update message`);
}
};
if (hasTTY) {
console.clear();
// Create a React component that reads displayedModel from closure
// Model will update when UI re-renders (on messageBuffer updates)
// We use a function component that reads displayedModel on each render
const DisplayComponent = () => {
// Read displayedModel from closure - it will have latest value on each render
const currentModelValue = displayedModel || 'gemini-2.5-pro';
// Don't log on every render to avoid spam - only log when model changes
return React.createElement(GeminiDisplay, {
messageBuffer,
logPath: process.env.DEBUG ? logger.getLogPath() : undefined,
currentModel: currentModelValue,
onExit: async () => {
logger.debug('[gemini]: Exiting agent via Ctrl-C');
shouldExit = true;
await handleAbort();
}
});
};
inkInstance = render(React.createElement(DisplayComponent), {
exitOnCtrlC: false,
patchConsole: false
});
// Send initial model to UI so it displays correctly from start
const initialModelName = displayedModel || 'gemini-2.5-pro';
logger.debug(`[gemini] Sending initial model to UI: ${initialModelName}`);
messageBuffer.addMessage(`[MODEL:${initialModelName}]`, 'system');
}
if (hasTTY) {
process.stdin.resume();
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding('utf8');
}
//
// Start Happy MCP server and create Gemini backend
//
const happyServer = await startHappyServer(session);
const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs');
const mcpServers = {
happy: {
command: bridgeCommand,
args: ['--url', happyServer.url]
}
};
// Create permission handler for tool approval
const permissionHandler = new GeminiPermissionHandler(session);
// Create reasoning processor for handling thinking/reasoning chunks
const reasoningProcessor = new GeminiReasoningProcessor((message) => {
// Callback to send messages directly from the processor
session.sendCodexMessage(message);
});
// Create diff processor for handling file edit events and diff tracking
const diffProcessor = new GeminiDiffProcessor((message) => {
// Callback to send messages directly from the processor
session.sendCodexMessage(message);
});
// Update permission handler when permission mode changes
const updatePermissionMode = (mode: PermissionMode) => {
permissionHandler.setPermissionMode(mode);
};
// Accumulate Gemini response text for sending complete message to mobile
let accumulatedResponse = '';
let isResponseInProgress = false;
let currentResponseMessageId: string | null = null; // Track the message ID for current response
/**
* Set up message handler for Gemini backend
* This function is called when backend is created or recreated
*/
function setupGeminiMessageHandler(backend: AgentBackend): void {
backend.onMessage((msg: AgentMessage) => {
switch (msg.type) {
case 'model-output':
if (msg.textDelta) {
// If this is the first delta of a new response, create a new message
// Otherwise, update the existing message for this response
if (!isResponseInProgress) {
// Start of new response - create new assistant message
// Remove "Thinking..." message if it exists (it will be replaced by actual response)
messageBuffer.removeLastMessage('system'); // Remove "Thinking..." if present
messageBuffer.addMessage(msg.textDelta, 'assistant');
isResponseInProgress = true;
logger.debug(`[gemini] Started new response, first chunk length: ${msg.textDelta.length}`);
} else {
// Continue existing response - update last assistant message
messageBuffer.updateLastMessage(msg.textDelta, 'assistant');
logger.debug(`[gemini] Updated response, chunk length: ${msg.textDelta.length}, total accumulated: ${accumulatedResponse.length + msg.textDelta.length}`);
}
accumulatedResponse += msg.textDelta;
}
break;
case 'status':
// Log status changes for debugging
logger.debug(`[gemini] Status changed: ${msg.status}${msg.detail ? ` - ${msg.detail}` : ''}`);
// Log error status with details
if (msg.status === 'error') {
logger.debug(`[gemini] ⚠️ Error status received: ${msg.detail || 'Unknown error'}`);
// Send turn_aborted event (like Codex) when error occurs
session.sendCodexMessage({
type: 'turn_aborted',
id: randomUUID(),
});
}
if (msg.status === 'running') {
thinking = true;
session.keepAlive(thinking, 'remote');
// Send task_started event (like Codex) when agent starts working
session.sendCodexMessage({
type: 'task_started',
id: randomUUID(),
});
// Show thinking indicator in UI when agent starts working (like Codex)
// This will be updated with actual thinking text when agent_thought_chunk events arrive
// Always show thinking indicator when status becomes 'running' to give user feedback
// Even if response is in progress, we want to show thinking for new operations
messageBuffer.addMessage('Thinking...', 'system');
// Don't reset accumulator here - tool calls can happen during a response
// Accumulator will be reset when a new prompt is sent (in the main loop)
} else if (msg.status === 'idle' || msg.status === 'stopped') {
if (thinking) {
// Clear thinking indicator when agent finishes
thinking = false;
// Remove thinking message from UI when agent finishes (like Codex)
// The thinking messages will be replaced by actual response
}
thinking = false;
session.keepAlive(thinking, 'remote');
// Complete reasoning processor when status becomes idle (like Codex)
// Only complete if there's actually reasoning content to complete
// Skip if this is just the initial idle status after session creation
const reasoningCompleted = reasoningProcessor.complete();
// Send task_complete event (like Codex) when agent finishes
// Only send if this is a real task completion (not initial idle)
if (reasoningCompleted || isResponseInProgress) {
session.sendCodexMessage({
type: 'task_complete',
id: randomUUID(),
});
}
// Send accumulated response to mobile app when response is complete
// Status 'idle' indicates task completion (similar to Codex's task_complete)
if (isResponseInProgress && accumulatedResponse.trim()) {
// Parse options from response text (for logging/debugging)
// But keep options IN the text - mobile app's parseMarkdown will extract them
const { text: messageText, options } = parseOptionsFromText(accumulatedResponse);
// Mobile app parses options from text via parseMarkdown, so we need to keep them in the message
// Re-add options XML block to the message text if options were found
let finalMessageText = messageText;
if (options.length > 0) {
const optionsXml = formatOptionsXml(options);
finalMessageText = messageText + optionsXml;
logger.debug(`[gemini] Found ${options.length} options in response:`, options);
logger.debug(`[gemini] Keeping options in message text for mobile app parsing`);
} else if (hasIncompleteOptions(accumulatedResponse)) {
// If we have incomplete options block, still send the message
// The mobile app will handle incomplete blocks gracefully
logger.debug(`[gemini] Warning: Incomplete options block detected but sending message anyway`);
}
const messageId = randomUUID();
const messagePayload: CodexMessagePayload = {
type: 'message',
message: finalMessageText, // Include options XML in text for mobile app
id: messageId,
...(options.length > 0 && { options }),
};
logger.debug(`[gemini] Sending complete message to mobile (length: ${finalMessageText.length}): ${finalMessageText.substring(0, 100)}...`);
logger.debug(`[gemini] Full message payload:`, JSON.stringify(messagePayload, null, 2));
// Use sendCodexMessage - mobile app parses options from message text via parseMarkdown
session.sendCodexMessage(messagePayload);
accumulatedResponse = '';
isResponseInProgress = false;
}
// Note: sendReady() is called via emitReadyIfIdle() in the finally block after prompt completes
// Don't call it here to avoid duplicates
} else if (msg.status === 'error') {
thinking = false;
session.keepAlive(thinking, 'remote');
accumulatedResponse = '';
isResponseInProgress = false;
currentResponseMessageId = null;
// Show error in CLI UI
const errorMessage = msg.detail || 'Unknown error';
messageBuffer.addMessage(`Error: ${errorMessage}`, 'status');
// Use sendCodexMessage for consistency with codex format
session.sendCodexMessage({
type: 'message',
message: `Error: ${errorMessage}`,
id: randomUUID(),
});
}
break;
case 'tool-call':
// Show tool call in UI like Codex does
const toolArgs = msg.args ? JSON.stringify(msg.args).substring(0, 100) : '';
const isInvestigationTool = msg.toolName === 'codebase_investigator' ||
(typeof msg.toolName === 'string' && msg.toolName.includes('investigator'));
logger.debug(`[gemini] 🔧 Tool call received: ${msg.toolName} (${msg.callId})${isInvestigationTool ? ' [INVESTIGATION]' : ''}`);
if (isInvestigationTool && msg.args && typeof msg.args === 'object' && 'objective' in msg.args) {
logger.debug(`[gemini] 🔍 Investigation objective: ${String(msg.args.objective).substring(0, 150)}...`);
}
messageBuffer.addMessage(`Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}${toolArgs.length >= 100 ? '...' : ''}` : ''}`, 'tool');
session.sendCodexMessage({
type: 'tool-call',
name: msg.toolName,
callId: msg.callId,
input: msg.args,
id: randomUUID(),
});
break;
case 'tool-result':
// Show tool result in UI like Codex does
// Check if result contains error information
const isError = msg.result && typeof msg.result === 'object' && 'error' in msg.result;
const resultText = typeof msg.result === 'string'
? msg.result.substring(0, 200)
: JSON.stringify(msg.result).substring(0, 200);
const truncatedResult = resultText + (typeof msg.result === 'string' && msg.result.length > 200 ? '...' : '');
const resultSize = typeof msg.result === 'string'
? msg.result.length
: JSON.stringify(msg.result).length;
logger.debug(`[gemini] ${isError ? '❌' : '✅'} Tool result received: ${msg.toolName} (${msg.callId}) - Size: ${resultSize} bytes${isError ? ' [ERROR]' : ''}`);
// Process tool result through diff processor to check for diff information (like Codex)
if (!isError) {
diffProcessor.processToolResult(msg.toolName, msg.result, msg.callId);
}
if (isError) {
const errorMsg = (msg.result as any).error || 'Tool call failed';
logger.debug(`[gemini] ❌ Tool call error: ${errorMsg.substring(0, 300)}`);
messageBuffer.addMessage(`Error: ${errorMsg}`, 'status');
} else {
// Log summary for large results (like investigation tools)
if (resultSize > 1000) {
logger.debug(`[gemini] ✅ Large tool result (${resultSize} bytes) - first 200 chars: ${truncatedResult}`);
}
messageBuffer.addMessage(`Result: ${truncatedResult}`, 'result');
}
session.sendCodexMessage({
type: 'tool-call-result',
callId: msg.callId,
output: msg.result,
id: randomUUID(),
});
break;
case 'fs-edit':
messageBuffer.addMessage(`File edit: ${msg.description}`, 'tool');
// Process fs-edit through diff processor (like Codex)
// msg.diff is optional (diff?: string), so it can be undefined
diffProcessor.processFsEdit(msg.path || '', msg.description, msg.diff);
session.sendCodexMessage({
type: 'file-edit',
description: msg.description,
diff: msg.diff,
path: msg.path,
id: randomUUID(),
});
break;
default:
// Handle token-count and other potential message types
if ((msg as any).type === 'token-count') {
// Forward token count to mobile app (like Codex)
// Note: Gemini ACP may not provide token_count events directly,
// but we handle them if they come from the backend
session.sendCodexMessage({
type: 'token_count',
...(msg as any),
id: randomUUID(),
});
}
break;
case 'terminal-output':
messageBuffer.addMessage(msg.data, 'result');
session.sendCodexMessage({
type: 'terminal-output',
data: msg.data,
id: randomUUID(),
});
break;
case 'permission-request':
// Forward permission request to mobile app
session.sendCodexMessage({
type: 'permission-request',
permissionId: msg.id,
reason: msg.reason,
payload: msg.payload,
id: randomUUID(),
});
break;
case 'exec-approval-request':
// Handle exec approval request (like Codex exec_approval_request)
// Convert to tool call for mobile app compatibility
const execApprovalMsg = msg as any;
const callId = execApprovalMsg.call_id || execApprovalMsg.callId || randomUUID();
const { call_id, type, ...inputs } = execApprovalMsg;
logger.debug(`[gemini] Exec approval request received: ${callId}`);
messageBuffer.addMessage(`Exec approval requested: ${callId}`, 'tool');
session.sendCodexMessage({
type: 'tool-call',
name: 'GeminiBash', // Similar to Codex's CodexBash
callId: callId,
input: inputs,
id: randomUUID(),
});
break;
case 'patch-apply-begin':
// Handle patch operation begin (like Codex patch_apply_begin)
const patchBeginMsg = msg as any;
const patchCallId = patchBeginMsg.call_id || patchBeginMsg.callId || randomUUID();
const { call_id: patchCallIdVar, type: patchType, auto_approved, changes } = patchBeginMsg;
// Add UI feedback for patch operation
const changeCount = changes ? Object.keys(changes).length : 0;
const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`;
messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool');
logger.debug(`[gemini] Patch apply begin: ${patchCallId}, files: ${changeCount}`);
session.sendCodexMessage({
type: 'tool-call',
name: 'GeminiPatch', // Similar to Codex's CodexPatch
callId: patchCallId,
input: {
auto_approved,
changes
},
id: randomUUID(),
});
break;
case 'patch-apply-end':
// Handle patch operation end (like Codex patch_apply_end)
const patchEndMsg = msg as any;
const patchEndCallId = patchEndMsg.call_id || patchEndMsg.callId || randomUUID();
const { call_id: patchEndCallIdVar, type: patchEndType, stdout, stderr, success } = patchEndMsg;
// Add UI feedback for completion
if (success) {
const message = stdout || 'Files modified successfully';
messageBuffer.addMessage(message.substring(0, 200), 'result');
} else {
const errorMsg = stderr || 'Failed to modify files';
messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, 'result');
}
logger.debug(`[gemini] Patch apply end: ${patchEndCallId}, success: ${success}`);
session.sendCodexMessage({
type: 'tool-call-result',
callId: patchEndCallId,
output: {
stdout,
stderr,
success
},
id: randomUUID(),
});
break;
case 'event':
// Handle thinking events - process through ReasoningProcessor like Codex
if (msg.name === 'thinking') {
const thinkingPayload = msg.payload as { text?: string } | undefined;
const thinkingText = (thinkingPayload && typeof thinkingPayload === 'object' && 'text' in thinkingPayload)
? String(thinkingPayload.text || '')
: '';
if (thinkingText) {
// Process thinking chunk through reasoning processor
// This will identify titled reasoning sections (**Title**) and convert them to tool calls
reasoningProcessor.processChunk(thinkingText);
// Log thinking chunks (especially useful for investigation tools)
logger.debug(`[gemini] 💭 Thinking chunk received: ${thinkingText.length} chars - Preview: ${thinkingText.substring(0, 100)}...`);
// Show thinking message in UI (truncated like Codex)
// For titled reasoning (starts with **), ReasoningProcessor will show it as tool call
// But we still show progress for long operations
if (!thinkingText.startsWith('**')) {
// Update existing "Thinking..." message or add new one for untitled reasoning
const thinkingPreview = thinkingText.substring(0, 100);
messageBuffer.updateLastMessage(`[Thinking] ${thinkingPreview}...`, 'system');
}
// For titled reasoning, ReasoningProcessor will send tool call, but we keep "Thinking..." visible
// This ensures user sees progress during long reasoning operations
}
// Also forward to mobile for UI feedback
session.sendCodexMessage({
type: 'thinking',
text: thinkingText,
id: randomUUID(),
});
}
break;
}
});
}
// Note: Backend will be created dynamically in the main loop based on model from first message
// This allows us to support model changes by recreating the backend
let first = true;
try {
let currentModeHash: string | null = null;
let pending: { message: string; mode: GeminiMode; isolate: boolean; hash: string } | null = null;
while (!shouldExit) {
let message: { message: string; mode: GeminiMode; isolate: boolean; hash: string } | null = pending;
pending = null;
if (!message) {
logger.debug('[gemini] Main loop: waiting for messages from queue...');
const waitSignal = abortController.signal;
const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal);
if (!batch) {
if (waitSignal.aborted && !shouldExit) {
logger.debug('[gemini] Main loop: wait aborted, continuing...');
continue;
}
logger.debug('[gemini] Main loop: no batch received, breaking...');
break;
}
logger.debug(`[gemini] Main loop: received message from queue (length: ${batch.message.length})`);
message = batch;
}
if (!message) {
break;
}
// Handle mode change (like Codex) - restart session if permission mode or model changed
if (wasSessionCreated && currentModeHash && message.hash !== currentModeHash) {
logger.debug('[Gemini] Mode changed – restarting Gemini session');
messageBuffer.addMessage('═'.repeat(40), 'status');
messageBuffer.addMessage('Starting new Gemini session (mode changed)...', 'status');
// Reset permission handler and reasoning processor on mode change (like Codex)
permissionHandler.reset();
reasoningProcessor.abort();
// Dispose old backend and create new one with new model
if (geminiBackend) {
await geminiBackend.dispose();
geminiBackend = null;
}
// Create new backend with new model
const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null);
geminiBackend = createGeminiBackend({
cwd: process.cwd(),
mcpServers,
permissionHandler,
cloudToken,
// Pass model from message - if undefined, will use local config/env/default
// If explicitly null, will skip local config and use env/default
model: modelToUse,
});
// Set up message handler again
setupGeminiMessageHandler(geminiBackend);
// Start new session
// Determine actual model that will be used (from backend creation logic)
// Replicate backend logic: message model > env var > local config > default
const localConfigForModel = readGeminiLocalConfig();
const actualModel = determineGeminiModel(modelToUse, localConfigForModel);
logger.debug(`[gemini] Model change - modelToUse=${modelToUse}, actualModel=${actualModel}`);
logger.debug('[gemini] Starting new ACP session with model:', actualModel);
const { sessionId } = await geminiBackend.startSession();
acpSessionId = sessionId;
logger.debug(`[gemini] New ACP session started: ${acpSessionId}`);
// Update displayed model in UI (don't save to config - this is backend initialization)
logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`);
updateDisplayedModel(actualModel, false);
// Don't add "Using model" message - model is shown in status bar
// Update permission handler with current permission mode
updatePermissionMode(message.mode.permissionMode);
wasSessionCreated = true;
currentModeHash = message.hash;
first = false; // Not first message anymore
}
currentModeHash = message.hash;
// Show only original user message in UI, not the full prompt with system prompt
const userMessageToShow = message.mode?.originalUserMessage || message.message;
messageBuffer.addMessage(userMessageToShow, 'user');
try {
if (first || !wasSessionCreated) {
// First message or session not created yet - create backend and start session
if (!geminiBackend) {
const modelToUse = message.mode?.model === undefined ? undefined : (message.mode.model || null);
geminiBackend = createGeminiBackend({
cwd: process.cwd(),
mcpServers,
permissionHandler,
cloudToken,
// Pass model from message - if undefined, will use local config/env/default
// If explicitly null, will skip local config and use env/default
model: modelToUse,
});
// Set up message handler
setupGeminiMessageHandler(geminiBackend);
// Determine actual model that will be used
// Backend will determine model from: message model > env var > local config > default
// We need to replicate this logic here to show correct model in UI
const localConfigForModel = readGeminiLocalConfig();
const actualModel = determineGeminiModel(modelToUse, localConfigForModel);
const modelSource = modelToUse !== undefined
? 'message'
: process.env[GEMINI_MODEL_ENV]
? 'env-var'
: localConfigForModel.model
? 'local-config'
: 'default';
logger.debug(`[gemini] Backend created, model will be: ${actualModel} (from ${modelSource})`);
logger.debug(`[gemini] Calling updateDisplayedModel with: ${actualModel}`);
updateDisplayedModel(actualModel, false); // Don't save - this is backend initialization
}
// Start session if not started
if (!acpSessionId) {
logger.debug('[gemini] Starting ACP session...');
// Update permission handler with current permission mode before starting session
updatePermissionMode(message.mode.permissionMode);
const { sessionId } = await geminiBackend.startSession();
acpSessionId = sessionId;
logger.debug(`[gemini] ACP session started: ${acpSessionId}`);
wasSessionCreated = true;
currentModeHash = message.hash;
// Model info is already shown in status bar via updateDisplayedModel
logger.debug(`[gemini] Displaying model in UI: ${displayedModel || 'gemini-2.5-pro'}, displayedModel: ${displayedModel}`);
}
}
if (!acpSessionId) {
throw new Error('ACP session not started');
}
// Reset accumulator when sending a new prompt (not when tool calls start)
// Reset accumulated response for new prompt
// This ensures a new assistant message will be created (not updating previous one)
accumulatedResponse = '';
isResponseInProgress = false;
if (!geminiBackend || !acpSessionId) {
throw new Error('Gemini backend or session not initialized');
}
// The prompt already includes system prompt and change_title instruction (added in onUserMessage handler)
// This is done in the message queue, so message.message already contains everything
const promptToSend = message.message;
logger.debug(`[gemini] Sending prompt to Gemini (length: ${promptToSend.length}): ${promptToSend.substring(0, 100)}...`);
logger.debug(`[gemini] Full prompt: ${promptToSend}`);
await geminiBackend.sendPrompt(acpSessionId, promptToSend);
logger.debug('[gemini] Prompt sent successfully');
// Mark as not first message after sending prompt
if (first) {
first = false;
}
} catch (error) {
logger.debug('[gemini] Error in gemini session:', error);
const isAbortError = error instanceof Error && error.name === 'AbortError';
if (isAbortError) {