From 12cc6c5fffbce6ef79d79d6fc28e7f795d99becf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:07:05 -0700 Subject: [PATCH] fix(kiro): report status on Kiro's V3 engine, including the built-in default agent (#33660) Kiro's V3 engine (default from Kiro CLI 3.0) now reports status in Orca: Orca installs a global hooks file under KIRO_HOME/hooks (covering the built-in default agent, local and remote, never overwriting user files) and recognizes V3's PascalCase hook events. Fixes #31577 Supersedes #24937 Co-authored-by: Dongho Lee <126236161+LDH1103@users.noreply.github.com> --- src/main/kiro/hook-service.test.ts | 6 +- src/main/kiro/hook-service.ts | 57 +++++--- src/main/kiro/hook-settings.ts | 2 +- src/main/kiro/standalone-hook-settings.ts | 95 +++++++++++++ src/main/kiro/standalone-hooks-remote.test.ts | 50 +++++++ src/main/kiro/standalone-hooks.test.ts | 130 ++++++++++++++++++ src/main/kiro/standalone-hooks.ts | 119 ++++++++++++++++ .../provider-event-routing.ts | 6 +- .../providers/kiro-events.test.ts | 79 ++++++++++- .../providers/kiro-events.ts | 6 + .../providers/kiro-tool-fields.ts | 8 +- 11 files changed, 531 insertions(+), 27 deletions(-) create mode 100644 src/main/kiro/standalone-hook-settings.ts create mode 100644 src/main/kiro/standalone-hooks-remote.test.ts create mode 100644 src/main/kiro/standalone-hooks.test.ts create mode 100644 src/main/kiro/standalone-hooks.ts diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index 01b90960ccf..d2c83c85a41 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -65,9 +65,9 @@ function readAgent(name: string): Record { const USER_STOP_HOOK = { command: 'say done' } describe('KiroHookService', () => { - it('reports the built-in default agent gap when no custom agents exist', () => { + it("reports the V2 default agent's gap as partial when no custom agents exist", () => { const result = new KiroHookService().install() - expect(result.state).toBe('not_installed') + expect(result.state).toBe('partial') expect(result.detail).toContain('kiro-cli agent create') expect(result.detail).toContain(agentsDir()) // The launcher is still laid down so the refresher can keep it current. @@ -254,7 +254,7 @@ describe('KiroHookService', () => { it('ignores non-JSON files such as the shipped example config', () => { mkdirSync(agentsDir(), { recursive: true }) writeFileSync(join(agentsDir(), 'agent_config.json.example'), '{}') - expect(new KiroHookService().install().state).toBe('not_installed') + expect(new KiroHookService().install().state).toBe('partial') expect(readFileSync(join(agentsDir(), 'agent_config.json.example'), 'utf-8')).toBe('{}') }) }) diff --git a/src/main/kiro/hook-service.ts b/src/main/kiro/hook-service.ts index a1d5e00fb2c..0a959579b39 100644 --- a/src/main/kiro/hook-service.ts +++ b/src/main/kiro/hook-service.ts @@ -38,6 +38,14 @@ import { removeManagedKiroHooks, serializeKiroAgentConfig } from './hook-settings' +import { getKiroRemoteStandaloneHooksFilePath } from './standalone-hook-settings' +import { + getKiroStandaloneHooksStatus, + installKiroStandaloneHooks, + installKiroStandaloneHooksRemote, + removeKiroStandaloneHooks, + withKiroStandaloneHooksStatus +} from './standalone-hooks' // Kiro injects hook stdout into model context; every path must stay silent. function getManagedScript(target: 'local' | 'posix' = 'local'): string { @@ -74,7 +82,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } function noAgentsDetail(agentsDir: string): string { - return `No custom Kiro agents in ${agentsDir}. Kiro's built-in default agent cannot carry hooks; create one with \`kiro-cli agent create\` and make it the default so Orca can track Kiro's status` + return `No custom Kiro agents in ${agentsDir}. On Kiro's V2 engine the built-in default agent cannot carry hooks; create one with \`kiro-cli agent create\` and make it the default so Orca can track Kiro's status` } type AgentFileRead = @@ -167,30 +175,31 @@ export class KiroHookService { getStatus(): AgentHookInstallStatus { const agentsDir = getKiroAgentsDir() const files = readLocalAgentFiles(agentsDir) + const standalone = getKiroStandaloneHooksStatus() if (files === null) { - return status(agentsDir, 'error', 'Could not read the Kiro agents directory') + return withKiroStandaloneHooksStatus( + status(agentsDir, 'error', 'Could not read the Kiro agents directory'), + standalone + ) } const result = buildStatus(agentsDir, files) - return result.managedHooksPresent && !existsSync(getKiroManagedScriptPath()) - ? { ...result, state: 'partial', detail: 'Managed hook script missing' } - : result + return withKiroStandaloneHooksStatus( + result.managedHooksPresent && !existsSync(getKiroManagedScriptPath()) + ? { ...result, state: 'partial', detail: 'Managed hook script missing' } + : result, + standalone + ) } install(): AgentHookInstallStatus { const agentsDir = getKiroAgentsDir() const files = readLocalAgentFiles(agentsDir) - if (files === null) { - return status(agentsDir, 'error', 'Could not read the Kiro agents directory') - } const scriptPath = getKiroManagedScriptPath() - // Write the script first so no agent config ever points at a missing file. + // Write the script first so no hooks file ever points at a missing file. writeManagedScript(scriptPath, getManagedScript()) - if (files.length === 0) { - return status(agentsDir, 'not_installed', noAgentsDetail(agentsDir)) - } const command = getKiroManagedCommand(scriptPath) const isManaged = getKiroManagedCommandMatcher() - for (const file of files) { + for (const file of files ?? []) { if ('error' in file) { continue } @@ -200,6 +209,8 @@ export class KiroHookService { preserveMode: true }) } + // V3 applies it to every agent, including the built-in default. + installKiroStandaloneHooks(command) return this.getStatus() } @@ -212,14 +223,23 @@ export class KiroHookService { const agentsDir = getKiroRemoteAgentsDir(remoteHome, kiroHomeDir) const scriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/kiro-hook.sh` try { + // Write the script first so no hooks file ever points at a missing file. + await writeManagedScriptRemote(sftp, scriptPath, getManagedScript('posix')) + const command = getKiroRemoteManagedCommand(scriptPath) + const standalone = await installKiroStandaloneHooksRemote( + sftp, + getKiroRemoteStandaloneHooksFilePath(remoteHome, kiroHomeDir), + command + ) const fileNames = ((await listRemoteDirectory(sftp, agentsDir)) ?? []) .filter(isKiroAgentConfigFileName) .sort() if (fileNames.length === 0) { - return status(agentsDir, 'not_installed', noAgentsDetail(agentsDir)) + return withKiroStandaloneHooksStatus( + status(agentsDir, 'not_installed', noAgentsDetail(agentsDir)), + standalone + ) } - await writeManagedScriptRemote(sftp, scriptPath, getManagedScript('posix')) - const command = getKiroRemoteManagedCommand(scriptPath) const isManaged = getKiroManagedCommandMatcher() const files: AgentFileRead[] = [] for (const fileName of fileNames) { @@ -238,17 +258,18 @@ export class KiroHookService { await writeTextFileRemoteAtomic(sftp, path, serializeKiroAgentConfig(next)) files.push({ fileName, config: next }) } - return buildStatus(agentsDir, files) + return withKiroStandaloneHooksStatus(buildStatus(agentsDir, files), standalone) } catch (err) { return status(agentsDir, 'error', err instanceof Error ? err.message : String(err)) } } remove(): AgentHookInstallStatus { + removeKiroStandaloneHooks() const agentsDir = getKiroAgentsDir() const files = readLocalAgentFiles(agentsDir) if (files === null) { - return status(agentsDir, 'error', 'Could not read the Kiro agents directory') + return this.getStatus() } const isManaged = getKiroManagedCommandMatcher() for (const file of files) { diff --git a/src/main/kiro/hook-settings.ts b/src/main/kiro/hook-settings.ts index 1c91b17f667..2e066df4fa9 100644 --- a/src/main/kiro/hook-settings.ts +++ b/src/main/kiro/hook-settings.ts @@ -38,7 +38,7 @@ export function isKiroHooksConfigSupported(config: Record): boo ) } -/** Kiro has no global hook config and no editable built-in agent; patch each user-owned global agent file. */ +/** Kiro V2 has no global hook config and no editable built-in agent; patch each user-owned global agent file. */ export function getKiroAgentsDir(): string { return join(resolveKiroHomeDir(), 'agents') } diff --git a/src/main/kiro/standalone-hook-settings.ts b/src/main/kiro/standalone-hook-settings.ts new file mode 100644 index 00000000000..306d496d621 --- /dev/null +++ b/src/main/kiro/standalone-hook-settings.ts @@ -0,0 +1,95 @@ +import { join, posix as pathPosix } from 'node:path' +import { resolveKiroHomeDir } from '../../shared/kiro-home' +import { isPlainObject, MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' +import { getKiroManagedCommandMatcher } from './hook-settings' + +const KIRO_STANDALONE_HOOKS_FILE_NAME = 'orca-agent-status.json' + +/** V3 triggers (kiro-cli 2.27+); the docs' `PromptSubmit`/`AgentStop` spellings are dropped on load. */ +export const KIRO_STANDALONE_HOOK_TRIGGERS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'SessionEnd' +] as const + +// V3 reads global hooks from $KIRO_HOME/hooks since kiro-cli 2.31 (2.29 used ~/.kiro/hooks). +export function getKiroStandaloneHooksFilePath(): string { + return join(resolveKiroHomeDir(), 'hooks', KIRO_STANDALONE_HOOKS_FILE_NAME) +} + +export function getKiroRemoteStandaloneHooksFilePath( + remoteHome: string, + kiroHomeDir?: string +): string { + const root = kiroHomeDir ?? pathPosix.join(remoteHome.replace(/\/$/, ''), '.kiro') + return pathPosix.join(root, 'hooks', KIRO_STANDALONE_HOOKS_FILE_NAME) +} + +export function buildKiroStandaloneHooksFile(command: string): Record { + return { + version: 'v1', + hooks: KIRO_STANDALONE_HOOK_TRIGGERS.map((trigger) => ({ + name: `orca-agent-status-${trigger}`, + trigger, + action: { type: 'command', command }, + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + })) + } +} + +function hookEntries(file: Record): unknown[] { + return Array.isArray(file.hooks) ? file.hooks : [] +} + +function runsManagedKiroScript(entry: unknown): boolean { + const action = isPlainObject(entry) ? entry.action : undefined + return ( + isPlainObject(action) && + typeof action.command === 'string' && + getKiroManagedCommandMatcher()(action.command) + ) +} + +/** `activeOnly` drops `enabled: false` entries, which Kiro skips. */ +export function readManagedKiroStandaloneTriggers( + file: Record, + options: { activeOnly?: boolean } = {} +): Set { + const present = new Set() + for (const entry of hookEntries(file)) { + if (!isPlainObject(entry) || typeof entry.trigger !== 'string') { + continue + } + if (options.activeOnly && entry.enabled === false) { + continue + } + if (runsManagedKiroScript(entry)) { + present.add(entry.trigger) + } + } + return present +} + +/** Why `file` is not Orca's to rewrite, or null when every field and hook is Orca's. */ +export function getKiroStandaloneHooksFileConflict(file: Record): string | null { + const otherField = Object.keys(file).find((key) => key !== 'version' && key !== 'hooks') + if (otherField !== undefined) { + return `it has a field Orca does not write (${otherField})` + } + if (!Array.isArray(file.hooks) || file.hooks.length === 0) { + return 'it has no hooks list' + } + if (!file.hooks.every(runsManagedKiroScript)) { + return 'it has hooks Orca did not write' + } + return file.version === 'v1' ? null : 'it is not a version "v1" hooks file' +} + +export function removeManagedKiroStandaloneHooks( + file: Record +): Record { + return { ...file, hooks: hookEntries(file).filter((entry) => !runsManagedKiroScript(entry)) } +} diff --git a/src/main/kiro/standalone-hooks-remote.test.ts b/src/main/kiro/standalone-hooks-remote.test.ts new file mode 100644 index 00000000000..c08fd3f067b --- /dev/null +++ b/src/main/kiro/standalone-hooks-remote.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createAgentHookMemorySftp } from '../agent-hooks/agent-hook-memory-sftp.test-fixture' +import { KiroHookService } from './hook-service' +import { KIRO_STANDALONE_HOOK_TRIGGERS } from './standalone-hook-settings' + +const REMOTE_HOOKS_FILE = '/home/dev/.kiro/hooks/orca-agent-status.json' + +afterEach(() => vi.restoreAllMocks()) + +describe('KiroHookService.installRemote with the V3 standalone hooks file', () => { + it('writes the V3 file pointing at the POSIX script with no custom agent, even from Windows', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const { sftp, fs } = createAgentHookMemorySftp() + + const status = await new KiroHookService().installRemote(sftp, '/home/dev/') + expect(status).toMatchObject({ agent: 'kiro', state: 'partial', managedHooksPresent: true }) + + const file = JSON.parse(fs.files.get(REMOTE_HOOKS_FILE) ?? '') + expect(file.version).toBe('v1') + expect(file.hooks.map((entry: { trigger: string }) => entry.trigger)).toEqual([ + ...KIRO_STANDALONE_HOOK_TRIGGERS + ]) + for (const entry of file.hooks) { + expect(entry.action.command).toContain('/home/dev/.orca/agent-hooks/kiro-hook.sh') + } + const script = fs.files.get('/home/dev/.orca/agent-hooks/kiro-hook.sh') ?? '' + expect(script.startsWith('#!/bin/sh')).toBe(true) + expect(script).toContain('/hook/kiro') + }) + + it('puts the V3 file under a probed remote KIRO_HOME', async () => { + const { sftp, fs } = createAgentHookMemorySftp() + + await new KiroHookService().installRemote(sftp, '/home/dev', '/opt/kiro') + expect(fs.files.has('/opt/kiro/hooks/orca-agent-status.json')).toBe(true) + expect(fs.files.has(REMOTE_HOOKS_FILE)).toBe(false) + }) + + it('refuses to overwrite a remote same-named file that holds hooks Orca did not write', async () => { + const userFile = JSON.stringify({ + version: 'v1', + hooks: [{ name: 'mine', trigger: 'Stop', action: { type: 'command', command: 'notify.sh' } }] + }) + const { sftp, fs } = createAgentHookMemorySftp({ [REMOTE_HOOKS_FILE]: userFile }) + + const status = await new KiroHookService().installRemote(sftp, '/home/dev') + expect(status.detail).toContain(`${REMOTE_HOOKS_FILE}: Left the Kiro hooks file`) + expect(fs.files.get(REMOTE_HOOKS_FILE)).toBe(userFile) + }) +}) diff --git a/src/main/kiro/standalone-hooks.test.ts b/src/main/kiro/standalone-hooks.test.ts new file mode 100644 index 00000000000..da6b5ee747e --- /dev/null +++ b/src/main/kiro/standalone-hooks.test.ts @@ -0,0 +1,130 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { KiroHookService } from './hook-service' +import { getKiroManagedCommandMatcher } from './hook-settings' +import { KIRO_STANDALONE_HOOK_TRIGGERS } from './standalone-hook-settings' +import { installKiroStandaloneHooks, removeKiroStandaloneHooks } from './standalone-hooks' + +// os.homedir() reads HOME on POSIX and USERPROFILE on Windows; set both to isolate real homes. +let home: string +const originalEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + KIRO_HOME: process.env.KIRO_HOME +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-kiro-standalone-')) + process.env.HOME = home + process.env.USERPROFILE = home + delete process.env.KIRO_HOME +}) + +afterEach(() => { + for (const [name, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } + rmSync(home, { recursive: true, force: true }) +}) + +type HooksFileEntry = { name: string; trigger: string; action: { type: string; command: string } } +type HooksFile = { version?: string; hooks?: HooksFileEntry[] } + +const hooksFilePath = (): string => join(home, '.kiro', 'hooks', 'orca-agent-status.json') +const agentPath = (kiroHome: string, name: string): string => + join(kiroHome, 'agents', `${name}.json`) + +function readHooksFile(): HooksFile { + return JSON.parse(readFileSync(hooksFilePath(), 'utf-8')) +} + +function writeHooksFile(content: string): void { + mkdirSync(dirname(hooksFilePath()), { recursive: true }) + writeFileSync(hooksFilePath(), content) +} + +function writeAgent(kiroHome: string, name: string): void { + mkdirSync(join(kiroHome, 'agents'), { recursive: true }) + writeFileSync(agentPath(kiroHome, name), JSON.stringify({ name })) +} + +const USER_HOOK = { + name: 'mine', + trigger: 'Stop', + action: { type: 'command', command: 'notify.sh' } +} +const ORCA_HOOK = { + name: 'orca-agent-status-Stop', + trigger: 'Stop', + action: { type: 'command', command: '/home/dev/.orca/agent-hooks/kiro-hook.sh' } +} + +describe('Kiro V3 standalone hooks file', () => { + const defaultKiroHome = (): string => join(home, '.kiro') + + it('covers the built-in default agent on V3 even with no custom agent', () => { + expect(new KiroHookService().install()).toMatchObject({ + state: 'partial', + managedHooksPresent: true + }) + const file = readHooksFile() + expect(file.version).toBe('v1') + expect(file.hooks?.map((entry) => entry.trigger)).toEqual([...KIRO_STANDALONE_HOOK_TRIGGERS]) + const isManaged = getKiroManagedCommandMatcher() + for (const entry of file.hooks ?? []) { + expect(isManaged(entry.action.command)).toBe(true) + } + }) + + it('follows KIRO_HOME, which V3 reads global hooks from since kiro-cli 2.31', () => { + const kiroHome = join(home, 'custom-kiro') + writeAgent(kiroHome, 'relocated') + process.env.KIRO_HOME = kiroHome + + expect(new KiroHookService().install().state).toBe('installed') + expect(existsSync(join(kiroHome, 'hooks', 'orca-agent-status.json'))).toBe(true) + expect(existsSync(hooksFilePath())).toBe(false) + }) + + it.each([ + ['unparsable JSON', '{ "version": "v1", "hooks": '], + ['a hook Orca did not write', JSON.stringify({ version: 'v1', hooks: [ORCA_HOOK, USER_HOOK] })] + ])('leaves a same-named file with %s untouched and reports it', (_label, userFile) => { + writeAgent(defaultKiroHome(), 'plain') + writeHooksFile(userFile) + + const status = new KiroHookService().install() + expect(status).toMatchObject({ + state: 'partial', + detail: expect.stringContaining(hooksFilePath()) + }) + expect(readFileSync(hooksFilePath(), 'utf-8')).toBe(userFile) + }) + + it('strips only its own hooks from a file that mixes in hooks someone else added', () => { + installKiroStandaloneHooks(ORCA_HOOK.action.command) + const file = readHooksFile() + writeHooksFile(JSON.stringify({ ...file, hooks: [...(file.hooks ?? []), USER_HOOK] })) + + removeKiroStandaloneHooks() + expect(readHooksFile()).toEqual({ version: 'v1', hooks: [USER_HOOK] }) + }) + + it('removes both the agent-config hooks and its own V3 file', () => { + writeAgent(defaultKiroHome(), 'plain') + const service = new KiroHookService() + expect(service.install().state).toBe('installed') + + expect(service.remove().state).toBe('not_installed') + expect(existsSync(hooksFilePath())).toBe(false) + expect(JSON.parse(readFileSync(agentPath(defaultKiroHome(), 'plain'), 'utf-8'))).toEqual({ + name: 'plain' + }) + }) +}) diff --git a/src/main/kiro/standalone-hooks.ts b/src/main/kiro/standalone-hooks.ts new file mode 100644 index 00000000000..0e3fdc21b84 --- /dev/null +++ b/src/main/kiro/standalone-hooks.ts @@ -0,0 +1,119 @@ +import { unlinkSync } from 'node:fs' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { parseHooksJsonText, readHooksJsonWithRaw } from '../agent-hooks/hooks-json-read' +import { writeHooksJson } from '../agent-hooks/installer-utils' +import { readTextFileRemote, writeHooksJsonRemote } from '../agent-hooks/installer-utils-remote' +import { + buildKiroStandaloneHooksFile, + getKiroStandaloneHooksFileConflict, + getKiroStandaloneHooksFilePath, + KIRO_STANDALONE_HOOK_TRIGGERS, + readManagedKiroStandaloneTriggers, + removeManagedKiroStandaloneHooks +} from './standalone-hook-settings' + +function standaloneStatus( + configPath: string, + state: AgentHookInstallState, + detail: string | null, + managedHooksPresent = false +): AgentHookInstallStatus { + return { agent: 'kiro', state, configPath, managedHooksPresent, detail } +} + +function getExistingFileStatus( + configPath: string, + file: Record | null +): AgentHookInstallStatus { + if (!file) { + return standaloneStatus(configPath, 'error', 'Could not parse the Orca Kiro hooks file') + } + const conflict = getKiroStandaloneHooksFileConflict(file) + if (conflict) { + return standaloneStatus( + configPath, + 'error', + `Left the Kiro hooks file at Orca's path untouched: ${conflict}`, + readManagedKiroStandaloneTriggers(file).size > 0 + ) + } + const active = readManagedKiroStandaloneTriggers(file, { activeOnly: true }) + const missing = KIRO_STANDALONE_HOOK_TRIGGERS.filter((trigger) => !active.has(trigger)) + return missing.length === 0 + ? standaloneStatus(configPath, 'installed', null, true) + : standaloneStatus(configPath, 'partial', `events: ${missing.join(', ')}`, true) +} + +export function getKiroStandaloneHooksStatus(): AgentHookInstallStatus { + const configPath = getKiroStandaloneHooksFilePath() + const { raw, config } = readHooksJsonWithRaw(configPath) + if (raw === null) { + return config + ? standaloneStatus(configPath, 'not_installed', null) + : standaloneStatus(configPath, 'error', 'Could not read the Orca Kiro hooks file') + } + return getExistingFileStatus(configPath, config) +} + +export function installKiroStandaloneHooks(command: string): AgentHookInstallStatus { + const configPath = getKiroStandaloneHooksFilePath() + const { raw, config } = readHooksJsonWithRaw(configPath) + // A file someone else wrote at Orca's name keeps its hooks. + if (config && (raw === null || getKiroStandaloneHooksFileConflict(config) === null)) { + writeHooksJson(configPath, buildKiroStandaloneHooksFile(command)) + } + return getKiroStandaloneHooksStatus() +} + +export async function installKiroStandaloneHooksRemote( + sftp: SFTPWrapper, + configPath: string, + command: string +): Promise { + try { + const body = await readTextFileRemote(sftp, configPath) + if (body !== null) { + const existing = getExistingFileStatus(configPath, parseHooksJsonText(body)) + if (existing.state === 'error') { + return existing + } + } + await writeHooksJsonRemote(sftp, configPath, buildKiroStandaloneHooksFile(command)) + return standaloneStatus(configPath, 'installed', null, true) + } catch (err) { + return standaloneStatus(configPath, 'error', err instanceof Error ? err.message : String(err)) + } +} + +export function removeKiroStandaloneHooks(): AgentHookInstallStatus { + const configPath = getKiroStandaloneHooksFilePath() + const { config } = readHooksJsonWithRaw(configPath) + if (config && readManagedKiroStandaloneTriggers(config).size > 0) { + if (getKiroStandaloneHooksFileConflict(config) === null) { + unlinkSync(configPath) + } else { + writeHooksJson(configPath, removeManagedKiroStandaloneHooks(config)) + } + } + return getKiroStandaloneHooksStatus() +} + +/** Agent-config status stays primary; the V3 file adds coverage or surfaces its error. */ +export function withKiroStandaloneHooksStatus( + primary: AgentHookInstallStatus, + standalone: AgentHookInstallStatus +): AgentHookInstallStatus { + if (standalone.state === 'installed' && primary.state === 'not_installed') { + return { ...primary, state: 'partial', managedHooksPresent: true } + } + if (standalone.state !== 'error') { + return primary + } + const detail = `${standalone.configPath}: ${standalone.detail}` + return { + ...primary, + state: primary.state === 'installed' ? 'partial' : primary.state, + detail: primary.detail ? `${primary.detail}; ${detail}` : detail + } +} diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index d25c8e238e6..c40460be815 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -84,7 +84,11 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo return eventName === 'turn_start' case 'kiro': // Why: agentSpawn opens a fresh session in the pane, so stale tool/prompt caches go too. - return eventName === 'agentSpawn' || eventName === 'userPromptSubmit' + return ( + eventName === 'agentSpawn' || + eventName === 'userPromptSubmit' || + eventName === 'UserPromptSubmit' + ) } } diff --git a/src/shared/agent-hook-listener/providers/kiro-events.test.ts b/src/shared/agent-hook-listener/providers/kiro-events.test.ts index 02e437c084b..3dbf1a420d9 100644 --- a/src/shared/agent-hook-listener/providers/kiro-events.test.ts +++ b/src/shared/agent-hook-listener/providers/kiro-events.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createHookListenerState, type HookListenerState } from '../listener-state' import { normalizeAndAccept } from '../../agent-hook-listener-test-harness' +import { isNewTurnEvent } from '../provider-event-routing' // Payload shapes copied from a live kiro-cli 2.28 TUI run with hooks on a custom agent. const base = { cwd: '/tmp/ws', session_id: 'f6fe70d7-a1b2-4c3d-9e8f-001122334455' } @@ -66,7 +67,81 @@ describe('normalizeKiroEvent', () => { expect(post?.payload.state).toBe('working') }) - it('ignores event names Kiro does not document', () => { - expect(normalizeAndAccept(state, 'kiro', event('Stop'))).toBeNull() + it("ignores names neither engine fires, and V3's lazy SessionStart", () => { + // Why: Kiro's docs spell V3 triggers PromptSubmit/AgentStop, but 2.27 never fires them. + for (const name of ['PromptSubmit', 'AgentStop', 'SessionStart', 'SomethingElse']) { + expect(normalizeAndAccept(state, 'kiro', event(name))).toBeNull() + } + }) +}) + +// Shapes captured from kiro-cli 2.27.0 (`chat --tui --v3`) running Orca's standalone +// `~/.kiro/hooks/*.json` file: Claude's PascalCase names, a `sess_` id, and no final reply. +const V3_SESSION_ID = 'sess_966a9c85-10a0-4a38-8fd0-91dbeffb032b' + +function v3Event(name: string, extra: Record = {}): Record { + return { + session_id: V3_SESSION_ID, + hook_event_name: name, + cwd: 'C:\\Users\\dev\\project', + ...extra + } +} + +describe('normalizeKiroEvent with the V3 engine', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + it('opens a turn on UserPromptSubmit, not on the SessionStart just before it', () => { + expect(isNewTurnEvent('kiro', 'UserPromptSubmit')).toBe(true) + expect(isNewTurnEvent('kiro', 'SessionStart')).toBe(false) + expect(normalizeAndAccept(state, 'kiro', v3Event('SessionStart'))).toBeNull() + + const submitted = normalizeAndAccept( + state, + 'kiro', + v3Event('UserPromptSubmit', { prompt: 'Run the shell command: echo probe-ok' }) + ) + expect(submitted?.payload).toMatchObject({ + state: 'working', + agentType: 'kiro', + prompt: 'Run the shell command: echo probe-ok' + }) + }) + + it('names the running tool and keeps a string tool_response as tool output', () => { + normalizeAndAccept(state, 'kiro', v3Event('UserPromptSubmit', { prompt: 'echo it' })) + const pre = normalizeAndAccept( + state, + 'kiro', + v3Event('PreToolUse', { tool_name: 'execute_pwsh', tool_input: { command: 'echo probe-ok' } }) + ) + expect(pre?.payload).toMatchObject({ state: 'working', toolName: 'execute_pwsh' }) + + const post = normalizeAndAccept( + state, + 'kiro', + v3Event('PostToolUse', { + tool_name: 'execute_pwsh', + tool_input: { command: 'echo probe-ok' }, + tool_response: 'probe-ok' + }) + ) + expect(post?.payload).toMatchObject({ + state: 'working', + lastAssistantMessage: 'probe-ok', + lastAssistantMessageIsToolOutput: true + }) + }) + + it('reports done on Stop and SessionEnd, which carry no final reply', () => { + normalizeAndAccept(state, 'kiro', v3Event('UserPromptSubmit', { prompt: 'finish' })) + for (const name of ['Stop', 'SessionEnd']) { + const event = normalizeAndAccept(state, 'kiro', v3Event(name)) + expect(event?.payload).toMatchObject({ state: 'done', agentType: 'kiro', prompt: 'finish' }) + } }) }) diff --git a/src/shared/agent-hook-listener/providers/kiro-events.ts b/src/shared/agent-hook-listener/providers/kiro-events.ts index 34363df67a5..d0ce7e7d20d 100644 --- a/src/shared/agent-hook-listener/providers/kiro-events.ts +++ b/src/shared/agent-hook-listener/providers/kiro-events.ts @@ -19,12 +19,18 @@ export function normalizeKiroEvent( case 'userPromptSubmit': case 'preToolUse': case 'postToolUse': + case 'UserPromptSubmit': + case 'PreToolUse': + case 'PostToolUse': stateName = 'working' break case 'agentSpawn': case 'stop': + case 'Stop': + case 'SessionEnd': stateName = 'done' break + // V3 fires SessionStart lazily, just before the first prompt; done would flash idle mid-turn. default: return null } diff --git a/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts b/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts index 6286af6c268..7d5fe87711e 100644 --- a/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts @@ -2,13 +2,17 @@ import type { ToolSnapshot } from '../listener-event' import { readString } from '../tool-input-preview' import { extractClaudeToolFields } from './claude-tool-fields' -/** Kiro's camelCase hook names mapped onto the Claude names its payload fields mirror. */ +/** Kiro V2 camelCase hook names mapped onto Claude's; V3 already sends Claude's names. */ export const KIRO_CLAUDE_EVENT_NAMES: Readonly> = Object.freeze({ agentSpawn: 'SessionStart', userPromptSubmit: 'UserPromptSubmit', preToolUse: 'PreToolUse', postToolUse: 'PostToolUse', - stop: 'Stop' + stop: 'Stop', + UserPromptSubmit: 'UserPromptSubmit', + PreToolUse: 'PreToolUse', + PostToolUse: 'PostToolUse', + Stop: 'Stop' }) /** Kiro keeps Claude tool fields, maps camelCase events and sends final text in assistant_response. */