mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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('{}')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -62,7 +62,8 @@ const AGENT_HOOK_SOURCES = [
|
||||
'muse',
|
||||
'zcode',
|
||||
'dsh',
|
||||
'jcode'
|
||||
'jcode',
|
||||
'kiro'
|
||||
] as const
|
||||
|
||||
export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number]
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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'"
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user