diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 12f8cbbe6375..d5411484d97d 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 32ec4c422011..a67c013b4b87 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 ac2af586f47a..a2c4297b3b1e 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 051e47259d7f..745d5fe92a96 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 1a104a3a3b11..f299a85fda73 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 79d0742ce6cd..f6e55eab1def 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 4d1baeef609e..e8a32187f8a0 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 57d8610bbc25..dca668b23f70 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 4f6dfb2afb48..d496f062cdc9 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 aec2999e7ec8..c554d90d5918 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/remote-session-scanner-kiro-source.ts b/src/main/ai-vault/remote-session-scanner-kiro-source.ts new file mode 100644 index 000000000000..a6320fc1056c --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-kiro-source.ts @@ -0,0 +1,70 @@ +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 +): RemoteSessionSource { + return { + agent: 'kiro', + rootDir: 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-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index b5c4dc32084c..3f6242b5315d 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, @@ -121,6 +122,7 @@ export function remoteSessionSources( (path) => remotePathSegments(path).includes('agent-transcripts') ), remoteClineSource(remoteHome, hostPlatform), + remoteKiroSource(remoteHome, hostPlatform), source( 'hermes', remoteHome, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 304adf38987a..7078cfef1924 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 18ae71095765..c657fd0baa4f 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 5ac8665f870a..c90656cb4f00 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 000000000000..fc825733f8ac --- /dev/null +++ b/src/main/ai-vault/session-scanner-kiro-parser.test.ts @@ -0,0 +1,114 @@ +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('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 000000000000..8a219f60e44b --- /dev/null +++ b/src/main/ai-vault/session-scanner-kiro-parser.ts @@ -0,0 +1,188 @@ +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, + sessionId: extractString(metadata.session_id) ?? 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 d84c3f08eda8..9dff12734736 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 cfaa7066d93c..dc92d643ed23 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-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 74c6fb5b3774..c15189c2a4bf 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 c73461b3d524..10c4019323de 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 d5acdb7b226c..ee4be3be96db 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/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index ec13423fcb6b..b4f039fe70a7 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -127,12 +127,54 @@ describe('preflight', () => { }) afterEach(() => { + vi.unstubAllEnvs() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) }) + it('detects Kiro through its configured local command outside PATH', async () => { + const command = '/custom/bin/kiro-cli' + vi.stubEnv('KIRO_CLI_PATH', command) + execFileAsyncMock.mockImplementation(async (program) => { + if (program !== command) { + throw new Error('not found') + } + return { stdout: 'kiro-cli 1.0', stderr: '' } + }) + + await expect(detectInstalledAgents()).resolves.toContain('kiro') + expect(execFileAsyncMock).toHaveBeenCalledWith(command, ['--version'], expect.anything()) + }) + + it('does not detect Kiro when its configured command fails, even if PATH has another copy', async () => { + const command = '/custom/bin/broken-kiro-cli' + vi.stubEnv('KIRO_CLI_PATH', command) + execFileAsyncMock.mockImplementation(async (program, args) => { + if (program === 'which' && args[0] === 'kiro-cli') { + return { stdout: '/usr/bin/kiro-cli\n', stderr: '' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.not.toContain('kiro') + }) + + it('does not apply the local Kiro command to WSL detection', async () => { + vi.stubEnv('KIRO_CLI_PATH', 'C:\\custom\\kiro-cli.cmd') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + runWslProcessMock.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) + + await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.not.toContain('kiro') + expect(execFileAsyncMock).not.toHaveBeenCalled() + }) + it('only reports agents when which/where resolves to a real executable path', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts new file mode 100644 index 000000000000..b806ea9638ae --- /dev/null +++ b/src/main/kiro/hook-service.test.ts @@ -0,0 +1,178 @@ +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 { 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 as Record[]> + expect(Object.keys(hooks).sort()).toEqual([...KIRO_HOOK_EVENTS].sort()) + const isManaged = getKiroManagedCommandMatcher() + for (const event of KIRO_HOOK_EVENTS) { + expect(hooks[event].filter((entry) => isManaged(String(entry.command)))).toHaveLength(1) + } + expect(hooks.stop[0]).toEqual(USER_STOP_HOOK) + expect(hooks.preToolUse.at(-1)?.matcher).toBe('*') + expect(hooks.stop.at(-1)?.matcher).toBeUndefined() + 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('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('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 000000000000..8078d68d25c8 --- /dev/null +++ b/src/main/kiro/hook-service.ts @@ -0,0 +1,258 @@ +import { 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, + 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) + return isPlainObject(parsed) + ? { fileName, config: parsed } + : { fileName, error: 'not a JSON object' } + } 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, 'not_installed', null) + } + 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) + return files === null + ? status(agentsDir, 'error', 'Could not read the Kiro agents directory') + : buildStatus(agentsDir, files) + } + + 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) + const file = 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 000000000000..0405e6d36f06 --- /dev/null +++ b/src/main/kiro/hook-settings.ts @@ -0,0 +1,156 @@ +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']) + +/** + * 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/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index 92484812c3bf..b04e4a56ac43 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -43,6 +43,7 @@ import { import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' import { detectAgentCommandsOnHost } from './agent-command-detection' +import { resolveKiroCommand } from '../rate-limits/kiro-usage-fetcher' export { detectAgentCommandsOnHost } from './agent-command-detection' export type PreflightStatus = { @@ -145,15 +146,23 @@ async function detectCommandRuntime( } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { + const wslTarget = getPreflightWslTarget(context) const commands = getTuiAgentDetectionProbeCommands( KNOWN_TUI_AGENT_DETECTION_COMMANDS, - getPreflightWslTarget(context) ? 'wsl' : process.platform + wslTarget ? 'wsl' : process.platform ) - return resolveDetectedTuiAgentIds( + const detected = resolveDetectedTuiAgentIds( KNOWN_TUI_AGENT_DETECTION_COMMANDS, await detectAgentCommandsOnHost(commands, { context }), - getPreflightWslTarget(context) ? 'wsl' : process.platform + wslTarget ? 'wsl' : process.platform ) + if (!wslTarget && process.env.KIRO_CLI_PATH?.trim()) { + const kiro = await findRunnableLocalCommand(resolveKiroCommand()) + return kiro.status === 'available' + ? [...new Set([...detected, 'kiro'])] + : detected.filter((id) => id !== 'kiro') + } + return detected } export async function detectInstalledAgentsWithShellPathHydration( diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts new file mode 100644 index 000000000000..aa55952b854e --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) + +const KIRO_USAGE_OUTPUT = + 'Estimated Usage | resets on 2026-09-01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%' + +describe('Kiro usage fetcher', () => { + it('uses the shared CLI resolver for version-manager installs', () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-kiro-command-')) + const command = join(homePath, '.volta', 'bin', 'kiro-cli') + mkdirSync(join(homePath, '.volta', 'bin'), { recursive: true }) + writeFileSync(command, '#!/bin/sh\n') + chmodSync(command, 0o755) + vi.stubEnv('KIRO_CLI_PATH', '') + try { + expect(resolveKiroCommand({ platform: 'darwin', pathEnv: '', homePath })).toBe(command) + } finally { + vi.unstubAllEnvs() + rmSync(homePath, { recursive: true, force: true }) + } + }) + + it('parses the official CLI usage command output', () => { + const result = parseKiroUsageOutput(` +\u001b[1mEstimated Usage | resets on 2026-09-01 | KIRO PRO+\u001b[0m +Credits (1233.74 of 2000 covered in plan) +\u001b[32m████ 61%\u001b[0m +`) + + expect(result).toMatchObject({ provider: 'kiro', status: 'ok', planType: 'KIRO PRO+' }) + expect(result.monthly?.usedPercent).toBeCloseTo(61.687) + expect(result.monthly).toMatchObject({ + resetsAt: null, + resetDescription: '2026-09-01' + }) + }) + + it('accepts a Kiro reset description without an ISO date', () => { + const output = + 'Estimated Usage | resets on 01/01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%' + + expect(parseKiroUsageOutput(output)).toMatchObject({ + status: 'ok', + planType: 'KIRO PRO', + monthly: { usedPercent: 10, resetsAt: null, resetDescription: '01/01' } + }) + }) + + it('runs the local non-model usage command', async () => { + const runner = vi.fn().mockResolvedValue({ + stdout: KIRO_USAGE_OUTPUT, + stderr: '' + }) + + const result = await fetchKiroRateLimits({ command: '/tmp/kiro-cli', runner }) + + expect(runner).toHaveBeenCalledWith( + '/tmp/kiro-cli', + ['chat', '/usage', '--no-interactive', '--wrap', 'never'], + undefined + ) + expect(result.status).toBe('ok') + }) + + it('uses the shared process wrapper with bounded timeout and cancellation', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: KIRO_USAGE_OUTPUT, + stderr: '', + timedOut: false + }) + const signal = new AbortController().signal + const command = 'C:\\Users\\me\\bin\\kiro-cli.cmd' + const result = await fetchKiroRateLimits({ command, signal }) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program: command, + args: ['chat', '/usage', '--no-interactive', '--wrap', 'never'], + timeoutMs: 20_000, + maxOutputBytes: 1024 * 1024, + killOnOutputLimit: true, + signal + }) + ) + expect(result.status).toBe('ok') + }) + + it.each([ + { code: 1, signal: null, timedOut: false }, + { code: 0, signal: null, timedOut: true }, + { code: null, signal: 'SIGTERM' as const, timedOut: false }, + // Why: a capped read can end on a valid-looking line that is not the real total. + { code: 0, signal: null, timedOut: false, outputTruncated: true } + ])('rejects unsuccessful process results even with valid output: %j', async (failure) => { + vi.mocked(runProcess).mockResolvedValue({ ...failure, stdout: KIRO_USAGE_OUTPUT, stderr: '' }) + expect(await fetchKiroRateLimits({ command: '/tmp/kiro-cli' })).toMatchObject({ + status: 'error', + usageMetadata: { failureKind: 'usage-unavailable' } + }) + }) + + it('fails closed on unexpected output', () => { + expect(parseKiroUsageOutput('Please sign in first')).toMatchObject({ + provider: 'kiro', + status: 'error', + usageMetadata: { failureKind: 'parse' } + }) + }) + + it('reports a missing CLI without exposing process details', async () => { + const error = Object.assign(new Error('spawn /secret/path ENOENT'), { code: 'ENOENT' }) + const result = await fetchKiroRateLimits({ runner: vi.fn().mockRejectedValue(error) }) + + expect(result).toMatchObject({ status: 'unavailable', error: 'Kiro CLI is not installed' }) + expect(JSON.stringify(result)).not.toContain('/secret/path') + }) + + it('distinguishes a failed usage command from a missing CLI', async () => { + const result = await fetchKiroRateLimits({ + runner: vi.fn().mockRejectedValue(new Error('command timed out')) + }) + + expect(result).toMatchObject({ + status: 'error', + error: 'Kiro usage command failed', + usageMetadata: { failureKind: 'usage-unavailable' } + }) + expect(JSON.stringify(result)).not.toContain('command timed out') + }) +}) diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts new file mode 100644 index 000000000000..d43939c1003c --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -0,0 +1,127 @@ +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { runProcess } from '../../shared/child-process/run-process' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' + +const CLI_TIMEOUT_MS = 20_000 +const MONTHLY_WINDOW_MINUTES = 43_200 + +type CommandResult = { stdout: string; stderr: string } +type CommandRunner = ( + command: string, + args: string[], + signal?: AbortSignal +) => Promise + +function result( + status: ProviderRateLimits['status'], + error: string | null, + monthly: RateLimitWindow | null = null, + planType: string | null = null, + failureKind?: 'cli-unavailable' | 'parse' | 'usage-unavailable' +): ProviderRateLimits { + return { + provider: 'kiro', + session: null, + weekly: null, + monthly, + planType, + updatedAt: Date.now(), + error, + status, + usageMetadata: { + source: 'cli', + ...(failureKind ? { failureKind } : {}) + } + } +} + +export function resolveKiroCommand( + options: { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string + } = {} +): string { + const configured = process.env.KIRO_CLI_PATH?.trim() + if (configured) { + return configured + } + return resolveCliCommand('kiro-cli', options) +} + +const runCommand: CommandRunner = async (command, args, signal) => { + const result = await runProcess({ + program: command, + args, + env: withCliRuntimeOnPath(command, { ...process.env }), + timeoutMs: CLI_TIMEOUT_MS, + maxOutputBytes: 1024 * 1024, + killOnOutputLimit: true, + signal + }) + if (result.code !== 0 || result.timedOut || result.signal || result.outputTruncated) { + throw new Error('Kiro usage command failed') + } + return { stdout: result.stdout, stderr: result.stderr } +} + +export function parseKiroUsageOutput(output: string): ProviderRateLimits { + const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') + const header = readable.match(/Estimated Usage\s*\|\s*resets on\s+([^|\n]+?)\s*\|\s*([^\n]+)/i) + const resetDescription = header?.[1].trim() + const credits = readable.match(/Credits\s*\(([\d,.]+)\s+of\s+([\d,.]+)\s+covered in plan\)/i) + const percent = readable.match(/(?:^|\s)(\d+(?:\.\d+)?)%\s*$/m) + const used = credits ? Number(credits[1].replaceAll(',', '')) : Number.NaN + const limit = credits ? Number(credits[2].replaceAll(',', '')) : Number.NaN + const usedPercent = + Number.isFinite(used) && Number.isFinite(limit) && limit > 0 + ? Math.min(100, Math.max(0, (used / limit) * 100)) + : percent + ? Math.min(100, Math.max(0, Number(percent[1]))) + : null + if (!header || !resetDescription || usedPercent === null || !Number.isFinite(usedPercent)) { + return result('error', 'Could not parse Kiro usage output', null, null, 'parse') + } + return result( + 'ok', + null, + { + usedPercent, + windowMinutes: MONTHLY_WINDOW_MINUTES, + // Kiro supplies no exact reset time or zone, so keep its text as display metadata. + resetsAt: null, + resetDescription + }, + header[2].trim() + ) +} + +export async function fetchKiroRateLimits( + options: { signal?: AbortSignal; runner?: CommandRunner; command?: string } = {} +): Promise { + try { + const { stdout, stderr } = await (options.runner ?? runCommand)( + options.command ?? resolveKiroCommand(), + ['chat', '/usage', '--no-interactive', '--wrap', 'never'], + options.signal + ) + return parseKiroUsageOutput(`${stdout}\n${stderr}`) + } catch (error) { + const code = + error !== null && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' + ? error.code + : null + const unavailable = code === 'ENOENT' + return result( + unavailable ? 'unavailable' : 'error', + unavailable ? 'Kiro CLI is not installed' : 'Kiro usage command failed', + null, + null, + unavailable ? 'cli-unavailable' : 'usage-unavailable' + ) + } +} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index caf5ed780a54..ec35f0b7aba2 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -11,6 +11,7 @@ import { readGrokAuthSession } from './grok-auth' import { fetchCursorRateLimits } from './cursor-fetcher' import { readCursorAuthSession } from './cursor-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' +import { fetchKiroRateLimits } from './kiro-usage-fetcher' import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' @@ -94,6 +95,7 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) + vi.mocked(fetchKiroRateLimits).mockImplementation(async () => unavailableProvider('kiro')) vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode')) vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => unavailableProvider('antigravity') @@ -116,6 +118,7 @@ export function resetRateLimitProviderMocks(): void { status: 'unavailable' }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchKiroRateLimits).mockResolvedValue(unavailableProvider('kiro')) vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode')) vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 27e93208c1a1..32bc0093df51 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -32,6 +32,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 8e65e53740be..0e76581e5cc0 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -54,6 +54,10 @@ vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts index 03f5c1f4a168..e085efb4f8e8 100644 --- a/src/main/rate-limits/service-cursor-usage.test.ts +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -23,6 +23,7 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: v vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./kiro-usage-fetcher', () => ({ fetchKiroRateLimits: vi.fn() })) vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) vi.mock('./antigravity-usage-fetcher', () => ({ diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index cae71c569e71..6895395dba3b 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -39,6 +39,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 8c35ab77acc7..4f99cf78b1fa 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -36,6 +36,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 8f31e9a6da7a..426ae7204c21 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -33,6 +33,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-opencode-go-credentials.test.ts b/src/main/rate-limits/service-opencode-go-credentials.test.ts index 5e98ce4a1d62..ab25f10c23bd 100644 --- a/src/main/rate-limits/service-opencode-go-credentials.test.ts +++ b/src/main/rate-limits/service-opencode-go-credentials.test.ts @@ -33,6 +33,7 @@ vi.mock('./kimi-fetcher', () => ({ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ fetchKiroRateLimits: vi.fn() })) vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) vi.mock('./antigravity-usage-fetcher', () => ({ fetchAntigravityRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 2cef94ba2b3e..9782833c313e 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -7,6 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' +import { fetchKiroRateLimits } from './kiro-usage-fetcher' import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' @@ -41,6 +42,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) @@ -368,6 +373,41 @@ describe('RateLimitService', () => { expect(refreshResolved).toBe(true) }) + it('publishes other provider results before a slow Kiro CLI fetch completes', async () => { + const service = new RateLimitService() + const kiro = deferred() + let refreshResolved = false + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 30, Date.now())) + vi.mocked(fetchOpenCodeGoUsage).mockResolvedValueOnce(okProvider('opencode-go', 40, Date.now())) + vi.mocked(fetchKimiRateLimits).mockResolvedValueOnce(okProvider('kimi', 50, Date.now())) + vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 60, Date.now())) + vi.mocked(fetchKiroRateLimits).mockReturnValueOnce(kiro.promise) + + const refresh = service.refresh().then(() => { + refreshResolved = true + }) + await flushMicrotasks() + + const pendingKiroState = service.getState() + expect(pendingKiroState.claude?.status).toBe('ok') + expect(pendingKiroState.codex?.status).toBe('ok') + expect(pendingKiroState.gemini?.status).toBe('ok') + expect(pendingKiroState.opencodeGo?.status).toBe('ok') + expect(pendingKiroState.kimi?.status).toBe('ok') + expect(pendingKiroState.minimax?.status).toBe('ok') + expect(pendingKiroState.kiro?.status).toBe('fetching') + expect(refreshResolved).toBe(false) + + kiro.resolve(okProvider('kiro', 70, Date.now())) + await refresh + + expect(service.getState().kiro?.status).toBe('ok') + expect(refreshResolved).toBe(true) + }) + it('aborts the active fetch cycle and clears queued refreshes on stop', async () => { const service = new RateLimitService() const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal; grok?: AbortSignal } = {} diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 8cbb25915037..af9d47828f8a 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -41,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-zcode-usage.test.ts b/src/main/rate-limits/service-zcode-usage.test.ts index 7aa9e911c602..68726db54774 100644 --- a/src/main/rate-limits/service-zcode-usage.test.ts +++ b/src/main/rate-limits/service-zcode-usage.test.ts @@ -37,6 +37,7 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ fetchKiroRateLimits: vi.fn() })) vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn(), hasZcodeCliPlanCredentials: vi.fn(() => false) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 2731f882be5a..70c2fb07a913 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,3 +1,5 @@ +import type { FetchAllCyclePrepared } from './service-full-cycle-preparation' +import type { SettledProviderResult } from './service-sibling-provider-result' import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { settleSiblingProviderResult } from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' @@ -25,8 +27,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ opencodeGeneration, miniMaxConfigChanged, miniMaxGeneration, - zcodeConfigChanged, - zcodeGeneration, claudeFetchGated, results: [ claudeResult, @@ -35,11 +35,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ opencodeGoResult, kimiResult, miniMaxResult - ], - grokResultPromise, - cursorResultPromise, - zcodeResultPromise, - antigravityResultPromise + ] } = prepared if (signal.aborted) { return @@ -191,6 +187,25 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) + await Promise.all([ + this.applySiblingProviderResults(prepared, signal), + this.applyKiroResult(prepared.kiroResultPromise, previousState.kiro, signal) + ]) + } + + private async applySiblingProviderResults( + prepared: FetchAllCyclePrepared, + signal: AbortSignal + ): Promise { + const { + grokResultPromise, + cursorResultPromise, + zcodeResultPromise, + antigravityResultPromise, + zcodeGeneration, + zcodeConfigChanged, + previousState + } = prepared const [grokSettled, cursorSettled, zcodeSettled, antigravitySettled] = await Promise.all([ grokResultPromise, cursorResultPromise, @@ -242,4 +257,18 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ antigravity: this.applyStalePolicy(antigravity, previousState.antigravity) }) } + + private async applyKiroResult( + resultPromise: Promise, + previousKiro: ProviderRateLimits | null, + signal: AbortSignal + ): Promise { + const settled = await resultPromise + if (signal.aborted) { + return + } + const kiro = settleSiblingProviderResult('kiro', settled) + this.trackActiveFailureStreak('kiro', kiro) + this.updateState({ ...this.state, kiro: this.applyStalePolicy(kiro, previousKiro) }) + } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index f847cbf15097..9ecd5c67ef4c 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -2,6 +2,7 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' +import { fetchKiroRateLimits } from '../kiro-usage-fetcher' import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' @@ -50,6 +51,7 @@ export type FetchAllCyclePrepared = { ] grokResultPromise: Promise cursorResultPromise: Promise + kiroResultPromise: Promise zcodeResultPromise: Promise antigravityResultPromise: Promise } @@ -164,6 +166,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), + kiro: this.withFetchingStatus(previousState.kiro, 'kiro'), zcode: zcodeConfigChanged ? this.withFetchingStatus(null, 'zcode') : this.withFetchingStatus(previousState.zcode, 'zcode') @@ -209,6 +212,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) + // Why: Kiro invokes a CLI with a long timeout; keep it outside the shared + // allSettled batch so healthy provider snapshots can publish immediately. + const kiroResultPromise = fetchKiroRateLimits({ signal }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. const claudeFetchGated = @@ -308,6 +317,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ], grokResultPromise, cursorResultPromise, + kiroResultPromise, zcodeResultPromise, antigravityResultPromise } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index 402495080c27..d0340f31a9f6 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -80,6 +80,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue grok: this.state.grok, antigravity: this.state.antigravity, cursor: this.state.cursor, + kiro: this.state.kiro, zcode: this.state.zcode } return Object.entries(byProvider).map(([provider, limits]) => ({ diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 0ec13020a6e9..037716c738a2 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -35,6 +35,7 @@ export abstract class RateLimitServiceState { minimax: null, grok: null, cursor: null, + kiro: null, zcode: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' @@ -56,6 +57,7 @@ export abstract class RateLimitServiceState { grok: 0, antigravity: 0, cursor: 0, + kiro: 0, zcode: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. @@ -69,6 +71,7 @@ export abstract class RateLimitServiceState { grok: 0, antigravity: 0, cursor: 0, + kiro: 0, zcode: 0 } protected mainWindow: BrowserWindow | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 5bae83b1449c..cf11df209447 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -131,6 +131,7 @@ export type InternalRateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + kiro: ProviderRateLimits | null zcode: ProviderRateLimits | null } diff --git a/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts new file mode 100644 index 000000000000..e826f93b50e5 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts @@ -0,0 +1,37 @@ +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getKiroStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'kiro', + title: translate('auto.components.settings.appearance.search.kiroUsageTitle', 'Kiro Usage'), + description: translate( + 'auto.components.settings.appearance.search.kiroUsageDescription', + 'Show Kiro subscription credit usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.kiro', 'kiro'), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword('auto.components.settings.appearance.search.kiroAws', 'aws'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.kiroToggleDescription', + 'Show Kiro subscription credit usage reported by Kiro CLI.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 16749f4ab311..a5244cd5d92b 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -6,6 +6,7 @@ import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-ba import { getMiniMaxStatusBarToggleSearchEntry } from './appearance-status-bar-minimax-toggle-search' import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' import { getCursorStatusBarToggleSearchEntry } from './appearance-status-bar-cursor-toggle-search' +import { getKiroStatusBarToggleSearchEntry } from './appearance-status-bar-kiro-toggle-search' import { getZcodeStatusBarToggleSearchEntry } from './appearance-status-bar-zcode-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( @@ -170,6 +171,7 @@ export const getStatusBarToggles = createLocalizedCatalog( getMiniMaxStatusBarToggleSearchEntry(), getGrokStatusBarToggleSearchEntry(), getCursorStatusBarToggleSearchEntry(), + getKiroStatusBarToggleSearchEntry(), getZcodeStatusBarToggleSearchEntry(), { id: 'ssh', diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 0dd665c939c0..03c46d6890a9 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -25,6 +25,6 @@ export { ClaudeSwitcherMenu } from './ClaudeSwitcherMenu' export { CodexSwitcherMenu } from './CodexSwitcherMenu' export { InlineUsageBars } from './InlineProviderUsage' export { ProviderDetailsMenu } from './ProviderDetailsMenu' -export { ProviderSegment } from './StatusBarProviderSegment' +export { getProviderLetter, ProviderSegment } from './StatusBarProviderSegment' export const StatusBar = React.memo(StatusBarSurface) diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index f9a484ede41a..518a68de752b 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -135,7 +135,7 @@ export function UsageOverflowChip({ ) } -function getProviderLetter(provider: ProviderRateLimits['provider']): string { +export function getProviderLetter(provider: ProviderRateLimits['provider']): string { switch (provider) { case 'claude': return 'C' @@ -153,6 +153,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'R' case 'cursor': return 'U' + case 'kiro': + return 'Q' case 'zcode': return 'Z' case 'codex': diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index c98f91c673ec..1452a2c6e7d5 100644 --- a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx +++ b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx @@ -133,6 +133,18 @@ export function StatusBarVisibilityMenu({ {translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')} )} + {isStatusBarItemAvailable('kiro', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('kiro') + }} + > + + {translate('auto.components.status.bar.StatusBar.kiroUsageMenu', 'Kiro Usage')} + + )} {isStatusBarItemAvailable('zcode', detectedAgentIds) && ( ({ AgentIcon: () => null })) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +import { formatRateLimitResetLabel } from './tooltip' + +describe('formatRateLimitResetLabel', () => { + it('shows a date-only reset without inventing an exact countdown', () => { + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: '2026-09-01' + }) + ).toBe('Resets on 2026-09-01') + }) + + it('keeps non-date reset descriptions instead of dropping the label', () => { + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: 'Thu' + }) + ).toBe('Thu') + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: '2:30 PM' + }) + ).toBe('2:30 PM') + }) + + it('omits the reset label when the window has no reset metadata', () => { + expect( + formatRateLimitResetLabel({ + usedPercent: 62, + windowMinutes: 43_200, + resetsAt: null, + resetDescription: null + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index 4c7ada35a47d..15c628c92762 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -20,6 +20,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) + expect(isStatusBarItemAvailable('kiro', null)).toBe(true) expect(isStatusBarItemAvailable('zcode', null)).toBe(true) }) @@ -29,6 +30,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) + expect(isStatusBarItemAvailable('kiro', ['claude', 'kimi'])).toBe(false) expect(isStatusBarItemAvailable('zcode', ['claude', 'kimi'])).toBe(false) }) @@ -38,6 +40,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) + expect(isStatusBarItemAvailable('kiro', ['kiro'])).toBe(true) expect(isStatusBarItemAvailable('zcode', ['zcode'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 85f641b96d2d..ce818239671f 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -14,6 +14,7 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'kimi', 'antigravity', 'grok', + 'kiro', 'zcode' ]) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts new file mode 100644 index 000000000000..4e36e37e2974 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { getProviderLetter } from './StatusBar' + +describe('status bar provider labels', () => { + it('distinguishes Kiro from Kimi in the narrow status bar', () => { + expect(getProviderLetter('kimi')).toBe('K') + expect(getProviderLetter('kiro')).toBe('Q') + }) +}) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 4ade7294abf8..aa4eda252bac 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -36,6 +36,8 @@ type UsageProviderSnapshots = { minimax: ProviderRateLimits | null | undefined grok: ProviderRateLimits | null | undefined cursor: ProviderRateLimits | null | undefined + // Optional for renderer HMR against an older main process and persisted test fixtures. + kiro?: ProviderRateLimits | null zcode?: ProviderRateLimits | null } @@ -191,6 +193,7 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) || isProviderSnapshotPending(providers.cursor) || + (providers.kiro !== undefined && isProviderSnapshotPending(providers.kiro)) || (providers.zcode !== undefined && isProviderSnapshotPending(providers.zcode)) ) { return false @@ -206,6 +209,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) && !isProviderConfigured(providers.cursor) && + !isProviderConfigured(providers.kiro) && !isProviderConfigured(providers.zcode) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index d871f8943eb4..92a2cc9ad6fe 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -53,6 +53,25 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } +export function formatRateLimitResetLabel( + window: RateLimitWindow, + now: number = Date.now() +): string | null { + if (window.resetsAt) { + return formatResetCountdown(window.resetsAt - now) + } + if (window.resetDescription) { + if (/^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { + return translate('auto.components.status.bar.tooltip.resetOnDate', 'Resets on {{value0}}', { + value0: window.resetDescription + }) + } + // Producers may already send human-readable values ("Thu", "2:30 PM"). + return window.resetDescription + } + return null +} + // Re-export so existing tooltip consumers/tests keep their import path; the // implementation is shared with mobile in src/shared/rate-limit-reset-format. export { formatResetCountdown } @@ -111,6 +130,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'cursor') { return } + if (provider === 'kiro') { + return + } return } @@ -238,7 +260,7 @@ function ProviderRateLimitWindowSection({ } const usedPct = clampUsedPercent(window.usedPercent) const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) - const resetLabel = window.resetsAt ? formatResetCountdown(window.resetsAt - now) : null + const resetLabel = formatRateLimitResetLabel(window, now) return (
diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 95942ddb5b72..dab0ac8ea8c8 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -32,6 +32,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'cursor') { return 'Cursor' } + if (provider === 'kiro') { + return 'Kiro' + } return provider } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index ec9b79ca82b9..67e651da5ac7 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -20,6 +20,7 @@ export function getUsageProviderAccountsSectionId( return 'accounts-cursor' case 'antigravity': case 'kimi': + case 'kiro': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. // Antigravity credentials live in the agy CLI; quota is fetched directly via agy. return null diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index d537eba860f5..9127ae2fe1fe 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -92,8 +92,19 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode } = - rateLimits + const { + claude, + codex, + gemini, + opencodeGo, + kimi, + antigravity, + minimax, + grok, + cursor, + kiro, + zcode + } = rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. @@ -119,6 +130,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const visibleCursor = getVisibleUsageProvider('cursor', cursor, usageSettings) + const visibleKiro = getVisibleUsageProvider('kiro', kiro, usageSettings) const visibleZcode = getVisibleUsageProvider('zcode', zcode, usageSettings) const showClaude = visibleClaude !== null && @@ -149,6 +161,10 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a Cursor session can come from the IDE alone, so PATH detection of // cursor-agent would hide a real meter from IDE-only users. const showCursor = visibleCursor !== null && statusBarItems.includes('cursor') + const showKiro = + visibleKiro !== null && + statusBarItems.includes('kiro') && + isStatusBarItemAvailable('kiro', detectedAgentIds) // Why: a saved Coding Plan key is site-auth, not a CLI on PATH — a subscriber // without the ZCode CLI must still earn the meter (same exemption as MiniMax/Cursor). const showZcode = @@ -174,11 +190,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showMiniMax || showGrok || showCursor || + showKiro || showZcode const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, kiro, zcode }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -193,6 +210,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { minimax?.status === 'fetching' || grok?.status === 'fetching' || cursor?.status === 'fetching' || + kiro?.status === 'fetching' || zcode?.status === 'fetching' const floatingTerminalActionLabel = floatingTerminalOpen @@ -212,6 +230,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showMiniMax ? visibleMiniMax : null, showGrok ? visibleGrok : null, showCursor ? visibleCursor : null, + showKiro ? visibleKiro : null, showZcode ? visibleZcode : null ].filter((p): p is ProviderRateLimits => p !== null) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 09cf619f4935..13b9918d19bd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -225,6 +225,7 @@ "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI.", "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE.", + "kiroToggleDescription": "Show Kiro subscription credit usage reported by Kiro CLI.", "zcodeToggleDescription": "Show ZCode Coding Plan quota usage." }, "menuBarIcon": { @@ -3988,6 +3989,7 @@ "codexSignInSuccess": "Signed in to Codex", "codexSignInError": "Codex sign-in failed. Please try again.", "cursorUsageMenu": "Cursor Usage", + "kiroUsageMenu": "Kiro Usage", "hiddenUsageProviders": "Also: {{value0}}", "zcodeUsageMenu": "ZCode Usage" }, @@ -4208,7 +4210,8 @@ "message": "Run cursor-agent login in a terminal on the computer running Orca, then retry usage." }, "plan": "Plan" - } + }, + "resetOnDate": "Resets on {{value0}}" }, "SshTargetStatusRow": { "sshHost": "SSH Host" @@ -9602,6 +9605,10 @@ "c5b9f8d1e3": "xai", "usagePercentageDisplayTitle": "Usage percentages", "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining.", + "kiroUsageTitle": "Kiro Usage", + "kiroUsageDescription": "Show Kiro subscription credit usage in the status bar.", + "kiro": "kiro", + "kiroAws": "aws", "zcodeUsageTitle": "ZCode Usage", "zcodeUsageDescription": "Show ZCode Coding Plan quota usage in the status bar.", "zcode": "zcode", 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 c1e23e62797d..5839816273d5 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/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 8a9e627788a9..36c32cc9cdd2 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -141,6 +141,7 @@ describe('createUISlice hydratePersistedUI', () => { 'antigravity', 'grok', 'cursor', + 'kiro', 'zcode' ]) expect(setUI).toHaveBeenCalledWith({ @@ -153,6 +154,7 @@ describe('createUISlice hydratePersistedUI', () => { 'antigravity', 'grok', 'cursor', + 'kiro', 'zcode' ], _portsStatusBarDefaultAdded: true, @@ -161,6 +163,7 @@ describe('createUISlice hydratePersistedUI', () => { _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true, _zcodeStatusBarDefaultAdded: true }) }) @@ -179,6 +182,7 @@ describe('createUISlice hydratePersistedUI', () => { _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true, _zcodeStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts index 26b6071d64dd..b410eb6960cb 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts @@ -8,6 +8,7 @@ const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' const DEFAULT_ON_CURSOR_STATUS_BAR_ITEM: StatusBarItem = 'cursor' +const DEFAULT_ON_KIRO_STATUS_BAR_ITEM: StatusBarItem = 'kiro' const DEFAULT_ON_ZCODE_STATUS_BAR_ITEM: StatusBarItem = 'zcode' export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { @@ -19,6 +20,7 @@ export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM], ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM], + ['_kiroStatusBarDefaultAdded', DEFAULT_ON_KIRO_STATUS_BAR_ITEM], ['_zcodeStatusBarDefaultAdded', DEFAULT_ON_ZCODE_STATUS_BAR_ITEM] ] as const for (const [flag, item] of defaults) { diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 75a318e877bf..52585c1dd050 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 71de20b1928b..53d360cd0618 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 000000000000..02e437c084b2 --- /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 000000000000..9c52cb62e732 --- /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 000000000000..4df695ce9c73 --- /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 c26a8073a03a..a8dfa25c5f59 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 814f74484762..0eac4ec6f6c3 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 977e7af15703..5cdb43dfda60 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 0adda53d2957..478ba4b9d86d 100644 --- a/src/shared/agent-resume-argv.ts +++ b/src/shared/agent-resume-argv.ts @@ -76,6 +76,10 @@ 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', 'chat', '--tui', '--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 b3f3f2ace44d..92d0f4914a65 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 18f373dec468..515108693af1 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 aded7de9bab2..e6a34d71e091 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -193,8 +193,9 @@ function defaultAiVaultResumeCommandBase(agent: AiVaultAgent): string { if (agent === 'hermes') { return 'hermes' } - if (agent === 'rovo') { - return TUI_AGENT_CONFIG.rovo.launchCmd + // Why: Kiro resumes through its `chat` subcommand, so the base is the full launch command. + if (agent === 'rovo' || agent === 'kiro') { + return TUI_AGENT_CONFIG[agent].launchCmd } return TUI_AGENT_CONFIG[agent].detectCmd } @@ -249,6 +250,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 750413d7431b..9134917daf8a 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 000000000000..0f134d3db3af --- /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 000000000000..2ee6bdb46add --- /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/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index ecd514295333..99e986cb3220 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -127,6 +127,8 @@ export type PersistedUIState = { _grokStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Cursor status item. */ _cursorStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Kiro status item. */ + _kiroStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on ZCode status item. */ _zcodeStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index b9af7fe1b68c..b17fd7b14577 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -12,6 +12,7 @@ export function createEmptyRateLimitState(overrides: Partial = { minimax: null, grok: null, cursor: null, + kiro: null, zcode: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 0a9abf64ab13..d464ede50558 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -84,6 +84,7 @@ export type ProviderRateLimits = { | 'grok' | 'antigravity' | 'cursor' + | 'kiro' | 'zcode' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null @@ -168,6 +169,7 @@ export type RateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + kiro: ProviderRateLimits | null zcode: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index 9f41e92674e4..2a0de6dc16cf 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -70,6 +70,7 @@ export const StatusBarItem = z.enum([ 'minimax', 'grok', 'cursor', + 'kiro', 'zcode', 'ssh', 'resource-usage', @@ -190,6 +191,7 @@ export const UiUpdateFields = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), _cursorStatusBarDefaultAdded: z.boolean().optional(), + _kiroStatusBarDefaultAdded: z.boolean().optional(), _zcodeStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index c32958ee9491..617f47a2fdd5 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -10,6 +10,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'minimax', 'grok', 'cursor', + 'kiro', 'zcode', 'ssh', 'resource-usage', diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index 2e7557bb5920..2aa9166ad592 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -65,6 +65,7 @@ export type StatusBarItem = | 'minimax' | 'grok' | 'cursor' + | 'kiro' | 'zcode' | 'ssh' | 'resource-usage'