mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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>
This commit is contained in:
@@ -65,9 +65,9 @@ function readAgent(name: string): Record<string, unknown> {
|
||||
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('{}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function isKiroHooksConfigSupported(config: Record<string, unknown>): 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')
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>): 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<string, unknown>,
|
||||
options: { activeOnly?: boolean } = {}
|
||||
): Set<string> {
|
||||
const present = new Set<string>()
|
||||
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, unknown>): 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<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return { ...file, hooks: hookEntries(file).filter((entry) => !runsManagedKiroScript(entry)) }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> | 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<AgentHookInstallStatus> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}): Record<string, unknown> {
|
||||
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' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>> = 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. */
|
||||
|
||||
Reference in New Issue
Block a user