From 331369d6c87e1b4656c1549857a4db07293d9e45 Mon Sep 17 00:00:00 2001 From: nnhhoang Date: Sat, 18 Apr 2026 03:11:31 +0700 Subject: [PATCH] feat(agent-manager): add Gemini CLI agent adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GeminiCliAdapter alongside the existing Claude Code and Codex adapters so `ai-devkit agent list` and `ai-devkit agent detail` can discover and inspect running Gemini CLI sessions. The adapter follows the canHandle / detectAgents / getConversation contract and registers in every agent.ts entrypoint that composes an AgentManager. Process detection reuses the shared `listAgentProcesses('gemini')` helper. `isGeminiExecutable` falls back to `path.win32.basename` when it sees a backslash separator so Windows-style command paths still resolve to a gemini.exe basename on either platform. Session discovery walks `~/.gemini/tmp//chats/session-*.json` across every project short-id directory Gemini maintains. Each session JSON carries its own `projectHash` — sha256 of the project root that Gemini CLI resolved at write time via its `.git`-bounded `findProjectRoot` walk. To stay consistent with that, the adapter enumerates every ancestor of each running process' CWD, hashes each candidate, and looks up any matching projectHash to populate `resolvedCwd` on a SessionFile. The shared `matchProcessesToSessions()` then performs the usual CWD + birthtime 1:1 greedy assignment, and processes without a matching session fall back to the process-only AgentInfo shape. `getConversation` parses the single-JSON-per-file layout Gemini uses (not JSONL): `messages` is an array with `type` of 'user' or 'gemini' for visible turns; 'thought' and 'tool' entries are hidden by default and surface as `system` role when `--verbose` is set. `displayContent` takes priority over `content` when both are present. Test coverage mirrors the depth of CodexAdapter — 35 unit tests across initialization, canHandle, detectAgents, discoverSessions (including the parent-of-cwd git-root case), determineStatus, parseSession, and getConversation. Also updates the jest mock in the CLI agent command test so `agent detail` can route `gemini_cli` agents through the new adapter for conversation rendering. --- .../adapters/GeminiCliAdapter.test.ts | 676 ++++++++++++++++++ .../src/adapters/GeminiCliAdapter.ts | 432 +++++++++++ packages/agent-manager/src/adapters/index.ts | 1 + packages/agent-manager/src/index.ts | 1 + .../cli/src/__tests__/commands/agent.test.ts | 1 + packages/cli/src/commands/agent.ts | 7 + 6 files changed, 1118 insertions(+) create mode 100644 packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts create mode 100644 packages/agent-manager/src/adapters/GeminiCliAdapter.ts diff --git a/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts new file mode 100644 index 00000000..3ce74954 --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/GeminiCliAdapter.test.ts @@ -0,0 +1,676 @@ +/** + * Tests for GeminiCliAdapter + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals'; +import { GeminiCliAdapter } from '../../adapters/GeminiCliAdapter'; +import type { ProcessInfo } from '../../adapters/AgentAdapter'; +import { AgentStatus } from '../../adapters/AgentAdapter'; +import { listAgentProcesses, enrichProcesses } from '../../utils/process'; +import { matchProcessesToSessions, generateAgentName } from '../../utils/matching'; + +jest.mock('../../utils/process', () => ({ + listAgentProcesses: jest.fn(), + enrichProcesses: jest.fn(), +})); + +jest.mock('../../utils/matching', () => ({ + matchProcessesToSessions: jest.fn(), + generateAgentName: jest.fn(), +})); + +const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction; +const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction; +const mockedMatchProcessesToSessions = matchProcessesToSessions as jest.MockedFunction; +const mockedGenerateAgentName = generateAgentName as jest.MockedFunction; + +describe('GeminiCliAdapter', () => { + let adapter: GeminiCliAdapter; + let tmpHome: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-adapter-test-')); + process.env.HOME = tmpHome; + + adapter = new GeminiCliAdapter(); + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedMatchProcessesToSessions.mockReset(); + mockedGenerateAgentName.mockReset(); + + mockedEnrichProcesses.mockImplementation((procs) => procs); + mockedMatchProcessesToSessions.mockReturnValue([]); + mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { + const folder = path.basename(cwd) || 'unknown'; + return `${folder} (${pid})`; + }); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + describe('initialization', () => { + it('should expose gemini_cli type', () => { + expect(adapter.type).toBe('gemini_cli'); + }); + }); + + describe('canHandle', () => { + it('should return true for plain gemini command', () => { + expect(adapter.canHandle({ pid: 1, command: 'gemini', cwd: '/repo', tty: 'ttys001' })).toBe(true); + }); + + it('should return true for gemini with full path (case-insensitive)', () => { + expect(adapter.canHandle({ + pid: 2, + command: '/usr/local/bin/GEMINI --yolo', + cwd: '/repo', + tty: 'ttys002', + })).toBe(true); + }); + + it('should return false for non-gemini processes', () => { + expect(adapter.canHandle({ pid: 3, command: 'node app.js', cwd: '/repo', tty: 'ttys003' })).toBe(false); + }); + + it('should return false when "gemini" appears only in path arguments', () => { + expect(adapter.canHandle({ + pid: 4, + command: 'node /path/to/gemini-runner.js', + cwd: '/repo', + tty: 'ttys004', + })).toBe(false); + }); + + it('should return true for Node-invoked gemini script (real install layout)', () => { + expect(adapter.canHandle({ + pid: 5, + command: 'node /Users/foo/.volta/tools/image/node/24.14.0/bin/gemini --help', + cwd: '/repo', + tty: 'ttys005', + })).toBe(true); + }); + + it('should return true for Node-invoked gemini.js bundle entrypoint', () => { + expect(adapter.canHandle({ + pid: 6, + command: 'node /opt/homebrew/lib/node_modules/@google/gemini-cli/bundle/gemini.js', + cwd: '/repo', + tty: 'ttys006', + })).toBe(true); + }); + }); + + describe('detectAgents', () => { + it('should return empty array when no gemini processes are running', async () => { + mockedListAgentProcesses.mockReturnValue([]); + const agents = await adapter.detectAgents(); + expect(agents).toEqual([]); + }); + + it('should filter non-gemini Node processes out of the node process pool', async () => { + const geminiProc: ProcessInfo = { + pid: 100, + command: 'node /Users/foo/.volta/tools/image/node/24.14.0/bin/gemini --help', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + const unrelatedNodeProc: ProcessInfo = { + pid: 200, + command: 'node /usr/local/bin/eslint src/', + cwd: '/other-repo', + tty: 'ttys002', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + mockedListAgentProcesses.mockReturnValue([geminiProc, unrelatedNodeProc]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].pid).toBe(100); + }); + + it('should return process-only agents when no session files exist for the process', async () => { + const proc: ProcessInfo = { + pid: 1234, + command: 'gemini', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'gemini_cli', + pid: 1234, + projectPath: '/repo', + status: AgentStatus.RUNNING, + sessionId: 'pid-1234', + }); + }); + + it('should map a process to its matching session file via projectHash', async () => { + const cwd = '/repo/project-a'; + const projectHash = hashProjectRoot(cwd); + const shortId = 'abc123'; + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', shortId, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionPath = path.join(chatsDir, 'session-2026-04-18T00-00-session1.json'); + const sessionStart = new Date('2026-04-18T00:00:00Z').toISOString(); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'session1', + projectHash, + startTime: sessionStart, + lastUpdated: sessionStart, + kind: 'main', + messages: [ + { id: 'm1', timestamp: sessionStart, type: 'user', content: 'hello gemini' }, + ], + }), + ); + + const proc: ProcessInfo = { + pid: 42, + command: 'gemini', + cwd, + tty: 'ttys001', + startTime: new Date('2026-04-18T00:00:00Z'), + }; + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'session1', + filePath: sessionPath, + projectDir: chatsDir, + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'gemini_cli', + pid: 42, + projectPath: cwd, + sessionId: 'session1', + sessionFilePath: sessionPath, + }); + expect(agents[0].summary).toContain('hello gemini'); + }); + + it('should not match sessions from other projects', async () => { + const procCwd = '/repo/project-a'; + const otherCwd = '/repo/project-b'; + const otherHash = hashProjectRoot(otherCwd); + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'other', 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionPath = path.join(chatsDir, 'session-2026-04-18T00-00-other.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'other-session', + projectHash: otherHash, + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }), + ); + + const proc: ProcessInfo = { + pid: 7, + command: 'gemini', + cwd: procCwd, + tty: 'ttys001', + startTime: new Date(), + }; + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + const candidateSessions = mockedMatchProcessesToSessions.mock.calls[0]?.[1] ?? []; + expect(candidateSessions).toHaveLength(0); + + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe(`pid-${proc.pid}`); + }); + }); + + describe('discoverSessions', () => { + it('should return empty when ~/.gemini/tmp does not exist', () => { + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date(), + }; + // tmp dir absent by default + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + expect(result.contentCache.size).toBe(0); + }); + + it('should skip processes with empty cwd when building the hash map', () => { + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: '', + tty: 'ttys001', + startTime: new Date(), + }; + writeSession(tmpHome, 'abc', 'session-x', { + sessionId: 's1', + projectHash: hashProjectRoot('/some/where'), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + }); + + it('should ignore sessions whose projectHash does not match any process cwd', () => { + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: '/repo/a', + tty: 'ttys001', + startTime: new Date(), + }; + writeSession(tmpHome, 'other', 'session-other', { + sessionId: 's-other', + projectHash: hashProjectRoot('/repo/different'), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + }); + + it('should skip malformed JSON files and still return valid ones', () => { + const cwd = '/repo/valid'; + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd, + tty: 'ttys001', + startTime: new Date(), + }; + + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'abc', 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync(path.join(chatsDir, 'session-bad.json'), '{ not valid'); + writeSession(tmpHome, 'abc', 'session-good', { + sessionId: 's-good', + projectHash: hashProjectRoot(cwd), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].sessionId).toBe('s-good'); + }); + + it('should match sessions whose projectHash is a parent of the process cwd (git root case)', () => { + const gitRoot = '/repo/monorepo'; + const procCwd = '/repo/monorepo/packages/inner'; + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd: procCwd, + tty: 'ttys001', + startTime: new Date(), + }; + writeSession(tmpHome, 'abc', 'session-rootmatch', { + sessionId: 's-root', + // Gemini CLI stores the hash of the walked-up project root, + // not the process CWD. + projectHash: hashProjectRoot(gitRoot), + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + kind: 'main', + messages: [], + }); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].resolvedCwd).toBe(procCwd); + }); + + it('should skip files that do not start with "session-"', () => { + const cwd = '/repo/keep'; + const proc: ProcessInfo = { + pid: 1, + command: 'gemini', + cwd, + tty: 'ttys001', + startTime: new Date(), + }; + + const chatsDir = path.join(tmpHome, '.gemini', 'tmp', 'abc', 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + fs.writeFileSync( + path.join(chatsDir, 'notsession.json'), + JSON.stringify({ + sessionId: 'skip', + projectHash: hashProjectRoot(cwd), + messages: [], + }), + ); + + const result = (adapter as any).discoverSessions([proc]); + expect(result.sessions).toEqual([]); + }); + }); + + describe('helper methods', () => { + describe('determineStatus', () => { + it('should return "waiting" when the last message is from gemini', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), lastActive: new Date(), + lastMessageType: 'gemini', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.WAITING); + }); + + it('should return "waiting" when the last message is from assistant', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), lastActive: new Date(), + lastMessageType: 'assistant', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.WAITING); + }); + + it('should return "running" when the last message is from the user', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), lastActive: new Date(), + lastMessageType: 'user', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.RUNNING); + }); + + it('should return "idle" when last activity is older than the threshold', () => { + const session = { + sessionId: 's', projectPath: '', summary: '', + sessionStart: new Date(), + lastActive: new Date(Date.now() - 10 * 60 * 1000), + lastMessageType: 'gemini', + }; + expect((adapter as any).determineStatus(session)).toBe(AgentStatus.IDLE); + }); + }); + + describe('parseSession', () => { + it('should parse a valid session file', () => { + const filePath = writeSession(tmpHome, 'p', 'session-a', { + sessionId: 's1', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:05:00Z', + kind: 'main', + directories: ['/repo'], + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: 'hello' }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result).toMatchObject({ + sessionId: 's1', + projectPath: '/repo', + summary: 'hello', + }); + }); + + it('should parse from cached content without reading disk', () => { + const content = JSON.stringify({ + sessionId: 's2', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [], + }); + + const result = (adapter as any).parseSession(content, '/does/not/exist.json'); + expect(result?.sessionId).toBe('s2'); + }); + + it('should return null for a missing file with no cached content', () => { + expect((adapter as any).parseSession(undefined, '/missing.json')).toBeNull(); + }); + + it('should return null when the file is not valid JSON', () => { + const filePath = path.join(tmpHome, 'broken.json'); + fs.writeFileSync(filePath, 'not json'); + expect((adapter as any).parseSession(undefined, filePath)).toBeNull(); + }); + + it('should return null when sessionId is missing', () => { + const filePath = path.join(tmpHome, 'no-id.json'); + fs.writeFileSync(filePath, JSON.stringify({ messages: [] })); + expect((adapter as any).parseSession(undefined, filePath)).toBeNull(); + }); + + it('should default the summary when no user message has content', () => { + const filePath = writeSession(tmpHome, 'p', 'session-empty', { + sessionId: 's3', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'gemini', content: 'only assistant' }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.summary).toBe('Gemini CLI session active'); + }); + + it('should truncate long summaries to 120 characters', () => { + const longContent = 'x'.repeat(200); + const filePath = writeSession(tmpHome, 'p', 'session-long', { + sessionId: 's4', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: longContent }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.summary.length).toBe(120); + expect(result?.summary.endsWith('...')).toBe(true); + }); + + it('should prefer lastUpdated over entry timestamp for lastActive', () => { + const filePath = writeSession(tmpHome, 'p', 'session-last', { + sessionId: 's5', + projectHash: 'h', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:10:00Z', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: 'hi' }, + ], + }); + + const result = (adapter as any).parseSession(undefined, filePath); + expect(result?.lastActive.toISOString()).toBe('2026-04-18T00:10:00.000Z'); + }); + }); + }); + + describe('getConversation', () => { + it('should return messages from a valid Gemini session file', () => { + const sessionPath = path.join(tmpHome, 'session-2026-04-18T00-00-id.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + projectHash: 'hash', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + kind: 'main', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: 'hi' }, + { id: 'm2', timestamp: '2026-04-18T00:00:02Z', type: 'gemini', content: 'hello' }, + { id: 'm3', timestamp: '2026-04-18T00:00:03Z', type: 'tool', content: 'unused' }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath); + expect(messages).toEqual([ + { role: 'user', content: 'hi', timestamp: '2026-04-18T00:00:01Z' }, + { role: 'assistant', content: 'hello', timestamp: '2026-04-18T00:00:02Z' }, + ]); + }); + + it('should include tool entries when verbose is true', () => { + const sessionPath = path.join(tmpHome, 'session-verbose.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + projectHash: 'hash', + startTime: '2026-04-18T00:00:00Z', + lastUpdated: '2026-04-18T00:00:00Z', + kind: 'main', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'tool', content: 'tool call' }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath, { verbose: true }); + expect(messages).toEqual([ + { role: 'system', content: 'tool call', timestamp: '2026-04-18T00:00:01Z' }, + ]); + }); + + it('should return empty array for missing or malformed files', () => { + expect(adapter.getConversation('/nonexistent/file.json')).toEqual([]); + + const brokenPath = path.join(tmpHome, 'broken.json'); + fs.writeFileSync(brokenPath, '{ not valid json'); + expect(adapter.getConversation(brokenPath)).toEqual([]); + }); + + it('should prefer displayContent over content when both are present', () => { + const sessionPath = path.join(tmpHome, 'session-display.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { + id: 'm1', + timestamp: '2026-04-18T00:00:01Z', + type: 'user', + content: 'raw', + displayContent: 'rendered', + }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath); + expect(messages[0].content).toBe('rendered'); + }); + + it('should skip entries with empty content', () => { + const sessionPath = path.join(tmpHome, 'session-empty.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', type: 'user', content: '' }, + { id: 'm2', timestamp: '2026-04-18T00:00:02Z', type: 'user', content: 'real' }, + ], + }), + ); + + const messages = adapter.getConversation(sessionPath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('real'); + }); + + it('should skip entries without a type', () => { + const sessionPath = path.join(tmpHome, 'session-no-type.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ + sessionId: 'abc', + messages: [ + { id: 'm1', timestamp: '2026-04-18T00:00:01Z', content: 'typeless' }, + ], + }), + ); + + expect(adapter.getConversation(sessionPath)).toEqual([]); + }); + + it('should return empty array when messages is not an array', () => { + const sessionPath = path.join(tmpHome, 'session-bad-messages.json'); + fs.writeFileSync( + sessionPath, + JSON.stringify({ sessionId: 'abc', messages: 'not-an-array' }), + ); + + expect(adapter.getConversation(sessionPath)).toEqual([]); + }); + }); +}); + +/** + * Write a Gemini session JSON to the temporary home under the expected + * ~/.gemini/tmp//chats/.json layout. Returns the full path. + */ +function writeSession( + home: string, + shortId: string, + fileName: string, + body: Record, +): string { + const chatsDir = path.join(home, '.gemini', 'tmp', shortId, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const filePath = path.join(chatsDir, `${fileName}.json`); + fs.writeFileSync(filePath, JSON.stringify(body)); + return filePath; +} + +/** + * Mirror the projectHash algo used by Gemini CLI: + * sha256(projectRoot) as hex. + */ +function hashProjectRoot(projectRoot: string): string { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const crypto = require('crypto'); + return crypto.createHash('sha256').update(projectRoot).digest('hex'); +} diff --git a/packages/agent-manager/src/adapters/GeminiCliAdapter.ts b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts new file mode 100644 index 00000000..63d0b5a5 --- /dev/null +++ b/packages/agent-manager/src/adapters/GeminiCliAdapter.ts @@ -0,0 +1,432 @@ +/** + * Gemini CLI Adapter + * + * Detects running Gemini CLI agents by: + * 1. Finding running gemini processes via shared listAgentProcesses() + * 2. Enriching with CWD and start times via shared enrichProcesses() + * 3. Discovering session files from ~/.gemini/tmp//chats/session-*.json + * 4. Matching sessions to processes via shared matchProcessesToSessions() + * using sha256(cwd) === session.projectHash as the resolvedCwd source + * 5. Extracting summary from the most recent user message in the session JSON + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter'; +import { AgentStatus } from './AgentAdapter'; +import { listAgentProcesses, enrichProcesses } from '../utils/process'; +import type { SessionFile } from '../utils/session'; +import { matchProcessesToSessions, generateAgentName } from '../utils/matching'; + +interface GeminiMessageEntry { + id?: string; + timestamp?: string; + type?: string; + content?: string; + displayContent?: string; +} + +interface GeminiSessionFile { + sessionId?: string; + projectHash?: string; + startTime?: string; + lastUpdated?: string; + messages?: GeminiMessageEntry[]; + directories?: string[]; + kind?: string; +} + +interface GeminiSession { + sessionId: string; + projectPath: string; + summary: string; + sessionStart: Date; + lastActive: Date; + lastMessageType?: string; +} + +export class GeminiCliAdapter implements AgentAdapter { + readonly type = 'gemini_cli' as const; + + private static readonly IDLE_THRESHOLD_MINUTES = 5; + private static readonly SESSION_FILE_PREFIX = 'session-'; + private static readonly CHATS_DIR_NAME = 'chats'; + private static readonly TMP_DIR_NAME = 'tmp'; + + private geminiTmpDir: string; + + constructor() { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.geminiTmpDir = path.join(homeDir, '.gemini', GeminiCliAdapter.TMP_DIR_NAME); + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isGeminiExecutable(processInfo.command); + } + + /** + * Detect running Gemini CLI agents. + * + * Gemini CLI ships as a Node script (`bundle/gemini.js` with shebang + * `#!/usr/bin/env node`) — unlike Claude Code (native binary per + * platform) or Codex CLI (Node wrapper that execs a native Rust + * binary). The primary running process is therefore the Node runtime + * itself, and `ps aux` lists it as `node /path/to/gemini ...` with + * argv[0] = `node`. We scan the Node process pool via the shared + * helper and keep only those whose command line references the gemini + * executable or script via isGeminiExecutable(). + */ + async detectAgents(): Promise { + const nodeProcesses = enrichProcesses(listAgentProcesses('node')); + const processes = nodeProcesses.filter((proc) => this.isGeminiExecutable(proc.command)); + if (processes.length === 0) return []; + + const { sessions, contentCache } = this.discoverSessions(processes); + if (sessions.length === 0) { + return processes.map((p) => this.mapProcessOnlyAgent(p)); + } + + const matches = matchProcessesToSessions(processes, sessions); + const matchedPids = new Set(matches.map((m) => m.process.pid)); + const agents: AgentInfo[] = []; + + for (const match of matches) { + const cachedContent = contentCache.get(match.session.filePath); + const sessionData = this.parseSession(cachedContent, match.session.filePath); + if (sessionData) { + agents.push(this.mapSessionToAgent(sessionData, match.process, match.session.filePath)); + } else { + matchedPids.delete(match.process.pid); + } + } + + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); + } + } + + return agents; + } + + /** + * Discover session files for the given processes. + * + * Gemini CLI writes sessions to ~/.gemini/tmp//chats/session-*.json + * where is opaque (managed by a project registry). We scan every + * shortId directory and filter by matching session.projectHash against + * sha256(process.cwd) to bind each session to a candidate process CWD. + */ + private discoverSessions(processes: ProcessInfo[]): { + sessions: SessionFile[]; + contentCache: Map; + } { + const empty = { sessions: [] as SessionFile[], contentCache: new Map() }; + if (!fs.existsSync(this.geminiTmpDir)) return empty; + + const cwdHashMap = this.buildCwdHashMap(processes); + if (cwdHashMap.size === 0) return empty; + + const contentCache = new Map(); + const sessions: SessionFile[] = []; + + let shortIdEntries: string[]; + try { + shortIdEntries = fs.readdirSync(this.geminiTmpDir); + } catch { + return empty; + } + + for (const shortId of shortIdEntries) { + const chatsDir = path.join(this.geminiTmpDir, shortId, GeminiCliAdapter.CHATS_DIR_NAME); + try { + if (!fs.statSync(chatsDir).isDirectory()) continue; + } catch { + continue; + } + + let chatFiles: string[]; + try { + chatFiles = fs.readdirSync(chatsDir); + } catch { + continue; + } + + for (const fileName of chatFiles) { + if (!fileName.startsWith(GeminiCliAdapter.SESSION_FILE_PREFIX) || !fileName.endsWith('.json')) { + continue; + } + + const filePath = path.join(chatsDir, fileName); + + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + continue; + } + + let parsed: GeminiSessionFile; + try { + parsed = JSON.parse(content); + } catch { + continue; + } + + if (!parsed.projectHash) continue; + const resolvedCwd = cwdHashMap.get(parsed.projectHash); + if (!resolvedCwd) continue; + + let birthtimeMs = 0; + try { + birthtimeMs = fs.statSync(filePath).birthtimeMs; + } catch { + continue; + } + + const sessionId = + parsed.sessionId || fileName.replace(/\.json$/, ''); + + contentCache.set(filePath, content); + sessions.push({ + sessionId, + filePath, + projectDir: chatsDir, + birthtimeMs, + resolvedCwd, + }); + } + } + + return { sessions, contentCache }; + } + + private buildCwdHashMap(processes: ProcessInfo[]): Map { + const map = new Map(); + for (const proc of processes) { + if (!proc.cwd) continue; + // Gemini CLI resolves its project root by walking up from the + // startup directory looking for a `.git` boundary marker. A + // session's projectHash therefore tracks that ancestor rather + // than the process' actual CWD. Enumerate every ancestor as a + // candidate so subdirectory invocations still line up with the + // session the Gemini process wrote. + for (const candidate of this.candidateProjectRoots(proc.cwd)) { + if (!map.has(this.hashProjectRoot(candidate))) { + map.set(this.hashProjectRoot(candidate), proc.cwd); + } + } + } + return map; + } + + private candidateProjectRoots(cwd: string): string[] { + const roots: string[] = []; + let current = path.resolve(cwd); + let parent = path.dirname(current); + while (parent !== current) { + roots.push(current); + current = parent; + parent = path.dirname(current); + } + roots.push(current); + return roots; + } + + private hashProjectRoot(projectRoot: string): string { + return crypto.createHash('sha256').update(projectRoot).digest('hex'); + } + + /** + * Parse session file content into GeminiSession. + * Uses cached content if available, otherwise reads from disk. + */ + private parseSession(cachedContent: string | undefined, filePath: string): GeminiSession | null { + let content: string; + if (cachedContent !== undefined) { + content = cachedContent; + } else { + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + } + + let parsed: GeminiSessionFile; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + + if (!parsed.sessionId) return null; + + const messages = Array.isArray(parsed.messages) ? parsed.messages : []; + const lastEntry = messages.length > 0 ? messages[messages.length - 1] : undefined; + + let mtime: Date | null = null; + try { + mtime = fs.statSync(filePath).mtime; + } catch { + mtime = null; + } + + const lastActive = + this.parseTimestamp(parsed.lastUpdated) || + this.parseTimestamp(lastEntry?.timestamp) || + mtime || + new Date(); + + const sessionStart = + this.parseTimestamp(parsed.startTime) || lastActive; + + const projectPath = + Array.isArray(parsed.directories) && parsed.directories.length > 0 + ? parsed.directories[0] + : ''; + + return { + sessionId: parsed.sessionId, + projectPath, + summary: this.extractSummary(messages), + sessionStart, + lastActive, + lastMessageType: lastEntry?.type, + }; + } + + private mapSessionToAgent(session: GeminiSession, processInfo: ProcessInfo, filePath: string): AgentInfo { + const projectPath = session.projectPath || processInfo.cwd || ''; + return { + name: generateAgentName(projectPath, processInfo.pid), + type: this.type, + status: this.determineStatus(session), + summary: session.summary || 'Gemini CLI session active', + pid: processInfo.pid, + projectPath, + sessionId: session.sessionId, + lastActive: session.lastActive, + sessionFilePath: filePath, + }; + } + + private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { + return { + name: generateAgentName(processInfo.cwd || '', processInfo.pid), + type: this.type, + status: AgentStatus.RUNNING, + summary: 'Gemini CLI process running', + pid: processInfo.pid, + projectPath: processInfo.cwd || '', + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), + }; + } + + private parseTimestamp(value?: string): Date | null { + if (!value) return null; + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) ? null : timestamp; + } + + private determineStatus(session: GeminiSession): AgentStatus { + const diffMs = Date.now() - session.lastActive.getTime(); + const diffMinutes = diffMs / 60000; + + if (diffMinutes > GeminiCliAdapter.IDLE_THRESHOLD_MINUTES) { + return AgentStatus.IDLE; + } + + if (session.lastMessageType === 'gemini' || session.lastMessageType === 'assistant') { + return AgentStatus.WAITING; + } + + return AgentStatus.RUNNING; + } + + private extractSummary(messages: GeminiMessageEntry[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const entry = messages[i]; + if (entry?.type !== 'user') continue; + const text = (entry.displayContent || entry.content || '').trim(); + if (text) return this.truncate(text, 120); + } + + return 'Gemini CLI session active'; + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; + } + + private isGeminiExecutable(command: string): boolean { + // Accept any token in the command line whose basename matches a + // known gemini entrypoint. This is intentionally broader than the + // other adapters' argv[0]-only check because the Node-script + // distribution puts the real gemini path in argv[1..], not argv[0]. + for (const token of command.trim().split(/\s+/)) { + const base = path.basename(token).toLowerCase(); + if (base === 'gemini' || base === 'gemini.exe' || base === 'gemini.js') { + return true; + } + } + return false; + } + + /** + * Read the full conversation from a Gemini CLI session JSON file. + * + * Gemini sessions store messages in an array with `type` field — typically + * 'user' or 'gemini' for visible turns, with tool and system entries mixed in. + */ + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const verbose = options?.verbose ?? false; + + let content: string; + try { + content = fs.readFileSync(sessionFilePath, 'utf-8'); + } catch { + return []; + } + + let parsed: GeminiSessionFile; + try { + parsed = JSON.parse(content); + } catch { + return []; + } + + const messages: ConversationMessage[] = []; + if (!Array.isArray(parsed.messages)) return messages; + + for (const entry of parsed.messages) { + const entryType = entry?.type; + if (!entryType) continue; + + let role: ConversationMessage['role']; + if (entryType === 'user') { + role = 'user'; + } else if (entryType === 'gemini' || entryType === 'assistant') { + role = 'assistant'; + } else if (verbose) { + role = 'system'; + } else { + continue; + } + + const text = (entry.displayContent || entry.content || '').trim(); + if (!text) continue; + + messages.push({ + role, + content: text, + timestamp: entry.timestamp, + }); + } + + return messages; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 896bf41d..62e458b0 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -1,4 +1,5 @@ export { ClaudeCodeAdapter } from './ClaudeCodeAdapter'; export { CodexAdapter } from './CodexAdapter'; +export { GeminiCliAdapter } from './GeminiCliAdapter'; export { AgentStatus } from './AgentAdapter'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 53e9ed9e..b10624ad 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -2,6 +2,7 @@ export { AgentManager } from './AgentManager'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; export { CodexAdapter } from './adapters/CodexAdapter'; +export { GeminiCliAdapter } from './adapters/GeminiCliAdapter'; export { AgentStatus } from './adapters/AgentAdapter'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, ConversationMessage } from './adapters/AgentAdapter'; diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 8bbb2cb9..6bbef559 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -29,6 +29,7 @@ jest.mock('@ai-devkit/agent-manager', () => ({ AgentManager: jest.fn(() => mockManager), ClaudeCodeAdapter: jest.fn(), CodexAdapter: jest.fn(), + GeminiCliAdapter: jest.fn(), TerminalFocusManager: jest.fn(() => mockFocusManager), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 347ffd28..592c902c 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -6,6 +6,7 @@ import { AgentManager, ClaudeCodeAdapter, CodexAdapter, + GeminiCliAdapter, AgentStatus, TerminalFocusManager, TtyWriter, @@ -83,6 +84,7 @@ export function registerAgentCommand(program: Command): void { // In the future, we might load these dynamically or based on config manager.registerAdapter(new ClaudeCodeAdapter()); manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); const agents = await manager.listAgents(); @@ -148,6 +150,7 @@ export function registerAgentCommand(program: Command): void { manager.registerAdapter(new ClaudeCodeAdapter()); manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); const agents = await manager.listAgents(); if (agents.length === 0) { @@ -222,6 +225,7 @@ export function registerAgentCommand(program: Command): void { const manager = new AgentManager(); manager.registerAdapter(new ClaudeCodeAdapter()); manager.registerAdapter(new CodexAdapter()); + manager.registerAdapter(new GeminiCliAdapter()); const agents = await manager.listAgents(); if (agents.length === 0) { @@ -279,10 +283,12 @@ export function registerAgentCommand(program: Command): void { try { const claudeAdapter = new ClaudeCodeAdapter(); const codexAdapter = new CodexAdapter(); + const geminiAdapter = new GeminiCliAdapter(); const manager = new AgentManager(); manager.registerAdapter(claudeAdapter); manager.registerAdapter(codexAdapter); + manager.registerAdapter(geminiAdapter); const agents = await manager.listAgents(); if (agents.length === 0) { @@ -317,6 +323,7 @@ export function registerAgentCommand(program: Command): void { const adapters: Record = { claude: claudeAdapter, codex: codexAdapter, + gemini_cli: geminiAdapter, }; const adapter = adapters[agent.type]; if (!adapter) {