feat(kiro): report Kiro agent status and list Kiro sessions in history (#26695)

* feat(kiro): report Kiro agent status through managed hooks

Kiro keeps hooks inside each agent config, so Orca installs one managed
hook per event into $KIRO_HOME/agents/*.json (locally and on SSH hosts
under the remote KIRO_HOME) and normalizes the payloads on /hook/kiro.
Pets, sidebar, notifications and mobile now see Kiro working and done.
Kiro resume is gated on a new agent-session.kiro-resume.v1 capability.

* feat(kiro): list Kiro sessions in Agent session history

Reads $KIRO_HOME/sessions/cli/<id>.json metadata plus the streamed
<id>.jsonl transcript locally, under WSL and over SSH, skips subagent
child sessions, and resumes with kiro-cli chat --tui --resume-id <id>.

* fix(kiro): report a remote agent deleted mid-install as unreadable

* fix(kiro): resume history rows by the validated session file name

* fix(kiro): preserve hook configs and restore sessions correctly

* test(kiro): use typed filesystem callbacks

* test(kiro): preserve older-peer search expectations

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Willian Felipe
2026-10-09 12:08:18 -07:00
committed by GitHub
co-authored by Neil
parent 9021133929
commit 150bf49cb2
52 changed files with 1616 additions and 43 deletions
+2
View File
@@ -259,6 +259,8 @@
"../src/main/jcode/hook-service.ts",
"../src/main/kimi/hook-service.ts",
"../src/main/kimi/kimi-hook-config-toml.ts",
"../src/main/kiro/hook-service.ts",
"../src/main/kiro/hook-settings.ts",
"../src/main/dsh/dsh-home-patch.ts",
"../src/main/dsh/hook-service.ts",
"../src/main/dsh/hook-settings.ts",
@@ -135,6 +135,21 @@ export async function readTextFileRemote(
}
}
/** Entry names in a remote directory, or null when the directory does not exist. */
export async function listRemoteDirectory(
sftp: SFTPWrapper,
remotePath: string
): Promise<string[] | null> {
try {
return (await readdir(sftp, remotePath)).map((entry) => entry.filename)
} catch (err) {
if (isNoEntryError(err)) {
return null
}
throw err
}
}
export async function writeTextFileRemoteAtomic(
sftp: SFTPWrapper,
remotePath: string,
@@ -18,6 +18,7 @@ import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { jcodeHookService } from '../jcode/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { kiroHookService } from '../kiro/hook-service'
import { museHookService } from '../muse/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { zcodeHookService } from '../zcode/hook-service'
@@ -65,7 +66,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['muse', () => museHookService.install()],
['zcode', () => zcodeHookService.install()],
['dsh', () => dshHookService.install()],
['jcode', () => jcodeHookService.install()]
['jcode', () => jcodeHookService.install()],
['kiro', () => kiroHookService.install()]
]
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
@@ -94,7 +96,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri
['muse', () => museHookService.refreshManagedScripts()],
['zcode', () => zcodeHookService.refreshManagedScripts()],
['dsh', () => dshHookService.refreshManagedScripts()],
['jcode', () => jcodeHookService.refreshManagedScripts()]
['jcode', () => jcodeHookService.refreshManagedScripts()],
['kiro', () => kiroHookService.refreshManagedScripts()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
@@ -119,7 +122,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['muse', () => museHookService.remove()],
['zcode', () => zcodeHookService.remove()],
['dsh', () => dshHookService.remove()],
['jcode', () => jcodeHookService.remove()]
['jcode', () => jcodeHookService.remove()],
['kiro', () => kiroHookService.remove()]
]
export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [
@@ -148,5 +152,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR
['muse', () => museHookService.getStatus()],
['zcode', () => zcodeHookService.getStatus()],
['dsh', () => dshHookService.getStatus()],
['jcode', () => jcodeHookService.getStatus()]
['jcode', () => jcodeHookService.getStatus()],
['kiro', () => kiroHookService.getStatus()]
]
@@ -25,6 +25,7 @@ import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook
import { getDshManagedCommand, getDshRemoteManagedCommand } from '../dsh/hook-settings'
import { getZCodeManagedCommand, getZCodeRemoteManagedCommand } from '../zcode/hook-settings'
import { getJcodeManagedCommand, getJcodeRemoteManagedCommand } from '../jcode/hook-settings'
import { getKiroManagedCommand, getKiroRemoteManagedCommand } from '../kiro/hook-settings'
import {
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
@@ -227,6 +228,13 @@ const buildersByAgent = new Map<string, CommandBuilders>([
local: (path) => [getJcodeManagedCommand(path)],
remote: (path) => [getJcodeRemoteManagedCommand(path)]
}
],
[
'kiro',
{
local: (path) => [getKiroManagedCommand(path)],
remote: (path) => [getKiroRemoteManagedCommand(path)]
}
]
])
@@ -36,10 +36,16 @@ export function createManagedHookLocalFilesystem(): SFTPWrapper {
stat(path: string, callback: Callback<{ mode: number }>): void {
stat(path, (error, stats) => finish(callback, error, stats))
},
readdir(path: string, callback: Callback<[]>): void {
// Why: installers use readdir only as an existence check; names and
// attrs would allocate work that no caller consumes.
readdir(path, (error) => finish(callback, error, []))
readdir(path: string, callback: Callback<{ filename: string }[]>): void {
// Why names only: Kiro lists its agent configs by name; attrs have no consumer,
// matching the WSL adapter's `{ filename }` entries.
readdir(path, (error, names) =>
finish(
callback,
error,
names?.map((filename) => ({ filename }))
)
)
},
mkdir(path: string, callback: Callback): void {
mkdir(path, (error) => finish(callback, error))
@@ -17,7 +17,8 @@ const { execFile } = await import('node:child_process')
const execFileMock = vi.mocked(execFile)
const { execFile: actualExecFile } =
await vi.importActual<typeof NodeChildProcess>('node:child_process')
const { installManagedHooks, resolveRelayGrokHome } = await import('./managed-hook-runtime')
const { installManagedHooks, resolveRelayGrokHome, resolveRelayKiroHome } =
await import('./managed-hook-runtime')
type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void
@@ -120,6 +121,25 @@ describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => {
})
})
describe.runIf(process.platform !== 'win32')('resolveRelayKiroHome', () => {
it('reads KIRO_HOME from the login shell', async () => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeOutput('/srv/kiro/\n')
await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/srv/kiro')
expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('printenv KIRO_HOME')
})
it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => {
vi.stubEnv('SHELL', '/bin/sh')
stubProbeOutput('\n')
await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro')
stubProbeFailure(new Error('spawn failed'))
await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro')
})
})
describe.runIf(process.platform !== 'win32')('installManagedHooks', () => {
it.each([
['omitted', undefined],
+30 -15
View File
@@ -12,16 +12,16 @@ import {
} from './managed-hook-owner-identity'
const execFileAsync = promisify(execFile)
const GROK_HOME_MAX_LENGTH = 4096
const GROK_HOME_PROBE_TIMEOUT_MS = 8_000
const AGENT_HOME_MAX_LENGTH = 4096
const AGENT_HOME_PROBE_TIMEOUT_MS = 8_000
export type ManagedHookInstallSummary = {
installers: number
errors: number
}
function defaultGrokHome(home: string): string {
return `${home.replace(/\/+$/, '') || home}/.grok`
function defaultAgentHome(home: string, dirName: string): string {
return `${home.replace(/\/+$/, '') || home}/${dirName}`
}
function hasControlCharacter(value: string): boolean {
@@ -31,10 +31,10 @@ function hasControlCharacter(value: string): boolean {
})
}
function normalizeGrokHome(candidate: string): string | null {
function normalizeAgentHome(candidate: string): string | null {
if (
candidate.length === 0 ||
candidate.length > GROK_HOME_MAX_LENGTH ||
candidate.length > AGENT_HOME_MAX_LENGTH ||
candidate !== candidate.trim() ||
!candidate.startsWith('/') ||
candidate.includes('\\') ||
@@ -53,26 +53,38 @@ function resolveLoginShell(): string {
return candidate
}
export async function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise<string> {
const fallback = defaultGrokHome(home)
async function resolveRelayAgentHome(
envName: 'GROK_HOME' | 'KIRO_HOME',
fallback: string,
signal?: AbortSignal
): Promise<string> {
try {
const shell = resolveLoginShell()
const shellName = basename(shell)
const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc'
// Why: agent PTYs start login shells, so read the same profile-derived
// GROK_HOME without opening two additional SSH exec channels.
// home override without opening two additional SSH exec channels.
const { stdout } = await execFileAsync(
shell,
[mode, `printenv GROK_HOME | head -c ${GROK_HOME_MAX_LENGTH + 1}`],
{ encoding: 'utf8', timeout: GROK_HOME_PROBE_TIMEOUT_MS, signal }
[mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`],
{ encoding: 'utf8', timeout: AGENT_HOME_PROBE_TIMEOUT_MS, signal }
)
return normalizeGrokHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback
return normalizeAgentHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback
} catch {
signal?.throwIfAborted()
return fallback
}
}
export function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise<string> {
return resolveRelayAgentHome('GROK_HOME', defaultAgentHome(home, '.grok'), signal)
}
/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */
export function resolveRelayKiroHome(home: string, signal?: AbortSignal): Promise<string> {
return resolveRelayAgentHome('KIRO_HOME', defaultAgentHome(home, '.kiro'), signal)
}
export async function installManagedHooks(options?: {
signal?: AbortSignal
hostKeyFingerprint?: string
@@ -86,9 +98,11 @@ export async function installManagedHooks(options?: {
return { installers: 0, errors: 0 }
}
const home = homedir()
const grokHomeDir = agents.includes('grok')
? await resolveRelayGrokHome(home, options?.signal)
: undefined
// Why parallel: each probe may wait out its own login-shell timeout.
const [grokHomeDir, kiroHomeDir] = await Promise.all([
agents.includes('grok') ? resolveRelayGrokHome(home, options?.signal) : undefined,
agents.includes('kiro') ? resolveRelayKiroHome(home, options?.signal) : undefined
])
options?.signal?.throwIfAborted()
const hostIdentity = scopeManagedHookHostIdentity(
await readManagedHookHostIdentity(),
@@ -103,6 +117,7 @@ export async function installManagedHooks(options?: {
home,
{
grokHomeDir,
kiroHomeDir,
signal: options?.signal,
agents,
...(options?.claudeVersion ? { claudeVersion: options.claudeVersion } : {})
@@ -21,6 +21,7 @@ import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { dshHookService } from '../dsh/hook-service'
import { kiroHookService } from '../kiro/hook-service'
import { museHookService } from '../muse/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { qwenCodeHookService } from '../qwen-code/hook-service'
@@ -61,6 +62,7 @@ describe('remote hook service registry coverage', () => {
['codebuddy', codebuddyHookService],
['zcode', zcodeHookService],
['dsh', dshHookService],
['kiro', kiroHookService],
['jcode', jcodeHookService]
])
@@ -17,6 +17,7 @@ import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { jcodeHookService } from '../jcode/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { kiroHookService } from '../kiro/hook-service'
import { dshHookService } from '../dsh/hook-service'
import { museHookService } from '../muse/hook-service'
import { zcodeHookService } from '../zcode/hook-service'
@@ -29,6 +30,8 @@ export type RemoteManagedHookInstallOptions = {
deferTrustUntilConfigToml?: boolean
/** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */
grokHomeDir?: string
/** Login-shell KIRO_HOME on the execution host; it replaces ~/.kiro outright. */
kiroHomeDir?: string
/** Version reported by Claude on this execution host. */
claudeVersion?: string
/** Stops before starting the next installer when the owning relay request
@@ -87,7 +90,12 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)],
['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)],
['dsh', (sftp, remoteHome) => dshHookService.installRemote(sftp, remoteHome)],
['jcode', (sftp, remoteHome) => jcodeHookService.installRemote(sftp, remoteHome)]
['jcode', (sftp, remoteHome) => jcodeHookService.installRemote(sftp, remoteHome)],
[
'kiro',
(sftp, remoteHome, options) =>
kiroHookService.installRemote(sftp, remoteHome, options?.kiroHomeDir)
]
]
/** Agents wired into the remote (SSH) hook installer. Exported so an invariant
@@ -48,7 +48,8 @@ const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
muse: 'UserPromptSubmit',
zcode: 'SessionStart',
dsh: 'SessionStart',
jcode: 'turn_start'
jcode: 'turn_start',
kiro: 'agentSpawn'
}
function reviveRetiredPane(source: unknown, hookEventName: string): boolean {
@@ -76,8 +76,8 @@ test.each(['qoder', 'jcode'] as const)(
filters: {
agents: AI_VAULT_AGENTS.filter((candidate) =>
agent === 'qoder'
? candidate !== 'jcode'
: !['codebuddy', 'zcode', 'qoder'].includes(candidate)
? !['jcode', 'kiro'].includes(candidate)
: !['codebuddy', 'zcode', 'qoder', 'kiro'].includes(candidate)
)
}
},
@@ -0,0 +1,73 @@
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
import {
remoteSessionContentLines,
streamedSessionContentLines
} from './remote-session-content-lines'
import type { RemoteScannerContext, RemoteSessionSource } from './remote-session-scanner-types'
import {
isKiroSessionMetadataPath,
kiroTranscriptPathForMetadata,
parseKiroSessionContent
} from './session-scanner-kiro-parser'
async function readTranscriptLines(
metadataPath: string,
context: RemoteScannerContext
): Promise<Iterable<string> | AsyncIterable<string> | null> {
const transcriptPath = kiroTranscriptPathForMetadata(metadataPath)
// Why: beside the execution host the transcript streams; megabyte tool outputs are common.
if (context.provider.readTranscriptBytes) {
return streamedSessionContentLines(
context.provider.readTranscriptBytes(transcriptPath, context.signal),
context.signal
)
}
try {
throwIfAiVaultScanCancelled(context.signal)
const read = await context.provider.readFile(transcriptPath)
throwIfAiVaultScanCancelled(context.signal)
return read.isBinary ? null : remoteSessionContentLines(read.content, context.signal)
} catch (error) {
throwIfAiVaultScanCancelled(context.signal)
if (isMissingRemoteSessionPathError(error)) {
return null
}
throw error
}
}
export function remoteKiroSource(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
kiroHomeDir?: string
): RemoteSessionSource {
return {
agent: 'kiro',
rootDir: kiroHomeDir
? joinRemotePath(hostPlatform, kiroHomeDir, 'sessions', 'cli')
: joinRemotePath(hostPlatform, remoteHome, '.kiro', 'sessions', 'cli'),
extensions: ['.json'],
filePredicate: isKiroSessionMetadataPath,
contentDependencyPath: kiroTranscriptPathForMetadata,
directoryPredicate: () => false,
parse: async (file, content, context) => {
const options = {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
}
try {
const lines = await readTranscriptLines(file.path, context)
return await parseKiroSessionContent(file, content, lines, context.hostPlatform.os, options)
} catch (error) {
// A transcript that vanished mid-stream leaves a valid metadata-only session.
if (!isMissingRemoteSessionPathError(error)) {
throw error
}
return parseKiroSessionContent(file, content, null, context.hostPlatform.os, options)
}
}
}
}
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
import { KIRO_FIXTURE_SESSION_ID } from './session-scanner-kiro-fixtures'
afterEach(() => vi.unstubAllEnvs())
describe('remote Kiro history', () => {
it.each([
['linux-x64', '/home/ada', '/srv/kiro'],
['win32-x64', 'C:/Users/Ada', 'D:/kiro']
] as const)(
'reads the %s execution host override, independent of the client',
async (platform, remoteHome, kiroHomeDir) => {
vi.stubEnv('KIRO_HOME', '/client/must-not-be-read')
const provider = new MemoryRemoteProvider()
const path = `${kiroHomeDir}/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json`
provider.addFile(path, JSON.stringify({ cwd: remoteHome, title: 'Remote Kiro' }), 10)
provider.addFile(
`${path}l`,
jsonLines([
{ kind: 'Prompt', data: { content: [{ kind: 'text', data: 'Read the file' }] } },
{ kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'Done' }] } }
]),
11
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:kiro-host',
remoteHome,
kiroHomeDir,
hostPlatform: getRemoteHostPlatform(platform)
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'kiro',
sessionId: KIRO_FIXTURE_SESSION_ID,
filePath: path,
messageCount: 2,
executionHostId: 'ssh:kiro-host'
})
}
)
it('keeps the remote default when the peer supplies no override', async () => {
vi.stubEnv('KIRO_HOME', '/client/must-not-be-read')
const provider = new MemoryRemoteProvider()
const path = `/home/ada/.kiro/sessions/cli/${KIRO_FIXTURE_SESSION_ID}.json`
provider.addFile(path, JSON.stringify({ cwd: '/home/ada', title: 'Remote Kiro' }), 10)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:kiro-host',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions[0]?.filePath).toBe(path)
})
})
@@ -34,6 +34,7 @@ import {
import type { FileWithMtime } from './session-scanner-types'
import { remoteCodexSources } from './remote-session-scanner-codex-sources'
import { remoteClineSource } from './remote-session-scanner-cline-source'
import { remoteKiroSource } from './remote-session-scanner-kiro-source'
import { remoteDevinSource } from './remote-session-scanner-devin-source'
import type {
RemoteParserOptions,
@@ -53,7 +54,8 @@ type RemoteContentParser<T = string> = (
export function remoteSessionSources(
remoteHome: string,
hostPlatform: RemoteHostPlatform,
includeAntigravityIdeSessions = false
includeAntigravityIdeSessions = false,
kiroHomeDir?: string
): RemoteSessionSource[] {
return [
...remoteCodexSources(remoteHome, hostPlatform),
@@ -121,6 +123,7 @@ export function remoteSessionSources(
(path) => remotePathSegments(path).includes('agent-transcripts')
),
remoteClineSource(remoteHome, hostPlatform),
remoteKiroSource(remoteHome, hostPlatform, kiroHomeDir),
source(
'hermes',
remoteHome,
+3 -1
View File
@@ -56,6 +56,7 @@ export async function scanRemoteAiVaultSessions(args: {
provider: RemoteSessionFilesystemProvider
executionHostId: ExecutionHostId
remoteHome: string
kiroHomeDir?: string
hostPlatform: RemoteHostPlatform
includeAntigravityIdeSessions?: boolean
limit?: number
@@ -86,7 +87,8 @@ export async function scanRemoteAiVaultSessions(args: {
...remoteSessionSources(
args.remoteHome,
args.hostPlatform,
args.includeAntigravityIdeSessions
args.includeAntigravityIdeSessions,
args.kiroHomeDir
),
...remoteOpenCodeSources(
provider.openCode,
@@ -12,6 +12,7 @@ import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { parseMuseSessionFile } from './session-scanner-muse-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { parseJcodeSessionFile } from './session-scanner-jcode-parser'
import { parseKiroSessionFile } from './session-scanner-kiro-parser'
import {
captureOpenCodeSqliteSessionViaWorker,
captureOpenCode2SqliteSessionViaWorker,
@@ -164,5 +165,7 @@ export async function parseAgentSessionFile(
return parseMuseSessionFile(candidate.file, platform, messages)
case 'jcode':
return parseJcodeSessionFile(candidate.file, platform, messages)
case 'kiro':
return parseKiroSessionFile(candidate.file, platform, messages)
}
}
@@ -5,6 +5,7 @@ import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override'
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion'
import { resolveGrokSessionsDir } from '../../shared/grok-session-paths'
import { resolveKiroHomeDir } from '../../shared/kiro-home'
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
import {
clineMessagesPathForMetadata,
@@ -14,6 +15,10 @@ import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta'
import { devinSessionsDbDependencyPath } from './session-scanner-devin-db'
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
import { resolveMuseSessionsDir } from './session-scanner-muse-paths'
import {
isKiroSessionMetadataPath,
kiroTranscriptPathForMetadata
} from './session-scanner-kiro-parser'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
import {
claudeProjectsRootDirs,
@@ -65,6 +70,7 @@ const DEVIN_TRANSCRIPTS_DIR = join(
),
'transcripts'
)
const KIRO_SESSIONS_DIR = join(resolveKiroHomeDir(), 'sessions', 'cli')
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
const CLINE_SESSIONS_DIR =
@@ -228,6 +234,20 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
// Why: skip the live .journal.jsonl appends and consolidated backups.
filePredicate: (filePath) => basename(filePath).startsWith('session_')
},
kiro: {
rootDirs: (options, wslHomeDirs) =>
sessionRootDirs(options.kiroSessionsDir ?? KIRO_SESSIONS_DIR, wslHomeDirs, [
'.kiro',
'sessions',
'cli'
]),
extensions: ['.json'],
filePredicate: isKiroSessionMetadataPath,
// Why: turns append to the .jsonl transcript; its stat must refresh the cached row.
contentDependencyPath: kiroTranscriptPathForMetadata,
// Per-session subdirectories hold background task state, not sessions.
directoryPredicate: () => false
},
rovo: {
rootDirs: (options, wslHomeDirs) =>
sessionRootDirs(options.rovoSessionsDir ?? ROVO_SESSIONS_DIR, wslHomeDirs, [
@@ -4,6 +4,7 @@ import {
writeMuseScannerFixture,
writeOpenCode2SqliteFixture
} from './session-scanner-test-fixtures'
import { writeKiroSessionFixture } from './session-scanner-kiro-fixtures'
import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures'
import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures'
import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture'
@@ -36,6 +37,7 @@ export async function writeEveryAgentVault(root: string): Promise<EveryAgentVaul
await writeDocumentAgentFixtures(root, roots, antigravitySessionId)
await writeMuseScannerFixture(roots.museSessionsDir)
await writeJcodeSessionFixture(roots)
await writeKiroSessionFixture(roots)
roots.opencodeDbPaths = [await writeOpenCode2SqliteFixture(root)]
writeOpenCodeSqliteDatabase(roots.zcodeDbPath, [
{
@@ -0,0 +1,56 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { type isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures'
export const KIRO_FIXTURE_SESSION_ID = '0b5e1c2a-3d4f-4a5b-8c6d-7e8f9a0b1c2d'
/** Kiro CLI's flat layout: `<id>.json` metadata beside its `<id>.jsonl` transcript. */
export async function writeKiroSessionFixture(
roots: ReturnType<typeof isolatedScanRoots>
): Promise<void> {
const dir = roots.kiroSessionsDir
await mkdir(join(dir, KIRO_FIXTURE_SESSION_ID, 'tasks'), { recursive: true })
await writeFile(
join(dir, `${KIRO_FIXTURE_SESSION_ID}.json`),
JSON.stringify({
session_id: KIRO_FIXTURE_SESSION_ID,
cwd: '/tmp/kiro',
created_at: '2026-05-01T10:13:00.000000000Z',
updated_at: '2026-05-01T10:13:02.000000000Z',
title: 'Kiro title',
session_created_reason: 'subagent',
session_state: { rts_model_state: { model_info: { model_id: 'kiro-model' } } }
})
)
await writeJsonlFile(join(dir, `${KIRO_FIXTURE_SESSION_ID}.jsonl`), [
{
version: 'v1',
kind: 'Prompt',
data: { content: [{ kind: 'text', data: 'Kiro title' }], meta: { timestamp: 1777630381 } }
},
{
version: 'v1',
kind: 'AssistantMessage',
data: {
content: [
{ kind: 'thinking', data: { text: 'hidden reasoning' } },
{ kind: 'toolUse', data: { toolUseId: 't1', name: 'read', input: { path: 'a.txt' } } }
]
}
},
{
version: 'v1',
kind: 'ToolResults',
data: {
content: [
{ kind: 'toolResult', data: { toolUseId: 't1', content: [{ kind: 'text', data: 'a' }] } }
]
}
},
{
version: 'v1',
kind: 'AssistantMessage',
data: { content: [{ kind: 'text', data: 'Kiro reply' }] }
}
])
}
@@ -0,0 +1,121 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
isKiroSessionMetadataPath,
kiroTranscriptPathForMetadata,
parseKiroSessionContent,
parseKiroSessionFile
} from './session-scanner-kiro-parser'
const SESSION_ID = '0b5e1c2a-3d4f-4a5b-8c6d-7e8f9a0b1c2d'
const metadata = (extra: Record<string, unknown> = {}): string =>
JSON.stringify({
session_id: SESSION_ID,
cwd: '/tmp/kiro',
created_at: '2026-05-01T10:13:00.123456789Z',
updated_at: '2026-05-01T10:13:05.000000000Z',
title: 'Fix the login bug',
session_state: { rts_model_state: { model_info: { model_id: 'claude-opus-5' } } },
...extra
})
const prompt = JSON.stringify({
version: 'v1',
kind: 'Prompt',
data: { content: [{ kind: 'text', data: 'Fix the login bug' }], meta: { timestamp: 1777630381 } }
})
const reply = JSON.stringify({
version: 'v1',
kind: 'AssistantMessage',
data: { content: [{ kind: 'text', data: 'Fixed it.' }] }
})
const fileAt = (path: string) => ({ path, mtimeMs: 1, modifiedAt: new Date(1).toISOString() })
let root: string | null = null
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true })
root = null
}
})
async function writeSession(meta: string, transcript?: string): Promise<string> {
root = await mkdtemp(join(tmpdir(), 'orca-kiro-sessions-'))
const path = join(root, `${SESSION_ID}.json`)
await writeFile(path, meta)
if (transcript !== undefined) {
await writeFile(kiroTranscriptPathForMetadata(path), transcript)
}
return path
}
describe('Kiro session parser', () => {
it('matches only <uuid>.json metadata files', () => {
expect(isKiroSessionMetadataPath(`/x/${SESSION_ID}.json`)).toBe(true)
expect(isKiroSessionMetadataPath(`/x/${SESSION_ID}.jsonl`)).toBe(false)
expect(isKiroSessionMetadataPath(`/x/${SESSION_ID}.lock`)).toBe(false)
expect(isKiroSessionMetadataPath('/x/agent_config.json')).toBe(false)
})
it('reads title, cwd, model and turns from metadata plus transcript', async () => {
const path = await writeSession(metadata(), `${prompt}\n${reply}\n`)
const session = await parseKiroSessionFile(fileAt(path), 'linux')
expect(session).toMatchObject({
agent: 'kiro',
sessionId: SESSION_ID,
title: 'Fix the login bug',
cwd: '/tmp/kiro',
model: 'claude-opus-5',
messageCount: 2
})
expect(session?.previewMessages.map((message) => message.text)).toEqual([
'Fix the login bug',
'Fixed it.'
])
})
it('keeps a complete final record that lacks a trailing newline', async () => {
const path = await writeSession(metadata(), `${prompt}\n${reply}`)
expect((await parseKiroSessionFile(fileAt(path), 'linux'))?.messageCount).toBe(2)
})
it('lists a session whose transcript has not been written yet', async () => {
const path = await writeSession(metadata())
const session = await parseKiroSessionFile(fileAt(path), 'linux')
expect(session?.messageCount).toBe(0)
expect(session?.title).toBe('Fix the login bug')
})
it('omits a subagent child session from the top-level list', async () => {
const path = await writeSession(
metadata({ parent_session_id: '1c6f2d3b-4e5a-4b6c-9d7e-8f9a0b1c2d3e' }),
`${prompt}\n`
)
expect(await parseKiroSessionFile(fileAt(path), 'linux')).toBeNull()
})
it('resumes by the validated file name, not a mismatched metadata session_id', async () => {
const path = await writeSession(metadata({ session_id: "x' && rm -rf ~" }))
const session = await parseKiroSessionFile(fileAt(path), 'linux')
expect(session?.sessionId).toBe(SESSION_ID)
expect(session?.resumeCommand).toContain(`--resume-id '${SESSION_ID}'`)
})
it('parses streamed remote lines the same way', async () => {
const session = await parseKiroSessionContent(
fileAt(`/home/u/.kiro/sessions/cli/${SESSION_ID}.json`),
metadata(),
[prompt, reply],
'linux'
)
expect(session?.messageCount).toBe(2)
expect(session?.resumeCommand).toBe(
`cd '/tmp/kiro' && kiro-cli chat --tui --resume-id '${SESSION_ID}'`
)
})
})
@@ -0,0 +1,189 @@
import { basename } from 'node:path'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
addPreviewContent,
createAccumulator,
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
arrayValue,
asRecord,
extractString,
normalizeTitleText,
parseJsonObject
} from './session-scanner-values'
type ParserSessionOptions = {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
messages?: TranscriptMessageSink
}
// Kiro CLI keeps each session as <id>.json (metadata) beside <id>.jsonl (transcript),
// flat under $KIRO_HOME/sessions/cli; per-session subdirectories hold task state only.
// This is the `kiro-cli chat` store Orca launches; the opt-in ACP V3 engine owns its own.
const KIRO_SESSION_FILE_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/i
export function isKiroSessionMetadataPath(filePath: string): boolean {
return KIRO_SESSION_FILE_PATTERN.test(basename(filePath))
}
export function kiroTranscriptPathForMetadata(filePath: string): string {
return filePath.endsWith('.json') ? `${filePath}l` : ''
}
/** Kiro's `{ kind, data }` blocks restated as the Claude-shaped blocks the previewer reads. */
function toPreviewBlocks(content: unknown): Record<string, unknown>[] {
const blocks: Record<string, unknown>[] = []
for (const value of arrayValue(content)) {
const block = asRecord(value)
const data = block?.data
switch (block?.kind) {
case 'text':
if (typeof data === 'string' && data) {
blocks.push({ type: 'text', text: data })
}
break
case 'json':
blocks.push({ type: 'text', text: JSON.stringify(data) })
break
case 'toolUse': {
const tool = asRecord(data)
blocks.push({ type: 'tool_use', name: extractString(tool?.name), input: tool?.input })
break
}
case 'toolResult':
blocks.push({ type: 'tool_result', content: toPreviewBlocks(asRecord(data)?.content) })
break
// thinking and image blocks carry nothing the history list shows.
default:
break
}
}
return blocks
}
function consumeKiroTranscriptLine(accumulator: SessionAccumulator, line: string): void {
const record = parseJsonObject(line)
const data = asRecord(record?.data)
if (!record || !data) {
return
}
const content = toPreviewBlocks(data.content)
switch (record.kind) {
case 'Prompt': {
const timestamp = asRecord(data.meta)?.timestamp
updateTimeline(accumulator, timestamp)
accumulator.messageCount++
addPreviewContent(accumulator, 'user', content, timestamp)
break
}
case 'AssistantMessage':
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', content)
break
case 'ToolResults':
addPreviewContent(accumulator, 'tool', content)
break
default:
break
}
}
/** null for a subagent's child session: it is reached from its parent, not listed. */
function createKiroAccumulator(
file: FileWithMtime,
metadataContent: string,
messages?: TranscriptMessageSink
): SessionAccumulator | null {
const metadata = parseJsonObject(metadataContent)
if (!metadata || extractString(metadata.parent_session_id)) {
return null
}
const accumulator = createAccumulator({
agent: 'kiro',
file,
// The scanner only admits UUID file names, so the basename is the safe resume id.
sessionId: basename(file.path, '.json'),
messages
})
accumulator.cwd = extractString(metadata.cwd)
accumulator.title = normalizeTitleText(extractString(metadata.title) ?? '') || null
const state = asRecord(metadata.session_state)
accumulator.model = extractString(
asRecord(asRecord(state?.rts_model_state)?.model_info)?.model_id
)
updateTimeline(accumulator, metadata.created_at)
updateTimeline(accumulator, metadata.updated_at)
return accumulator
}
export async function parseKiroSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const accumulator = createKiroAccumulator(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
messages
)
if (!accumulator) {
return null
}
try {
// Why streamed: Kiro transcripts embed whole tool outputs and reach megabytes.
const { trailingPartialLine } = await consumeCompleteJsonlLines({
path: kiroTranscriptPathForMetadata(file.path),
start: 0,
onLine: (line) => consumeKiroTranscriptLine(accumulator, line)
})
// A torn final write fails JSON parsing and is dropped; a complete one still counts.
if (trailingPartialLine) {
consumeKiroTranscriptLine(accumulator, trailingPartialLine)
}
} catch (error) {
// The metadata is written before the first turn; a missing transcript is a valid
// empty session, while a WSL gate refusal must stay visible.
if (error instanceof WslTranscriptFsError || !isMissingPathError(error)) {
throw error
}
}
return finalizeSession(accumulator, platform)
}
/** Remote/whole-content form: `transcriptLines` is null when the transcript is absent. */
export async function parseKiroSessionContent(
file: FileWithMtime,
metadataContent: string,
transcriptLines: Iterable<string> | AsyncIterable<string> | null,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
const accumulator = createKiroAccumulator(file, metadataContent, options.messages)
if (!accumulator) {
return null
}
if (transcriptLines) {
for await (const line of transcriptLines) {
consumeKiroTranscriptLine(accumulator, line)
}
}
return finalizeSession(accumulator, platform, options)
}
function isMissingPathError(error: unknown): boolean {
const code =
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
return code === 'ENOENT' || code === 'ENOTDIR'
}
@@ -60,6 +60,7 @@ function isolatedScanRoots(root: string) {
zcodeDbPath: join(root, 'zcode-db.sqlite'),
ompSessionsDir: join(root, 'omp-sessions'),
jcodeSessionsDir: join(root, 'jcode-sessions'),
kiroSessionsDir: join(root, 'kiro-sessions'),
primeAgentSessionsDir: join(root, 'prime-agent-sessions')
}
}
@@ -85,6 +85,7 @@ function resumableStateFactoryFor(
case 'jcode':
case 'cline':
case 'kimi':
case 'kiro':
case 'muse':
case 'opencode':
case 'opencode2':
@@ -38,6 +38,7 @@ describe('buildAiVaultServiceEnv', () => {
DEVIN_HOME: '/home/dev/.devin',
GROK_HOME: '/home/dev/.grok',
KIMI_CODE_HOME: '/home/dev/.kimi-code',
KIRO_HOME: '/home/dev/kiro-data',
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
@@ -55,6 +56,7 @@ describe('buildAiVaultServiceEnv', () => {
DEVIN_HOME: '/home/dev/.devin',
GROK_HOME: '/home/dev/.grok',
KIMI_CODE_HOME: '/home/dev/.kimi-code',
KIRO_HOME: '/home/dev/kiro-data',
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
@@ -113,6 +115,14 @@ describe('buildAiVaultServiceEnv', () => {
})
describe('buildRelayAiVaultServiceEnv', () => {
it('preserves the execution host Kiro home with Windows case folding', () => {
expect(buildRelayAiVaultServiceEnv({ KIRO_HOME: '/srv/kiro' }, 'linux')).toEqual({
KIRO_HOME: '/srv/kiro'
})
expect(buildRelayAiVaultServiceEnv({ kiro_home: 'D:\\kiro' }, 'win32')).toEqual({
KIRO_HOME: 'D:\\kiro'
})
})
it('drops Node flag injection variables', () => {
const env = buildRelayAiVaultServiceEnv(
{ NODE_OPTIONS: '--max-old-space-size=8192', NODE_PATH: '/tmp/evil', HOME: '/home/ada' },
@@ -42,6 +42,7 @@ const AGENT_ROOT_ENV_ALLOWLIST = [
'DEVIN_HOME',
'GROK_HOME',
'KIMI_CODE_HOME',
'KIRO_HOME',
'OMP_CODING_AGENT_DIR',
'OMP_PROFILE',
'OPENCLAW_STATE_DIR',
@@ -103,7 +104,7 @@ export function buildRelayAiVaultServiceEnv(
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
return pickAllowedEnv(
[...RUNTIME_ENV_ALLOWLIST, 'XDG_DATA_HOME', 'OPENCODE_DB'],
[...RUNTIME_ENV_ALLOWLIST, 'XDG_DATA_HOME', 'OPENCODE_DB', 'KIRO_HOME'],
baseEnv,
platform
)
@@ -80,7 +80,8 @@ export function isolatedScanRoots(root: string) {
clineSessionsDir: join(root, 'cline-sessions'),
kimiSessionsDir: join(root, 'kimi-sessions'),
museSessionsDir: join(root, 'muse-sessions'),
jcodeSessionsDir: join(root, 'jcode-sessions')
jcodeSessionsDir: join(root, 'jcode-sessions'),
kiroSessionsDir: join(root, 'kiro-sessions')
}
}
@@ -52,6 +52,7 @@ export type AiVaultScanOptions = {
kimiSessionsDir?: string
museSessionsDir?: string
jcodeSessionsDir?: string
kiroSessionsDir?: string
limit?: number
unlimited?: boolean
limitPerAgent?: number
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
import { KIRO_FIXTURE_SESSION_ID } from './session-scanner-kiro-fixtures'
import { writeEveryAgentVault } from './session-scanner-every-agent-fixture'
// Why: the SQLite worker bundle does not exist in the test runtime; route the
@@ -463,6 +464,9 @@ describe('scanAiVaultSessions', () => {
expect(commandByAgent.get('jcode')).toBe(
"cd '/tmp/jcode' && jcode --resume 'session_jcode-session'"
)
expect(commandByAgent.get('kiro')).toBe(
`cd '/tmp/kiro' && kiro-cli chat --tui --resume-id '${KIRO_FIXTURE_SESSION_ID}'`
)
const ompSession = result.sessions.find((session) => session.agent === 'omp')
expect(ompSession?.model).toBe('gpt-5.4-mini')
+243
View File
@@ -0,0 +1,243 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createManagedHookLocalFilesystem } from '../agent-hooks/managed-hook-local-filesystem'
import { isPlainObject } from '../agent-hooks/installer-utils'
import { KiroHookService } from './hook-service'
import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings'
// Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows; setting
// only one would let a run edit the developer's real ~/.kiro or ~/.orca.
let home: string
let originalHome: string | undefined
let originalUserProfile: string | undefined
let originalKiroHome: string | undefined
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-kiro-hook-'))
originalHome = process.env.HOME
originalUserProfile = process.env.USERPROFILE
process.env.HOME = home
process.env.USERPROFILE = home
originalKiroHome = process.env.KIRO_HOME
delete process.env.KIRO_HOME
})
afterEach(() => {
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
if (originalKiroHome === undefined) {
delete process.env.KIRO_HOME
} else {
process.env.KIRO_HOME = originalKiroHome
}
rmSync(home, { recursive: true, force: true })
})
const agentsDir = (): string => join(home, '.kiro', 'agents')
const agentPath = (name: string): string => join(agentsDir(), `${name}.json`)
const scriptPath = (): string =>
join(
home,
'.orca',
'agent-hooks',
process.platform === 'win32' ? 'kiro-hook.cmd' : 'kiro-hook.sh'
)
function writeAgent(name: string, config: unknown): void {
mkdirSync(agentsDir(), { recursive: true })
writeFileSync(agentPath(name), `${JSON.stringify(config, null, 2)}\n`)
}
function readAgent(name: string): Record<string, unknown> {
return JSON.parse(readFileSync(agentPath(name), 'utf-8'))
}
const USER_STOP_HOOK = { command: 'say done' }
describe('KiroHookService', () => {
it('reports the built-in default agent gap when no custom agents exist', () => {
const result = new KiroHookService().install()
expect(result.state).toBe('not_installed')
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.
expect(existsSync(scriptPath())).toBe(true)
})
it('adds one managed hook per event to every agent and keeps user hooks', () => {
writeAgent('caverna', { name: 'caverna', tools: ['*'], hooks: { stop: [USER_STOP_HOOK] } })
writeAgent('reviewer', { name: 'reviewer', tools: ['read'] })
const service = new KiroHookService()
expect(service.install().state).toBe('installed')
// Idempotent: a second install must not duplicate the managed entries.
expect(service.install().state).toBe('installed')
const caverna = readAgent('caverna')
expect(caverna.tools).toEqual(['*'])
const hooks = caverna.hooks
if (!isPlainObject(hooks)) {
throw new Error('Expected hooks object')
}
expect(Object.keys(hooks).sort()).toEqual([...KIRO_HOOK_EVENTS].sort())
const isManaged = getKiroManagedCommandMatcher()
for (const event of KIRO_HOOK_EVENTS) {
const entries = hooks[event]
if (!Array.isArray(entries)) {
throw new Error('Expected hook array')
}
expect(
entries.filter((entry) => isPlainObject(entry) && isManaged(String(entry.command)))
).toHaveLength(1)
}
expect(hooks.stop).toEqual([
USER_STOP_HOOK,
expect.objectContaining({ command: expect.any(String) })
])
expect(hooks.preToolUse).toEqual([expect.objectContaining({ matcher: '*' })])
expect(readAgent('reviewer').hooks).toBeDefined()
})
it('reports partial when an agent file cannot be parsed and leaves it untouched', () => {
writeAgent('good', { name: 'good' })
mkdirSync(agentsDir(), { recursive: true })
writeFileSync(agentPath('broken'), '{ not json')
const result = new KiroHookService().install()
expect(result.state).toBe('partial')
expect(result.detail).toContain('broken.json is not valid JSON')
expect(readFileSync(agentPath('broken'), 'utf-8')).toBe('{ not json')
})
it('reports partial when an agent created after install has no hooks yet', () => {
writeAgent('first', { name: 'first' })
const service = new KiroHookService()
service.install()
writeAgent('later', { name: 'later' })
const result = service.getStatus()
expect(result.state).toBe('partial')
expect(result.detail).toContain('later.json has no Orca hooks')
})
it('reports a missing launcher as partial instead of installed', () => {
writeAgent('tracked', { name: 'tracked' })
const service = new KiroHookService()
service.install()
rmSync(scriptPath())
expect(service.getStatus()).toMatchObject({
state: 'partial',
managedHooksPresent: true,
detail: expect.stringContaining('script missing')
})
})
it.each([{ hooks: [] }, { hooks: 'keep this' }, { hooks: { stop: { command: 'say done' } } }])(
'preserves unsupported user hook structures and reports them locally and remotely: %j',
async ({ hooks }) => {
writeAgent('unsupported', { name: 'unsupported', hooks })
const original = readFileSync(agentPath('unsupported'), 'utf-8')
const service = new KiroHookService()
expect(service.install()).toMatchObject({ state: 'error' })
expect(readFileSync(agentPath('unsupported'), 'utf-8')).toBe(original)
expect(await service.installRemote(createManagedHookLocalFilesystem(), home)).toMatchObject({
state: 'error'
})
expect(readFileSync(agentPath('unsupported'), 'utf-8')).toBe(original)
}
)
it('removes only managed hooks and drops the hooks block it emptied', () => {
writeAgent('caverna', { name: 'caverna', hooks: { stop: [USER_STOP_HOOK] } })
writeAgent('plain', { name: 'plain' })
const service = new KiroHookService()
service.install()
expect(service.remove().state).toBe('not_installed')
expect(readAgent('caverna').hooks).toEqual({ stop: [USER_STOP_HOOK] })
expect(readAgent('plain')).toEqual({ name: 'plain' })
})
it('patches the agents under $KIRO_HOME instead of ~/.kiro', () => {
const kiroHome = join(home, 'custom-kiro')
mkdirSync(join(kiroHome, 'agents'), { recursive: true })
writeFileSync(join(kiroHome, 'agents', 'relocated.json'), JSON.stringify({ name: 'relocated' }))
process.env.KIRO_HOME = kiroHome
expect(new KiroHookService().install().state).toBe('installed')
expect(existsSync(agentsDir())).toBe(false)
})
it('patches the remote agents under the probed KIRO_HOME, not ~/.kiro', async () => {
const kiroHome = join(home, 'remote-kiro')
mkdirSync(join(kiroHome, 'agents'), { recursive: true })
writeFileSync(join(kiroHome, 'agents', 'remote.json'), JSON.stringify({ name: 'remote' }))
writeAgent('stale', { name: 'stale' })
const result = await new KiroHookService().installRemote(
createManagedHookLocalFilesystem(),
home,
kiroHome
)
expect(result.state).toBe('installed')
const remote = JSON.parse(readFileSync(join(kiroHome, 'agents', 'remote.json'), 'utf-8'))
expect(Object.keys(remote.hooks).sort()).toEqual([...KIRO_HOOK_EVENTS].sort())
// The inactive default home is left alone.
expect(readAgent('stale').hooks).toBeUndefined()
})
it('falls back to <remoteHome>/.kiro/agents when no KIRO_HOME was probed', async () => {
writeAgent('default', { name: 'default' })
const result = await new KiroHookService().installRemote(
createManagedHookLocalFilesystem(),
home
)
expect(result.state).toBe('installed')
expect(readAgent('default').hooks).toBeDefined()
})
it('reports a remote agent deleted after the listing as unreadable, not invalid JSON', async () => {
writeAgent('kept', { name: 'kept' })
writeAgent('gone', { name: 'gone' })
const sftp = createManagedHookLocalFilesystem()
const readFile = sftp.readFile
Object.assign(sftp, {
readFile: (
path: string,
encoding: BufferEncoding,
callback: (error: Error | undefined, contents: Buffer) => void
) => {
if (path.endsWith('gone.json')) {
rmSync(path)
}
return readFile(path, encoding, callback)
}
})
const result = await new KiroHookService().installRemote(sftp, home)
expect(result.state).toBe('partial')
expect(result.detail).toContain('gone.json is could not be read')
expect(result.detail).not.toContain('not valid JSON')
expect(readAgent('kept').hooks).toBeDefined()
})
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(readFileSync(join(agentsDir(), 'agent_config.json.example'), 'utf-8')).toBe('{}')
})
})
+275
View File
@@ -0,0 +1,275 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, posix as pathPosix } from 'node:path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import {
buildWindowsAgentHookCurlPostCommand,
isPlainObject,
writeHooksJson,
writeManagedScript
} from '../agent-hooks/installer-utils'
import {
listRemoteDirectory,
readTextFileRemote,
writeManagedScriptRemote,
writeTextFileRemoteAtomic
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
applyManagedKiroHooks,
getKiroAgentsDir,
getKiroManagedCommand,
getKiroManagedCommandMatcher,
getKiroManagedScriptPath,
getKiroRemoteAgentsDir,
getKiroRemoteManagedCommand,
isKiroAgentConfigFileName,
isKiroHooksConfigSupported,
KIRO_HOOK_EVENTS,
readManagedKiroHookEvents,
removeManagedKiroHooks,
serializeKiroAgentConfig
} from './hook-settings'
// Why: hook stdout from agentSpawn/userPromptSubmit is injected into Kiro's model context,
// so every path below must stay silent.
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
buildWindowsAgentHookCurlPostCommand('kiro'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('kiro'),
// Why: the endpoint file holds the live port/token; a PTY that outlived an Orca
// restart carries stale env, so source it to reach the new server.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
...buildPosixAgentHookPostCommand('kiro').map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
''
].join('\n')
}
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`
}
type AgentFileRead =
| { fileName: string; config: Record<string, unknown> }
| { fileName: string; error: string }
function status(
configPath: string,
state: AgentHookInstallState,
detail: string | null,
managedHooksPresent = false
): AgentHookInstallStatus {
return { agent: 'kiro', state, configPath, managedHooksPresent, detail }
}
function parseAgentConfig(fileName: string, text: string): AgentFileRead {
try {
const parsed: unknown = JSON.parse(text)
if (!isPlainObject(parsed)) {
return { fileName, error: 'not a JSON object' }
}
if (!isKiroHooksConfigSupported(parsed)) {
return { fileName, error: 'unsupported hooks structure' }
}
return { fileName, config: parsed }
} catch {
return { fileName, error: 'not valid JSON' }
}
}
/** null when the directory itself cannot be read; [] when it does not exist. */
function readLocalAgentFiles(agentsDir: string): AgentFileRead[] | null {
let fileNames: string[]
try {
fileNames = readdirSync(agentsDir).filter(isKiroAgentConfigFileName).sort()
} catch (error) {
return isDefinitiveAbsence(error) ? [] : null
}
return fileNames.map((fileName) => {
try {
return parseAgentConfig(fileName, readFileSync(join(agentsDir, fileName), 'utf-8'))
} catch {
return { fileName, error: 'could not be read' }
}
})
}
function buildStatus(agentsDir: string, files: AgentFileRead[]): AgentHookInstallStatus {
if (files.length === 0) {
return status(agentsDir, 'not_installed', noAgentsDetail(agentsDir))
}
const isManaged = getKiroManagedCommandMatcher()
const problems: string[] = []
let managedHooksPresent = false
let complete = 0
for (const file of files) {
if ('error' in file) {
problems.push(`${file.fileName} is ${file.error}`)
continue
}
const present = readManagedKiroHookEvents(file.config, isManaged)
managedHooksPresent ||= present.size > 0
const missing = KIRO_HOOK_EVENTS.filter((event) => !present.has(event))
if (missing.length === 0) {
complete += 1
} else if (present.size > 0) {
problems.push(`${file.fileName} is missing managed hooks for: ${missing.join(', ')}`)
} else {
problems.push(`${file.fileName} has no Orca hooks`)
}
}
if (problems.length === 0) {
return status(agentsDir, 'installed', null, true)
}
if (!managedHooksPresent && complete === 0) {
return status(
agentsDir,
files.some((file) => 'error' in file) ? 'error' : 'not_installed',
problems.join('; ')
)
}
return status(agentsDir, 'partial', problems.join('; '), managedHooksPresent)
}
/** Installs Orca's status hooks into every global Kiro agent config. */
export class KiroHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getKiroManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const agentsDir = getKiroAgentsDir()
const files = readLocalAgentFiles(agentsDir)
if (files === null) {
return status(agentsDir, 'error', 'Could not read the Kiro agents directory')
}
const result = buildStatus(agentsDir, files)
return result.managedHooksPresent && !existsSync(getKiroManagedScriptPath())
? { ...result, state: 'partial', detail: 'Managed hook script missing' }
: result
}
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.
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) {
if ('error' in file) {
continue
}
const next = applyManagedKiroHooks(file.config, command, isManaged)
writeHooksJson(join(agentsDir, file.fileName), next, {
serialized: serializeKiroAgentConfig(next),
preserveMode: true
})
}
return this.getStatus()
}
/** Install on an SSH execution host, where Kiro runs hooks through sh. */
async installRemote(
sftp: SFTPWrapper,
remoteHome: string,
kiroHomeDir?: string
): Promise<AgentHookInstallStatus> {
const agentsDir = getKiroRemoteAgentsDir(remoteHome, kiroHomeDir)
const scriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/kiro-hook.sh`
try {
const fileNames = ((await listRemoteDirectory(sftp, agentsDir)) ?? [])
.filter(isKiroAgentConfigFileName)
.sort()
if (fileNames.length === 0) {
return status(agentsDir, 'not_installed', noAgentsDetail(agentsDir))
}
await writeManagedScriptRemote(sftp, scriptPath, getManagedScript('posix'))
const command = getKiroRemoteManagedCommand(scriptPath)
const isManaged = getKiroManagedCommandMatcher()
const files: AgentFileRead[] = []
for (const fileName of fileNames) {
const path = pathPosix.join(agentsDir, fileName)
const text = await readTextFileRemote(sftp, path)
// Deleted after the listing: same status as the local unreadable-file path.
const file: AgentFileRead =
text === null
? { fileName, error: 'could not be read' }
: parseAgentConfig(fileName, text)
if ('error' in file) {
files.push(file)
continue
}
const next = applyManagedKiroHooks(file.config, command, isManaged)
await writeTextFileRemoteAtomic(sftp, path, serializeKiroAgentConfig(next))
files.push({ fileName, config: next })
}
return buildStatus(agentsDir, files)
} catch (err) {
return status(agentsDir, 'error', err instanceof Error ? err.message : String(err))
}
}
remove(): AgentHookInstallStatus {
const agentsDir = getKiroAgentsDir()
const files = readLocalAgentFiles(agentsDir)
if (files === null) {
return status(agentsDir, 'error', 'Could not read the Kiro agents directory')
}
const isManaged = getKiroManagedCommandMatcher()
for (const file of files) {
if ('error' in file) {
continue
}
const next = removeManagedKiroHooks(file.config, isManaged)
if (next !== file.config) {
writeHooksJson(join(agentsDir, file.fileName), next, {
serialized: serializeKiroAgentConfig(next),
preserveMode: true
})
}
}
return this.getStatus()
}
}
export const kiroHookService = new KiroHookService()
+169
View File
@@ -0,0 +1,169 @@
import { join, posix as pathPosix } from 'node:path'
import { resolveKiroHomeDir } from '../../shared/kiro-home'
import {
createManagedCommandMatcher,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
isPlainObject,
MANAGED_HOOK_TIMEOUT_MILLISECONDS,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsHookCommand,
type HookDefinition
} from '../agent-hooks/installer-utils'
const KIRO_SCRIPT_BASE = 'kiro-hook'
/** Every event Kiro CLI fires to an agent config's `hooks` block (verified on kiro-cli 2.28). */
export const KIRO_HOOK_EVENTS = [
'agentSpawn',
'userPromptSubmit',
'preToolUse',
'postToolUse',
'stop'
] as const
const KIRO_TOOL_EVENTS: ReadonlySet<string> = new Set(['preToolUse', 'postToolUse'])
export function isKiroHooksConfigSupported(config: Record<string, unknown>): boolean {
if (config.hooks === undefined) {
return true
}
if (!isPlainObject(config.hooks)) {
return false
}
const hooks = config.hooks
return KIRO_HOOK_EVENTS.every(
(event) => hooks[event] === undefined || Array.isArray(hooks[event])
)
}
/**
* Kiro has no global hook config: hooks live in each agent file, and the built-in
* `kiro_default` agent has none to edit. So Orca patches every global agent the user owns.
*/
export function getKiroAgentsDir(): string {
return join(resolveKiroHomeDir(), 'agents')
}
/** Without a probed $KIRO_HOME the default matches the CLI's own fallback. */
export function getKiroRemoteAgentsDir(remoteHome: string, kiroHomeDir?: string): string {
return kiroHomeDir
? pathPosix.join(kiroHomeDir, 'agents')
: pathPosix.join(remoteHome.replace(/\/$/, ''), '.kiro', 'agents')
}
export function isKiroAgentConfigFileName(fileName: string): boolean {
return fileName.endsWith('.json')
}
export function getKiroManagedScriptFileName(): string {
return process.platform === 'win32' ? `${KIRO_SCRIPT_BASE}.cmd` : `${KIRO_SCRIPT_BASE}.sh`
}
export function getKiroManagedScriptPath(): string {
return getSharedManagedScriptPath(getKiroManagedScriptFileName())
}
export function getKiroManagedCommand(scriptPath: string): string {
// Why: Kiro runs hook commands through PowerShell on Windows and sh elsewhere.
return process.platform === 'win32'
? wrapWindowsHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
}
export function getKiroRemoteManagedCommand(scriptPath: string): string {
return wrapPosixHookCommand(scriptPath)
}
export function getKiroManagedCommandMatcher(): (command: string | undefined) => boolean {
return createManagedCommandMatcher(getKiroManagedScriptFileName())
}
function buildManagedDefinition(event: string, command: string): HookDefinition {
return {
...(KIRO_TOOL_EVENTS.has(event) ? { matcher: '*' } : {}),
command,
timeout_ms: MANAGED_HOOK_TIMEOUT_MILLISECONDS
}
}
function splitDefinitions(value: unknown): { definitions: HookDefinition[]; other: unknown[] } {
const entries = Array.isArray(value) ? value : []
return {
definitions: entries.filter(isPlainObject),
other: entries.filter((entry) => !isPlainObject(entry))
}
}
function hasManagedDefinition(
value: unknown,
isManagedCommand: (command: string | undefined) => boolean
): boolean {
return splitDefinitions(value).definitions.some((definition) =>
hookDefinitionHasManagedCommand(definition, isManagedCommand)
)
}
/** Returns the agent config with exactly one Orca hook per event, user hooks untouched. */
export function applyManagedKiroHooks(
config: Record<string, unknown>,
command: string,
isManagedCommand: (command: string | undefined) => boolean
): Record<string, unknown> {
const hooks: Record<string, unknown> = isPlainObject(config.hooks) ? { ...config.hooks } : {}
for (const event of KIRO_HOOK_EVENTS) {
const { definitions, other } = splitDefinitions(hooks[event])
hooks[event] = [
...removeManagedCommands(definitions, isManagedCommand),
...other,
buildManagedDefinition(event, command)
]
}
return { ...config, hooks }
}
export function removeManagedKiroHooks(
config: Record<string, unknown>,
isManagedCommand: (command: string | undefined) => boolean
): Record<string, unknown> {
const original = config.hooks
if (
!isPlainObject(original) ||
!Object.values(original).some((value) => hasManagedDefinition(value, isManagedCommand))
) {
return config
}
const hooks: Record<string, unknown> = { ...original }
for (const [event, value] of Object.entries(hooks)) {
if (!hasManagedDefinition(value, isManagedCommand)) {
continue
}
const { definitions, other } = splitDefinitions(value)
const remaining = [...removeManagedCommands(definitions, isManagedCommand), ...other]
if (remaining.length > 0) {
hooks[event] = remaining
} else {
delete hooks[event]
}
}
if (Object.keys(hooks).length > 0) {
return { ...config, hooks }
}
const { hooks: _emptied, ...rest } = config
return rest
}
export function readManagedKiroHookEvents(
config: unknown,
isManagedCommand: (command: string | undefined) => boolean
): Set<string> {
const hooks = isPlainObject(config) && isPlainObject(config.hooks) ? config.hooks : {}
return new Set(
KIRO_HOOK_EVENTS.filter((event) => hasManagedDefinition(hooks[event], isManagedCommand))
)
}
export function serializeKiroAgentConfig(config: Record<string, unknown>): string {
return `${JSON.stringify(config, null, 2)}\n`
}
@@ -59,7 +59,7 @@ describe('session search runtime RPC', () => {
limit: 20,
filters: {
agents: AI_VAULT_AGENTS.filter(
(agent) => !['codebuddy', 'zcode', 'qoder', 'jcode'].includes(agent)
(agent) => !['codebuddy', 'zcode', 'qoder', 'jcode', 'kiro'].includes(agent)
)
}
},
+6
View File
@@ -1,4 +1,6 @@
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import { resolveAbsoluteDirOverride } from '../shared/absolute-dir-override'
import { joinRemotePath } from '../main/ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
@@ -50,6 +52,10 @@ async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
provider,
executionHostId: LOCAL_EXECUTION_HOST_ID,
remoteHome: init.remoteHome,
kiroHomeDir: resolveAbsoluteDirOverride(
process.env.KIRO_HOME,
joinRemotePath(init.hostPlatform, init.remoteHome, '.kiro')
),
hostPlatform: init.hostPlatform,
limit: request.params.limit,
unlimited: request.params.unlimited,
+1 -1
View File
@@ -59,7 +59,7 @@ describe('session search over real relay frames', () => {
limit: 20,
filters: {
agents: AI_VAULT_AGENTS.filter(
(agent) => !['codebuddy', 'zcode', 'qoder', 'jcode'].includes(agent)
(agent) => !['codebuddy', 'zcode', 'qoder', 'jcode', 'kiro'].includes(agent)
)
}
},
@@ -72,7 +72,8 @@ describe('agentResumeHostAuthorityCapability', () => {
muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
dsh: AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY
dsh: AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
kiro: 'agent-session.kiro-resume.v1'
})
})
})
@@ -14,6 +14,7 @@ import {
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../shared/protocol-version'
import { AGENT_SESSION_KIRO_RESUME_RUNTIME_CAPABILITY } from '../../../shared/agent-session-resume-runtime-capabilities'
// Why: every agent added to RESUMABLE_TUI_AGENTS after agent-session.host-authority.v1 widens the
// host's ensureAgentSession enum. An older host answers the unknown member with invalid_argument,
@@ -50,7 +51,8 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = {
kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
dsh: AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
// Why: jcode resumed by id (--resume <id>) through the same host-authority session enum the generic probe covers.
jcode: undefined
jcode: undefined,
kiro: AGENT_SESSION_KIRO_RESUME_RUNTIME_CAPABILITY
} satisfies Record<ResumableTuiAgent, RuntimeCapability | undefined>
export function agentResumeHostAuthorityCapability(
@@ -29,6 +29,7 @@ import { normalizeMuseEvent } from './providers/muse-events'
import { normalizeDshEvent } from './providers/dsh-events'
import { normalizeZCodeEvent } from './providers/zcode-events'
import { normalizeJcodeEvent } from './providers/jcode-events'
import { normalizeKiroEvent } from './providers/kiro-events'
export type ProviderDispatchResult = {
payload: ParsedAgentStatusPayload | null
@@ -206,7 +207,10 @@ export function normalizeProviderEvent(input: {
resolvedPromptText = promptText
}
payload = normalizeJcodeEvent(state, eventName, resolvedPromptText, paneKey, hookPayload)
break
}
case 'kiro':
payload = normalizeKiroEvent(state, eventName, promptText, paneKey, hookPayload)
}
return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence }
@@ -20,6 +20,7 @@ import { isGrokEvent } from './provider-event-names'
import { extractGrokToolFields } from './providers/grok-tool-fields'
import { extractHermesToolFields } from './providers/hermes-tool-fields'
import { extractJcodeToolFields } from './providers/jcode-tool-fields'
import { extractKiroToolFields } from './providers/kiro-tool-fields'
/** The per-provider answer to "is this event a user-initiated new turn?". Exported so the
* observation stamp reuses it instead of minting a second list of event-name literals. */
@@ -87,6 +88,9 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo
// prompt before the model generates — its real turn boundary. session_start
// returns early in normalizeJcodeEvent and clears the cache itself.
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'
}
}
@@ -210,5 +214,7 @@ export function extractToolFields(
return extractClaudeToolFields(eventName, hookPayload)
case 'jcode':
return extractJcodeToolFields(eventName, hookPayload)
case 'kiro':
return extractKiroToolFields(eventName, hookPayload)
}
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createHookListenerState, type HookListenerState } from '../listener-state'
import { normalizeAndAccept } from '../../agent-hook-listener-test-harness'
// 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' }
function event(name: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
return { ...base, hook_event_name: name, ...extra }
}
describe('normalizeKiroEvent', () => {
let state: HookListenerState
beforeEach(() => {
state = createHookListenerState()
})
it('lands an idle row when the agent spawns', () => {
const spawned = normalizeAndAccept(state, 'kiro', event('agentSpawn'))
expect(spawned?.payload.state).toBe('done')
expect(spawned?.payload.agentType).toBe('kiro')
})
it('reports a prompt as working and stop as done with the final reply', () => {
const submitted = normalizeAndAccept(
state,
'kiro',
event('userPromptSubmit', { prompt: 'read note.txt' })
)
expect(submitted?.payload.state).toBe('working')
expect(submitted?.payload.prompt).toBe('read note.txt')
const stopped = normalizeAndAccept(
state,
'kiro',
event('stop', { assistant_response: 'hello' })
)
expect(stopped?.payload.state).toBe('done')
expect(stopped?.payload.prompt).toBe('read note.txt')
expect(stopped?.payload.lastAssistantMessage).toBe('hello')
})
it('surfaces the running tool while working', () => {
normalizeAndAccept(state, 'kiro', event('userPromptSubmit', { prompt: 'read it' }))
const pre = normalizeAndAccept(
state,
'kiro',
event('preToolUse', {
tool_name: 'read',
tool_input: { operations: [{ mode: 'Line', path: '/tmp/ws/note.txt' }] }
})
)
expect(pre?.payload.state).toBe('working')
expect(pre?.payload.toolName).toBe('read')
const post = normalizeAndAccept(
state,
'kiro',
event('postToolUse', {
tool_name: 'read',
tool_input: { operations: [{ mode: 'Line', path: '/tmp/ws/note.txt' }] },
tool_response: { items: [{ Text: 'hello' }] }
})
)
expect(post?.payload.state).toBe('working')
})
it('ignores event names Kiro does not document', () => {
expect(normalizeAndAccept(state, 'kiro', event('Stop'))).toBeNull()
})
})
@@ -0,0 +1,56 @@
import {
normalizeAgentStatusPayload,
type ParsedAgentStatusPayload
} from '../../agent-status-types'
import type { HookListenerState } from '../listener-state'
import { resolvePrompt, resolveToolState } from '../prompt-fields'
import { extractToolFields, isNewTurnEvent } from '../provider-event-routing'
/**
* Kiro CLI hooks live in each agent config (`~/.kiro/agents/<name>.json`) and fire
* agentSpawn, userPromptSubmit, preToolUse, postToolUse and stop with a Claude-shaped
* stdin payload (`session_id`, `cwd`, `prompt`, `tool_name`, `tool_input`,
* `tool_response`, `assistant_response`). There is no permission or notification event,
* so an approval pause reads as `working` — the agent is mid-tool, not idle.
* Subagents run under their own config and do not fire the lead's hooks.
*/
export function normalizeKiroEvent(
state: HookListenerState,
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
let stateName: 'working' | 'done'
switch (eventName) {
case 'userPromptSubmit':
case 'preToolUse':
case 'postToolUse':
stateName = 'working'
break
case 'agentSpawn':
case 'stop':
stateName = 'done'
break
default:
return null
}
const resetOnNewTurn = isNewTurnEvent('kiro', eventName)
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('kiro', eventName, hookPayload),
{ resetOnNewTurn }
)
return normalizeAgentStatusPayload({
state: stateName,
prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }),
agentType: 'kiro',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage,
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput
})
}
@@ -0,0 +1,31 @@
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. */
export const KIRO_CLAUDE_EVENT_NAMES: Readonly<Record<string, string>> = Object.freeze({
agentSpawn: 'SessionStart',
userPromptSubmit: 'UserPromptSubmit',
preToolUse: 'PreToolUse',
postToolUse: 'PostToolUse',
stop: 'Stop'
})
/**
* Kiro CLI sends Claude's `tool_name`/`tool_input`/`tool_response` verbatim; only the event
* names differ, and `stop` carries the final reply as `assistant_response`.
*/
export function extractKiroToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
const claudeEventName = typeof eventName === 'string' ? KIRO_CLAUDE_EVENT_NAMES[eventName] : null
if (!claudeEventName) {
return {}
}
if (claudeEventName !== 'Stop') {
return extractClaudeToolFields(claudeEventName, hookPayload)
}
const reply = readString(hookPayload, 'assistant_response')
return reply ? { lastAssistantMessage: reply } : {}
}
@@ -29,7 +29,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
'/hook/muse': 'muse',
'/hook/zcode': 'zcode',
'/hook/dsh': 'dsh',
'/hook/jcode': 'jcode'
'/hook/jcode': 'jcode',
'/hook/kiro': 'kiro'
})
export function resolveHookSource(pathname: string): AgentHookSource | null {
+2 -1
View File
@@ -62,7 +62,8 @@ const AGENT_HOOK_SOURCES = [
'muse',
'zcode',
'dsh',
'jcode'
'jcode',
'kiro'
] as const
export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number]
+2 -1
View File
@@ -25,7 +25,8 @@ export const AGENT_HOOK_TARGETS = [
'muse',
'zcode',
'dsh',
'jcode'
'jcode',
'kiro'
] as const
export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number]
+2
View File
@@ -75,6 +75,8 @@ export function getAgentResumeArgv(
return providerSession.key === 'session_id' ? ['dsh-tui', '--resume', id] : null
case 'jcode':
return providerSession.key === 'session_id' ? ['jcode', '--resume', id] : null
case 'kiro':
return providerSession.key === 'session_id' ? ['kiro-cli', '--resume-id', id] : null
}
}
@@ -8,6 +8,7 @@ export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY =
'agent-session.opencode2-resume.v1' as const
export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const
export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const
export const AGENT_SESSION_KIRO_RESUME_RUNTIME_CAPABILITY = 'agent-session.kiro-resume.v1' as const
export const AGENT_SESSION_CODEBUDDY_RESUME_RUNTIME_CAPABILITY =
'agent-session.codebuddy-resume.v1' as const
export const AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY =
@@ -25,6 +26,7 @@ export const AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES = [
AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_KIRO_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QODER_CN_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_QWEN_CODE_RESUME_RUNTIME_CAPABILITY,
+4 -1
View File
@@ -27,7 +27,8 @@ export const RESUMABLE_TUI_AGENTS = [
'muse',
'zcode',
'dsh',
'jcode'
'jcode',
'kiro'
] as const satisfies readonly TuiAgent[]
export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number]
@@ -229,6 +230,8 @@ export function extractAgentProviderSession(
}
// Why: DSH's hook bridge always sends an empty `transcript_path` (its persistence seam
// exposes no artifact path), so the session id alone carries the resume target.
// Why: Kiro's hooks send no transcript path; the id alone feeds `--resume-id`.
case 'kiro':
case 'dsh': {
const id = readSessionId(payload, ['session_id'])
return id ? { key: 'session_id', id } : null
+4 -1
View File
@@ -193,7 +193,8 @@ function defaultAiVaultResumeCommandBase(agent: AiVaultAgent): string {
if (agent === 'hermes') {
return 'hermes'
}
if (agent === 'rovo' || agent === 'opencode2') {
// Preserve launch subcommands and mode flags when resuming.
if (agent === 'rovo' || agent === 'opencode2' || agent === 'kiro') {
return TUI_AGENT_CONFIG[agent].launchCmd
}
return TUI_AGENT_CONFIG[agent].detectCmd
@@ -248,6 +249,8 @@ function buildAgentResumeInvocation(
return `${baseCommand} --resume ${sessionArg}`
case 'antigravity':
return `${baseCommand} --conversation ${sessionArg}`
case 'kiro':
return `${baseCommand} --resume-id ${sessionArg}`
}
}
+4 -2
View File
@@ -25,7 +25,8 @@ export const AI_VAULT_AGENTS = [
'cline',
'kimi',
'muse',
'jcode'
'jcode',
'kiro'
] as const satisfies readonly TuiAgent[]
// Why: the aiVault.listSessions RPC schema CLAMPS scopePaths to this bound
@@ -76,7 +77,8 @@ export const AI_VAULT_AGENT_LABELS = {
cline: 'Cline',
kimi: 'Kimi',
muse: 'Muse',
jcode: 'Jcode'
jcode: 'Jcode',
kiro: 'Kiro'
} as const satisfies Record<AiVaultAgent, string>
export type AiVaultSessionPreviewMessage = {
+19
View File
@@ -0,0 +1,19 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveKiroHomeDir } from './kiro-home'
describe('resolveKiroHomeDir', () => {
it('defaults to ~/.kiro', () => {
expect(resolveKiroHomeDir({})).toBe(join(homedir(), '.kiro'))
})
it('uses an absolute KIRO_HOME as the root itself', () => {
const custom = join(homedir(), 'kiro-elsewhere')
expect(resolveKiroHomeDir({ KIRO_HOME: ` ${custom} ` })).toBe(custom)
})
it('ignores a relative KIRO_HOME, which would resolve against Orca’s cwd', () => {
expect(resolveKiroHomeDir({ KIRO_HOME: 'relative/kiro' })).toBe(join(homedir(), '.kiro'))
})
})
+8
View File
@@ -0,0 +1,8 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { resolveAbsoluteDirOverride } from './absolute-dir-override'
/** Kiro CLI's config root: `$KIRO_HOME` replaces `~/.kiro` outright (verified on kiro-cli 2.28). */
export function resolveKiroHomeDir(env: NodeJS.ProcessEnv = process.env): string {
return resolveAbsoluteDirOverride(env.KIRO_HOME, join(homedir(), '.kiro'))
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { buildAgentResumeStartupPlan } from './tui-agent-resume-startup'
describe('Kiro cold restore', () => {
it.each([
['darwin', 'posix', "kiro-cli chat --tui '--trust-all-tools' '--resume-id' 'session-1'"],
['linux', 'posix', "kiro-cli chat --tui '--trust-all-tools' '--resume-id' 'session-1'"],
['win32', 'powershell', "kiro-cli chat --tui '--trust-all-tools' '--resume-id' 'session-1'"],
['win32', 'cmd', 'kiro-cli chat --tui "--trust-all-tools" "--resume-id" "session-1"']
] as const)('appends only the resume selector on %s/%s', (platform, shell, expected) => {
const plan = buildAgentResumeStartupPlan({
agent: 'kiro',
providerSession: { key: 'session_id', id: 'session-1' },
cmdOverrides: {},
agentArgs: '--trust-all-tools',
platform,
shell
})
expect(plan?.launchCommand).toBe(expected)
})
it('preserves the captured engine and agent selection on a remote host', () => {
const plan = buildAgentResumeStartupPlan({
agent: 'kiro',
providerSession: { key: 'session_id', id: 'session-1' },
cmdOverrides: {},
agentCommand: 'kiro-cli chat --tui --v2 --agent reviewer',
platform: 'linux',
isRemote: true
})
expect(plan?.launchCommand).toBe(
"kiro-cli chat --tui --v2 --agent reviewer '--resume-id' 'session-1'"
)
})
})