diff --git a/README.md b/README.md index 10c3e36a..417efaf4 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,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..7fe798ab --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts @@ -0,0 +1,534 @@ +/** + * Tests for KiroAdapter + */ + +import type { MockedFunction } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { KiroAdapter } from "../../adapters/KiroAdapter.js"; +import type { ProcessInfo } from "../../adapters/AgentAdapter.js"; +import { AgentStatus } from "../../adapters/AgentAdapter.js"; +import { + listAgentProcesses, + enrichProcesses, + captureProcessSnapshot, +} from "../../utils/process.js"; +import { generateAgentName } from "../../utils/matching.js"; + +vi.mock("../../utils/process.js", async (importOriginal) => { + const actual = (await importOriginal()) as typeof import("../../utils/process.js"); + return { + ...actual, + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), + captureProcessSnapshot: vi.fn(), + }; +}); + +vi.mock("../../utils/matching.js", async (importOriginal) => { + const actual = (await importOriginal()) as typeof import("../../utils/matching.js"); + return { + ...actual, + generateAgentName: vi.fn(), + }; +}); + +const mockedListAgentProcesses = listAgentProcesses as MockedFunction; +const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedCaptureProcessSnapshot = captureProcessSnapshot as MockedFunction< + typeof captureProcessSnapshot +>; +const mockedGenerateAgentName = generateAgentName as MockedFunction; + +describe("KiroAdapter", () => { + let adapter: KiroAdapter; + let tmpHome: string; + let sessionsDir: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "kiro-adapter-test-")); + process.env.HOME = tmpHome; + sessionsDir = path.join(tmpHome, ".kiro", "sessions", "cli"); + fs.mkdirSync(sessionsDir, { recursive: true }); + + adapter = new KiroAdapter(); + + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedCaptureProcessSnapshot.mockReset(); + mockedGenerateAgentName.mockReset(); + + mockedEnrichProcesses.mockImplementation((procs) => procs); + // Compatibility shim for standalone adapter discovery; the manager captures once and slices by name. + mockedCaptureProcessSnapshot.mockImplementation(async (names) => + enrichProcesses(names.flatMap((name) => listAgentProcesses(name))), + ); + mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { + const folder = path.basename(cwd) || "unknown"; + return `${folder} (${pid})`; + }); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + describe("initialization", () => { + it("exposes the kiro type and process names", () => { + expect(adapter.type).toBe("kiro"); + expect(adapter.processNames).toEqual(["kiro-cli", "kiro", "kiro-cli-chat", "node"]); + }); + }); + + describe("canHandle", () => { + 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); + expect( + adapter.canHandle({ + pid: 5, + command: "kiro-cli chat", + cwd: "/repo", + tty: "ttys005", + }), + ).toBe(true); + expect( + adapter.canHandle({ + pid: 6, + command: "bun /opt/kiro/bin/kiro-cli.js", + cwd: "/repo", + tty: "ttys006", + }), + ).toBe(true); + expect( + adapter.canHandle({ + pid: 7, + command: "node /usr/local/bin/ai-devkit agent start --type kiro", + cwd: "/repo", + tty: "ttys007", + }), + ).toBe(false); + expect( + adapter.canHandle({ + pid: 8, + command: "node server.js --name kiro", + cwd: "/repo", + tty: "ttys008", + }), + ).toBe(false); + expect( + adapter.canHandle({ + pid: 9, + command: "kiro-cli-chat acp", + cwd: "/repo", + tty: "ttys009", + }), + ).toBe(false); + }); + }); + + describe("detectAgents", () => { + it("returns [] when there are no Kiro processes", async () => { + mockedListAgentProcesses.mockReturnValue([]); + expect(await adapter.detectAgents()).toEqual([]); + }); + + it("maps a running Kiro process through its session lock and metadata", async () => { + const cwd = "/repo/project-a"; + const proc = makeProcess({ pid: 101, cwd: "/process/cwd" }); + const updatedAt = new Date().toISOString(); + const sessionFile = writeKiroSession( + "sess-101", + cwd, + [prompt("implement Kiro adapter", 1781098057), assistantText("working on it")], + 101, + updatedAt, + ); + mockedCaptureProcessSnapshot.mockResolvedValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: "kiro", + pid: 101, + projectPath: cwd, + sessionId: "sess-101", + summary: "implement Kiro adapter", + status: AgentStatus.WAITING, + sessionFilePath: sessionFile, + lastActive: new Date(updatedAt), + }); + }); + + it("matches a lock held by a kiro-cli-chat acp descendant when bun is not collected", async () => { + const cwd = "/repo/project-a"; + const sessionFile = writeKiroSession( + "sess-acp", + cwd, + [prompt("implement Kiro adapter", 1781098057), assistantText("working on it")], + 55236, + ); + const kiro = makeProcess({ pid: 55111, command: "kiro-cli", cwd: "/process/cwd", ppid: 1 }); + const chat = makeProcess({ + pid: 55168, + command: "kiro-cli-chat chat", + ppid: 55111, + tty: "ttys001", + }); + const acp = makeProcess({ + pid: 55236, + command: "kiro-cli-chat acp", + ppid: 55198, + tty: "ttys001", + }); + mockedCaptureProcessSnapshot.mockResolvedValue([kiro, chat, acp]); + + const agents = await adapter.detectAgents(); + + expect(agents).toEqual([ + expect.objectContaining({ + type: "kiro", + pid: 55111, + projectPath: cwd, + sessionId: "sess-acp", + summary: "implement Kiro adapter", + sessionFilePath: sessionFile, + }), + ]); + }); + + it("walks a collected kiro-cli-chat parent chain to the owning kiro-cli", async () => { + const sessionFile = writeKiroSession( + "sess-owner", + "/repo/owner", + [prompt("owned session", 1781098057)], + 55236, + ); + const owner = makeProcess({ pid: 55111, command: "kiro-cli", cwd: "/repo/owner", ppid: 1 }); + const other = makeProcess({ pid: 66111, command: "kiro-cli", cwd: "/repo/other", ppid: 1 }); + const chat = makeProcess({ pid: 55168, command: "kiro-cli-chat chat", ppid: 55111 }); + const acp = makeProcess({ pid: 55236, command: "kiro-cli-chat acp", ppid: 55168 }); + mockedCaptureProcessSnapshot.mockResolvedValue([owner, other, chat, acp]); + + const agents = await adapter.detectAgents(); + + expect(agents).toEqual([ + expect.objectContaining({ + pid: 55111, + projectPath: "/repo/owner", + sessionId: "sess-owner", + sessionFilePath: sessionFile, + }), + expect.objectContaining({ pid: 66111, sessionId: "pid-66111" }), + ]); + }); + + it("does not attach a session through tty ?? or an ambiguous tty", async () => { + writeKiroSession("sess-unknown-tty", "/repo/unknown", [prompt("hello", 1781098057)], 55236); + const unknownTty = makeProcess({ pid: 55111, command: "kiro-cli", tty: "??", ppid: 1 }); + const unknownAcp = makeProcess({ + pid: 55236, + command: "kiro-cli-chat acp", + tty: "??", + ppid: 55198, + }); + mockedCaptureProcessSnapshot.mockResolvedValue([unknownTty, unknownAcp]); + + expect(await adapter.detectAgents()).toEqual([ + expect.objectContaining({ pid: 55111, sessionId: "pid-55111" }), + ]); + + writeKiroSession("sess-shared-tty", "/repo/shared", [prompt("hello", 1781098057)], 66236); + const first = makeProcess({ pid: 55111, command: "kiro-cli", tty: "ttys001", ppid: 1 }); + const second = makeProcess({ pid: 66111, command: "kiro-cli", tty: "ttys001", ppid: 1 }); + const sharedAcp = makeProcess({ + pid: 66236, + command: "kiro-cli-chat acp", + tty: "ttys001", + ppid: 55198, + }); + mockedCaptureProcessSnapshot.mockResolvedValue([first, second, sharedAcp]); + + expect(await adapter.detectAgents()).toEqual([ + expect.objectContaining({ pid: 55111, sessionId: "pid-55111" }), + expect.objectContaining({ pid: 66111, sessionId: "pid-66111" }), + ]); + }); + + it("uses a supplied process snapshot instead of scanning again", async () => { + const proc = makeProcess({ pid: 606, cwd: "/repo/context" }); + mockedCaptureProcessSnapshot.mockResolvedValue([makeProcess({ pid: 999 })]); + + const agents = await adapter.detectAgents({ processes: [proc] }); + + expect(mockedCaptureProcessSnapshot).not.toHaveBeenCalled(); + expect(agents).toEqual([ + expect.objectContaining({ + pid: 606, + projectPath: "/repo/context", + sessionId: "pid-606", + }), + ]); + }); + + it("uses only a lock whose PID belongs to a running Kiro process", async () => { + writeKiroSession("ended-session", "/repo/ended", [prompt("old conversation", 1781098057)]); + writeKiroSession( + "other-process", + "/repo/other", + [prompt("other conversation", 1781098057)], + 999, + ); + const proc = makeProcess({ pid: 202, cwd: "/repo/current" }); + mockedCaptureProcessSnapshot.mockResolvedValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toEqual([ + expect.objectContaining({ + pid: 202, + projectPath: "/repo/current", + sessionId: "pid-202", + summary: "Kiro process running", + }), + ]); + }); + + it("ignores malformed lock files", async () => { + writeKiroSession("bad-lock", "/repo/project", [prompt("hello", 1781098057)]); + fs.writeFileSync(path.join(sessionsDir, "bad-lock.lock"), "{bad json"); + const proc = makeProcess({ pid: 303, cwd: "/repo/project" }); + mockedCaptureProcessSnapshot.mockResolvedValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0]).toMatchObject({ sessionId: "pid-303" }); + }); + + it("reports running while the latest assistant event invokes a tool", async () => { + writeKiroSession( + "tool-session", + "/repo/project", + [ + prompt("inspect the file", Math.floor(Date.now() / 1000)), + assistantTool("fs_read", { path: "/repo/project/file.ts" }), + ], + 404, + new Date().toISOString(), + ); + const proc = makeProcess({ pid: 404, cwd: "/repo/project" }); + mockedCaptureProcessSnapshot.mockResolvedValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0].status).toBe(AgentStatus.RUNNING); + }); + + it("returns a process-only agent when the locked transcript is missing", async () => { + fs.writeFileSync(path.join(sessionsDir, "missing.lock"), JSON.stringify({ pid: 505 })); + const proc = makeProcess({ pid: 505, cwd: "/repo/project-e" }); + mockedCaptureProcessSnapshot.mockResolvedValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toEqual([ + expect.objectContaining({ + type: "kiro", + status: AgentStatus.RUNNING, + pid: 505, + projectPath: "/repo/project-e", + sessionId: "pid-505", + summary: "Kiro process running", + }), + ]); + }); + }); + + describe("getConversation", () => { + it("reads real Kiro prompt and assistant message envelopes", () => { + const sessionFile = writeKiroSession("conversation", "/repo/project-f", [ + prompt("hello kiro", 1781098057), + assistantText("Hello! How can I help?"), + "{not json", + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: "user", content: "hello kiro", timestamp: "2026-06-10T13:27:37.000Z" }, + { role: "assistant", content: "Hello! How can I help?", timestamp: undefined }, + ]); + }); + + it("includes Kiro tool use and results only in verbose conversation mode", () => { + const sessionFile = writeKiroSession("tools", "/repo/project-tools", [ + prompt("read package.json", 1781098057), + assistantTool("fs_read", { path: "package.json" }), + toolResult("contents", "success"), + assistantText("Done."), + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: "user", content: "read package.json", timestamp: "2026-06-10T13:27:37.000Z" }, + { role: "assistant", content: "Done.", timestamp: undefined }, + ]); + expect(adapter.getConversation(sessionFile, { verbose: true })).toEqual([ + { role: "user", content: "read package.json", timestamp: "2026-06-10T13:27:37.000Z" }, + { + role: "assistant", + content: '[Tool: fs_read] {"path":"package.json"}', + timestamp: undefined, + }, + { role: "system", content: "[Tool Result] contents", timestamp: undefined }, + { role: "assistant", content: "Done.", timestamp: undefined }, + ]); + }); + }); + + describe("listSessions", () => { + it("lists historical sessions using metadata and applies cwd filtering", async () => { + const matchingCwd = "/repo/project-g"; + const matchingSession = writeKiroSession("sess-g", matchingCwd, [ + prompt("first matching message", 1781098057), + assistantText("response"), + ]); + writeKiroSession("sess-h", "/repo/project-h", [prompt("other message", 1781098057)]); + + const sessions = await adapter.listSessions({ cwd: matchingCwd }); + + expect(sessions).toEqual([ + expect.objectContaining({ + type: "kiro", + sessionId: "sess-g", + cwd: matchingCwd, + firstUserMessage: "first matching message", + startedAt: new Date("2026-06-10T13:27:17.000Z"), + lastActive: new Date("2026-06-10T13:27:40.000Z"), + sessionFilePath: matchingSession, + }), + ]); + }); + }); + + function makeProcess(overrides: Partial): ProcessInfo { + return { + pid: 1, + command: "kiro-cli chat", + cwd: "/repo", + tty: "ttys001", + startTime: new Date("2026-06-10T13:27:17.000Z"), + ...overrides, + }; + } + + function writeKiroSession( + sessionId: string, + cwd: string, + entries: Array | string>, + pid?: number, + updatedAt = "2026-06-10T13:27:40.000Z", + ): string { + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}.json`), + JSON.stringify({ + session_id: sessionId, + cwd, + created_at: "2026-06-10T13:27:17.000Z", + updated_at: updatedAt, + title: "Session title", + }), + ); + const filePath = path.join(sessionsDir, `${sessionId}.jsonl`); + fs.writeFileSync( + filePath, + entries + .map((entry) => (typeof entry === "string" ? entry : JSON.stringify(entry))) + .join("\n"), + ); + if (pid !== undefined) { + fs.writeFileSync( + path.join(sessionsDir, `${sessionId}.lock`), + JSON.stringify({ + pid, + started_at: "2026-06-10T13:27:17.000Z", + }), + ); + } + return filePath; + } + + function prompt(text: string, timestamp: number): Record { + return { + version: "v1", + kind: "Prompt", + data: { + content: [{ kind: "text", data: text }], + meta: { timestamp }, + }, + }; + } + + function assistantText(text: string): Record { + return { + version: "v1", + kind: "AssistantMessage", + data: { content: [{ kind: "text", data: text }] }, + }; + } + + function assistantTool(name: string, input: Record): Record { + return { + version: "v1", + kind: "AssistantMessage", + data: { + content: [ + { + kind: "toolUse", + data: { toolUseId: "tool-1", name, input }, + }, + ], + }, + }; + } + + function toolResult(result: string, status: string): Record { + return { + version: "v1", + kind: "ToolResults", + data: { + content: [ + { + kind: "toolResult", + data: { toolUseId: "tool-1", status, result }, + }, + ], + }, + }; + } +}); diff --git a/packages/agent-manager/src/__tests__/utils/agents.test.ts b/packages/agent-manager/src/__tests__/utils/agents.test.ts index 4b29b6f3..a4f4a60e 100644 --- a/packages/agent-manager/src/__tests__/utils/agents.test.ts +++ b/packages/agent-manager/src/__tests__/utils/agents.test.ts @@ -21,4 +21,11 @@ describe("AGENTS", () => { expect(AGENTS.grok_cli.matches("/Users/dev/.grok/bin/grok --always-approve")).toBe(true); expect(AGENTS.grok_cli.matches("node /repo/feature-grok-cli/script.js")).toBe(false); }); + + it("includes Kiro as a startable agent", () => { + expect(AGENTS.kiro.command).toBe("kiro-cli"); + expect(AGENTS.kiro.matches("kiro-cli")).toBe(true); + expect(AGENTS.kiro.matches("/usr/local/bin/kiro --model x")).toBe(true); + expect(AGENTS.kiro.matches("node /repo/feature-kiro-adapter/script.js")).toBe(false); + }); }); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index 5973c3d7..2f2c1a1d 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -12,6 +12,7 @@ export type AgentType = | "claude" | "gemini_cli" | "grok_cli" + | "kiro" | "codex" | "opencode" | "copilot" diff --git a/packages/agent-manager/src/adapters/KiroAdapter.ts b/packages/agent-manager/src/adapters/KiroAdapter.ts new file mode 100644 index 00000000..68310af5 --- /dev/null +++ b/packages/agent-manager/src/adapters/KiroAdapter.ts @@ -0,0 +1,456 @@ +/** + * Kiro Adapter + * + * Detects running Kiro agents by matching process IDs from + * ~/.kiro/sessions/cli/.lock to the sibling metadata and transcript files. + */ + +import * as path from "path"; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, + AgentDetectionContext, +} from "./AgentAdapter.js"; +import { AgentStatus } from "./AgentAdapter.js"; +import { + captureProcessSnapshot, + executableBasename, + filterByProcessNames, +} from "../utils/process.js"; +import { isDirectory, safeReadFile, safeReaddir, safeStat } from "../utils/session.js"; +import { generateAgentName } from "../utils/matching.js"; + +type KiroRecord = Record; + +interface KiroMetadata { + sessionId: string; + cwd: string; + title: string; + createdAt: Date | null; + updatedAt: Date | null; +} + +interface KiroLine { + kind?: string; + timestamp?: string; + data?: KiroRecord; +} + +interface KiroSession { + sessionId: string; + projectPath: string; + summary: string; + firstUserMessage: string; + sessionStart: Date; + lastActive: Date; + lastEventKind?: string; + lastAssistantHasToolUse: boolean; + filePath: string; +} + +interface KiroLock { + sessionId: string; + pid: number; +} + +const IDLE_THRESHOLD_MINUTES = 5; +const KIRO_BASENAMES = new Set(["kiro-cli", "kiro"]); +const SCRIPT_RUNTIMES = new Set(["node", "bun"]); + +export class KiroAdapter implements AgentAdapter { + readonly type = "kiro" as const; + readonly processNames = ["kiro-cli", "kiro", "kiro-cli-chat", "node"] as const; + + private kiroSessionsDir: string; + + constructor() { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + this.kiroSessionsDir = path.join(homeDir, ".kiro", "sessions", "cli"); + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isKiroExecutable(processInfo.command); + } + + async detectAgents(context?: AgentDetectionContext): Promise { + const snapshot = context?.processes ?? (await captureProcessSnapshot(this.processNames)); + const relevant = filterByProcessNames(snapshot, this.processNames); + const processes = relevant.filter((process) => this.canHandle(process)); + if (processes.length === 0) return []; + + const collectedByPid = new Map(relevant.map((proc) => [proc.pid, proc])); + const matchedPids = new Set(); + const agents: AgentInfo[] = []; + + for (const lock of this.discoverActiveLocks()) { + const proc = this.resolveLockProcess(lock.pid, collectedByPid, processes); + if (!proc) continue; + + const session = this.readSession(lock.sessionId, proc.cwd); + if (!session) continue; + + agents.push(this.mapSessionToAgent(session, proc)); + matchedPids.add(proc.pid); + } + + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); + } + } + + return agents; + } + + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + return this.entriesToMessages(this.readJsonl(sessionFilePath), options?.verbose ?? false); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + if (!isDirectory(this.kiroSessionsDir)) return []; + + const summaries: SessionSummary[] = []; + for (const entry of safeReaddir(this.kiroSessionsDir)) { + if (!entry.endsWith(".jsonl")) continue; + + const sessionId = entry.slice(0, -".jsonl".length); + const session = this.readSession(sessionId); + if (!session) continue; + if (opts?.cwd !== undefined && session.projectPath !== opts.cwd) continue; + + summaries.push({ + type: this.type, + sessionId: session.sessionId, + cwd: session.projectPath, + firstUserMessage: session.firstUserMessage, + lastActive: session.lastActive, + startedAt: session.sessionStart, + sessionFilePath: session.filePath, + }); + } + return summaries; + } + + private discoverActiveLocks(): KiroLock[] { + if (!isDirectory(this.kiroSessionsDir)) return []; + + const locks: KiroLock[] = []; + for (const entry of safeReaddir(this.kiroSessionsDir)) { + if (!entry.endsWith(".lock")) continue; + + const content = safeReadFile(path.join(this.kiroSessionsDir, entry)); + if (content === undefined) continue; + + try { + const parsed = JSON.parse(content) as unknown; + const record = this.asRecord(parsed); + const pid = this.toPid(record?.pid); + if (pid === null) continue; + + locks.push({ + sessionId: entry.slice(0, -".lock".length), + pid, + }); + } catch { + continue; + } + } + return locks; + } + + private readSession(sessionId: string, fallbackCwd = ""): KiroSession | null { + const filePath = path.join(this.kiroSessionsDir, `${sessionId}.jsonl`); + const stat = safeStat(filePath); + if (!stat?.isFile()) return null; + + const entries = this.readJsonl(filePath); + const metadata = this.readMetadata(sessionId); + const messages = this.entriesToMessages(entries, false); + const userMessages = messages.filter((message) => message.role === "user"); + const timestamps = entries + .map((entry) => this.entryDate(entry)) + .filter((value): value is Date => value !== null); + const sessionStart = metadata.createdAt ?? timestamps[0] ?? stat.birthtime ?? stat.mtime; + const lastActive = metadata.updatedAt ?? timestamps[timestamps.length - 1] ?? stat.mtime; + const lastEntry = entries[entries.length - 1]; + + return { + sessionId: metadata.sessionId || sessionId, + projectPath: metadata.cwd || fallbackCwd, + summary: this.truncate( + userMessages.at(-1)?.content || metadata.title || "Kiro session active", + 120, + ), + firstUserMessage: userMessages[0]?.content ?? "", + sessionStart, + lastActive, + lastEventKind: lastEntry?.kind, + lastAssistantHasToolUse: + lastEntry?.kind === "AssistantMessage" && this.hasContentKind(lastEntry, "toolUse"), + filePath, + }; + } + + private readMetadata(sessionId: string): KiroMetadata { + const empty: KiroMetadata = { + sessionId, + cwd: "", + title: "", + createdAt: null, + updatedAt: null, + }; + const content = safeReadFile(path.join(this.kiroSessionsDir, `${sessionId}.json`)); + if (content === undefined) return empty; + + try { + const parsed = this.asRecord(JSON.parse(content)); + if (!parsed) return empty; + return { + sessionId: this.firstString(parsed.session_id, parsed.sessionId) ?? sessionId, + cwd: this.firstString(parsed.cwd) ?? "", + title: this.firstString(parsed.title) ?? "", + createdAt: this.parseDate(parsed.created_at ?? parsed.createdAt), + updatedAt: this.parseDate(parsed.updated_at ?? parsed.updatedAt), + }; + } catch { + return empty; + } + } + + private readJsonl(filePath: string): KiroLine[] { + const content = safeReadFile(filePath); + if (content === undefined) return []; + + const entries: KiroLine[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = this.asRecord(JSON.parse(trimmed)); + if (parsed) entries.push(parsed as KiroLine); + } catch { + continue; + } + } + return entries; + } + + private entriesToMessages(entries: KiroLine[], verbose: boolean): ConversationMessage[] { + const messages: ConversationMessage[] = []; + for (const entry of entries) { + const message = this.entryToMessage(entry, verbose); + if (message) messages.push(message); + } + return messages; + } + + private entryToMessage(entry: KiroLine, verbose: boolean): ConversationMessage | null { + let role: ConversationMessage["role"]; + let content: string; + + if (entry.kind === "Prompt") { + role = "user"; + content = this.textContent(entry); + } else if (entry.kind === "AssistantMessage") { + role = "assistant"; + const parts = [this.textContent(entry)]; + if (verbose) parts.push(...this.toolUseContent(entry)); + content = parts.filter(Boolean).join("\n"); + } else if (entry.kind === "ToolResults" && verbose) { + role = "system"; + content = this.toolResultContent(entry).join("\n"); + } else { + return null; + } + + if (!content) return null; + return { + role, + content, + timestamp: this.entryTimestamp(entry), + }; + } + + private textContent(entry: KiroLine): string { + return this.contentBlocks(entry) + .filter((block) => block.kind === "text") + .map((block) => (typeof block.data === "string" ? block.data : "")) + .filter(Boolean) + .join(""); + } + + private toolUseContent(entry: KiroLine): string[] { + return this.contentBlocks(entry) + .filter((block) => block.kind === "toolUse") + .map((block) => { + const data = this.asRecord(block.data); + const name = this.firstString(data?.name) ?? "unknown"; + const input = this.formatValue(data?.input); + return `[Tool: ${name}]${input ? ` ${input}` : ""}`; + }); + } + + private toolResultContent(entry: KiroLine): string[] { + return this.contentBlocks(entry) + .filter((block) => block.kind === "toolResult") + .map((block) => { + const data = this.asRecord(block.data); + const prefix = data?.status === "error" ? "[Tool Error]" : "[Tool Result]"; + const result = this.formatValue(data?.result ?? data?.results ?? data?.content); + return `${prefix}${result ? ` ${result}` : ""}`; + }); + } + + private contentBlocks(entry: KiroLine): Array<{ kind?: string; data?: unknown }> { + const content = entry.data?.content; + if (!Array.isArray(content)) return []; + return content + .map((block) => this.asRecord(block)) + .filter((block): block is KiroRecord => block !== null); + } + + private hasContentKind(entry: KiroLine, kind: string): boolean { + return this.contentBlocks(entry).some((block) => block.kind === kind); + } + + private entryTimestamp(entry: KiroLine): string | undefined { + const direct = this.firstString(entry.timestamp); + if (direct) return direct; + + const meta = this.asRecord(entry.data?.meta); + const parsed = this.parseDate(meta?.timestamp); + return parsed?.toISOString(); + } + + private entryDate(entry: KiroLine): Date | null { + return this.parseDate(this.entryTimestamp(entry)); + } + + private mapSessionToAgent(session: KiroSession, processInfo: ProcessInfo): AgentInfo { + const projectPath = session.projectPath || processInfo.cwd || ""; + return { + name: generateAgentName(projectPath, processInfo.pid), + type: this.type, + status: this.determineStatus(session), + summary: session.summary, + pid: processInfo.pid, + projectPath, + sessionId: session.sessionId, + lastActive: session.lastActive, + sessionFilePath: session.filePath, + }; + } + + private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { + return { + name: generateAgentName(processInfo.cwd || "", processInfo.pid), + type: this.type, + status: AgentStatus.RUNNING, + summary: "Kiro process running", + pid: processInfo.pid, + projectPath: processInfo.cwd || "", + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), + }; + } + + private determineStatus(session: KiroSession): AgentStatus { + const diffMinutes = (Date.now() - session.lastActive.getTime()) / 60000; + if (diffMinutes > IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE; + if (session.lastEventKind === "AssistantMessage" && !session.lastAssistantHasToolUse) { + return AgentStatus.WAITING; + } + return AgentStatus.RUNNING; + } + + private resolveLockProcess( + lockPid: number, + collectedByPid: Map, + topLevel: ProcessInfo[], + ): ProcessInfo | null { + let current = collectedByPid.get(lockPid); + const seen = new Set(); + let resolved: ProcessInfo | null = null; + + while (current && !seen.has(current.pid)) { + seen.add(current.pid); + if (this.canHandle(current)) resolved = current; + + const parentPid = current.ppid; + current = parentPid === undefined ? undefined : collectedByPid.get(parentPid); + } + + if (resolved) return resolved; + return this.matchSoleTopLevelOnTty(collectedByPid.get(lockPid)?.tty, topLevel); + } + + private matchSoleTopLevelOnTty( + tty: string | undefined, + topLevel: readonly ProcessInfo[], + ): ProcessInfo | null { + if (!tty || tty === "??" || tty === "?") return null; + const matches = topLevel.filter((proc) => proc.tty === tty); + return matches.length === 1 ? matches[0] : null; + } + + private isKiroExecutable(command: string): boolean { + const tokens = command.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return false; + + const executable = this.kiroBasename(tokens[0]); + if (KIRO_BASENAMES.has(executable)) return true; + if (!SCRIPT_RUNTIMES.has(executable)) return false; + + const script = tokens.slice(1).find((token) => !token.startsWith("-")); + return script !== undefined && KIRO_BASENAMES.has(this.kiroBasename(script)); + } + + private kiroBasename(token: string): string { + return executableBasename(token).replace(/\.(exe|js)$/, ""); + } + + private toPid(value: unknown): number | null { + if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value; + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + + private parseDate(value: unknown): Date | null { + if (typeof value === "number") { + const date = new Date(value < 1_000_000_000_000 ? value * 1000 : value); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof value !== "string" || !value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + private firstString(...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === "string" && value.length > 0); + } + + private asRecord(value: unknown): KiroRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as KiroRecord; + } + + private formatValue(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined || value === null) return ""; + try { + return JSON.stringify(value); + } catch { + return ""; + } + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index aa835f37..6ac9291c 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -3,6 +3,7 @@ export { CodexAdapter } from "../providers/codex/CodexAdapter.js"; export { CopilotAdapter } from "../providers/copilot/CopilotAdapter.js"; export { GeminiCliAdapter } from "../providers/gemini/GeminiCliAdapter.js"; export { GrokCliAdapter } from "../providers/grok/GrokCliAdapter.js"; +export { KiroAdapter } from "./KiroAdapter.js"; export { OpenCodeAdapter } from "../providers/opencode/OpenCodeAdapter.js"; export { PiAdapter } from "../providers/pi/PiAdapter.js"; export { AgentStatus } from "./AgentAdapter.js"; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index e47dc35d..c15a1feb 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -35,6 +35,7 @@ export { CodexAdapter } from "./providers/codex/CodexAdapter.js"; export { CopilotAdapter } from "./providers/copilot/CopilotAdapter.js"; export { GeminiCliAdapter } from "./providers/gemini/GeminiCliAdapter.js"; export { GrokCliAdapter } from "./providers/grok/GrokCliAdapter.js"; +export { KiroAdapter } from "./adapters/KiroAdapter.js"; export { OpenCodeAdapter } from "./providers/opencode/OpenCodeAdapter.js"; export { PiAdapter } from "./providers/pi/PiAdapter.js"; export { AgentStatus } from "./adapters/AgentAdapter.js"; diff --git a/packages/agent-manager/src/readiness/AgentReadiness.ts b/packages/agent-manager/src/readiness/AgentReadiness.ts index 530f313d..184f9d18 100644 --- a/packages/agent-manager/src/readiness/AgentReadiness.ts +++ b/packages/agent-manager/src/readiness/AgentReadiness.ts @@ -12,7 +12,7 @@ const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/] export type ReadinessStatus = "pass" | "warn" | "fail"; export type ReadinessAuthState = "authenticated" | "unauthenticated" | "unknown"; -export type ReadinessAgentType = Exclude; +export type ReadinessAgentType = Exclude; type ReadinessInfoStatus = ReadinessStatus | "info"; type CommandResult = { stdout: string; stderr: string }; diff --git a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts index bb03f9dc..961084a9 100644 --- a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts +++ b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts @@ -262,5 +262,6 @@ function agentRuntimeKind(type: StartableAgentType, provider: AgentRuntimeProvid grok_cli: "grok", opencode: "opencode", pi: "pi", + kiro: "kiro", }[type]; } diff --git a/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts b/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts index 290c6871..7ca30a17 100644 --- a/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts +++ b/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts @@ -93,6 +93,7 @@ function herdrAgentMatchesType(herdrAgent: string, agentType: AgentType): boolea copilot: ["copilot"], gemini_cli: ["gemini", "gemini_cli"], grok_cli: ["grok", "grok_cli"], + kiro: ["kiro", "kiro_cli", "kiro-cli"], opencode: ["opencode"], pi: ["pi"], other: ["other"], diff --git a/packages/agent-manager/src/utils/agents.ts b/packages/agent-manager/src/utils/agents.ts index f49c9e9e..80f03099 100644 --- a/packages/agent-manager/src/utils/agents.ts +++ b/packages/agent-manager/src/utils/agents.ts @@ -3,7 +3,7 @@ import type { AgentType } from "../adapters/AgentAdapter.js"; export type StartableAgentType = Extract< AgentType, - "claude" | "codex" | "copilot" | "gemini_cli" | "grok_cli" | "opencode" | "pi" + "claude" | "codex" | "copilot" | "gemini_cli" | "grok_cli" | "opencode" | "pi" | "kiro" >; export interface AgentConfig { @@ -26,6 +26,7 @@ export const AGENTS: Record = { grok_cli: { command: "grok", matches: matchArgv0("grok") }, 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 fc546672..ed2a2ed8 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -228,6 +228,7 @@ vi.mock( CopilotAdapter: vi.fn(), GeminiCliAdapter: vi.fn(), GrokCliAdapter: vi.fn(), + KiroAdapter: vi.fn(), OpenCodeAdapter: vi.fn(), PiAdapter: vi.fn(), DurableAgentRepository: vi.fn(function () { @@ -284,6 +285,7 @@ vi.mock( grok_cli: { command: "grok", matches: () => true }, opencode: { command: "opencode", matches: () => true }, pi: { command: "pi", matches: () => true }, + kiro: { command: "kiro-cli", matches: () => true }, }, RenameNotFoundError: RenameNotFoundError, RenameConflictError: RenameConflictError, @@ -603,7 +605,7 @@ describe("agent command", () => { onRuntimeDiscoveryError: expect.any(Function), }), ); - expect(mockManager.registerAdapter).toHaveBeenCalledTimes(7); + expect(mockManager.registerAdapter).toHaveBeenCalledTimes(8); expect(logSpy).toHaveBeenCalledWith( JSON.stringify([{ ...agents[0], mode: "interactive" }], null, 2), ); diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index 4087c6c6..1064c6d8 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -105,6 +105,7 @@ vi.mock( CopilotAdapter: vi.fn(), GeminiCliAdapter: vi.fn(), GrokCliAdapter: vi.fn(), + KiroAdapter: vi.fn(), PiAdapter: vi.fn(), TerminalFocusManager: vi.fn(function () { return mockTerminalFocusManager; @@ -925,7 +926,7 @@ describe("channel command", () => { bridgePid: process.pid, }), ); - expect(mockAgentManager.registerAdapter).toHaveBeenCalledTimes(6); + expect(mockAgentManager.registerAdapter).toHaveBeenCalledTimes(7); 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 f33398a7..fbd72260 100644 --- a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts @@ -17,6 +17,7 @@ describe("StartAgentPane helpers", () => { "grok_cli", "opencode", "pi", + "kiro", ]); }); @@ -25,14 +26,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 96da13fc..6507cc5b 100644 --- a/packages/cli/src/__tests__/util/sessions.test.ts +++ b/packages/cli/src/__tests__/util/sessions.test.ts @@ -49,6 +49,7 @@ describe("sessions util", () => { "codex", "gemini_cli", "grok_cli", + "kiro", "opencode", "copilot", "pi", @@ -60,7 +61,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, grok_cli, opencode, copilot, pi.', + 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, grok_cli, kiro, opencode, copilot, pi.', ); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index a2b6c91e..d7499bed 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -12,6 +12,7 @@ import { CopilotAdapter, GeminiCliAdapter, GrokCliAdapter, + KiroAdapter, OpenCodeAdapter, PiAdapter, ClaudePrintAgentService, @@ -148,6 +149,7 @@ const TYPE_LABELS: Record = { copilot: "Copilot", gemini_cli: "Gemini CLI", grok_cli: "Grok CLI", + kiro: "Kiro", opencode: "OpenCode", pi: "Pi", other: "Other", @@ -278,6 +280,7 @@ function createAgentManager(): AgentManager { manager.registerAdapter(new CopilotAdapter()); manager.registerAdapter(new GeminiCliAdapter()); manager.registerAdapter(new GrokCliAdapter()); + manager.registerAdapter(new KiroAdapter()); manager.registerAdapter(new OpenCodeAdapter()); manager.registerAdapter(new PiAdapter()); return manager; @@ -618,7 +621,7 @@ export function registerAgentCommand(program: Command): void { agentCommand .command("sessions") .description( - "List historical Claude/Codex/Gemini/Grok/OpenCode sessions for resume", + "List historical Claude/Codex/Gemini/Grok/Kiro/OpenCode sessions for resume", ) .option( "--all", @@ -630,7 +633,7 @@ export function registerAgentCommand(program: Command): void { ) .option( "--type ", - "Filter to one of: claude, codex, gemini_cli, grok_cli, opencode, copilot, pi", + "Filter to one of: claude, codex, gemini_cli, grok_cli, kiro, opencode, copilot, pi", ) .option( "--limit ", @@ -706,7 +709,7 @@ export function registerAgentCommand(program: Command): void { .option("-j, --json", "Output as JSON") .option( "--type ", - "Filter to one of: claude, codex, gemini_cli, grok_cli, opencode, copilot, pi", + "Filter to one of: claude, codex, gemini_cli, grok_cli, kiro, opencode, copilot, pi", ) .option("--full", "Show entire conversation history") .option("--tail ", "Show last N messages (default: 20)", "20") diff --git a/packages/cli/src/services/channel/channel-runner.ts b/packages/cli/src/services/channel/channel-runner.ts index 2616b0b7..78913dc0 100644 --- a/packages/cli/src/services/channel/channel-runner.ts +++ b/packages/cli/src/services/channel/channel-runner.ts @@ -6,6 +6,7 @@ import { CopilotAdapter, GeminiCliAdapter, GrokCliAdapter, + KiroAdapter, PiAdapter, TerminalFocusManager, TtyWriter, @@ -49,6 +50,7 @@ function createAgentManager(): AgentManager { manager.registerAdapter(new CopilotAdapter()); manager.registerAdapter(new GeminiCliAdapter()); manager.registerAdapter(new GrokCliAdapter()); + manager.registerAdapter(new KiroAdapter()); manager.registerAdapter(new PiAdapter()); return manager; } diff --git a/packages/cli/src/util/sessions.ts b/packages/cli/src/util/sessions.ts index a119d5d2..de758be1 100644 --- a/packages/cli/src/util/sessions.ts +++ b/packages/cli/src/util/sessions.ts @@ -8,6 +8,7 @@ const VALID_AGENT_TYPES: AgentType[] = [ "codex", "gemini_cli", "grok_cli", + "kiro", "opencode", "copilot", "pi", diff --git a/web/content/docs/13-agent-console.md b/web/content/docs/13-agent-console.md index 0d624727..32440dac 100644 --- a/web/content/docs/13-agent-console.md +++ b/web/content/docs/13-agent-console.md @@ -100,6 +100,7 @@ Supported start types: | `grok_cli` | Grok CLI | | `opencode` | opencode | | `pi` | Pi | +| `kiro` | Kiro CLI | Starting an agent from the console uses a managed tmux session. If tmux is not installed or the selected agent command is not in `PATH`, the console shows an error. diff --git a/web/content/docs/8-agent-management.md b/web/content/docs/8-agent-management.md index c73ff365..21d55ce9 100644 --- a/web/content/docs/8-agent-management.md +++ b/web/content/docs/8-agent-management.md @@ -52,7 +52,7 @@ Start a named agent in a managed tmux session: ai-devkit agent start --type claude --name backend --cwd ./packages/backend ``` -`--type` accepts `claude`, `codex`, `copilot`, `gemini_cli`, `grok_cli`, `opencode`, or `pi`. Names default to the current folder plus a timestamp. Use `--cwd ` to choose a working directory and `--debug` to show startup diagnostics. +`--type` accepts `claude`, `codex`, `copilot`, `gemini_cli`, `grok_cli`, `kiro`, `opencode`, or `pi`. Names default to the current folder plus a timestamp. Use `--cwd ` to choose a working directory and `--debug` to show startup diagnostics. The default `--mode interactive` starts the agent in tmux. Claude also supports a durable mode that keeps a named agent available without an interactive terminal: