diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 12f8cbbe637..d5411484d97 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index 32ec4c42201..a67c013b4b8 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.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 { + 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, diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index ac2af586f47..a2c4297b3b1 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -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()] ] diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 051e47259d7..745d5fe92a9 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -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([ local: (path) => [getJcodeManagedCommand(path)], remote: (path) => [getJcodeRemoteManagedCommand(path)] } + ], + [ + 'kiro', + { + local: (path) => [getKiroManagedCommand(path)], + remote: (path) => [getKiroRemoteManagedCommand(path)] + } ] ]) diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.ts b/src/main/agent-hooks/managed-hook-local-filesystem.ts index 1a104a3a3b1..f299a85fda7 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.ts @@ -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)) diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index 79d0742ce6c..f6e55eab1de 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -17,7 +17,8 @@ const { execFile } = await import('node:child_process') const execFileMock = vi.mocked(execFile) const { execFile: actualExecFile } = await vi.importActual('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], diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index 4d1baeef609..e8a32187f8a 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -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 { - const fallback = defaultGrokHome(home) +async function resolveRelayAgentHome( + envName: 'GROK_HOME' | 'KIRO_HOME', + fallback: string, + signal?: AbortSignal +): Promise { 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 { + 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 { + 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 } : {}) diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts index 57d8610bbc2..dca668b23f7 100644 --- a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts +++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts @@ -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] ]) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 4f6dfb2afb4..d496f062cdc 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -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 diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index aec2999e7ec..c554d90d591 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -48,7 +48,8 @@ const NEW_TURN_EVENT: Record = { muse: 'UserPromptSubmit', zcode: 'SessionStart', dsh: 'SessionStart', - jcode: 'turn_start' + jcode: 'turn_start', + kiro: 'agentSpawn' } function reviveRetiredPane(source: unknown, hookEventName: string): boolean { diff --git a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts index d04cf73f667..a0458abd94e 100644 --- a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts +++ b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts @@ -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) ) } }, diff --git a/src/main/ai-vault/remote-session-scanner-kiro-source.ts b/src/main/ai-vault/remote-session-scanner-kiro-source.ts new file mode 100644 index 00000000000..5362d4033f5 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-kiro-source.ts @@ -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 | AsyncIterable | 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) + } + } + } +} diff --git a/src/main/ai-vault/remote-session-scanner-kiro.test.ts b/src/main/ai-vault/remote-session-scanner-kiro.test.ts new file mode 100644 index 00000000000..78700bbe370 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-kiro.test.ts @@ -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) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index b5c4dc32084..b98b36a64c3 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -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 = ( 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, diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 74d5a0d22aa..d240ee40e0c 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -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, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 304adf38987..7078cfef192 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -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) } } diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 18ae7109576..c657fd0baa4 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -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, [ diff --git a/src/main/ai-vault/session-scanner-every-agent-fixture.ts b/src/main/ai-vault/session-scanner-every-agent-fixture.ts index 5ac8665f870..c90656cb4f0 100644 --- a/src/main/ai-vault/session-scanner-every-agent-fixture.ts +++ b/src/main/ai-vault/session-scanner-every-agent-fixture.ts @@ -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.json` metadata beside its `.jsonl` transcript. */ +export async function writeKiroSessionFixture( + roots: ReturnType +): Promise { + 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' }] } + } + ]) +} diff --git a/src/main/ai-vault/session-scanner-kiro-parser.test.ts b/src/main/ai-vault/session-scanner-kiro-parser.test.ts new file mode 100644 index 00000000000..3e238c513a1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kiro-parser.test.ts @@ -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 => + 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 { + 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 .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}'` + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-kiro-parser.ts b/src/main/ai-vault/session-scanner-kiro-parser.ts new file mode 100644 index 00000000000..8eef66422ea --- /dev/null +++ b/src/main/ai-vault/session-scanner-kiro-parser.ts @@ -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 .json (metadata) beside .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[] { + const blocks: Record[] = [] + 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 { + 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 | AsyncIterable | null, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + 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' +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index d84c3f08eda..9dff1273473 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -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') } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index cfaa7066d93..dc92d643ed2 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -85,6 +85,7 @@ function resumableStateFactoryFor( case 'jcode': case 'cline': case 'kimi': + case 'kiro': case 'muse': case 'opencode': case 'opencode2': diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts index e658cb63bbb..6585f179613 100644 --- a/src/main/ai-vault/session-scanner-service-env.test.ts +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -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' }, diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index 063df2dcc63..faaba071fbf 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -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 ) diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 74c6fb5b377..c15189c2a4b 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -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') } } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index c73461b3d52..10c4019323d 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -52,6 +52,7 @@ export type AiVaultScanOptions = { kimiSessionsDir?: string museSessionsDir?: string jcodeSessionsDir?: string + kiroSessionsDir?: string limit?: number unlimited?: boolean limitPerAgent?: number diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d5acdb7b226..ee4be3be96d 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -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') diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts new file mode 100644 index 00000000000..f7b092813e5 --- /dev/null +++ b/src/main/kiro/hook-service.test.ts @@ -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 { + 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 /.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('{}') + }) +}) diff --git a/src/main/kiro/hook-service.ts b/src/main/kiro/hook-service.ts new file mode 100644 index 00000000000..a0b70e6a8cc --- /dev/null +++ b/src/main/kiro/hook-service.ts @@ -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 } + | { 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 { + 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 { + 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() diff --git a/src/main/kiro/hook-settings.ts b/src/main/kiro/hook-settings.ts new file mode 100644 index 00000000000..97245f12e16 --- /dev/null +++ b/src/main/kiro/hook-settings.ts @@ -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 = new Set(['preToolUse', 'postToolUse']) + +export function isKiroHooksConfigSupported(config: Record): 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, + command: string, + isManagedCommand: (command: string | undefined) => boolean +): Record { + const hooks: Record = 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, + isManagedCommand: (command: string | undefined) => boolean +): Record { + const original = config.hooks + if ( + !isPlainObject(original) || + !Object.values(original).some((value) => hasManagedDefinition(value, isManagedCommand)) + ) { + return config + } + const hooks: Record = { ...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 { + 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 { + return `${JSON.stringify(config, null, 2)}\n` +} diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index 7ed8ed4ffa4..e7e6c2f4b2c 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -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) ) } }, diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts index 63c67885ca1..5d2f66fe3d9 100644 --- a/src/relay/ai-vault-service-entry.ts +++ b/src/relay/ai-vault-service-entry.ts @@ -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 { 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, diff --git a/src/relay/session-search-transport.test.ts b/src/relay/session-search-transport.test.ts index 69d2a736974..4331c1b7821 100644 --- a/src/relay/session-search-transport.test.ts +++ b/src/relay/session-search-transport.test.ts @@ -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) ) } }, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 861157eb6a8..c9e99bbd2ed 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -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' }) }) }) diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index c1e23e62797..5839816273d 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -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 ) through the same host-authority session enum the generic probe covers. - jcode: undefined + jcode: undefined, + kiro: AGENT_SESSION_KIRO_RESUME_RUNTIME_CAPABILITY } satisfies Record export function agentResumeHostAuthorityCapability( diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 75a318e877b..52585c1dd05 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -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 } diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 71de20b1928..53d360cd061 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -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) } } diff --git a/src/shared/agent-hook-listener/providers/kiro-events.test.ts b/src/shared/agent-hook-listener/providers/kiro-events.test.ts new file mode 100644 index 00000000000..02e437c084b --- /dev/null +++ b/src/shared/agent-hook-listener/providers/kiro-events.test.ts @@ -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 = {}): Record { + 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() + }) +}) diff --git a/src/shared/agent-hook-listener/providers/kiro-events.ts b/src/shared/agent-hook-listener/providers/kiro-events.ts new file mode 100644 index 00000000000..9c52cb62e73 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/kiro-events.ts @@ -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/.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 +): 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 + }) +} diff --git a/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts b/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts new file mode 100644 index 00000000000..4df695ce9c7 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/kiro-tool-fields.ts @@ -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> = 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 +): 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 } : {} +} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index c26a8073a03..a8dfa25c5f5 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -29,7 +29,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/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 { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 814f7448476..0eac4ec6f6c 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -62,7 +62,8 @@ const AGENT_HOOK_SOURCES = [ 'muse', 'zcode', 'dsh', - 'jcode' + 'jcode', + 'kiro' ] as const export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number] diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 977e7af1570..5cdb43dfda6 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -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] diff --git a/src/shared/agent-resume-argv.ts b/src/shared/agent-resume-argv.ts index 51189d0b75f..b884e4e032d 100644 --- a/src/shared/agent-resume-argv.ts +++ b/src/shared/agent-resume-argv.ts @@ -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 } } diff --git a/src/shared/agent-session-resume-runtime-capabilities.ts b/src/shared/agent-session-resume-runtime-capabilities.ts index b3f3f2ace44..92d0f4914a6 100644 --- a/src/shared/agent-session-resume-runtime-capabilities.ts +++ b/src/shared/agent-session-resume-runtime-capabilities.ts @@ -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, diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 18f373dec46..515108693af 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -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 diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index e56f944b9aa..2c3dcdf6f26 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -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}` } } diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 750413d7431..9134917daf8 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -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 export type AiVaultSessionPreviewMessage = { diff --git a/src/shared/kiro-home.test.ts b/src/shared/kiro-home.test.ts new file mode 100644 index 00000000000..0f134d3db3a --- /dev/null +++ b/src/shared/kiro-home.test.ts @@ -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')) + }) +}) diff --git a/src/shared/kiro-home.ts b/src/shared/kiro-home.ts new file mode 100644 index 00000000000..2ee6bdb46ad --- /dev/null +++ b/src/shared/kiro-home.ts @@ -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')) +} diff --git a/src/shared/tui-agent-startup-kiro-resume.test.ts b/src/shared/tui-agent-startup-kiro-resume.test.ts new file mode 100644 index 00000000000..892786b9b6e --- /dev/null +++ b/src/shared/tui-agent-startup-kiro-resume.test.ts @@ -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'" + ) + }) +})