From e0d90bfbd6c139f4b460a6d5b2d934882fd434ed Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:13:33 -0700 Subject: [PATCH 1/2] feat: add Kiro CLI adapter Detects running Kiro CLI agents by matching PIDs from ~/.kiro/sessions/cli/.lock to the sibling metadata and transcript files, and exposes sessions through the standard adapter surface. Follows the GrokCliAdapter shape for process discovery: declares processNames and resolves against the shared AgentDetectionContext snapshot when one is supplied, so the manager scans processes once per refresh rather than per adapter. Registered alongside the other agents in the adapter index, the startable-agent registry, the CLI type filters and the docs. --- README.md | 1 + .../__tests__/adapters/KiroAdapter.test.ts | 399 +++++++++++++++++ .../src/__tests__/utils/agents.test.ts | 7 + .../src/adapters/AgentAdapter.ts | 1 + .../agent-manager/src/adapters/KiroAdapter.ts | 416 ++++++++++++++++++ packages/agent-manager/src/adapters/index.ts | 1 + packages/agent-manager/src/index.ts | 1 + .../src/readiness/AgentReadiness.ts | 2 +- .../src/runtime/ManagedAgentRuntime.ts | 1 + .../src/runtime/herdr/HerdrAgentDiscovery.ts | 5 +- 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 | 7 +- .../cli/src/__tests__/util/sessions.test.ts | 3 +- packages/cli/src/commands/agent.ts | 9 +- .../src/services/channel/channel-runner.ts | 2 + packages/cli/src/util/sessions.ts | 1 + web/content/docs/13-agent-console.md | 1 + web/content/docs/8-agent-management.md | 2 +- 20 files changed, 857 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 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..0de1b1d0 --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts @@ -0,0 +1,399 @@ +/** + * 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", "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); + }); + }); + + 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("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 62796141..91ca9b65 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..1f0d3d79 --- /dev/null +++ b/packages/agent-manager/src/adapters/KiroAdapter.ts @@ -0,0 +1,416 @@ +/** + * 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; + +export class KiroAdapter implements AgentAdapter { + readonly type = "kiro" as const; + readonly processNames = ["kiro-cli", "kiro", "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 processByPid = new Map(processes.map((proc) => [proc.pid, proc])); + const matchedPids = new Set(); + const agents: AgentInfo[] = []; + + for (const lock of this.discoverActiveLocks()) { + const proc = processByPid.get(lock.pid); + if (!proc) continue; + + const session = this.readSession(lock.sessionId, proc.cwd); + if (!session) continue; + + agents.push(this.mapSessionToAgent(session, proc)); + matchedPids.add(proc.pid); + } + + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); + } + } + + return agents; + } + + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + return this.entriesToMessages(this.readJsonl(sessionFilePath), options?.verbose ?? false); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + if (!isDirectory(this.kiroSessionsDir)) return []; + + const summaries: SessionSummary[] = []; + for (const entry of safeReaddir(this.kiroSessionsDir)) { + if (!entry.endsWith(".jsonl")) continue; + + const sessionId = entry.slice(0, -".jsonl".length); + const session = this.readSession(sessionId); + if (!session) continue; + if (opts?.cwd !== undefined && session.projectPath !== opts.cwd) continue; + + summaries.push({ + type: this.type, + sessionId: session.sessionId, + cwd: session.projectPath, + firstUserMessage: session.firstUserMessage, + lastActive: session.lastActive, + startedAt: session.sessionStart, + sessionFilePath: session.filePath, + }); + } + return summaries; + } + + private 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 isKiroExecutable(command: string): boolean { + for (const token of command.trim().split(/\s+/)) { + const base = executableBasename(token).replace(/\.(exe|js)$/, ""); + if (base === "kiro-cli" || base === "kiro") return true; + } + return false; + } + + private toPid(value: unknown): number | null { + if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return value; + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + + private parseDate(value: unknown): Date | null { + if (typeof value === "number") { + const date = new Date(value < 1_000_000_000_000 ? value * 1000 : value); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof value !== "string" || !value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + private firstString(...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === "string" && value.length > 0); + } + + private asRecord(value: unknown): KiroRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as KiroRecord; + } + + private formatValue(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined || value === null) return ""; + try { + return JSON.stringify(value); + } catch { + return ""; + } + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 9557dbca..756ca57b 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 "./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 8d41c9aa..95c8c1cf 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -25,6 +25,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 "./adapters/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..497e1649 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"], @@ -119,7 +120,9 @@ async function enrichHerdrAgentPanes( ); } -function extractPaneProcessIds(value: unknown): Pick { +function extractPaneProcessIds( + value: unknown, +): Pick { const processInfo = getObject(value, ["result", "process_info"]); if (!processInfo) return {}; 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 47b314a5..ae63e77e 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -214,6 +214,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 () { @@ -266,6 +267,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, @@ -551,7 +553,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 79e3dee3..347c8deb 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -99,6 +99,7 @@ vi.mock( CopilotAdapter: vi.fn(), GeminiCliAdapter: vi.fn(), GrokCliAdapter: vi.fn(), + KiroAdapter: vi.fn(), PiAdapter: vi.fn(), TerminalFocusManager: vi.fn(function () { return mockTerminalFocusManager; @@ -788,7 +789,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 36c28877..71f12880 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, @@ -113,6 +114,7 @@ const TYPE_LABELS: Record = { copilot: "Copilot", gemini_cli: "Gemini CLI", grok_cli: "Grok CLI", + kiro: "Kiro", opencode: "OpenCode", pi: "Pi", other: "Other", @@ -210,6 +212,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; @@ -471,12 +474,12 @@ export function registerAgentCommand(program: Command): void { agentCommand .command("sessions") - .description("List historical Claude/Codex/Gemini/Grok/OpenCode sessions for resume") + .description("List historical Claude/Codex/Gemini/Grok/Kiro/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, grok_cli, opencode, copilot, pi", + "Filter to one of: claude, codex, gemini_cli, grok_cli, kiro, opencode, copilot, pi", ) .option("--limit ", "Max rows to print (default: 50; 0 = no limit)", "50") .option("-j, --json", "Output as JSON") @@ -539,7 +542,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: From 92778b86a3b125f2dfe70b1ed7c70d26d5cd4ebc Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:16:16 -0700 Subject: [PATCH 2/2] fix(kiro): match only Kiro executables and resolve lock PIDs to the owning session --- .../__tests__/adapters/KiroAdapter.test.ts | 137 +++++++++++++++++- .../agent-manager/src/adapters/KiroAdapter.ts | 56 ++++++- .../src/runtime/herdr/HerdrAgentDiscovery.ts | 4 +- 3 files changed, 185 insertions(+), 12 deletions(-) diff --git a/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts index 0de1b1d0..7fe798ab 100644 --- a/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/KiroAdapter.test.ts @@ -78,7 +78,7 @@ describe("KiroAdapter", () => { describe("initialization", () => { it("exposes the kiro type and process names", () => { expect(adapter.type).toBe("kiro"); - expect(adapter.processNames).toEqual(["kiro-cli", "kiro", "node"]); + expect(adapter.processNames).toEqual(["kiro-cli", "kiro", "kiro-cli-chat", "node"]); }); }); @@ -111,6 +111,46 @@ describe("KiroAdapter", () => { 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); }); }); @@ -148,6 +188,101 @@ describe("KiroAdapter", () => { }); }); + 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 })]); diff --git a/packages/agent-manager/src/adapters/KiroAdapter.ts b/packages/agent-manager/src/adapters/KiroAdapter.ts index 1f0d3d79..68310af5 100644 --- a/packages/agent-manager/src/adapters/KiroAdapter.ts +++ b/packages/agent-manager/src/adapters/KiroAdapter.ts @@ -58,10 +58,12 @@ interface KiroLock { } 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", "node"] as const; + readonly processNames = ["kiro-cli", "kiro", "kiro-cli-chat", "node"] as const; private kiroSessionsDir: string; @@ -80,12 +82,12 @@ export class KiroAdapter implements AgentAdapter { const processes = relevant.filter((process) => this.canHandle(process)); if (processes.length === 0) return []; - const processByPid = new Map(processes.map((proc) => [proc.pid, proc])); + 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 = processByPid.get(lock.pid); + const proc = this.resolveLockProcess(lock.pid, collectedByPid, processes); if (!proc) continue; const session = this.readSession(lock.sessionId, proc.cwd); @@ -365,12 +367,50 @@ export class KiroAdapter implements AgentAdapter { return AgentStatus.RUNNING; } - private isKiroExecutable(command: string): boolean { - for (const token of command.trim().split(/\s+/)) { - const base = executableBasename(token).replace(/\.(exe|js)$/, ""); - if (base === "kiro-cli" || base === "kiro") return true; + 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); } - return false; + + 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 { diff --git a/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts b/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts index 497e1649..7ca30a17 100644 --- a/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts +++ b/packages/agent-manager/src/runtime/herdr/HerdrAgentDiscovery.ts @@ -120,9 +120,7 @@ async function enrichHerdrAgentPanes( ); } -function extractPaneProcessIds( - value: unknown, -): Pick { +function extractPaneProcessIds(value: unknown): Pick { const processInfo = getObject(value, ["result", "process_info"]); if (!processInfo) return {};