diff --git a/README.md b/README.md index 717af9a6..520136b6 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ One `.ai-devkit.json` configures all of them. Add a new agent to your team witho | [Devin](https://devin.ai/) | yes | — | | [opencode](https://opencode.ai/) | yes | testing | | [Pi](https://pi.dev) | yes | yes | +| [Kiro CLI](https://kiro.dev/cli/) | yes | yes | | [Cursor](https://cursor.sh/) | yes | — | | [GitHub Copilot](https://code.visualstudio.com/) | yes | — | | [Antigravity](https://antigravity.google/) | yes | — | diff --git a/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts new file mode 100644 index 00000000..042ae8a0 --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts @@ -0,0 +1,435 @@ +/** + * Tests for KiroAdapter + */ + +import type { MockedFunction } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { KiroAdapter } from '../../adapters/KiroAdapter.js'; +import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; +import { AgentStatus } from '../../adapters/AgentAdapter.js'; +import { AgentRegistry } from '../../utils/AgentRegistry.js'; +import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js'; + +vi.mock('../../utils/process.js', () => ({ + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), +})); + +vi.mock('../../utils/matching.js', () => ({ + matchProcessesToSessions: vi.fn(), + generateAgentName: vi.fn(), +})); + +const mockedListAgentProcesses = listAgentProcesses as MockedFunction; +const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction; +const mockedGenerateAgentName = generateAgentName as MockedFunction; + +describe('KiroAdapter', () => { + let adapter: KiroAdapter; + let tmpHome: string; + let sessionsDir: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-adapter-test-')); + process.env.HOME = tmpHome; + sessionsDir = path.join(tmpHome, '.kiro', 'cli', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + adapter = new KiroAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json'))); + 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 }); + }); + + it('exposes kiro type', () => { + expect(adapter.type).toBe('kiro'); + }); + + it('identifies Kiro commands without matching unrelated paths', () => { + expect(adapter.canHandle({ pid: 1, command: 'kiro-cli', cwd: '/repo', tty: 'ttys001' })).toBe(true); + expect(adapter.canHandle({ pid: 2, command: '/usr/local/bin/kiro --model x', cwd: '/repo', tty: 'ttys002' })).toBe(true); + expect(adapter.canHandle({ pid: 3, command: 'node /opt/kiro/bin/kiro-cli.js', cwd: '/repo', tty: 'ttys003' })).toBe(true); + expect(adapter.canHandle({ pid: 4, command: 'node /repo/feature-kiro-adapter/script.js', cwd: '/repo', tty: 'ttys004' })).toBe(false); + }); + + it('maps a running Kiro process to the tracker session for its PID', async () => { + const cwd = '/repo/project-a'; + const proc = makeProcess({ pid: 101, cwd }); + const sessionFile = writeKiroSession(cwd, [ + { type: 'session_meta', timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-101', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'implement Kiro adapter' }, + { role: 'assistant', timestamp: new Date().toISOString(), content: 'working on it' }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), + JSON.stringify({ 101: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'kiro', + pid: 101, + projectPath: cwd, + sessionId: 'sess-101', + summary: 'implement Kiro adapter', + status: AgentStatus.WAITING, + sessionFilePath: sessionFile, + }); + expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled(); + }); + + it('truncates long user prompts in detected agent summaries', async () => { + const cwd = '/repo/project-long-summary'; + const proc = makeProcess({ pid: 112, cwd }); + const longPrompt = 'x'.repeat(140); + const sessionFile = writeKiroSession(cwd, [ + { type: 'session', timestamp: '2026-06-10T08:58:20.754Z', id: 'sess-long', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: longPrompt }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), + JSON.stringify({ 112: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0].summary).toHaveLength(120); + expect(agents[0].summary.endsWith('...')).toBe(true); + }); + + it('uses the filename session id fallback and reports running when the latest message is from the user', async () => { + const cwd = '/repo/project-filename-fallback'; + const proc = makeProcess({ pid: 113, cwd }); + const sessionFile = writeKiroSessionWithFileName(cwd, 'plain-session.jsonl', [ + { role: 'user', timestamp: new Date().toISOString(), content: 'still working' }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), + JSON.stringify({ 113: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0]).toMatchObject({ + sessionId: 'plain-session', + summary: 'still working', + status: AgentStatus.RUNNING, + }); + }); + + it('falls back to legacy matching when sessions.json is missing', async () => { + const cwd = '/repo/project-b'; + const proc = makeProcess({ pid: 202, cwd }); + const sessionFile = writeKiroSession(cwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-202' }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'fallback matching please' }, + ]); + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'sess-202', + filePath: sessionFile, + projectDir: path.dirname(sessionFile), + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'kiro', + pid: 202, + projectPath: cwd, + sessionId: 'sess-202', + summary: 'fallback matching please', + }); + expect(mockedMatchProcessesToSessions).toHaveBeenCalledWith( + [proc], + expect.arrayContaining([ + expect.objectContaining({ filePath: sessionFile, resolvedCwd: cwd }), + ]), + ); + }); + + it('ignores malformed tracker metadata and still falls back to legacy matching', async () => { + const cwd = '/repo/project-c'; + const proc = makeProcess({ pid: 303, cwd }); + const sessionFile = writeKiroSession(cwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-303', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'recover from bad tracker' }, + ]); + fs.writeFileSync(path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), '{bad json'); + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'sess-303', + filePath: sessionFile, + projectDir: path.dirname(sessionFile), + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe('sess-303'); + }); + + it('falls back to legacy matching when a trusted tracker session is unparseable', async () => { + const cwd = '/repo/project-bad-tracker-session'; + const proc = makeProcess({ pid: 304, cwd }); + const badSessionFile = writeKiroSessionWithFileName(cwd, 'bad.jsonl', ['{not json']); + const fallbackSessionFile = writeKiroSession(cwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-304', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'fallback after bad tracker session' }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), + JSON.stringify({ 304: badSessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'sess-304', + filePath: fallbackSessionFile, + projectDir: path.dirname(fallbackSessionFile), + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + sessionId: 'sess-304', + summary: 'fallback after bad tracker session', + sessionFilePath: fallbackSessionFile, + }); + expect(mockedMatchProcessesToSessions).toHaveBeenCalled(); + }); + + it('does not trust tracker paths outside the Kiro sessions directory', async () => { + const cwd = '/repo/project-d'; + const proc = makeProcess({ pid: 404, cwd }); + const outside = path.join(tmpHome, 'outside.jsonl'); + fs.writeFileSync(outside, JSON.stringify({ role: 'user', content: 'nope' })); + fs.writeFileSync( + path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), + JSON.stringify({ 404: outside }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'kiro', + pid: 404, + sessionId: 'pid-404', + summary: 'Kiro process running', + }); + }); + + it('returns a process-only agent when no session can be matched', async () => { + const proc = makeProcess({ pid: 505, cwd: '/repo/project-e' }); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'kiro', + status: AgentStatus.RUNNING, + pid: 505, + projectPath: '/repo/project-e', + sessionId: 'pid-505', + summary: 'Kiro process running', + }); + }); + + it('reads user and assistant conversation messages from JSONL', () => { + const cwd = '/repo/project-f'; + const sessionFile = writeKiroSession(cwd, [ + { role: 'system', timestamp: '2026-06-10T08:58:20.000Z', content: 'hidden' }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'hello kiro' }, + { type: 'assistant', timestamp: '2026-06-10T08:58:22.000Z', message: { content: 'hello human' } }, + '{not json', + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'hello kiro', timestamp: '2026-06-10T08:58:21.000Z' }, + { role: 'assistant', content: 'hello human', timestamp: '2026-06-10T08:58:22.000Z' }, + ]); + }); + + it('includes system entries only in verbose conversation mode', () => { + const cwd = '/repo/project-verbose'; + const sessionFile = writeKiroSession(cwd, [ + { role: 'system', timestamp: '2026-06-10T08:58:20.000Z', content: 'model changed' }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'visible' }, + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'visible', timestamp: '2026-06-10T08:58:21.000Z' }, + ]); + expect(adapter.getConversation(sessionFile, { verbose: true })).toEqual([ + { role: 'system', content: 'model changed', timestamp: '2026-06-10T08:58:20.000Z' }, + { role: 'user', content: 'visible', timestamp: '2026-06-10T08:58:21.000Z' }, + ]); + }); + + it('reads real Kiro message entries with nested role and text parts', async () => { + const cwd = '/repo/project-real'; + const proc = makeProcess({ pid: 606, cwd }); + const sessionFile = writeKiroSession(cwd, [ + { type: 'session', version: 3, id: 'sess-real', timestamp: '2026-06-10T13:27:17.581Z', cwd }, + { type: 'model_change', id: 'model-1', timestamp: '2026-06-10T13:27:17.655Z', modelId: 'claude-sonnet-4-6' }, + { + type: 'message', + id: 'msg-user', + timestamp: '2026-06-10T13:27:37.975Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + timestamp: 1781098057974, + }, + }, + { + type: 'message', + id: 'msg-assistant', + timestamp: '2026-06-10T13:27:40.161Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How can I help you today?' }], + provider: 'anthropic', + model: 'claude-sonnet-4-6', + timestamp: 1781098058012, + }, + }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), + JSON.stringify({ 606: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'hello', timestamp: '2026-06-10T13:27:37.975Z' }, + { role: 'assistant', content: 'Hello! How can I help you today?', timestamp: '2026-06-10T13:27:40.161Z' }, + ]); + + const agents = await adapter.detectAgents(); + expect(agents[0]).toMatchObject({ + sessionId: 'sess-real', + summary: 'hello', + lastActive: new Date('2026-06-10T13:27:40.161Z'), + }); + }); + + it('lists historical sessions and applies cwd filtering', async () => { + const matchingCwd = '/repo/project-g'; + const otherCwd = '/repo/project-h'; + const matchingSession = writeKiroSession(matchingCwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-g', cwd: matchingCwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'first matching message' }, + ]); + writeKiroSession(otherCwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-h', cwd: otherCwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'other message' }, + ]); + + const sessions = await adapter.listSessions({ cwd: matchingCwd }); + + expect(sessions).toEqual([ + expect.objectContaining({ + type: 'kiro', + sessionId: 'sess-g', + cwd: matchingCwd, + firstUserMessage: 'first matching message', + sessionFilePath: matchingSession, + }), + ]); + }); + + function makeProcess(overrides: Partial): ProcessInfo { + return { + pid: 1, + command: 'kiro', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date('2026-06-10T08:58:20.000Z'), + ...overrides, + }; + } + + function writeKiroSession(cwd: string, entries: Array | string>): string { + const projectDir = path.join(sessionsDir, encodeProjectDir(cwd)); + fs.mkdirSync(projectDir, { recursive: true }); + const sessionId = entries + .map((entry) => typeof entry === 'string' ? undefined : entry.sessionId) + .find((value): value is string => typeof value === 'string') ?? cryptoRandomSessionId(); + const filePath = path.join(projectDir, `2026-06-10T08-58-20-754Z_${sessionId}.jsonl`); + fs.writeFileSync( + filePath, + entries.map((entry) => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n'), + ); + return filePath; + } + + function writeKiroSessionWithFileName(cwd: string, fileName: string, entries: Array | string>): string { + const projectDir = path.join(sessionsDir, encodeProjectDir(cwd)); + fs.mkdirSync(projectDir, { recursive: true }); + const filePath = path.join(projectDir, fileName); + fs.writeFileSync( + filePath, + entries.map((entry) => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n'), + ); + return filePath; + } + + function encodeProjectDir(cwd: string): string { + return cwd.replace(/\//g, '-').replace(/^-?/, '--') + '--'; + } + + function cryptoRandomSessionId(): string { + return `019eb0c1-06d2-71ed-90ee-${Math.random().toString(16).slice(2, 14).padEnd(12, '0')}`; + } +}); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index bb9d93fd..b0efb6ce 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -8,7 +8,7 @@ /** * Type of AI agent */ -export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'copilot' | 'pi' | 'other'; +export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'copilot' | 'pi' | 'kiro' | 'other'; /** * Current status of an agent diff --git a/packages/agent-manager/src/adapters/KiroAdapter.ts b/packages/agent-manager/src/adapters/KiroAdapter.ts new file mode 100644 index 00000000..18d590c3 --- /dev/null +++ b/packages/agent-manager/src/adapters/KiroAdapter.ts @@ -0,0 +1,600 @@ +/** + * Kiro Adapter + * + * Detects running Kiro agents by: + * 1. Finding running Kiro processes + * 2. Matching exact PID-to-session metadata from ~/.kiro/cli/sessions.json + * 3. Falling back to shared process/session matching over Kiro JSONL session files + * 4. Parsing Kiro JSONL entries defensively for summary and conversation output + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, +} from './AgentAdapter.js'; +import { AgentStatus } from './AgentAdapter.js'; +import { listAgentProcesses, enrichProcesses } from '../utils/process.js'; +import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js'; +import type { SessionFile } from '../utils/session.js'; +import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; +import { AgentRegistry } from '../utils/AgentRegistry.js'; + +interface KiroSession { + sessionId: string; + projectPath: string; + summary: string; + sessionStart: Date; + lastActive: Date; + lastRole?: ConversationMessage['role']; +} + +interface KiroLine { + timestamp?: string; + role?: string; + type?: string; + content?: unknown; + text?: unknown; + message?: unknown; + sessionId?: string; + session_id?: string; + id?: string; + cwd?: string; + projectPath?: string; + project_path?: string; + payload?: Record; + data?: Record; + [key: string]: unknown; +} + +type KiroRecord = Record; + +interface TrackerMatch { + process: ProcessInfo; + filePath: string; +} + +interface TrackerAgentResult { + agents: AgentInfo[]; + fallback: ProcessInfo[]; +} + +export class KiroAdapter implements AgentAdapter { + readonly type = 'kiro' as const; + + private static readonly IDLE_THRESHOLD_MINUTES = 5; + + private kiroCliDir: string; + private kiroSessionsDir: string; + private trackerPath: string; + private registry: AgentRegistry; + + constructor(registry: AgentRegistry = AgentRegistry.default()) { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.kiroCliDir = path.join(homeDir, '.kiro', 'cli'); + this.kiroSessionsDir = path.join(this.kiroCliDir, 'sessions'); + this.trackerPath = path.join(this.kiroCliDir, 'sessions.json'); + this.registry = registry; + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isKiroExecutable(processInfo.command); + } + + async detectAgents(): Promise { + const processes = enrichProcesses(this.listKiroProcesses()); + if (processes.length === 0) return []; + + const { cachedAgents, remaining } = this.tryRegistryCache(processes); + if (remaining.length === 0) return cachedAgents; + + const trackerResult = this.mapTrackerMatches(remaining); + const fallbackAgents = this.mapFallbackMatches(trackerResult.fallback); + + return [ + ...cachedAgents, + ...trackerResult.agents, + ...fallbackAgents, + ]; + } + + private mapTrackerMatches(processes: ProcessInfo[]): TrackerAgentResult { + const { matches: trackerMatches, fallback } = this.matchFromTracker(processes); + const agents: AgentInfo[] = []; + + for (const match of trackerMatches) { + const session = this.parseSession(match.filePath, match.process.cwd); + if (session) { + agents.push(this.mapSessionToAgent(session, match.process, match.filePath)); + } else { + fallback.push(match.process); + } + } + + return { agents, fallback }; + } + + private mapFallbackMatches(processes: ProcessInfo[]): AgentInfo[] { + if (processes.length === 0) return []; + + const sessions = 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 session = this.parseSession(match.session.filePath, match.process.cwd); + if (session) { + agents.push(this.mapSessionToAgent(session, 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; + } + + private listKiroProcesses(): ProcessInfo[] { + const byPid = new Map(); + for (const proc of listAgentProcesses('kiro-cli')) { + if (this.canHandle(proc)) byPid.set(proc.pid, proc); + } + for (const proc of listAgentProcesses('kiro')) { + if (this.canHandle(proc)) byPid.set(proc.pid, proc); + } + for (const proc of listAgentProcesses('node')) { + if (this.canHandle(proc)) byPid.set(proc.pid, proc); + } + return Array.from(byPid.values()); + } + + private tryRegistryCache(processes: ProcessInfo[]): { + cachedAgents: AgentInfo[]; + remaining: ProcessInfo[]; + } { + const cachedAgents: AgentInfo[] = []; + const remaining: ProcessInfo[] = []; + const byPid = new Map(this.registry.list().map((e) => [e.pid, e])); + + for (const proc of processes) { + const entry = byPid.get(proc.pid); + if ( + !entry || + entry.type !== this.type || + !entry.sessionFilePath || + !fs.existsSync(entry.sessionFilePath) + ) { + remaining.push(proc); + continue; + } + + const session = this.parseSession(entry.sessionFilePath, proc.cwd); + if (!session) { + remaining.push(proc); + continue; + } + + cachedAgents.push(this.mapSessionToAgent(session, proc, entry.sessionFilePath)); + } + + return { cachedAgents, remaining }; + } + + private matchFromTracker(processes: ProcessInfo[]): { + matches: TrackerMatch[]; + fallback: ProcessInfo[]; + } { + const tracker = this.readTracker(); + if (tracker.size === 0) return { matches: [], fallback: processes }; + + const matches: TrackerMatch[] = []; + const fallback: ProcessInfo[] = []; + + for (const proc of processes) { + const filePath = tracker.get(proc.pid); + if (!filePath || !this.isTrustedSessionPath(filePath) || !fs.existsSync(filePath)) { + fallback.push(proc); + continue; + } + matches.push({ process: proc, filePath }); + } + + return { matches, fallback }; + } + + private readTracker(): Map { + const content = safeReadFile(this.trackerPath); + if (content === undefined) return new Map(); + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return new Map(); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Map(); + + const map = new Map(); + for (const [key, value] of Object.entries(parsed)) { + const keyPid = this.toPid(key); + if (keyPid !== null && typeof value === 'string' && value) { + map.set(keyPid, value); + } + } + return map; + } + + private toPid(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + + private isTrustedSessionPath(filePath: string): boolean { + const resolvedRoot = path.resolve(this.kiroSessionsDir); + const resolvedPath = path.resolve(filePath); + return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${path.sep}`); + } + + private discoverSessions(processes: ProcessInfo[] = []): SessionFile[] { + if (!isDirectory(this.kiroSessionsDir)) return []; + + const cwdByProjectDir = this.buildProjectDirCwdMap(processes); + const sessions: SessionFile[] = []; + for (const filePath of this.collectJsonlFiles(this.kiroSessionsDir)) { + const stat = safeStat(filePath); + if (!stat) continue; + + const session = this.parseSession(filePath); + const sessionId = session?.sessionId || this.sessionIdFromFile(filePath); + const projectDir = path.dirname(filePath); + sessions.push({ + sessionId, + filePath, + projectDir, + birthtimeMs: stat.birthtimeMs || stat.mtimeMs, + resolvedCwd: session?.projectPath || cwdByProjectDir.get(path.basename(projectDir)) || '', + }); + } + + return sessions; + } + + private buildProjectDirCwdMap(processes: ProcessInfo[]): Map { + const map = new Map(); + for (const proc of processes) { + if (!proc.cwd) continue; + map.set(this.encodeProjectDir(proc.cwd), proc.cwd); + } + return map; + } + + private collectJsonlFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of safeReaddir(dir)) { + const fullPath = path.join(dir, entry); + const stat = safeStat(fullPath); + if (!stat) continue; + if (stat.isDirectory()) { + files.push(...this.collectJsonlFiles(fullPath)); + } else if (stat.isFile() && entry.endsWith('.jsonl')) { + files.push(fullPath); + } + } + return files; + } + + private parseSession(filePath: string, fallbackCwd = ''): KiroSession | null { + const entries = this.readJsonl(filePath); + if (entries.length === 0) return null; + return this.sessionFromEntries(entries, filePath, fallbackCwd); + } + + private sessionFromEntries(entries: KiroLine[], filePath: string, fallbackCwd = ''): KiroSession { + const stat = safeStat(filePath); + const timestamps = entries + .map((entry) => this.parseTimestamp(this.entryTimestamp(entry))) + .filter((value): value is Date => value !== null); + + const sessionStart = timestamps[0] ?? stat?.birthtime ?? stat?.mtime ?? new Date(); + const lastActive = timestamps[timestamps.length - 1] ?? stat?.mtime ?? sessionStart; + const messages = this.entriesToMessages(entries, true); + const lastUser = [...messages].reverse().find((msg) => msg.role === 'user'); + const lastMessage = messages[messages.length - 1]; + + return { + sessionId: this.sessionIdFromEntries(entries) || this.sessionIdFromFile(filePath), + projectPath: this.cwdFromEntries(entries) || fallbackCwd, + summary: lastUser?.content ? this.truncate(lastUser.content, 120) : 'Kiro session active', + sessionStart, + lastActive, + lastRole: lastMessage?.role, + }; + } + + private readJsonl(filePath: string): KiroLine[] { + const content = safeReadFile(filePath); + if (content === undefined) return []; + + const entries: KiroLine[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + entries.push(parsed as KiroLine); + } + } catch { + continue; + } + } + return entries; + } + + private entryToMessage(entry: KiroLine, includeSystem: boolean): ConversationMessage | null { + const role = this.entryRole(entry); + if (!role) return null; + if (role === 'system' && !includeSystem) return null; + + const content = this.entryContent(entry).trim(); + if (!content) return null; + + return { + role, + content, + timestamp: this.entryTimestamp(entry), + }; + } + + private entryRole(entry: KiroLine): ConversationMessage['role'] | null { + const message = this.messageRecord(entry); + const raw = this.firstString( + entry.role, + message?.role, + this.roleLikeType(entry.type), + entry.payload?.role, + entry.payload?.type, + entry.data?.role, + entry.data?.type, + ); + if (!raw) return null; + const normalized = raw.toLowerCase(); + if (normalized === 'user' || normalized === 'human') return 'user'; + if (normalized === 'assistant' || normalized === 'ai' || normalized === 'kiro') return 'assistant'; + if (normalized === 'system') return 'system'; + return null; + } + + private entryContent(entry: KiroLine): string { + const message = this.messageRecord(entry); + const candidates = [ + entry.content, + entry.text, + message?.content, + message?.text, + message?.message, + entry.message, + entry.payload?.content, + entry.payload?.text, + entry.payload?.message, + entry.data?.content, + entry.data?.text, + entry.data?.message, + ]; + + for (const candidate of candidates) { + const text = this.contentToString(candidate); + if (text) return text; + } + return ''; + } + + private contentToString(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => this.contentToString(item)).filter(Boolean).join(''); + } + if (!value || typeof value !== 'object') return ''; + + const record = value as Record; + return this.contentToString(record.content ?? record.text ?? record.value); + } + + private asRecord(value: unknown): KiroRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as KiroRecord; + } + + private messageRecord(entry: KiroLine): KiroRecord | null { + return this.asRecord(entry.message); + } + + private roleLikeType(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.toLowerCase(); + if (['user', 'human', 'assistant', 'ai', 'kiro', 'system'].includes(normalized)) { + return value; + } + return undefined; + } + + private entryTimestamp(entry: KiroLine): string | undefined { + return this.firstString( + entry.timestamp, + entry.payload?.timestamp, + entry.data?.timestamp, + entry.createdAt, + entry.created_at, + ); + } + + private sessionIdFromEntries(entries: KiroLine[]): string | null { + for (const entry of entries) { + const sessionId = this.firstString( + entry.sessionId, + entry.session_id, + entry.id, + entry.payload?.sessionId, + entry.payload?.session_id, + entry.payload?.id, + entry.data?.sessionId, + entry.data?.session_id, + entry.data?.id, + ); + if (sessionId) return sessionId; + } + return null; + } + + private cwdFromEntries(entries: KiroLine[]): string { + for (const entry of entries) { + const cwd = this.firstString( + entry.cwd, + entry.projectPath, + entry.project_path, + entry.payload?.cwd, + entry.payload?.projectPath, + entry.payload?.project_path, + entry.data?.cwd, + entry.data?.projectPath, + entry.data?.project_path, + ); + if (cwd) return cwd; + } + return ''; + } + + private sessionIdFromFile(filePath: string): string { + const base = path.basename(filePath, '.jsonl'); + const underscore = base.lastIndexOf('_'); + return underscore >= 0 ? base.slice(underscore + 1) : base; + } + + private encodeProjectDir(cwd: string): string { + const normalized = path.resolve(cwd); + return `--${normalized.replace(/^\//, '').replace(/\//g, '-')}--`; + } + + private firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value) return value; + } + return undefined; + } + + private parseTimestamp(value?: string): Date | null { + if (!value) return null; + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) ? null : timestamp; + } + + private mapSessionToAgent(session: KiroSession, 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 || 'Kiro 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: 'Kiro process running', + pid: processInfo.pid, + projectPath: processInfo.cwd || '', + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), + }; + } + + private determineStatus(session: KiroSession): AgentStatus { + const diffMs = Date.now() - session.lastActive.getTime(); + const diffMinutes = diffMs / 60000; + + if (diffMinutes > KiroAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE; + if (session.lastRole === 'assistant') return AgentStatus.WAITING; + return AgentStatus.RUNNING; + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; + } + + private isKiroExecutable(command: string): boolean { + for (const token of command.trim().split(/\s+/)) { + const base = path.basename(token).toLowerCase().replace(/\.(exe|js)$/, ''); + if (base === 'kiro-cli' || base === 'kiro') return true; + } + return false; + } + + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const includeSystem = options?.verbose ?? false; + return this.entriesToMessages(this.readJsonl(sessionFilePath), includeSystem); + } + + private entriesToMessages(entries: KiroLine[], includeSystem: boolean): ConversationMessage[] { + return entries + .map((entry) => this.entryToMessage(entry, includeSystem)) + .filter((msg): msg is ConversationMessage => msg !== null); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + if (!isDirectory(this.kiroSessionsDir)) return []; + + const summaries: SessionSummary[] = []; + for (const filePath of this.collectJsonlFiles(this.kiroSessionsDir)) { + const summary = this.fileToSessionSummary(filePath); + if (!summary) continue; + if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue; + summaries.push(summary); + } + return summaries; + } + + private fileToSessionSummary(filePath: string): SessionSummary | null { + const entries = this.readJsonl(filePath); + if (entries.length === 0) return null; + + const session = this.sessionFromEntries(entries, filePath); + const firstUserMessage = this.entriesToMessages(entries, false) + .find((msg) => msg.role === 'user')?.content ?? ''; + return { + type: this.type, + sessionId: session.sessionId, + cwd: session.projectPath, + firstUserMessage, + lastActive: session.lastActive, + startedAt: session.sessionStart, + sessionFilePath: filePath, + }; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 384f6111..3230eba5 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -4,5 +4,6 @@ export { CopilotAdapter } from './CopilotAdapter.js'; export { GeminiCliAdapter } from './GeminiCliAdapter.js'; export { OpenCodeAdapter } from './OpenCodeAdapter.js'; export { PiAdapter } from './PiAdapter.js'; +export { KiroAdapter } from './KiroAdapter.js'; export { AgentStatus } from './AgentAdapter.js'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter.js'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 6379144f..1f589ccd 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -6,6 +6,7 @@ export { CopilotAdapter } from './adapters/CopilotAdapter.js'; export { GeminiCliAdapter } from './adapters/GeminiCliAdapter.js'; export { OpenCodeAdapter } from './adapters/OpenCodeAdapter.js'; export { PiAdapter } from './adapters/PiAdapter.js'; +export { KiroAdapter } from './adapters/KiroAdapter.js'; export { AgentStatus } from './adapters/AgentAdapter.js'; export type { AgentAdapter, diff --git a/packages/agent-manager/src/utils/agents.ts b/packages/agent-manager/src/utils/agents.ts index f4ac7825..ff40fb81 100644 --- a/packages/agent-manager/src/utils/agents.ts +++ b/packages/agent-manager/src/utils/agents.ts @@ -1,7 +1,7 @@ import path from 'path'; import type { AgentType } from '../adapters/AgentAdapter.js'; -export type StartableAgentType = Extract; +export type StartableAgentType = Extract; export interface AgentConfig { /** Shell command to launch the agent (sent to tmux via `send-keys`). */ @@ -22,6 +22,7 @@ export const AGENTS: Record = { gemini_cli: { command: 'gemini', matches: matchAnyToken('gemini') }, opencode: { command: 'opencode', matches: matchArgv0('opencode') }, pi: { command: 'pi', matches: matchAnyBasename(['pi']) }, + kiro: { command: 'kiro-cli', matches: matchAnyBasename(['kiro-cli', 'kiro']) }, }; function matchArgv0(name: string): (psCommand: string) => boolean { diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 3837cb0d..ae1b9dd9 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -81,6 +81,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ GeminiCliAdapter: vi.fn(), OpenCodeAdapter: vi.fn(), PiAdapter: vi.fn(), + KiroAdapter: vi.fn(), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -107,6 +108,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ gemini_cli: { command: 'gemini', matches: () => true }, opencode: { command: 'opencode', matches: () => true }, pi: { command: 'pi', matches: () => true }, + kiro: { command: 'kiro-cli', matches: () => true }, }, RenameNotFoundError: RenameNotFoundError, RenameConflictError: RenameConflictError, @@ -259,7 +261,7 @@ describe('agent command', () => { await program.parseAsync(['node', 'test', 'agent', 'list', '--json']); expect(AgentManager).toHaveBeenCalled(); - expect(mockManager.registerAdapter).toHaveBeenCalledTimes(6); + expect(mockManager.registerAdapter).toHaveBeenCalledTimes(7); expect(logSpy).toHaveBeenCalledWith(JSON.stringify(agents, null, 2)); }); diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index 0cdba724..e4dc55d1 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -71,6 +71,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ CopilotAdapter: vi.fn(), GeminiCliAdapter: vi.fn(), PiAdapter: vi.fn(), + KiroAdapter: vi.fn(), TerminalFocusManager: vi.fn(function () { return mockTerminalFocusManager; }), TtyWriter: { send: vi.fn(), @@ -595,7 +596,7 @@ describe('channel command', () => { agentPid: 4321, bridgePid: process.pid, })); - expect(mockAgentManager.registerAdapter).toHaveBeenCalledTimes(5); + expect(mockAgentManager.registerAdapter).toHaveBeenCalledTimes(6); expect(mockChannelService.registerBridge.mock.invocationCallOrder[0]) .toBeLessThan(mockChannelManager.startAll.mock.invocationCallOrder[0]); diff --git a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts index 8eb4f1de..dad7bd5f 100644 --- a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts @@ -9,7 +9,7 @@ import { describe('StartAgentPane helpers', () => { it('lists supported agent start types in pane order', () => { - expect(STARTABLE_AGENT_TYPES).toEqual(['claude', 'codex', 'copilot', 'gemini_cli', 'opencode', 'pi']); + expect(STARTABLE_AGENT_TYPES).toEqual(['claude', 'codex', 'copilot', 'gemini_cli', 'opencode', 'pi', 'kiro']); }); it('cycles to the next agent type', () => { @@ -17,14 +17,16 @@ describe('StartAgentPane helpers', () => { expect(nextStartAgentType('codex')).toBe('copilot'); expect(nextStartAgentType('copilot')).toBe('gemini_cli'); expect(nextStartAgentType('opencode')).toBe('pi'); - expect(nextStartAgentType('pi')).toBe('claude'); + expect(nextStartAgentType('pi')).toBe('kiro'); + expect(nextStartAgentType('kiro')).toBe('claude'); }); it('cycles to the previous agent type', () => { expect(previousStartAgentType('copilot')).toBe('codex'); expect(previousStartAgentType('gemini_cli')).toBe('copilot'); expect(previousStartAgentType('pi')).toBe('opencode'); - expect(previousStartAgentType('claude')).toBe('pi'); + expect(previousStartAgentType('kiro')).toBe('pi'); + expect(previousStartAgentType('claude')).toBe('kiro'); }); it('normalizes submitted name and cwd without changing the selected type', () => { diff --git a/packages/cli/src/__tests__/util/sessions.test.ts b/packages/cli/src/__tests__/util/sessions.test.ts index b548f12c..959c938d 100644 --- a/packages/cli/src/__tests__/util/sessions.test.ts +++ b/packages/cli/src/__tests__/util/sessions.test.ts @@ -45,7 +45,7 @@ describe('sessions util', () => { }); it('forwards a valid --type', () => { - for (const type of ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot', 'pi'] as const) { + for (const type of ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot', 'pi', 'kiro'] as const) { const result = resolveListSessionsOptions({ all: true, type }); expect(result.adapterOptions.type).toBe(type); } @@ -53,7 +53,7 @@ describe('sessions util', () => { it('throws on an invalid --type', () => { expect(() => resolveListSessionsOptions({ all: true, type: 'wrong' })).toThrow( - 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, opencode, copilot, pi.', + 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, opencode, copilot, pi, kiro.', ); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index c02bf8cc..7312c531 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -13,6 +13,7 @@ import { GeminiCliAdapter, OpenCodeAdapter, PiAdapter, + KiroAdapter, AgentStatus, TerminalFocusManager, AgentRegistry, @@ -90,6 +91,7 @@ const TYPE_LABELS: Record = { gemini_cli: 'Gemini CLI', opencode: 'OpenCode', pi: 'Pi', + kiro: 'Kiro', other: 'Other', }; @@ -172,6 +174,7 @@ function createAgentManager(): AgentManager { manager.registerAdapter(new GeminiCliAdapter()); manager.registerAdapter(new OpenCodeAdapter()); manager.registerAdapter(new PiAdapter()); + manager.registerAdapter(new KiroAdapter()); return manager; } @@ -354,7 +357,7 @@ export function registerAgentCommand(program: Command): void { .description('List historical Claude/Codex/Gemini/OpenCode sessions for resume') .option('--all', 'Include sessions from every cwd (default: only current cwd)') .option('--cwd ', 'Override the cwd filter (implies non-default scope)') - .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode, copilot, pi') + .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode, copilot, pi, kiro') .option('--limit ', 'Max rows to print (default: 50; 0 = no limit)', '50') .option('-j, --json', 'Output as JSON') .action(withErrorHandler('list sessions', async (options) => { @@ -410,7 +413,7 @@ export function registerAgentCommand(program: Command): void { .description('Show detailed information about a historical session') .requiredOption('--id ', 'Session ID (as shown in agent sessions)') .option('-j, --json', 'Output as JSON') - .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode, copilot, pi') + .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode, copilot, pi, kiro') .option('--full', 'Show entire conversation history') .option('--tail ', 'Show last N messages (default: 20)', '20') .option('--verbose', 'Include tool call/result details') diff --git a/packages/cli/src/services/channel/channel-runner.ts b/packages/cli/src/services/channel/channel-runner.ts index 4bd288b9..0ab47246 100644 --- a/packages/cli/src/services/channel/channel-runner.ts +++ b/packages/cli/src/services/channel/channel-runner.ts @@ -5,6 +5,7 @@ import { CopilotAdapter, GeminiCliAdapter, PiAdapter, + KiroAdapter, TerminalFocusManager, TtyWriter, type AgentAdapter, @@ -41,6 +42,7 @@ function createAgentManager(): AgentManager { manager.registerAdapter(new CopilotAdapter()); manager.registerAdapter(new GeminiCliAdapter()); manager.registerAdapter(new PiAdapter()); + manager.registerAdapter(new KiroAdapter()); return manager; } diff --git a/packages/cli/src/util/sessions.ts b/packages/cli/src/util/sessions.ts index 2f0fb00a..c8daa452 100644 --- a/packages/cli/src/util/sessions.ts +++ b/packages/cli/src/util/sessions.ts @@ -7,7 +7,7 @@ import { truncate } from './text.js'; const FIRST_MESSAGE_MAX_WIDTH = 80; const FIRST_MESSAGE_PLACEHOLDER = '(no message yet)'; -const VALID_AGENT_TYPES: AgentType[] = ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot', 'pi']; +const VALID_AGENT_TYPES: AgentType[] = ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot', 'pi', 'kiro']; export interface ResolvedListSessionsOptions { adapterOptions: ListSessionsOptions;