cyrus-gemini-runner 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +411 -0
- package/dist/GeminiRunner.d.ts +136 -0
- package/dist/GeminiRunner.d.ts.map +1 -0
- package/dist/GeminiRunner.js +683 -0
- package/dist/GeminiRunner.js.map +1 -0
- package/dist/SimpleGeminiRunner.d.ts +27 -0
- package/dist/SimpleGeminiRunner.d.ts.map +1 -0
- package/dist/SimpleGeminiRunner.js +149 -0
- package/dist/SimpleGeminiRunner.js.map +1 -0
- package/dist/adapters.d.ts +37 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +317 -0
- package/dist/adapters.js.map +1 -0
- package/dist/formatter.d.ts +40 -0
- package/dist/formatter.d.ts.map +1 -0
- package/dist/formatter.js +363 -0
- package/dist/formatter.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/system.md +108 -0
- package/dist/schemas.d.ts +1472 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +678 -0
- package/dist/schemas.js.map +1 -0
- package/dist/settingsGenerator.d.ts +72 -0
- package/dist/settingsGenerator.d.ts.map +1 -0
- package/dist/settingsGenerator.js +255 -0
- package/dist/settingsGenerator.js.map +1 -0
- package/dist/systemPromptManager.d.ts +27 -0
- package/dist/systemPromptManager.d.ts.map +1 -0
- package/dist/systemPromptManager.js +65 -0
- package/dist/systemPromptManager.js.map +1 -0
- package/dist/types.d.ts +113 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { StreamingPrompt, } from "cyrus-core";
|
|
7
|
+
import { extractSessionId, geminiEventToSDKMessage } from "./adapters.js";
|
|
8
|
+
import { GeminiMessageFormatter } from "./formatter.js";
|
|
9
|
+
import { safeParseGeminiStreamEvent, } from "./schemas.js";
|
|
10
|
+
import { autoDetectMcpConfig, convertToGeminiMcpConfig, loadMcpConfigFromPaths, setupGeminiSettings, } from "./settingsGenerator.js";
|
|
11
|
+
import { SystemPromptManager } from "./systemPromptManager.js";
|
|
12
|
+
/**
|
|
13
|
+
* Manages Gemini CLI sessions and communication
|
|
14
|
+
*
|
|
15
|
+
* GeminiRunner implements the IAgentRunner interface to provide a provider-agnostic
|
|
16
|
+
* wrapper around the Gemini CLI. It spawns the Gemini CLI process in headless mode
|
|
17
|
+
* and translates between the CLI's JSON streaming format and Claude SDK message types.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const runner = new GeminiRunner({
|
|
22
|
+
* cyrusHome: '/home/user/.cyrus',
|
|
23
|
+
* workingDirectory: '/path/to/repo',
|
|
24
|
+
* model: 'gemini-2.5-flash',
|
|
25
|
+
* autoApprove: true
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // String mode
|
|
29
|
+
* await runner.start("Analyze this codebase");
|
|
30
|
+
*
|
|
31
|
+
* // Streaming mode
|
|
32
|
+
* await runner.startStreaming("Initial task");
|
|
33
|
+
* runner.addStreamMessage("Additional context");
|
|
34
|
+
* runner.completeStream();
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class GeminiRunner extends EventEmitter {
|
|
38
|
+
/**
|
|
39
|
+
* GeminiRunner does not support true streaming input.
|
|
40
|
+
* While startStreaming() exists, it only accepts an initial prompt and does not support
|
|
41
|
+
* addStreamMessage() for adding messages after the session starts.
|
|
42
|
+
*/
|
|
43
|
+
supportsStreamingInput = false;
|
|
44
|
+
config;
|
|
45
|
+
process = null;
|
|
46
|
+
sessionInfo = null;
|
|
47
|
+
logStream = null;
|
|
48
|
+
readableLogStream = null;
|
|
49
|
+
messages = [];
|
|
50
|
+
streamingPrompt = null;
|
|
51
|
+
cyrusHome;
|
|
52
|
+
// Delta message accumulation
|
|
53
|
+
accumulatingMessage = null;
|
|
54
|
+
accumulatingRole = null;
|
|
55
|
+
// Track last assistant message for result coercion
|
|
56
|
+
lastAssistantMessage = null;
|
|
57
|
+
// Settings cleanup function
|
|
58
|
+
settingsCleanup = null;
|
|
59
|
+
// System prompt manager
|
|
60
|
+
systemPromptManager;
|
|
61
|
+
// Message formatter
|
|
62
|
+
formatter;
|
|
63
|
+
// Readline interface for stdout processing
|
|
64
|
+
readlineInterface = null;
|
|
65
|
+
// Deferred result message to emit after loop completes
|
|
66
|
+
pendingResultMessage = null;
|
|
67
|
+
constructor(config) {
|
|
68
|
+
super();
|
|
69
|
+
this.config = config;
|
|
70
|
+
this.cyrusHome = config.cyrusHome;
|
|
71
|
+
// Use workspaceName for unique system prompt file paths (supports parallel execution)
|
|
72
|
+
const workspaceName = config.workspaceName || "default";
|
|
73
|
+
this.systemPromptManager = new SystemPromptManager(config.cyrusHome, workspaceName);
|
|
74
|
+
// Use GeminiMessageFormatter for Gemini-specific tool names
|
|
75
|
+
this.formatter = new GeminiMessageFormatter();
|
|
76
|
+
// Forward config callbacks to events
|
|
77
|
+
if (config.onMessage)
|
|
78
|
+
this.on("message", config.onMessage);
|
|
79
|
+
if (config.onError)
|
|
80
|
+
this.on("error", config.onError);
|
|
81
|
+
if (config.onComplete)
|
|
82
|
+
this.on("complete", config.onComplete);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Start a new Gemini session with string prompt (legacy mode)
|
|
86
|
+
*/
|
|
87
|
+
async start(prompt) {
|
|
88
|
+
return this.startWithPrompt(prompt);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Start a new Gemini session with streaming input
|
|
92
|
+
*/
|
|
93
|
+
async startStreaming(initialPrompt) {
|
|
94
|
+
return this.startWithPrompt(null, initialPrompt);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Add a message to the streaming prompt (only works when in streaming mode)
|
|
98
|
+
*/
|
|
99
|
+
addStreamMessage(content) {
|
|
100
|
+
if (!this.streamingPrompt) {
|
|
101
|
+
throw new Error("Cannot add stream message when not in streaming mode");
|
|
102
|
+
}
|
|
103
|
+
this.streamingPrompt.addMessage(content);
|
|
104
|
+
// Write to stdin if process is running
|
|
105
|
+
if (this.process?.stdin && !this.process.stdin.destroyed) {
|
|
106
|
+
console.log(`[GeminiRunner] Writing to stdin (${content.length} chars): ${content.substring(0, 100)}...`);
|
|
107
|
+
this.process.stdin.write(`${content}\n`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
console.log(`[GeminiRunner] Cannot write to stdin - process stdin is ${this.process?.stdin ? "destroyed" : "null"}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Complete the streaming prompt (no more messages will be added)
|
|
115
|
+
*/
|
|
116
|
+
completeStream() {
|
|
117
|
+
if (this.streamingPrompt) {
|
|
118
|
+
this.streamingPrompt.complete();
|
|
119
|
+
// Close stdin to signal completion to Gemini CLI
|
|
120
|
+
if (this.process?.stdin && !this.process.stdin.destroyed) {
|
|
121
|
+
this.process.stdin.end();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Get the last assistant message (used for result coercion)
|
|
127
|
+
*/
|
|
128
|
+
getLastAssistantMessage() {
|
|
129
|
+
return this.lastAssistantMessage;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Internal method to start a Gemini session with either string or streaming prompt
|
|
133
|
+
*/
|
|
134
|
+
async startWithPrompt(stringPrompt, streamingInitialPrompt) {
|
|
135
|
+
if (this.isRunning()) {
|
|
136
|
+
throw new Error("Gemini session already running");
|
|
137
|
+
}
|
|
138
|
+
// Initialize session info without session ID (will be set from init event)
|
|
139
|
+
this.sessionInfo = {
|
|
140
|
+
sessionId: null,
|
|
141
|
+
startedAt: new Date(),
|
|
142
|
+
isRunning: true,
|
|
143
|
+
};
|
|
144
|
+
console.log(`[GeminiRunner] Starting new session (session ID will be assigned by Gemini)`);
|
|
145
|
+
console.log("[GeminiRunner] Working directory:", this.config.workingDirectory);
|
|
146
|
+
// Ensure working directory exists
|
|
147
|
+
if (this.config.workingDirectory) {
|
|
148
|
+
try {
|
|
149
|
+
mkdirSync(this.config.workingDirectory, { recursive: true });
|
|
150
|
+
console.log("[GeminiRunner] Created working directory");
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
console.error("[GeminiRunner] Failed to create working directory:", err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Set up logging (initial setup without session ID)
|
|
157
|
+
this.setupLogging();
|
|
158
|
+
// Reset messages array
|
|
159
|
+
this.messages = [];
|
|
160
|
+
// Build MCP servers configuration
|
|
161
|
+
const mcpServers = this.buildMcpServers();
|
|
162
|
+
// Setup Gemini settings with MCP servers and maxTurns
|
|
163
|
+
const settingsOptions = {};
|
|
164
|
+
if (this.config.maxTurns) {
|
|
165
|
+
settingsOptions.maxSessionTurns = this.config.maxTurns;
|
|
166
|
+
}
|
|
167
|
+
if (Object.keys(mcpServers).length > 0) {
|
|
168
|
+
settingsOptions.mcpServers = mcpServers;
|
|
169
|
+
}
|
|
170
|
+
if (this.config.allowMCPServers) {
|
|
171
|
+
settingsOptions.allowMCPServers = this.config.allowMCPServers;
|
|
172
|
+
}
|
|
173
|
+
if (this.config.excludeMCPServers) {
|
|
174
|
+
settingsOptions.excludeMCPServers = this.config.excludeMCPServers;
|
|
175
|
+
}
|
|
176
|
+
// Only setup settings if we have something to configure
|
|
177
|
+
if (Object.keys(settingsOptions).length > 0) {
|
|
178
|
+
this.settingsCleanup = setupGeminiSettings(settingsOptions);
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
// Build Gemini CLI command
|
|
182
|
+
const geminiPath = this.config.geminiPath || "gemini";
|
|
183
|
+
const args = ["--output-format", "stream-json"];
|
|
184
|
+
// Add model if specified
|
|
185
|
+
if (this.config.model) {
|
|
186
|
+
args.push("--model", this.config.model);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// Default to gemini-2.5-pro
|
|
190
|
+
args.push("--model", "gemini-2.5-pro");
|
|
191
|
+
}
|
|
192
|
+
// Add resume session flag if provided
|
|
193
|
+
if (this.config.resumeSessionId) {
|
|
194
|
+
args.push("-r", this.config.resumeSessionId);
|
|
195
|
+
console.log(`[GeminiRunner] Resuming session: ${this.config.resumeSessionId}`);
|
|
196
|
+
}
|
|
197
|
+
// This will be added in the future
|
|
198
|
+
// Add auto-approve flags
|
|
199
|
+
// if (this.config.autoApprove) {
|
|
200
|
+
// args.push("--yolo");
|
|
201
|
+
// }
|
|
202
|
+
args.push("--yolo");
|
|
203
|
+
if (this.config.approvalMode) {
|
|
204
|
+
args.push("--approval-mode", this.config.approvalMode);
|
|
205
|
+
}
|
|
206
|
+
// Add debug flag
|
|
207
|
+
if (this.config.debug) {
|
|
208
|
+
args.push("--debug");
|
|
209
|
+
}
|
|
210
|
+
// Add include-directories flag if specified
|
|
211
|
+
if (this.config.allowedDirectories &&
|
|
212
|
+
this.config.allowedDirectories.length > 0) {
|
|
213
|
+
args.push("--include-directories", this.config.allowedDirectories.join(","));
|
|
214
|
+
}
|
|
215
|
+
// Handle prompt mode
|
|
216
|
+
let useStdin = false;
|
|
217
|
+
let fullStreamingPrompt;
|
|
218
|
+
if (stringPrompt !== null && stringPrompt !== undefined) {
|
|
219
|
+
console.log(`[GeminiRunner] Starting with string prompt length: ${stringPrompt.length} characters`);
|
|
220
|
+
args.push("-p");
|
|
221
|
+
args.push(stringPrompt);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
// Streaming mode - use stdin
|
|
225
|
+
fullStreamingPrompt = streamingInitialPrompt || undefined;
|
|
226
|
+
console.log(`[GeminiRunner] Starting with streaming prompt`);
|
|
227
|
+
this.streamingPrompt = new StreamingPrompt(null, fullStreamingPrompt);
|
|
228
|
+
useStdin = true;
|
|
229
|
+
}
|
|
230
|
+
// Prepare environment variables for Gemini CLI
|
|
231
|
+
const geminiEnv = { ...process.env };
|
|
232
|
+
if (this.config.appendSystemPrompt) {
|
|
233
|
+
try {
|
|
234
|
+
const systemPromptPath = await this.systemPromptManager.prepareSystemPrompt(this.config.appendSystemPrompt);
|
|
235
|
+
geminiEnv.GEMINI_SYSTEM_MD = systemPromptPath;
|
|
236
|
+
console.log(`[GeminiRunner] Prepared system prompt at: ${systemPromptPath}`);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
console.error("[GeminiRunner] Failed to prepare system prompt, continuing without it:", error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Spawn Gemini CLI process
|
|
243
|
+
console.log(`[GeminiRunner] Spawning: ${geminiPath} ${args.join(" ")}`);
|
|
244
|
+
this.process = spawn(geminiPath, args, {
|
|
245
|
+
cwd: this.config.workingDirectory,
|
|
246
|
+
stdio: useStdin ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
247
|
+
env: geminiEnv,
|
|
248
|
+
});
|
|
249
|
+
// IMPORTANT: Write initial streaming prompt to stdin immediately after spawn
|
|
250
|
+
// This prevents gemini from hanging waiting for input.
|
|
251
|
+
//
|
|
252
|
+
// How gemini-cli stdin works (from packages/cli/src/utils/readStdin.ts):
|
|
253
|
+
// 1. Has a 500ms timeout - if NO data arrives, assumes nothing is piped and returns empty
|
|
254
|
+
// 2. Once data arrives, timeout is canceled and it waits for stdin to close ('end' event)
|
|
255
|
+
// 3. Continues reading chunks as they arrive until stdin closes
|
|
256
|
+
//
|
|
257
|
+
// Therefore:
|
|
258
|
+
// - We MUST write initial prompt immediately to cancel the 500ms timeout
|
|
259
|
+
// - We MUST NOT close stdin here - keep it open for addStreamMessage() calls
|
|
260
|
+
// - stdin.end() is called later in completeStream() when all messages are sent
|
|
261
|
+
if (useStdin && fullStreamingPrompt && this.process.stdin) {
|
|
262
|
+
console.log(`[GeminiRunner] Writing initial streaming prompt to stdin (${fullStreamingPrompt.length} chars): ${fullStreamingPrompt.substring(0, 150)}...`);
|
|
263
|
+
this.process.stdin.write(`${fullStreamingPrompt}\n`);
|
|
264
|
+
}
|
|
265
|
+
else if (useStdin) {
|
|
266
|
+
console.log(`[GeminiRunner] Cannot write initial prompt - fullStreamingPrompt=${!!fullStreamingPrompt}, stdin=${!!this.process.stdin}`);
|
|
267
|
+
}
|
|
268
|
+
// Set up stdout line reader for JSON events
|
|
269
|
+
this.readlineInterface = createInterface({
|
|
270
|
+
input: this.process.stdout,
|
|
271
|
+
crlfDelay: Infinity,
|
|
272
|
+
});
|
|
273
|
+
// Process each line as a JSON event with Zod validation
|
|
274
|
+
this.readlineInterface.on("line", (line) => {
|
|
275
|
+
const event = safeParseGeminiStreamEvent(line);
|
|
276
|
+
if (event) {
|
|
277
|
+
this.processStreamEvent(event);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
console.error("[GeminiRunner] Failed to parse/validate JSON event:", line);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
// Handle stderr
|
|
284
|
+
this.process.stderr?.on("data", (data) => {
|
|
285
|
+
console.error("[GeminiRunner] stderr:", data.toString());
|
|
286
|
+
});
|
|
287
|
+
// Wait for process to complete
|
|
288
|
+
await new Promise((resolve, reject) => {
|
|
289
|
+
if (!this.process) {
|
|
290
|
+
reject(new Error("Process not started"));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.process.on("close", (code) => {
|
|
294
|
+
console.log(`[GeminiRunner] Process exited with code ${code}`);
|
|
295
|
+
if (code === 0) {
|
|
296
|
+
resolve();
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
reject(new Error(`Gemini CLI exited with code ${code}`));
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
this.process.on("error", (err) => {
|
|
303
|
+
console.error("[GeminiRunner] Process error:", err);
|
|
304
|
+
reject(err);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
// Flush any remaining accumulated message
|
|
308
|
+
this.flushAccumulatedMessage();
|
|
309
|
+
// Session completed successfully - mark as not running BEFORE emitting result
|
|
310
|
+
// This ensures any code checking isRunning() during result processing sees the correct state
|
|
311
|
+
console.log(`[GeminiRunner] Session completed with ${this.messages.length} messages`);
|
|
312
|
+
this.sessionInfo.isRunning = false;
|
|
313
|
+
// Emit deferred result message after marking isRunning = false
|
|
314
|
+
if (this.pendingResultMessage) {
|
|
315
|
+
this.emitMessage(this.pendingResultMessage);
|
|
316
|
+
this.pendingResultMessage = null;
|
|
317
|
+
}
|
|
318
|
+
this.emit("complete", this.messages);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
console.error("[GeminiRunner] Session error:", error);
|
|
322
|
+
if (this.sessionInfo) {
|
|
323
|
+
this.sessionInfo.isRunning = false;
|
|
324
|
+
}
|
|
325
|
+
// Emit error result message to maintain consistent message flow
|
|
326
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
327
|
+
const errorResult = {
|
|
328
|
+
type: "result",
|
|
329
|
+
subtype: "error_during_execution",
|
|
330
|
+
duration_ms: Date.now() - this.sessionInfo.startedAt.getTime(),
|
|
331
|
+
duration_api_ms: 0,
|
|
332
|
+
is_error: true,
|
|
333
|
+
num_turns: 0,
|
|
334
|
+
errors: [errorMessage],
|
|
335
|
+
total_cost_usd: 0,
|
|
336
|
+
usage: {
|
|
337
|
+
input_tokens: 0,
|
|
338
|
+
output_tokens: 0,
|
|
339
|
+
cache_creation_input_tokens: 0,
|
|
340
|
+
cache_read_input_tokens: 0,
|
|
341
|
+
cache_creation: {
|
|
342
|
+
ephemeral_1h_input_tokens: 0,
|
|
343
|
+
ephemeral_5m_input_tokens: 0,
|
|
344
|
+
},
|
|
345
|
+
server_tool_use: {
|
|
346
|
+
web_fetch_requests: 0,
|
|
347
|
+
web_search_requests: 0,
|
|
348
|
+
},
|
|
349
|
+
service_tier: "standard",
|
|
350
|
+
},
|
|
351
|
+
modelUsage: {},
|
|
352
|
+
permission_denials: [],
|
|
353
|
+
uuid: crypto.randomUUID(),
|
|
354
|
+
session_id: this.sessionInfo?.sessionId || "pending",
|
|
355
|
+
};
|
|
356
|
+
this.emitMessage(errorResult);
|
|
357
|
+
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
// Clean up
|
|
361
|
+
this.process = null;
|
|
362
|
+
this.pendingResultMessage = null;
|
|
363
|
+
// Complete and clean up streaming prompt if it exists
|
|
364
|
+
if (this.streamingPrompt) {
|
|
365
|
+
this.streamingPrompt.complete();
|
|
366
|
+
this.streamingPrompt = null;
|
|
367
|
+
}
|
|
368
|
+
// Close log streams
|
|
369
|
+
if (this.logStream) {
|
|
370
|
+
this.logStream.end();
|
|
371
|
+
this.logStream = null;
|
|
372
|
+
}
|
|
373
|
+
if (this.readableLogStream) {
|
|
374
|
+
this.readableLogStream.end();
|
|
375
|
+
this.readableLogStream = null;
|
|
376
|
+
}
|
|
377
|
+
// Restore Gemini settings
|
|
378
|
+
if (this.settingsCleanup) {
|
|
379
|
+
this.settingsCleanup();
|
|
380
|
+
this.settingsCleanup = null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return this.sessionInfo;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Process a Gemini stream event and convert to SDK message
|
|
387
|
+
*/
|
|
388
|
+
processStreamEvent(event) {
|
|
389
|
+
console.log(`[GeminiRunner] Stream event: ${event.type}`, JSON.stringify(event).substring(0, 200));
|
|
390
|
+
// Emit raw stream event
|
|
391
|
+
this.emit("streamEvent", event);
|
|
392
|
+
// Extract session ID from init event
|
|
393
|
+
const sessionId = extractSessionId(event);
|
|
394
|
+
if (sessionId && !this.sessionInfo?.sessionId) {
|
|
395
|
+
this.sessionInfo.sessionId = sessionId;
|
|
396
|
+
console.log(`[GeminiRunner] Session ID assigned: ${sessionId}`);
|
|
397
|
+
// Update streaming prompt with session ID if it exists
|
|
398
|
+
if (this.streamingPrompt) {
|
|
399
|
+
this.streamingPrompt.updateSessionId(sessionId);
|
|
400
|
+
}
|
|
401
|
+
// Re-setup logging now that we have the session ID
|
|
402
|
+
this.setupLogging();
|
|
403
|
+
}
|
|
404
|
+
// Handle delta message accumulation
|
|
405
|
+
if (event.type === "message") {
|
|
406
|
+
const messageEvent = event;
|
|
407
|
+
// Check if this is a delta message
|
|
408
|
+
if (messageEvent.delta === true) {
|
|
409
|
+
// Accumulate delta message
|
|
410
|
+
this.accumulateDeltaMessage(messageEvent);
|
|
411
|
+
return; // Don't process further, just accumulate
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// Not a delta message - flush any accumulated message first
|
|
415
|
+
this.flushAccumulatedMessage();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
// Non-message event - flush any accumulated message
|
|
420
|
+
this.flushAccumulatedMessage();
|
|
421
|
+
}
|
|
422
|
+
// Convert to SDK message format
|
|
423
|
+
const message = geminiEventToSDKMessage(event, this.sessionInfo?.sessionId || null, this.lastAssistantMessage);
|
|
424
|
+
if (message) {
|
|
425
|
+
// Track last assistant message for result coercion
|
|
426
|
+
if (message.type === "assistant") {
|
|
427
|
+
this.lastAssistantMessage = message;
|
|
428
|
+
}
|
|
429
|
+
// Defer result message emission until after loop completes to avoid race conditions
|
|
430
|
+
// where subroutine transitions start before the runner has fully cleaned up
|
|
431
|
+
if (message.type === "result") {
|
|
432
|
+
this.pendingResultMessage = message;
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
this.emitMessage(message);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Accumulate a delta message (message with delta: true)
|
|
441
|
+
*/
|
|
442
|
+
accumulateDeltaMessage(event) {
|
|
443
|
+
console.log(`[GeminiRunner] Accumulating delta message (role: ${event.role})`);
|
|
444
|
+
// If role changed or no accumulating message exists, start new accumulation
|
|
445
|
+
if (!this.accumulatingMessage || this.accumulatingRole !== event.role) {
|
|
446
|
+
// Flush previous accumulation if exists
|
|
447
|
+
this.flushAccumulatedMessage();
|
|
448
|
+
// Start new accumulation using Claude SDK format (array of content blocks)
|
|
449
|
+
if (event.role === "user") {
|
|
450
|
+
this.accumulatingMessage = {
|
|
451
|
+
type: "user",
|
|
452
|
+
message: {
|
|
453
|
+
role: "user",
|
|
454
|
+
content: [{ type: "text", text: event.content }],
|
|
455
|
+
},
|
|
456
|
+
parent_tool_use_id: null,
|
|
457
|
+
session_id: this.sessionInfo?.sessionId || "pending",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
// assistant role
|
|
462
|
+
this.accumulatingMessage = {
|
|
463
|
+
type: "assistant",
|
|
464
|
+
message: {
|
|
465
|
+
role: "assistant",
|
|
466
|
+
content: [{ type: "text", text: event.content }],
|
|
467
|
+
},
|
|
468
|
+
session_id: this.sessionInfo?.sessionId || "pending",
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
this.accumulatingRole = event.role;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
// Same role - append content to existing text block
|
|
475
|
+
if (this.accumulatingMessage.type === "user" ||
|
|
476
|
+
this.accumulatingMessage.type === "assistant") {
|
|
477
|
+
const currentContent = this.accumulatingMessage.message.content;
|
|
478
|
+
if (Array.isArray(currentContent) && currentContent.length > 0) {
|
|
479
|
+
const lastBlock = currentContent[currentContent.length - 1];
|
|
480
|
+
if (lastBlock && lastBlock.type === "text" && "text" in lastBlock) {
|
|
481
|
+
lastBlock.text += event.content;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Flush the accumulated delta message
|
|
489
|
+
*/
|
|
490
|
+
flushAccumulatedMessage() {
|
|
491
|
+
if (this.accumulatingMessage) {
|
|
492
|
+
console.log(`[GeminiRunner] Flushing accumulated message (role: ${this.accumulatingRole})`);
|
|
493
|
+
// Track last assistant message for result coercion BEFORE emitting
|
|
494
|
+
if (this.accumulatingMessage.type === "assistant") {
|
|
495
|
+
this.lastAssistantMessage = this.accumulatingMessage;
|
|
496
|
+
}
|
|
497
|
+
this.emitMessage(this.accumulatingMessage);
|
|
498
|
+
this.accumulatingMessage = null;
|
|
499
|
+
this.accumulatingRole = null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Emit a message (add to messages array, log, and emit event)
|
|
504
|
+
*/
|
|
505
|
+
emitMessage(message) {
|
|
506
|
+
this.messages.push(message);
|
|
507
|
+
// Log to detailed JSON log
|
|
508
|
+
if (this.logStream) {
|
|
509
|
+
const logEntry = {
|
|
510
|
+
type: "sdk-message",
|
|
511
|
+
message,
|
|
512
|
+
timestamp: new Date().toISOString(),
|
|
513
|
+
};
|
|
514
|
+
this.logStream.write(`${JSON.stringify(logEntry)}\n`);
|
|
515
|
+
}
|
|
516
|
+
// Log to human-readable log
|
|
517
|
+
if (this.readableLogStream) {
|
|
518
|
+
this.writeReadableLogEntry(message);
|
|
519
|
+
}
|
|
520
|
+
// Emit message event
|
|
521
|
+
this.emit("message", message);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Stop the current Gemini session
|
|
525
|
+
*/
|
|
526
|
+
stop() {
|
|
527
|
+
// Flush any accumulated message before stopping
|
|
528
|
+
this.flushAccumulatedMessage();
|
|
529
|
+
// Close readline interface first to stop processing stdout
|
|
530
|
+
if (this.readlineInterface) {
|
|
531
|
+
// Close() method stops the readline interface from emitting further events
|
|
532
|
+
// and allows cleanup of underlying streams
|
|
533
|
+
if (typeof this.readlineInterface.close === "function") {
|
|
534
|
+
this.readlineInterface.close();
|
|
535
|
+
}
|
|
536
|
+
this.readlineInterface.removeAllListeners();
|
|
537
|
+
this.readlineInterface = null;
|
|
538
|
+
}
|
|
539
|
+
if (this.process) {
|
|
540
|
+
console.log("[GeminiRunner] Stopping Gemini process");
|
|
541
|
+
this.process.kill("SIGTERM");
|
|
542
|
+
this.process = null;
|
|
543
|
+
}
|
|
544
|
+
if (this.sessionInfo) {
|
|
545
|
+
this.sessionInfo.isRunning = false;
|
|
546
|
+
}
|
|
547
|
+
// Complete streaming prompt if active
|
|
548
|
+
if (this.streamingPrompt) {
|
|
549
|
+
this.streamingPrompt.complete();
|
|
550
|
+
}
|
|
551
|
+
// Restore Gemini settings
|
|
552
|
+
if (this.settingsCleanup) {
|
|
553
|
+
this.settingsCleanup();
|
|
554
|
+
this.settingsCleanup = null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Check if the session is currently running
|
|
559
|
+
*/
|
|
560
|
+
isRunning() {
|
|
561
|
+
return this.sessionInfo?.isRunning ?? false;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Get all messages from the current session
|
|
565
|
+
*/
|
|
566
|
+
getMessages() {
|
|
567
|
+
return [...this.messages];
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Get the message formatter for this runner
|
|
571
|
+
*/
|
|
572
|
+
getFormatter() {
|
|
573
|
+
return this.formatter;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Build MCP servers configuration from config paths and inline config
|
|
577
|
+
*
|
|
578
|
+
* MCP configuration loading follows a layered approach:
|
|
579
|
+
* 1. Auto-detect .mcp.json in working directory (base config)
|
|
580
|
+
* 2. Load from explicitly configured paths via mcpConfigPath (extends/overrides)
|
|
581
|
+
* 3. Merge inline mcpConfig (highest priority, overrides file configs)
|
|
582
|
+
*
|
|
583
|
+
* HTTP-based MCP servers (like Linear's https://mcp.linear.app/mcp) are filtered out
|
|
584
|
+
* since Gemini CLI only supports stdio (command-based) MCP servers.
|
|
585
|
+
*
|
|
586
|
+
* @returns Record of MCP server name to GeminiMcpServerConfig
|
|
587
|
+
*/
|
|
588
|
+
buildMcpServers() {
|
|
589
|
+
const geminiMcpServers = {};
|
|
590
|
+
// Build config paths list, starting with auto-detected .mcp.json
|
|
591
|
+
const configPaths = [];
|
|
592
|
+
// 1. Auto-detect .mcp.json in working directory
|
|
593
|
+
const autoDetectedPath = autoDetectMcpConfig(this.config.workingDirectory);
|
|
594
|
+
if (autoDetectedPath) {
|
|
595
|
+
configPaths.push(autoDetectedPath);
|
|
596
|
+
}
|
|
597
|
+
// 2. Add explicitly configured paths
|
|
598
|
+
if (this.config.mcpConfigPath) {
|
|
599
|
+
const explicitPaths = Array.isArray(this.config.mcpConfigPath)
|
|
600
|
+
? this.config.mcpConfigPath
|
|
601
|
+
: [this.config.mcpConfigPath];
|
|
602
|
+
configPaths.push(...explicitPaths);
|
|
603
|
+
}
|
|
604
|
+
// Load from all config paths
|
|
605
|
+
const fileBasedServers = loadMcpConfigFromPaths(configPaths.length > 0 ? configPaths : undefined);
|
|
606
|
+
// 3. Merge inline config (overrides file-based config)
|
|
607
|
+
const allServers = this.config.mcpConfig
|
|
608
|
+
? { ...fileBasedServers, ...this.config.mcpConfig }
|
|
609
|
+
: fileBasedServers;
|
|
610
|
+
// Convert each server to Gemini format
|
|
611
|
+
for (const [serverName, serverConfig] of Object.entries(allServers)) {
|
|
612
|
+
const geminiConfig = convertToGeminiMcpConfig(serverName, serverConfig);
|
|
613
|
+
if (geminiConfig) {
|
|
614
|
+
geminiMcpServers[serverName] = geminiConfig;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (Object.keys(geminiMcpServers).length > 0) {
|
|
618
|
+
console.log(`[GeminiRunner] Configured ${Object.keys(geminiMcpServers).length} MCP server(s): ${Object.keys(geminiMcpServers).join(", ")}`);
|
|
619
|
+
}
|
|
620
|
+
return geminiMcpServers;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Set up logging streams for this session
|
|
624
|
+
*/
|
|
625
|
+
setupLogging() {
|
|
626
|
+
const logsDir = join(this.cyrusHome, "logs");
|
|
627
|
+
const workspaceName = this.config.workspaceName ||
|
|
628
|
+
(this.config.workingDirectory
|
|
629
|
+
? this.config.workingDirectory.split("/").pop()
|
|
630
|
+
: "default") ||
|
|
631
|
+
"default";
|
|
632
|
+
const workspaceLogsDir = join(logsDir, workspaceName);
|
|
633
|
+
const sessionId = this.sessionInfo?.sessionId || "pending";
|
|
634
|
+
// Close existing streams if they exist
|
|
635
|
+
if (this.logStream) {
|
|
636
|
+
this.logStream.end();
|
|
637
|
+
}
|
|
638
|
+
if (this.readableLogStream) {
|
|
639
|
+
this.readableLogStream.end();
|
|
640
|
+
}
|
|
641
|
+
// Ensure logs directory exists
|
|
642
|
+
mkdirSync(workspaceLogsDir, { recursive: true });
|
|
643
|
+
// Create log streams
|
|
644
|
+
const logPath = join(workspaceLogsDir, `${sessionId}.ndjson`);
|
|
645
|
+
const readableLogPath = join(workspaceLogsDir, `${sessionId}.log`);
|
|
646
|
+
console.log(`[GeminiRunner] Logging to: ${logPath}`);
|
|
647
|
+
console.log(`[GeminiRunner] Readable log: ${readableLogPath}`);
|
|
648
|
+
this.logStream = createWriteStream(logPath, { flags: "a" });
|
|
649
|
+
this.readableLogStream = createWriteStream(readableLogPath, { flags: "a" });
|
|
650
|
+
// Log session start
|
|
651
|
+
const startEntry = {
|
|
652
|
+
type: "session-start",
|
|
653
|
+
sessionId,
|
|
654
|
+
timestamp: new Date().toISOString(),
|
|
655
|
+
config: {
|
|
656
|
+
model: this.config.model,
|
|
657
|
+
workingDirectory: this.config.workingDirectory,
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
this.logStream.write(`${JSON.stringify(startEntry)}\n`);
|
|
661
|
+
this.readableLogStream.write(`=== Session ${sessionId} started at ${new Date().toISOString()} ===\n\n`);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Write a human-readable log entry for a message
|
|
665
|
+
*/
|
|
666
|
+
writeReadableLogEntry(message) {
|
|
667
|
+
if (!this.readableLogStream)
|
|
668
|
+
return;
|
|
669
|
+
const timestamp = new Date().toISOString();
|
|
670
|
+
this.readableLogStream.write(`[${timestamp}] ${message.type}\n`);
|
|
671
|
+
if (message.type === "user" || message.type === "assistant") {
|
|
672
|
+
const content = typeof message.message.content === "string"
|
|
673
|
+
? message.message.content
|
|
674
|
+
: JSON.stringify(message.message.content, null, 2);
|
|
675
|
+
this.readableLogStream.write(`${content}\n\n`);
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
// Other message types (system, result, etc.)
|
|
679
|
+
this.readableLogStream.write(`${JSON.stringify(message, null, 2)}\n\n`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
//# sourceMappingURL=GeminiRunner.js.map
|