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..2277767d --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts @@ -0,0 +1,298 @@ +/** + * 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 { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { generateAgentName } from '../../utils/matching.js'; + +vi.mock('../../utils/process.js', () => ({ + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), +})); + +vi.mock('../../utils/matching.js', () => ({ + generateAgentName: vi.fn(), +})); + +const mockedListAgentProcesses = listAgentProcesses as MockedFunction; +const mockedEnrichProcesses = enrichProcesses 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', 'sessions', 'cli'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + adapter = new KiroAdapter(); + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedGenerateAgentName.mockReset(); + + mockedEnrichProcesses.mockImplementation((procs) => procs); + 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 through its session lock and metadata', async () => { + const cwd = '/repo/project-a'; + const proc = makeProcess({ pid: 101, cwd: '/process/cwd' }); + const updatedAt = new Date().toISOString(); + const sessionFile = writeKiroSession('sess-101', cwd, [ + prompt('implement Kiro adapter', 1781098057), + assistantText('working on it'), + ], 101, updatedAt); + 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, + lastActive: new Date(updatedAt), + }); + }); + + it('uses only a lock whose PID belongs to a running Kiro process', async () => { + writeKiroSession('ended-session', '/repo/ended', [ + prompt('old conversation', 1781098057), + ]); + writeKiroSession('other-process', '/repo/other', [ + prompt('other conversation', 1781098057), + ], 999); + const proc = makeProcess({ pid: 202, cwd: '/repo/current' }); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toEqual([ + expect.objectContaining({ + pid: 202, + projectPath: '/repo/current', + sessionId: 'pid-202', + summary: 'Kiro process running', + }), + ]); + }); + + it('ignores malformed lock files', async () => { + writeKiroSession('bad-lock', '/repo/project', [prompt('hello', 1781098057)]); + fs.writeFileSync(path.join(sessionsDir, 'bad-lock.lock'), '{bad json'); + const proc = makeProcess({ pid: 303, cwd: '/repo/project' }); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0]).toMatchObject({ sessionId: 'pid-303' }); + }); + + it('reports running while the latest assistant event invokes a tool', async () => { + writeKiroSession('tool-session', '/repo/project', [ + prompt('inspect the file', Math.floor(Date.now() / 1000)), + assistantTool('fs_read', { path: '/repo/project/file.ts' }), + ], 404, new Date().toISOString()); + const proc = makeProcess({ pid: 404, cwd: '/repo/project' }); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0].status).toBe(AgentStatus.RUNNING); + }); + + it('returns a process-only agent when the locked transcript is missing', async () => { + fs.writeFileSync(path.join(sessionsDir, 'missing.lock'), JSON.stringify({ pid: 505 })); + const proc = makeProcess({ pid: 505, cwd: '/repo/project-e' }); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toEqual([ + expect.objectContaining({ + type: 'kiro', + status: AgentStatus.RUNNING, + pid: 505, + projectPath: '/repo/project-e', + sessionId: 'pid-505', + summary: 'Kiro process running', + }), + ]); + }); + + it('reads real Kiro prompt and assistant message envelopes', () => { + const sessionFile = writeKiroSession('conversation', '/repo/project-f', [ + prompt('hello kiro', 1781098057), + assistantText('Hello! How can I help?'), + '{not json', + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'hello kiro', timestamp: '2026-06-10T13:27:37.000Z' }, + { role: 'assistant', content: 'Hello! How can I help?', timestamp: undefined }, + ]); + }); + + it('includes Kiro tool use and results only in verbose conversation mode', () => { + const sessionFile = writeKiroSession('tools', '/repo/project-tools', [ + prompt('read package.json', 1781098057), + assistantTool('fs_read', { path: 'package.json' }), + toolResult('contents', 'success'), + assistantText('Done.'), + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'read package.json', timestamp: '2026-06-10T13:27:37.000Z' }, + { role: 'assistant', content: 'Done.', timestamp: undefined }, + ]); + expect(adapter.getConversation(sessionFile, { verbose: true })).toEqual([ + { role: 'user', content: 'read package.json', timestamp: '2026-06-10T13:27:37.000Z' }, + { role: 'assistant', content: '[Tool: fs_read] {"path":"package.json"}', timestamp: undefined }, + { role: 'system', content: '[Tool Result] contents', timestamp: undefined }, + { role: 'assistant', content: 'Done.', timestamp: undefined }, + ]); + }); + + it('lists historical sessions using metadata and applies cwd filtering', async () => { + const matchingCwd = '/repo/project-g'; + const matchingSession = writeKiroSession('sess-g', matchingCwd, [ + prompt('first matching message', 1781098057), + assistantText('response'), + ]); + writeKiroSession('sess-h', '/repo/project-h', [ + prompt('other message', 1781098057), + ]); + + const sessions = await adapter.listSessions({ cwd: matchingCwd }); + + expect(sessions).toEqual([ + expect.objectContaining({ + type: 'kiro', + sessionId: 'sess-g', + cwd: matchingCwd, + firstUserMessage: 'first matching message', + startedAt: new Date('2026-06-10T13:27:17.000Z'), + lastActive: new Date('2026-06-10T13:27:40.000Z'), + sessionFilePath: matchingSession, + }), + ]); + }); + + function makeProcess(overrides: Partial): ProcessInfo { + return { + pid: 1, + command: 'kiro-cli chat', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date('2026-06-10T13:27:17.000Z'), + ...overrides, + }; + } + + function writeKiroSession( + sessionId: string, + cwd: string, + entries: Array | string>, + pid?: number, + updatedAt = '2026-06-10T13:27:40.000Z', + ): string { + fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd, + created_at: '2026-06-10T13:27:17.000Z', + updated_at: updatedAt, + title: 'Session title', + })); + const filePath = path.join(sessionsDir, `${sessionId}.jsonl`); + fs.writeFileSync( + filePath, + entries.map((entry) => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n'), + ); + if (pid !== undefined) { + fs.writeFileSync(path.join(sessionsDir, `${sessionId}.lock`), JSON.stringify({ + pid, + started_at: '2026-06-10T13:27:17.000Z', + })); + } + return filePath; + } + + function prompt(text: string, timestamp: number): Record { + return { + version: 'v1', + kind: 'Prompt', + data: { + content: [{ kind: 'text', data: text }], + meta: { timestamp }, + }, + }; + } + + function assistantText(text: string): Record { + return { + version: 'v1', + kind: 'AssistantMessage', + data: { content: [{ kind: 'text', data: text }] }, + }; + } + + function assistantTool(name: string, input: Record): Record { + return { + version: 'v1', + kind: 'AssistantMessage', + data: { + content: [{ + kind: 'toolUse', + data: { toolUseId: 'tool-1', name, input }, + }], + }, + }; + } + + function toolResult(result: string, status: string): Record { + return { + version: 'v1', + kind: 'ToolResults', + data: { + content: [{ + kind: 'toolResult', + data: { toolUseId: 'tool-1', status, result }, + }], + }, + }; + } +}); 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..05d806a3 --- /dev/null +++ b/packages/agent-manager/src/adapters/KiroAdapter.ts @@ -0,0 +1,421 @@ +/** + * Kiro Adapter + * + * Detects running Kiro agents by matching process IDs from + * ~/.kiro/sessions/cli/.lock to the sibling metadata and transcript files. + */ + +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 { generateAgentName } from '../utils/matching.js'; + +type KiroRecord = Record; + +interface KiroMetadata { + sessionId: string; + cwd: string; + title: string; + createdAt: Date | null; + updatedAt: Date | null; +} + +interface KiroLine { + kind?: string; + timestamp?: string; + data?: KiroRecord; +} + +interface KiroSession { + sessionId: string; + projectPath: string; + summary: string; + firstUserMessage: string; + sessionStart: Date; + lastActive: Date; + lastEventKind?: string; + lastAssistantHasToolUse: boolean; + filePath: string; +} + +interface KiroLock { + sessionId: string; + pid: number; +} + +export class KiroAdapter implements AgentAdapter { + readonly type = 'kiro' as const; + + private static readonly IDLE_THRESHOLD_MINUTES = 5; + + private kiroSessionsDir: string; + + constructor() { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.kiroSessionsDir = path.join(homeDir, '.kiro', 'sessions', 'cli'); + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isKiroExecutable(processInfo.command); + } + + async detectAgents(): Promise { + const processes = enrichProcesses(this.listKiroProcesses()); + if (processes.length === 0) return []; + + const processByPid = new Map(processes.map((proc) => [proc.pid, proc])); + const matchedPids = new Set(); + const agents: AgentInfo[] = []; + + for (const lock of this.discoverActiveLocks()) { + const proc = processByPid.get(lock.pid); + if (!proc) continue; + + const session = this.readSession(lock.sessionId, proc.cwd); + if (!session) continue; + + agents.push(this.mapSessionToAgent(session, proc)); + matchedPids.add(proc.pid); + } + + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); + } + } + + return agents; + } + + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + return this.entriesToMessages( + this.readJsonl(sessionFilePath), + options?.verbose ?? false, + ); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + if (!isDirectory(this.kiroSessionsDir)) return []; + + const summaries: SessionSummary[] = []; + for (const entry of safeReaddir(this.kiroSessionsDir)) { + if (!entry.endsWith('.jsonl')) continue; + + const sessionId = entry.slice(0, -'.jsonl'.length); + const session = this.readSession(sessionId); + if (!session) continue; + if (opts?.cwd !== undefined && session.projectPath !== opts.cwd) continue; + + summaries.push({ + type: this.type, + sessionId: session.sessionId, + cwd: session.projectPath, + firstUserMessage: session.firstUserMessage, + lastActive: session.lastActive, + startedAt: session.sessionStart, + sessionFilePath: session.filePath, + }); + } + return summaries; + } + + 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 discoverActiveLocks(): KiroLock[] { + if (!isDirectory(this.kiroSessionsDir)) return []; + + const locks: KiroLock[] = []; + for (const entry of safeReaddir(this.kiroSessionsDir)) { + if (!entry.endsWith('.lock')) continue; + + const content = safeReadFile(path.join(this.kiroSessionsDir, entry)); + if (content === undefined) continue; + + try { + const parsed = JSON.parse(content) as unknown; + const record = this.asRecord(parsed); + const pid = this.toPid(record?.pid); + if (pid === null) continue; + + locks.push({ + sessionId: entry.slice(0, -'.lock'.length), + pid, + }); + } catch { + continue; + } + } + return locks; + } + + private readSession(sessionId: string, fallbackCwd = ''): KiroSession | null { + const filePath = path.join(this.kiroSessionsDir, `${sessionId}.jsonl`); + const stat = safeStat(filePath); + if (!stat?.isFile()) return null; + + const entries = this.readJsonl(filePath); + const metadata = this.readMetadata(sessionId); + const messages = this.entriesToMessages(entries, false); + const userMessages = messages.filter((message) => message.role === 'user'); + const timestamps = entries + .map((entry) => this.entryDate(entry)) + .filter((value): value is Date => value !== null); + const sessionStart = metadata.createdAt ?? timestamps[0] ?? stat.birthtime ?? stat.mtime; + const lastActive = metadata.updatedAt ?? timestamps[timestamps.length - 1] ?? stat.mtime; + const lastEntry = entries[entries.length - 1]; + + return { + sessionId: metadata.sessionId || sessionId, + projectPath: metadata.cwd || fallbackCwd, + summary: this.truncate(userMessages.at(-1)?.content || metadata.title || 'Kiro session active', 120), + firstUserMessage: userMessages[0]?.content ?? '', + sessionStart, + lastActive, + lastEventKind: lastEntry?.kind, + lastAssistantHasToolUse: lastEntry?.kind === 'AssistantMessage' && this.hasContentKind(lastEntry, 'toolUse'), + filePath, + }; + } + + private readMetadata(sessionId: string): KiroMetadata { + const empty: KiroMetadata = { + sessionId, + cwd: '', + title: '', + createdAt: null, + updatedAt: null, + }; + const content = safeReadFile(path.join(this.kiroSessionsDir, `${sessionId}.json`)); + if (content === undefined) return empty; + + try { + const parsed = this.asRecord(JSON.parse(content)); + if (!parsed) return empty; + return { + sessionId: this.firstString(parsed.session_id, parsed.sessionId) ?? sessionId, + cwd: this.firstString(parsed.cwd) ?? '', + title: this.firstString(parsed.title) ?? '', + createdAt: this.parseDate(parsed.created_at ?? parsed.createdAt), + updatedAt: this.parseDate(parsed.updated_at ?? parsed.updatedAt), + }; + } catch { + return empty; + } + } + + 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 = this.asRecord(JSON.parse(trimmed)); + if (parsed) entries.push(parsed as KiroLine); + } catch { + continue; + } + } + return entries; + } + + private entriesToMessages(entries: KiroLine[], verbose: boolean): ConversationMessage[] { + const messages: ConversationMessage[] = []; + for (const entry of entries) { + const message = this.entryToMessage(entry, verbose); + if (message) messages.push(message); + } + return messages; + } + + private entryToMessage(entry: KiroLine, verbose: boolean): ConversationMessage | null { + let role: ConversationMessage['role']; + let content: string; + + if (entry.kind === 'Prompt') { + role = 'user'; + content = this.textContent(entry); + } else if (entry.kind === 'AssistantMessage') { + role = 'assistant'; + const parts = [this.textContent(entry)]; + if (verbose) parts.push(...this.toolUseContent(entry)); + content = parts.filter(Boolean).join('\n'); + } else if (entry.kind === 'ToolResults' && verbose) { + role = 'system'; + content = this.toolResultContent(entry).join('\n'); + } else { + return null; + } + + if (!content) return null; + return { + role, + content, + timestamp: this.entryTimestamp(entry), + }; + } + + private textContent(entry: KiroLine): string { + return this.contentBlocks(entry) + .filter((block) => block.kind === 'text') + .map((block) => typeof block.data === 'string' ? block.data : '') + .filter(Boolean) + .join(''); + } + + private toolUseContent(entry: KiroLine): string[] { + return this.contentBlocks(entry) + .filter((block) => block.kind === 'toolUse') + .map((block) => { + const data = this.asRecord(block.data); + const name = this.firstString(data?.name) ?? 'unknown'; + const input = this.formatValue(data?.input); + return `[Tool: ${name}]${input ? ` ${input}` : ''}`; + }); + } + + private toolResultContent(entry: KiroLine): string[] { + return this.contentBlocks(entry) + .filter((block) => block.kind === 'toolResult') + .map((block) => { + const data = this.asRecord(block.data); + const prefix = data?.status === 'error' ? '[Tool Error]' : '[Tool Result]'; + const result = this.formatValue(data?.result ?? data?.results ?? data?.content); + return `${prefix}${result ? ` ${result}` : ''}`; + }); + } + + private contentBlocks(entry: KiroLine): Array<{ kind?: string; data?: unknown }> { + const content = entry.data?.content; + if (!Array.isArray(content)) return []; + return content + .map((block) => this.asRecord(block)) + .filter((block): block is KiroRecord => block !== null); + } + + private hasContentKind(entry: KiroLine, kind: string): boolean { + return this.contentBlocks(entry).some((block) => block.kind === kind); + } + + private entryTimestamp(entry: KiroLine): string | undefined { + const direct = this.firstString(entry.timestamp); + if (direct) return direct; + + const meta = this.asRecord(entry.data?.meta); + const parsed = this.parseDate(meta?.timestamp); + return parsed?.toISOString(); + } + + private entryDate(entry: KiroLine): Date | null { + return this.parseDate(this.entryTimestamp(entry)); + } + + private mapSessionToAgent(session: KiroSession, processInfo: ProcessInfo): AgentInfo { + const projectPath = session.projectPath || processInfo.cwd || ''; + return { + name: generateAgentName(projectPath, processInfo.pid), + type: this.type, + status: this.determineStatus(session), + summary: session.summary, + pid: processInfo.pid, + projectPath, + sessionId: session.sessionId, + lastActive: session.lastActive, + sessionFilePath: session.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 diffMinutes = (Date.now() - session.lastActive.getTime()) / 60000; + if (diffMinutes > KiroAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE; + if (session.lastEventKind === 'AssistantMessage' && !session.lastAssistantHasToolUse) { + return AgentStatus.WAITING; + } + return AgentStatus.RUNNING; + } + + 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; + } + + private toPid(value: unknown): number | null { + if (typeof value === 'number' && Number.isSafeInteger(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 parseDate(value: unknown): Date | null { + if (typeof value === 'number') { + const date = new Date(value < 1_000_000_000_000 ? value * 1000 : value); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof value !== 'string' || !value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + private firstString(...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === 'string' && value.length > 0); + } + + private asRecord(value: unknown): KiroRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as KiroRecord; + } + + private formatValue(value: unknown): string { + if (typeof value === 'string') return value; + if (value === undefined || value === null) return ''; + try { + return JSON.stringify(value); + } catch { + return ''; + } + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; + } +} 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;