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.
Files changed (40) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +411 -0
  3. package/dist/GeminiRunner.d.ts +136 -0
  4. package/dist/GeminiRunner.d.ts.map +1 -0
  5. package/dist/GeminiRunner.js +683 -0
  6. package/dist/GeminiRunner.js.map +1 -0
  7. package/dist/SimpleGeminiRunner.d.ts +27 -0
  8. package/dist/SimpleGeminiRunner.d.ts.map +1 -0
  9. package/dist/SimpleGeminiRunner.js +149 -0
  10. package/dist/SimpleGeminiRunner.js.map +1 -0
  11. package/dist/adapters.d.ts +37 -0
  12. package/dist/adapters.d.ts.map +1 -0
  13. package/dist/adapters.js +317 -0
  14. package/dist/adapters.js.map +1 -0
  15. package/dist/formatter.d.ts +40 -0
  16. package/dist/formatter.d.ts.map +1 -0
  17. package/dist/formatter.js +363 -0
  18. package/dist/formatter.js.map +1 -0
  19. package/dist/index.d.ts +36 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +56 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/prompts/system.md +108 -0
  24. package/dist/schemas.d.ts +1472 -0
  25. package/dist/schemas.d.ts.map +1 -0
  26. package/dist/schemas.js +678 -0
  27. package/dist/schemas.js.map +1 -0
  28. package/dist/settingsGenerator.d.ts +72 -0
  29. package/dist/settingsGenerator.d.ts.map +1 -0
  30. package/dist/settingsGenerator.js +255 -0
  31. package/dist/settingsGenerator.js.map +1 -0
  32. package/dist/systemPromptManager.d.ts +27 -0
  33. package/dist/systemPromptManager.d.ts.map +1 -0
  34. package/dist/systemPromptManager.js +65 -0
  35. package/dist/systemPromptManager.js.map +1 -0
  36. package/dist/types.d.ts +113 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +23 -0
  39. package/dist/types.js.map +1 -0
  40. 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