From 5f80cfd492b354930a5ee8ba3f7d4264568d0e70 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:21:43 -0700 Subject: [PATCH 1/2] feat: add Kiro CLI adapter --- README.md | 1 + .../__tests__/adapters/KiroAdapter.test.ts | 435 +++++++++++++ .../src/adapters/AgentAdapter.ts | 2 +- .../agent-manager/src/adapters/KiroAdapter.ts | 600 ++++++++++++++++++ packages/agent-manager/src/adapters/index.ts | 1 + packages/agent-manager/src/index.ts | 1 + packages/agent-manager/src/utils/agents.ts | 3 +- .../cli/src/__tests__/commands/agent.test.ts | 4 +- .../src/__tests__/commands/channel.test.ts | 3 +- .../tui/console/StartAgentPane.test.ts | 8 +- .../cli/src/__tests__/util/sessions.test.ts | 4 +- packages/cli/src/commands/agent.ts | 7 +- .../src/services/channel/channel-runner.ts | 2 + packages/cli/src/util/sessions.ts | 2 +- 14 files changed, 1061 insertions(+), 12 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts create mode 100644 packages/agent-manager/src/adapters/KiroAdapter.ts 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; From 27ec937a24116aa76a7aa333de24c3c36ef26483 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:28:35 -0700 Subject: [PATCH 2/2] fix: read Kiro CLI sessions from their real location The adapter looked under ~/.kiro/cli/sessions, which does not exist. Per review, Kiro stores CLI sessions under ~/.kiro/sessions/cli/ and marks a running session with a lock file in that directory. Discover active sessions from those locks and resolve each one to its sibling metadata and transcript, instead of scanning a path that is never populated. --- .../__tests__/adapters/KiroAdapter.test.ts | 411 ++++------- .../agent-manager/src/adapters/KiroAdapter.ts | 651 +++++++----------- 2 files changed, 373 insertions(+), 689 deletions(-) diff --git a/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts index 042ae8a0..2277767d 100644 --- a/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts @@ -10,9 +10,8 @@ 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'; +import { generateAgentName } from '../../utils/matching.js'; vi.mock('../../utils/process.js', () => ({ listAgentProcesses: vi.fn(), @@ -20,13 +19,11 @@ vi.mock('../../utils/process.js', () => ({ })); 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', () => { @@ -37,17 +34,15 @@ describe('KiroAdapter', () => { beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'kiro-adapter-test-')); process.env.HOME = tmpHome; - sessionsDir = path.join(tmpHome, '.kiro', 'cli', 'sessions'); + sessionsDir = path.join(tmpHome, '.kiro', 'sessions', 'cli'); fs.mkdirSync(sessionsDir, { recursive: true }); - adapter = new KiroAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json'))); + adapter = new KiroAdapter(); 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})`; @@ -69,18 +64,14 @@ describe('KiroAdapter', () => { 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 () => { + it('maps a running Kiro process through its session lock and metadata', 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 }), - ); + 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(); @@ -94,286 +85,116 @@ describe('KiroAdapter', () => { summary: 'implement Kiro adapter', status: AgentStatus.WAITING, sessionFilePath: sessionFile, + lastActive: new Date(updatedAt), }); - 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 }, + it('uses only a lock whose PID belongs to a running Kiro process', async () => { + writeKiroSession('ended-session', '/repo/ended', [ + prompt('old conversation', 1781098057), ]); - fs.writeFileSync( - path.join(tmpHome, '.kiro', 'cli', 'sessions.json'), - JSON.stringify({ 112: sessionFile }), - ); + 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[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' }, + expect(agents).toEqual([ + expect.objectContaining({ + pid: 202, + projectPath: '/repo/current', + sessionId: 'pid-202', + summary: 'Kiro process running', + }), ]); - 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' }, - ]); + 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]); - 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'); + expect(agents[0]).toMatchObject({ sessionId: 'pid-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 }), - ); + 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]); - 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(); + expect(agents[0].status).toBe(AgentStatus.RUNNING); }); - 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 () => { + 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).toHaveLength(1); - expect(agents[0]).toMatchObject({ - type: 'kiro', - status: AgentStatus.RUNNING, - pid: 505, - projectPath: '/repo/project-e', - sessionId: 'pid-505', - summary: 'Kiro process running', - }); + 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 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' } }, + 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-10T08:58:21.000Z' }, - { role: 'assistant', content: 'hello human', timestamp: '2026-06-10T08:58:22.000Z' }, + { role: 'user', content: 'hello kiro', timestamp: '2026-06-10T13:27:37.000Z' }, + { role: 'assistant', content: 'Hello! How can I help?', timestamp: undefined }, ]); }); - 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' }, + 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: 'visible', timestamp: '2026-06-10T08:58:21.000Z' }, + { 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: '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' }, + { 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 }, ]); - - 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 () => { + it('lists historical sessions using metadata 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' }, + const matchingSession = writeKiroSession('sess-g', matchingCwd, [ + prompt('first matching message', 1781098057), + assistantText('response'), ]); - 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' }, + writeKiroSession('sess-h', '/repo/project-h', [ + prompt('other message', 1781098057), ]); const sessions = await adapter.listSessions({ cwd: matchingCwd }); @@ -384,6 +205,8 @@ describe('KiroAdapter', () => { 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, }), ]); @@ -392,44 +215,84 @@ describe('KiroAdapter', () => { function makeProcess(overrides: Partial): ProcessInfo { return { pid: 1, - command: 'kiro', + command: 'kiro-cli chat', cwd: '/repo', tty: 'ttys001', - startTime: new Date('2026-06-10T08:58:20.000Z'), + startTime: new Date('2026-06-10T13:27:17.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`); + 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 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 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 encodeProjectDir(cwd: string): string { - return cwd.replace(/\//g, '-').replace(/^-?/, '--') + '--'; + function assistantTool(name: string, input: Record): Record { + return { + version: 'v1', + kind: 'AssistantMessage', + data: { + content: [{ + kind: 'toolUse', + data: { toolUseId: 'tool-1', name, input }, + }], + }, + }; } - function cryptoRandomSessionId(): string { - return `019eb0c1-06d2-71ed-90ee-${Math.random().toString(16).slice(2, 14).padEnd(12, '0')}`; + 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/KiroAdapter.ts b/packages/agent-manager/src/adapters/KiroAdapter.ts index 18d590c3..05d806a3 100644 --- a/packages/agent-manager/src/adapters/KiroAdapter.ts +++ b/packages/agent-manager/src/adapters/KiroAdapter.ts @@ -1,14 +1,10 @@ /** * 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 + * Detects running Kiro agents by matching process IDs from + * ~/.kiro/sessions/cli/.lock to the sibling metadata and transcript files. */ -import * as fs from 'fs'; import * as path from 'path'; import type { AgentAdapter, @@ -21,47 +17,39 @@ import type { 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'; +import { generateAgentName } from '../utils/matching.js'; -interface KiroSession { +type KiroRecord = Record; + +interface KiroMetadata { sessionId: string; - projectPath: string; - summary: string; - sessionStart: Date; - lastActive: Date; - lastRole?: ConversationMessage['role']; + cwd: string; + title: string; + createdAt: Date | null; + updatedAt: Date | null; } interface KiroLine { + kind?: string; 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; + data?: KiroRecord; } -type KiroRecord = Record; - -interface TrackerMatch { - process: ProcessInfo; +interface KiroSession { + sessionId: string; + projectPath: string; + summary: string; + firstUserMessage: string; + sessionStart: Date; + lastActive: Date; + lastEventKind?: string; + lastAssistantHasToolUse: boolean; filePath: string; } -interface TrackerAgentResult { - agents: AgentInfo[]; - fallback: ProcessInfo[]; +interface KiroLock { + sessionId: string; + pid: number; } export class KiroAdapter implements AgentAdapter { @@ -69,17 +57,11 @@ export class KiroAdapter implements AgentAdapter { private static readonly IDLE_THRESHOLD_MINUTES = 5; - private kiroCliDir: string; private kiroSessionsDir: string; - private trackerPath: string; - private registry: AgentRegistry; - constructor(registry: AgentRegistry = AgentRegistry.default()) { + constructor() { 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; + this.kiroSessionsDir = path.join(homeDir, '.kiro', 'sessions', 'cli'); } canHandle(processInfo: ProcessInfo): boolean { @@ -90,54 +72,19 @@ export class KiroAdapter implements AgentAdapter { 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 processByPid = new Map(processes.map((proc) => [proc.pid, proc])); + const matchedPids = new Set(); 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 }; - } + for (const lock of this.discoverActiveLocks()) { + const proc = processByPid.get(lock.pid); + if (!proc) continue; - private mapFallbackMatches(processes: ProcessInfo[]): AgentInfo[] { - if (processes.length === 0) return []; + const session = this.readSession(lock.sessionId, proc.cwd); + if (!session) continue; - 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); - } + agents.push(this.mapSessionToAgent(session, proc)); + matchedPids.add(proc.pid); } for (const proc of processes) { @@ -149,6 +96,38 @@ export class KiroAdapter implements AgentAdapter { 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')) { @@ -163,170 +142,86 @@ export class KiroAdapter implements AgentAdapter { 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 discoverActiveLocks(): KiroLock[] { + if (!isDirectory(this.kiroSessionsDir)) return []; - private matchFromTracker(processes: ProcessInfo[]): { - matches: TrackerMatch[]; - fallback: ProcessInfo[]; - } { - const tracker = this.readTracker(); - if (tracker.size === 0) return { matches: [], fallback: processes }; + const locks: KiroLock[] = []; + for (const entry of safeReaddir(this.kiroSessionsDir)) { + if (!entry.endsWith('.lock')) continue; - const matches: TrackerMatch[] = []; - const fallback: ProcessInfo[] = []; + const content = safeReadFile(path.join(this.kiroSessionsDir, entry)); + if (content === undefined) continue; - for (const proc of processes) { - const filePath = tracker.get(proc.pid); - if (!filePath || !this.isTrustedSessionPath(filePath) || !fs.existsSync(filePath)) { - fallback.push(proc); + 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; } - matches.push({ process: proc, filePath }); } - - return { matches, fallback }; + return locks; } - 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 readSession(sessionId: string, fallbackCwd = ''): KiroSession | null { + const filePath = path.join(this.kiroSessionsDir, `${sessionId}.jsonl`); + const stat = safeStat(filePath); + if (!stat?.isFile()) return null; - 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 metadata = this.readMetadata(sessionId); + const messages = this.entriesToMessages(entries, false); + const userMessages = messages.filter((message) => message.role === 'user'); const timestamps = entries - .map((entry) => this.parseTimestamp(this.entryTimestamp(entry))) + .map((entry) => this.entryDate(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]; + 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: this.sessionIdFromEntries(entries) || this.sessionIdFromFile(filePath), - projectPath: this.cwdFromEntries(entries) || fallbackCwd, - summary: lastUser?.content ? this.truncate(lastUser.content, 120) : 'Kiro session active', + 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, - lastRole: lastMessage?.role, + 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[] { @@ -338,10 +233,8 @@ export class KiroAdapter implements AgentAdapter { 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); - } + const parsed = this.asRecord(JSON.parse(trimmed)); + if (parsed) entries.push(parsed as KiroLine); } catch { continue; } @@ -349,14 +242,35 @@ export class KiroAdapter implements AgentAdapter { 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; + 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; + } - const content = this.entryContent(entry).trim(); if (!content) return null; - return { role, content, @@ -364,160 +278,73 @@ export class KiroAdapter implements AgentAdapter { }; } - 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 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 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 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 asRecord(value: unknown): KiroRecord | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - return value as KiroRecord; + 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 messageRecord(entry: KiroLine): KiroRecord | null { - return this.asRecord(entry.message); + 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 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 hasContentKind(entry: KiroLine, kind: string): boolean { + return this.contentBlocks(entry).some((block) => block.kind === kind); } 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 ''; - } + const direct = this.firstString(entry.timestamp); + if (direct) return direct; - private sessionIdFromFile(filePath: string): string { - const base = path.basename(filePath, '.jsonl'); - const underscore = base.lastIndexOf('_'); - return underscore >= 0 ? base.slice(underscore + 1) : base; + const meta = this.asRecord(entry.data?.meta); + const parsed = this.parseDate(meta?.timestamp); + return parsed?.toISOString(); } - private encodeProjectDir(cwd: string): string { - const normalized = path.resolve(cwd); - return `--${normalized.replace(/^\//, '').replace(/\//g, '-')}--`; + private entryDate(entry: KiroLine): Date | null { + return this.parseDate(this.entryTimestamp(entry)); } - 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 { + 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 || 'Kiro session active', + summary: session.summary, pid: processInfo.pid, projectPath, sessionId: session.sessionId, lastActive: session.lastActive, - sessionFilePath: filePath, + sessionFilePath: session.filePath, }; } @@ -535,19 +362,14 @@ export class KiroAdapter implements AgentAdapter { } private determineStatus(session: KiroSession): AgentStatus { - const diffMs = Date.now() - session.lastActive.getTime(); - const diffMinutes = diffMs / 60000; - + const diffMinutes = (Date.now() - session.lastActive.getTime()) / 60000; if (diffMinutes > KiroAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE; - if (session.lastRole === 'assistant') return AgentStatus.WAITING; + if (session.lastEventKind === 'AssistantMessage' && !session.lastAssistantHasToolUse) { + 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)$/, ''); @@ -556,45 +378,44 @@ export class KiroAdapter implements AgentAdapter { return false; } - getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { - const includeSystem = options?.verbose ?? false; - return this.entriesToMessages(this.readJsonl(sessionFilePath), includeSystem); + 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 entriesToMessages(entries: KiroLine[], includeSystem: boolean): ConversationMessage[] { - return entries - .map((entry) => this.entryToMessage(entry, includeSystem)) - .filter((msg): msg is ConversationMessage => msg !== 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; } - async listSessions(opts?: ListSessionsOptions): Promise { - if (!isDirectory(this.kiroSessionsDir)) return []; + private firstString(...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === 'string' && value.length > 0); + } - 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 asRecord(value: unknown): KiroRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as KiroRecord; } - private fileToSessionSummary(filePath: string): SessionSummary | null { - const entries = this.readJsonl(filePath); - if (entries.length === 0) return null; + private formatValue(value: unknown): string { + if (typeof value === 'string') return value; + if (value === undefined || value === null) return ''; + try { + return JSON.stringify(value); + } catch { + return ''; + } + } - 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, - }; + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; } }