From adc7455df2fabb6083d83546d66b6b2464de0366 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 12 Aug 2026 15:58:21 +0800 Subject: [PATCH 01/20] feat(usage): add Kiro subscription credits --- .../rate-limits/kiro-usage-fetcher.test.ts | 62 ++++++++ src/main/rate-limits/kiro-usage-fetcher.ts | 135 ++++++++++++++++++ .../rate-limit-service-test-harness.ts | 3 + .../service-account-target-selection.test.ts | 4 + .../service-inactive-account-previews.test.ts | 4 + .../service-live-claude-usage.test.ts | 4 + .../rate-limits/service-minimax-usage.test.ts | 4 + .../service-refresh-orchestration.test.ts | 4 + .../service-window-activation.test.ts | 4 + ...ppearance-status-bar-kiro-toggle-search.ts | 37 +++++ .../settings/appearance-status-bar-search.ts | 2 + .../status-bar-agent-gating.test.ts | 3 + .../status-bar/status-bar-agent-gating.ts | 3 +- .../status-bar-provider-visibility.ts | 8 +- .../src/components/status-bar/tooltip.tsx | 2 + .../components/status-bar/usage-error-copy.ts | 2 + .../usage-provider-settings-target.ts | 2 + src/renderer/src/i18n/locales/en.json | 10 +- ...ui-hydration-workspace-preferences.test.ts | 12 +- src/shared/rate-limit-state-factory.ts | 1 + src/shared/rate-limit-types.test.ts | 1 + src/shared/rate-limit-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 2 + src/shared/status-bar-defaults.ts | 1 + 24 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 src/main/rate-limits/kiro-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/kiro-usage-fetcher.ts create mode 100644 src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts 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..a28dd0496a45 --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { fetchKiroRateLimits, parseKiroUsageOutput } from './kiro-usage-fetcher' + +describe('Kiro usage fetcher', () => { + 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?.resetsAt).toBe(Date.parse('2026-09-01T00:00:00.000Z')) + }) + + it('runs the local non-model usage command', async () => { + const runner = vi.fn().mockResolvedValue({ + stdout: + 'Estimated Usage | resets on 2026-09-01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%', + 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('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..eec5b2c44332 --- /dev/null +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -0,0 +1,135 @@ +import { execFile } from 'node:child_process' +import { accessSync, constants } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +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 } : {}) + } + } +} + +function resolveKiroCommand(): string { + const configured = process.env.KIRO_CLI_PATH?.trim() + const candidates = [ + configured, + join(homedir(), 'bin', 'kiro-cli'), + join(homedir(), '.local', 'bin', 'kiro-cli') + ] + for (const candidate of candidates) { + if (!candidate) { + continue + } + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch { + // Continue to the next known install location. + } + } + return process.platform === 'win32' ? 'kiro-cli.exe' : 'kiro-cli' +} + +const runCommand: CommandRunner = (command, args, signal) => + new Promise((resolve, reject) => { + execFile( + command, + args, + { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal }, + (error, stdout, stderr) => { + if (error) { + reject(error) + return + } + resolve({ stdout, stderr }) + } + ) + }) + +function parseResetDate(value: string): number | null { + const timestamp = Date.parse(`${value}T00:00:00.000Z`) + return Number.isFinite(timestamp) ? timestamp : null +} + +export function parseKiroUsageOutput(output: string): ProviderRateLimits { + const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') + const header = readable.match( + /Estimated Usage\s*\|\s*resets on (\d{4}-\d{2}-\d{2})\s*\|\s*([^\n]+)/i + ) + 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 || 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, + resetsAt: parseResetDate(header[1]), + resetDescription: header[1] + }, + 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 as NodeJS.ErrnoException | null)?.code + 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 021de01108d3..f539daad57e3 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 { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' export type Deferred = { @@ -92,6 +93,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')) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -110,6 +112,7 @@ export function resetRateLimitProviderMocks(): void { status: 'unavailable' }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchKiroRateLimits).mockResolvedValue(unavailableProvider('kiro')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) 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 2deeadfcdaad..3222b888b9d9 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) 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 300292efcc21..564d0fc651c9 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: 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 3c51a09aa5ff..adad11c18608 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: 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 8b2253364723..45096d298f15 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: 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 b184d51eda6a..2c66951cabeb 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -40,6 +40,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./kiro-usage-fetcher', () => ({ + fetchKiroRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 7c83bd7d0260..547c509b2a5f 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('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) 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..0e597eaf7e78 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-kiro-toggle-search.ts @@ -0,0 +1,37 @@ +import type { StatusBarItem } from '../../../../shared/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 8a4d65f21fd1..f9990cb14eb6 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' export const getStatusBarToggles = createLocalizedCatalog( (): readonly { @@ -169,6 +170,7 @@ export const getStatusBarToggles = createLocalizedCatalog( getMiniMaxStatusBarToggleSearchEntry(), getGrokStatusBarToggleSearchEntry(), getCursorStatusBarToggleSearchEntry(), + getKiroStatusBarToggleSearchEntry(), { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), 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 cd98315df505..eb3d51c05bc6 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) }) it('hides CLI items not detected on PATH', () => { @@ -28,6 +29,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) }) it('shows CLI items detected on PATH', () => { @@ -36,5 +38,6 @@ 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) }) }) 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 145abef3a900..9ae7b5fa5a0b 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 @@ -13,7 +13,8 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'gemini', 'kimi', 'antigravity', - 'grok' + 'grok', + 'kiro' ]) export function isStatusBarItemAvailable( 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 086540781d71..b4ea99c7201e 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 @@ -34,6 +34,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 } type UsageProviderId = ProviderRateLimits['provider'] @@ -181,7 +183,8 @@ export function isUsageEmptyState( antigravitySnapshotPending || isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) || - isProviderSnapshotPending(providers.cursor) + isProviderSnapshotPending(providers.cursor) || + (providers.kiro !== undefined && isProviderSnapshotPending(providers.kiro)) ) { return false } @@ -195,6 +198,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.antigravity) && !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) && - !isProviderConfigured(providers.cursor) + !isProviderConfigured(providers.cursor) && + !isProviderConfigured(providers.kiro) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index be15ae18c02f..9e2b79934d7d 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -99,6 +99,8 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem } if (provider === 'cursor') { return + if (provider === 'kiro') { + return } 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 a0a6cabf1a9a..2114c1d896d4 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -28,6 +28,8 @@ 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 39380e135950..2ae101650f68 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 @@ -21,7 +21,9 @@ export function getUsageProviderAccountsSectionId( case 'cursor': return 'accounts-cursor' case 'kimi': + case 'kiro': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // Kiro likewise owns its CLI credential lifecycle. return null } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 14a780e7612c..53c177db0306 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -151,7 +151,8 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "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." + "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE.", + "kiroToggleDescription": "Show Kiro subscription credit usage reported by Kiro CLI." }, "menuBarIcon": { "title": "Show Menu Bar Icon", @@ -3820,7 +3821,8 @@ "floatingTerminalNewActivity": "{{label}}, new activity", "codexSignInSuccess": "Signed in to Codex", "codexSignInError": "Codex sign-in failed. Please try again.", - "cursorUsageMenu": "Cursor Usage" + "cursorUsageMenu": "Cursor Usage", + "kiroUsageMenu": "Kiro Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -9357,6 +9359,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", "tray": { "tray": "tray", "system": "system tray", 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 0a380a0625a1..b8e4a6a5974e 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 @@ -140,7 +140,8 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'kiro' ]) expect(setUI).toHaveBeenCalledWith({ statusBarItems: [ @@ -151,14 +152,16 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'kiro' ], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true }) }) @@ -175,7 +178,8 @@ describe('createUISlice hydratePersistedUI', () => { _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _kiroStatusBarDefaultAdded: true }) ) diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index 3dade988f0b9..4f645968b3ea 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, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index b24e40385794..1b034b4ea580 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -17,6 +17,7 @@ describe('RateLimitState', () => { minimax: null, grok: null, cursor: null, + kiro: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 48ff7c7abe35..d307560c92aa 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -58,6 +58,7 @@ export type ProviderRateLimits = { | 'grok' | 'antigravity' | 'cursor' + | 'kiro' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -139,6 +140,7 @@ export type RateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + kiro: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives * outside GlobalSettings, so this flag is the durable signal that the diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index b78a984889e4..0d63f9bb21c8 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', 'ssh', 'resource-usage', 'ports' @@ -184,6 +185,7 @@ export const UiUpdateFields = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), _cursorStatusBarDefaultAdded: z.boolean().optional(), + _kiroStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 2eb855d9dd5c..61b638ed7e9d 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', 'ssh', 'resource-usage', 'ports' From 1bc0fa1a84c8aa30c5594fa34c95635a1796de1d Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 12 Aug 2026 16:26:17 +0800 Subject: [PATCH 02/20] fix(usage): distinguish compact Kiro label --- .../status-bar/status-bar-provider-letter.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/renderer/src/components/status-bar/status-bar-provider-letter.test.ts 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..e24646f91f44 --- /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('KI') + }) +}) From 04eb6913b571007a67059d06662f1905a5f38a0e Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 12 Aug 2026 23:33:52 +0800 Subject: [PATCH 03/20] fix(usage): use compact Kiro Q label --- .../components/status-bar/status-bar-provider-letter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e24646f91f44..4e36e37e2974 100644 --- 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 @@ -4,6 +4,6 @@ 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('KI') + expect(getProviderLetter('kiro')).toBe('Q') }) }) From bba016d840e4a5e17bc7f56778108d1a577af44f Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 11:24:19 +0800 Subject: [PATCH 04/20] fix(usage): avoid inventing Kiro reset time --- src/main/rate-limits/kiro-usage-fetcher.test.ts | 5 ++++- src/main/rate-limits/kiro-usage-fetcher.ts | 9 +++------ .../src/components/status-bar/tooltip.test.ts | 14 ++++++++++++++ src/renderer/src/components/status-bar/tooltip.tsx | 14 +++++++++++++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index a28dd0496a45..3ac9ade8f091 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -11,7 +11,10 @@ Credits (1233.74 of 2000 covered in plan) expect(result).toMatchObject({ provider: 'kiro', status: 'ok', planType: 'KIRO PRO+' }) expect(result.monthly?.usedPercent).toBeCloseTo(61.687) - expect(result.monthly?.resetsAt).toBe(Date.parse('2026-09-01T00:00:00.000Z')) + expect(result.monthly).toMatchObject({ + resetsAt: null, + resetDescription: '2026-09-01' + }) }) it('runs the local non-model usage command', async () => { diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index eec5b2c44332..83327dbf1cd5 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -75,11 +75,6 @@ const runCommand: CommandRunner = (command, args, signal) => ) }) -function parseResetDate(value: string): number | null { - const timestamp = Date.parse(`${value}T00:00:00.000Z`) - return Number.isFinite(timestamp) ? timestamp : null -} - export function parseKiroUsageOutput(output: string): ProviderRateLimits { const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') const header = readable.match( @@ -104,7 +99,9 @@ export function parseKiroUsageOutput(output: string): ProviderRateLimits { { usedPercent, windowMinutes: MONTHLY_WINDOW_MINUTES, - resetsAt: parseResetDate(header[1]), + // Kiro reports a calendar date without a time zone or time of day. Keep it as + // display metadata instead of inventing a UTC-midnight countdown. + resetsAt: null, resetDescription: header[1] }, header[2].trim() diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index fd7561e250cd..332926906f96 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -27,6 +27,7 @@ import { clampUsedPercent, formatResetCreditExpiry, formatResetCountdown, + formatRateLimitResetLabel, getProviderUsageErrorMessage, getProviderUsageStatusLabel, getWindowSections, @@ -72,6 +73,19 @@ describe('formatResetCountdown', () => { }) }) +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') + }) +}) + describe('formatResetCreditExpiry', () => { it('shows singular and plural expiry countdowns', () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 9e2b79934d7d..69d2e6292b1a 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -45,6 +45,18 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } +export function formatRateLimitResetLabel(window: RateLimitWindow): string | null { + if (window.resetsAt) { + return formatResetCountdown(window.resetsAt - Date.now()) + } + if (window.resetDescription && /^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { + return translate('auto.components.status.bar.tooltip.resetOnDate', 'Resets on {{value0}}', { + value0: 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 } @@ -238,7 +250,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) return (
From 10affdc46852c41f6bc41490fa5a6890e0b22bfc Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 11:45:42 +0800 Subject: [PATCH 05/20] fix(usage): follow split shared UI types --- src/shared/persisted-ui-state-types.ts | 2 ++ src/shared/ui-chrome-types.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 6b1f20b58b2a..c873bcb39f7d 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -120,6 +120,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 statusBarItems: StatusBarItem[] statusBarVisible: boolean /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index d9aee820949e..6161dcbc49e8 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -63,6 +63,7 @@ export type StatusBarItem = | 'minimax' | 'grok' | 'cursor' + | 'kiro' | 'ssh' | 'resource-usage' | 'ports' From 136ae269a3891f7778e9f6ff097568ea16d7ff2a Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 16:51:27 +0800 Subject: [PATCH 06/20] fix(usage): keep non-date Kiro reset descriptions formatRateLimitResetLabel returned null for human-readable resetDescription values such as "Thu" or "2:30 PM" because only ISO dates were recognized. Return non-date descriptions unchanged so the status bar keeps a valid reset label. --- .../src/components/status-bar/tooltip.test.ts | 30 +++++++++++++++++++ .../src/components/status-bar/tooltip.tsx | 12 +++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 332926906f96..97693614c92a 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -84,6 +84,36 @@ describe('formatRateLimitResetLabel', () => { }) ).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() + }) }) describe('formatResetCreditExpiry', () => { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 69d2e6292b1a..7d023ebd773e 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -49,10 +49,14 @@ export function formatRateLimitResetLabel(window: RateLimitWindow): string | nul if (window.resetsAt) { return formatResetCountdown(window.resetsAt - Date.now()) } - if (window.resetDescription && /^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { - return translate('auto.components.status.bar.tooltip.resetOnDate', 'Resets on {{value0}}', { - value0: window.resetDescription - }) + 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 } From 3fcd012aef87d134f1a02035156b797c63a1bae5 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Fri, 14 Aug 2026 16:51:27 +0800 Subject: [PATCH 07/20] fix(usage): launch Kiro .cmd shims through cmd.exe execFile cannot launch configured .cmd/.bat paths directly on Windows. Route the Kiro usage command through getSpawnArgsForWindows like the other usage fetchers; native binaries keep the direct execFile path. --- .../rate-limits/kiro-usage-fetcher.test.ts | 41 ++++++++++++++++++- src/main/rate-limits/kiro-usage-fetcher.ts | 8 +++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index 3ac9ade8f091..dfc8aade22ed 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it, vi } from 'vitest' +import { execFile } from 'node:child_process' +import { getSpawnArgsForWindows } from '../win32-utils' import { fetchKiroRateLimits, parseKiroUsageOutput } from './kiro-usage-fetcher' +vi.mock('node:child_process', () => ({ execFile: vi.fn() })) +vi.mock('../win32-utils', () => ({ getSpawnArgsForWindows: 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('parses the official CLI usage command output', () => { const result = parseKiroUsageOutput(` @@ -19,8 +27,7 @@ Credits (1233.74 of 2000 covered in plan) it('runs the local non-model usage command', async () => { const runner = vi.fn().mockResolvedValue({ - stdout: - 'Estimated Usage | resets on 2026-09-01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%', + stdout: KIRO_USAGE_OUTPUT, stderr: '' }) @@ -34,6 +41,36 @@ Credits (1233.74 of 2000 covered in plan) expect(result.status).toBe('ok') }) + it('launches configured .cmd shims through the shared Windows batch launcher', async () => { + vi.mocked(getSpawnArgsForWindows).mockReturnValue({ + spawnCmd: 'C:\\Windows\\System32\\cmd.exe', + spawnArgs: ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'] + }) + vi.mocked(execFile).mockImplementation( + ((_command, _args, _options, callback) => { + callback(null, KIRO_USAGE_OUTPUT, '') + return undefined as never + }) as unknown as typeof execFile + ) + + const result = await fetchKiroRateLimits({ command: 'C:\\Users\\me\\bin\\kiro-cli.cmd' }) + + expect(getSpawnArgsForWindows).toHaveBeenCalledWith('C:\\Users\\me\\bin\\kiro-cli.cmd', [ + 'chat', + '/usage', + '--no-interactive', + '--wrap', + 'never' + ]) + expect(execFile).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'], + expect.anything(), + expect.any(Function) + ) + expect(result.status).toBe('ok') + }) + it('fails closed on unexpected output', () => { expect(parseKiroUsageOutput('Please sign in first')).toMatchObject({ provider: 'kiro', diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 83327dbf1cd5..88fae9191902 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -4,6 +4,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { getSpawnArgsForWindows } from '../win32-utils' const CLI_TIMEOUT_MS = 20_000 const MONTHLY_WINDOW_MINUTES = 43_200 @@ -61,9 +62,12 @@ function resolveKiroCommand(): string { const runCommand: CommandRunner = (command, args, signal) => new Promise((resolve, reject) => { + // Why: execFile cannot launch configured .cmd/.bat shims directly on + // Windows; route them through cmd.exe like the other usage fetchers. + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) execFile( - command, - args, + spawnCmd, + spawnArgs, { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal }, (error, stdout, stderr) => { if (error) { From 99960448d277899ccda16ac2f25dbf51332855f5 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Sat, 15 Aug 2026 08:56:17 +0800 Subject: [PATCH 08/20] fix(usage): align provider search with current lint gates --- .../settings/appearance-status-bar-kiro-toggle-search.ts | 2 +- src/renderer/src/i18n/locales/en.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index 0e597eaf7e78..e826f93b50e5 100644 --- 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 @@ -1,4 +1,4 @@ -import type { StatusBarItem } from '../../../../shared/types' +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 53c177db0306..eec0eac404d6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4020,7 +4020,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" From 2118b790c478d3c5bb34dc48172b358a54edfb24 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 24 Aug 2026 10:35:41 +0800 Subject: [PATCH 09/20] test(usage): mock the Kiro fetcher in the Antigravity service test The Antigravity coverage landed upstream after this branch last rebased. Its test file predates the Kiro provider, so the shared resetRateLimitProviderMocks harness now fails on the unmocked fetchKiroRateLimits import. Declare the same vi.mock the other service test files use. --- src/main/rate-limits/service-antigravity-usage.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f2fc2c8244c..bb90456d6047 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -47,6 +47,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' })) })) From e685ce3f187a7491a35d1825f09ecb2977b56212 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 31 Aug 2026 11:08:22 +0800 Subject: [PATCH 10/20] fix(usage): thread the reset-countdown clock into the Kiro reset label Upstream now feeds a ticking `now` into ProviderRateLimitWindowSection; `formatRateLimitResetLabel` gains an optional `now` (default Date.now()) so the label keeps re-rendering on the clock without inventing reset times. --- src/renderer/src/components/status-bar/tooltip.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 7d023ebd773e..0e0a1e0f490f 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -45,9 +45,12 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } -export function formatRateLimitResetLabel(window: RateLimitWindow): string | null { +export function formatRateLimitResetLabel( + window: RateLimitWindow, + now: number = Date.now() +): string | null { if (window.resetsAt) { - return formatResetCountdown(window.resetsAt - Date.now()) + return formatResetCountdown(window.resetsAt - now) } if (window.resetDescription) { if (/^\d{4}-\d{2}-\d{2}$/.test(window.resetDescription)) { @@ -254,7 +257,7 @@ function ProviderRateLimitWindowSection({ } const usedPct = clampUsedPercent(window.usedPercent) const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) - const resetLabel = formatRateLimitResetLabel(window) + const resetLabel = formatRateLimitResetLabel(window, now) return (
From 651b4a6d4367ba6f9397afb01a5b0d19ee81c9a3 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Wed, 2 Sep 2026 14:55:27 +0800 Subject: [PATCH 11/20] fix(usage): resolve Kiro review findings Signed-off-by: guanbear <123guan@gmail.com> --- .../rate-limits/kiro-usage-fetcher.test.ts | 30 +++++++++---- src/main/rate-limits/kiro-usage-fetcher.ts | 34 ++++++--------- .../service-refresh-orchestration.test.ts | 38 +++++++++++++++++ .../service/service-full-cycle-application.ts | 42 +++++++++++++++---- .../service/service-full-cycle-preparation.ts | 14 ++++++- .../rate-limits/service/service-polling.ts | 3 +- src/main/rate-limits/service/service-state.ts | 9 ++-- src/main/rate-limits/service/service-types.ts | 3 +- .../src/components/status-bar/StatusBar.tsx | 2 +- .../status-bar/StatusBarProviderSegment.tsx | 4 +- .../status-bar/StatusBarVisibilityMenu.tsx | 12 ++++++ .../status-bar/use-status-bar-controller.ts | 19 ++++++--- .../ui/ui-slice-hydration-status-bar-items.ts | 4 +- 13 files changed, 163 insertions(+), 51 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index dfc8aade22ed..7cdc482aad80 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { execFile } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { getSpawnArgsForWindows } from '../win32-utils' -import { fetchKiroRateLimits, parseKiroUsageOutput } from './kiro-usage-fetcher' +import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher' vi.mock('node:child_process', () => ({ execFile: vi.fn() })) vi.mock('../win32-utils', () => ({ getSpawnArgsForWindows: vi.fn() })) @@ -10,6 +13,21 @@ 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 @@ -46,12 +64,10 @@ Credits (1233.74 of 2000 covered in plan) spawnCmd: 'C:\\Windows\\System32\\cmd.exe', spawnArgs: ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'] }) - vi.mocked(execFile).mockImplementation( - ((_command, _args, _options, callback) => { - callback(null, KIRO_USAGE_OUTPUT, '') - return undefined as never - }) as unknown as typeof execFile - ) + vi.mocked(execFile).mockImplementation(((_command, _args, _options, callback) => { + callback(null, KIRO_USAGE_OUTPUT, '') + return undefined as never + }) as unknown as typeof execFile) const result = await fetchKiroRateLimits({ command: 'C:\\Users\\me\\bin\\kiro-cli.cmd' }) diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 88fae9191902..0e6f8ad4018f 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -1,8 +1,6 @@ import { execFile } from 'node:child_process' -import { accessSync, constants } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { getSpawnArgsForWindows } from '../win32-utils' @@ -39,25 +37,18 @@ function result( } } -function resolveKiroCommand(): string { +export function resolveKiroCommand( + options: { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string + } = {} +): string { const configured = process.env.KIRO_CLI_PATH?.trim() - const candidates = [ - configured, - join(homedir(), 'bin', 'kiro-cli'), - join(homedir(), '.local', 'bin', 'kiro-cli') - ] - for (const candidate of candidates) { - if (!candidate) { - continue - } - try { - accessSync(candidate, constants.X_OK) - return candidate - } catch { - // Continue to the next known install location. - } + if (configured) { + return configured } - return process.platform === 'win32' ? 'kiro-cli.exe' : 'kiro-cli' + return resolveCliCommand('kiro-cli', options) } const runCommand: CommandRunner = (command, args, signal) => @@ -65,10 +56,11 @@ const runCommand: CommandRunner = (command, args, signal) => // Why: execFile cannot launch configured .cmd/.bat shims directly on // Windows; route them through cmd.exe like the other usage fetchers. const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) + const env = withCliRuntimeOnPath(command, { ...process.env }) execFile( spawnCmd, spawnArgs, - { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal }, + { encoding: 'utf8', timeout: CLI_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal, env }, (error, stdout, stderr) => { if (error) { reject(error) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 2c66951cabeb..5f4d561854f0 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 { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { @@ -312,6 +313,43 @@ 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(fetchOpenCodeGoRateLimits).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/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 9e9d9506a051..1ac0907758c8 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,7 +1,7 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' -import { settleSiblingProviderResult } from './service-sibling-provider-result' -import type { ProviderRateLimits } from './service-types' +import { settleSiblingProviderResult, type SettledProviderResult } from './service-sibling-provider-result' +import type { InternalRateLimitState, ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { protected async runFetchAllCycle( @@ -36,7 +36,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + kiroResultPromise } = prepared if (signal.aborted) { return @@ -193,17 +194,25 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) + await Promise.all([ + this.applyGrokAndCursorResults(grokResultPromise, cursorResultPromise, previousState, signal), + this.applyKiroResult(kiroResultPromise, previousState.kiro, signal) + ]) + } + + private async applyGrokAndCursorResults( + grokResultPromise: Promise, + cursorResultPromise: Promise, + previousState: Pick, + signal: AbortSignal + ): Promise { const [grokSettled, cursorSettled] = await Promise.all([grokResultPromise, cursorResultPromise]) if (signal.aborted) { return } const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) - // Why: the stale policy keeps a recent snapshot through a failed refresh, but - // a snapshot belonging to a different Cursor account must not survive the - // switch — the Accounts pane would name the new account beside the old - // account's figures. Only a known-and-changed identity clears it, so an - // errored refresh that reports no account still keeps its own last reading. + // A changed Cursor identity must not inherit another account's stale figures. const previousCursorAccount = previousState.cursor?.usageMetadata?.authProvenance const cursorAccount = cursor.usageMetadata?.authProvenance const cursorAccountChanged = @@ -218,4 +227,21 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor) }) } + + 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 df36011ab312..63fab594f94d 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' @@ -44,6 +45,7 @@ export type FetchAllCyclePrepared = { ] grokResultPromise: Promise cursorResultPromise: Promise + kiroResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -130,7 +132,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), - cursor: this.withFetchingStatus(previousState.cursor, 'cursor') + cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), + kiro: this.withFetchingStatus(previousState.kiro, 'kiro') }) // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the @@ -154,6 +157,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 = @@ -231,7 +240,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + kiroResultPromise } } } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index e49c003cbb1d..973fe6f035a4 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -79,7 +79,8 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue minimax: this.state.minimax, grok: this.state.grok, antigravity: this.state.antigravity, - cursor: this.state.cursor + cursor: this.state.cursor, + kiro: this.state.kiro } return Object.entries(byProvider).map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index a10bfa3f3c00..f295c603b02e 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -32,7 +32,8 @@ export abstract class RateLimitServiceState { antigravity: null, minimax: null, grok: null, - cursor: null + cursor: null, + kiro: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' // Why: the Cursor probe reads the macOS Keychain, so it cannot run synchronously @@ -52,7 +53,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + kiro: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. protected activeFailureStreakByProvider: Record = { @@ -64,7 +66,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + kiro: 0 } protected mainWindow: BrowserWindow | null = null protected detachWindowListeners: (() => void) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 9783f37f77df..f46840be2134 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -109,7 +109,8 @@ export type InternalRateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null - cursor: ProviderRateLimits | null + cursor: ProviderRateLimits | null, + kiro: ProviderRateLimits | null } export function normalizePollingInterval(ms: number): number { 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 3a9bc0cba485..7d7e07a6f9bc 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -67,7 +67,7 @@ export function ProviderLetterBadge({ p }: { p: ProviderRateLimits }): React.JSX ) } -function getProviderLetter(provider: ProviderRateLimits['provider']): string { +export function getProviderLetter(provider: ProviderRateLimits['provider']): string { switch (provider) { case 'claude': return 'C' @@ -85,6 +85,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'R' case 'cursor': return 'U' + case 'kiro': + return 'Q' case 'codex': return 'X' } diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index a2dab03d9c09..c5541b0b9e7e 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')} + + )} { 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 b7f57c7f1e7b..d4a57586160d 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 @@ -99,7 +99,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, kiro } = + 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. @@ -125,6 +126,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 showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -154,6 +156,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: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') @@ -172,11 +178,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showAntigravity || showMiniMax || showGrok || - showCursor + showCursor || + showKiro 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 }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, kiro }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -190,7 +197,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { antigravity?.status === 'fetching' || minimax?.status === 'fetching' || grok?.status === 'fetching' || - cursor?.status === 'fetching' + cursor?.status === 'fetching' || + kiro?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -210,7 +218,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showKimi ? visibleKimi : null, showMiniMax ? visibleMiniMax : null, showGrok ? visibleGrok : null, - showCursor ? visibleCursor : null + showCursor ? visibleCursor : null, + showKiro ? visibleKiro : null ].filter((p): p is ProviderRateLimits => p !== null) const handleManageAccounts = (): void => { 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 47c770804c23..3b3037501edb 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' export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { let items = migrateStatusBarItems(ui.statusBarItems) @@ -17,7 +18,8 @@ export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { ['_minimaxStatusBarDefaultAdded', DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM], ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM], - ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM] + ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM], + ['_kiroStatusBarDefaultAdded', DEFAULT_ON_KIRO_STATUS_BAR_ITEM] ] as const for (const [flag, item] of defaults) { if (!ui[flag] && !items.includes(item)) { From b28b50ed4b347fb7082e12f68639af67ddf60e60 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 10:13:34 +0800 Subject: [PATCH 12/20] fix(usage): complete Kiro rebase integration --- src/main/rate-limits/service-refresh-orchestration.test.ts | 4 +--- .../rate-limits/service/service-full-cycle-application.ts | 5 ++++- src/main/rate-limits/service/service-types.ts | 2 +- src/renderer/src/components/status-bar/tooltip.tsx | 1 + src/renderer/src/components/status-bar/usage-error-copy.ts | 1 + 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 5f4d561854f0..a75ae90053c4 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -321,9 +321,7 @@ describe('RateLimitService', () => { 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(fetchOpenCodeGoRateLimits).mockResolvedValueOnce( - okProvider('opencode-go', 40, 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) 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 1ac0907758c8..590acf4e5ef7 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,6 +1,9 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' -import { settleSiblingProviderResult, type SettledProviderResult } from './service-sibling-provider-result' +import { + settleSiblingProviderResult, + type SettledProviderResult +} from './service-sibling-provider-result' import type { InternalRateLimitState, ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index f46840be2134..fcf987cd5419 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -109,7 +109,7 @@ export type InternalRateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null - cursor: ProviderRateLimits | null, + cursor: ProviderRateLimits | null kiro: ProviderRateLimits | null } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 0e0a1e0f490f..52dcc2eba75a 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -118,6 +118,7 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem } if (provider === 'cursor') { return + } if (provider === 'kiro') { 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 2114c1d896d4..939200ab557c 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -28,6 +28,7 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) } if (provider === 'cursor') { return 'Cursor' + } if (provider === 'kiro') { return 'Kiro' } From 2a1ab9d1fb217f55eab2b4a20a19b360d852ce7d Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Mon, 28 Sep 2026 17:09:06 +0800 Subject: [PATCH 13/20] fix(usage): narrow Kiro CLI errors without assertions --- .../rate-limits/kiro-usage-fetcher.test.ts | 18 ++++++++++++------ src/main/rate-limits/kiro-usage-fetcher.ts | 8 +++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/rate-limits/kiro-usage-fetcher.test.ts b/src/main/rate-limits/kiro-usage-fetcher.test.ts index 7cdc482aad80..9a061dd25b79 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from 'vitest' -import { execFile } from 'node:child_process' +import * as childProcess from 'node:child_process' import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { getSpawnArgsForWindows } from '../win32-utils' import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher' -vi.mock('node:child_process', () => ({ execFile: vi.fn() })) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: vi.fn() +})) vi.mock('../win32-utils', () => ({ getSpawnArgsForWindows: vi.fn() })) const KIRO_USAGE_OUTPUT = @@ -64,10 +67,13 @@ Credits (1233.74 of 2000 covered in plan) spawnCmd: 'C:\\Windows\\System32\\cmd.exe', spawnArgs: ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'] }) - vi.mocked(execFile).mockImplementation(((_command, _args, _options, callback) => { + vi.mocked(childProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (!callback) { + throw new Error('Expected execFile callback') + } callback(null, KIRO_USAGE_OUTPUT, '') - return undefined as never - }) as unknown as typeof execFile) + return new childProcess.ChildProcess() + }) const result = await fetchKiroRateLimits({ command: 'C:\\Users\\me\\bin\\kiro-cli.cmd' }) @@ -78,7 +84,7 @@ Credits (1233.74 of 2000 covered in plan) '--wrap', 'never' ]) - expect(execFile).toHaveBeenCalledWith( + expect(childProcess.execFile).toHaveBeenCalledWith( 'C:\\Windows\\System32\\cmd.exe', ['/d', '/c', 'C:\\Users\\me\\bin\\kiro-cli.cmd', 'chat'], expect.anything(), diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index 0e6f8ad4018f..9fa1ab1b4b7c 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -115,7 +115,13 @@ export async function fetchKiroRateLimits( ) return parseKiroUsageOutput(`${stdout}\n${stderr}`) } catch (error) { - const code = (error as NodeJS.ErrnoException | null)?.code + 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', From b9af9b949973cba845d2934fa756e02800adbf9a Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Thu, 8 Oct 2026 16:33:56 +0800 Subject: [PATCH 14/20] fix(kiro): isolate reset label coverage and preserve upstream formatting --- .../structured-agent-session-queued-mutations.ts | 2 +- src/relay/agent-exec-disposal.test.ts | 4 +--- .../src/components/native-chat/NativeChatComposer.tsx | 8 ++++++-- .../native-chat/NativeChatInterruptedContinue.test.tsx | 4 +++- .../src/components/status-bar/reset-date-label.test.ts | 2 ++ 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index e20bd60b9eba..3ba5bbb612e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -115,7 +115,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId + carriedFrom: ctx.sessionId, }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/relay/agent-exec-disposal.test.ts b/src/relay/agent-exec-disposal.test.ts index 475239d63e3b..9d250e108a30 100644 --- a/src/relay/agent-exec-disposal.test.ts +++ b/src/relay/agent-exec-disposal.test.ts @@ -125,9 +125,7 @@ it('kills a child tracked after the fence instead of throwing', async () => { void lifetime.dispose() const child = createFakeChild() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: track reads only pid, kill and the close/error events the fake emits. - expect(() => - lifetime.track(child as unknown as Parameters[0]) - ).not.toThrow() + expect(() => lifetime.track(child as unknown as Parameters[0])).not.toThrow() if (process.platform !== 'win32') { expect(child.kill).toHaveBeenCalledWith('SIGKILL') } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index f9519ed859f1..2dd6a57b55a0 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -174,8 +174,12 @@ const NativeChatComposerPane = forwardRef {continuation.offeredTurnItemId ?? 'none'} {String(continuation.view.continueAvailable)} - {composerError ?? continuation.continueError?.text ?? 'none'} + + {composerError ?? continuation.continueError?.text ?? 'none'} + diff --git a/src/renderer/src/components/status-bar/reset-date-label.test.ts b/src/renderer/src/components/status-bar/reset-date-label.test.ts index c8ba7b285ebd..29ca57c969fa 100644 --- a/src/renderer/src/components/status-bar/reset-date-label.test.ts +++ b/src/renderer/src/components/status-bar/reset-date-label.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) + vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => { let result = fallback From 4a278eab76d9bd529b0e104d7a1ef052d3b0762c Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 14:50:56 -0300 Subject: [PATCH 15/20] feat(kiro): report agent status and list Kiro sessions in history Kiro panes never reached the hook server, so pets, sidebar status and "agent finished" notifications ignored them, and Kiro conversations were missing from Agent session history. - Install Orca status hooks into every global Kiro agent config (~/.kiro/agents/*.json), locally and over SSH, and normalize Kiro's agentSpawn/userPromptSubmit/preToolUse/postToolUse/stop events. - Scan ~/.kiro/sessions/cli (.json metadata + .jsonl transcript) into the AI Vault, skipping subagent child sessions, with `kiro-cli chat --tui --resume-id ` as the resume command. - Gate Kiro resume on a new agent-session.kiro-resume.v1 capability so older remote hosts never see the widened agent enum. --- config/tsconfig.cli.json | 2 + .../agent-hooks/installer-utils-remote.ts | 15 ++ .../managed-agent-hook-registry.ts | 13 +- .../managed-hook-command-contract.test.ts | 8 + ...ote-hook-service-registry-coverage.test.ts | 2 + .../remote-managed-hook-installers.ts | 4 +- .../server-retired-pane-new-turn.test.ts | 3 +- .../remote-session-scanner-kiro-source.ts | 70 +++++ .../remote-session-scanner-sources.ts | 2 + .../ai-vault/session-scanner-agent-parser.ts | 3 + .../ai-vault/session-scanner-agent-sources.ts | 19 ++ .../session-scanner-every-agent-fixture.ts | 2 + .../ai-vault/session-scanner-kiro-fixtures.ts | 56 ++++ .../session-scanner-kiro-parser.test.ts | 114 ++++++++ .../ai-vault/session-scanner-kiro-parser.ts | 187 +++++++++++++ ...canner-opencode-sqlite-coexistence.test.ts | 1 + .../ai-vault/session-scanner-parse-cache.ts | 1 + .../ai-vault/session-scanner-test-fixtures.ts | 3 +- src/main/ai-vault/session-scanner-types.ts | 1 + src/main/ai-vault/session-scanner.test.ts | 4 + src/main/kiro/hook-service.test.ts | 128 +++++++++ src/main/kiro/hook-service.ts | 253 ++++++++++++++++++ src/main/kiro/hook-settings.ts | 153 +++++++++++ .../agent-resume-host-authority-capability.ts | 4 +- .../agent-hook-listener/provider-dispatch.ts | 4 + .../provider-event-routing.ts | 6 + .../providers/kiro-events.test.ts | 72 +++++ .../providers/kiro-events.ts | 56 ++++ .../providers/kiro-tool-fields.ts | 31 +++ .../agent-hook-listener/source-routing.ts | 3 +- src/shared/agent-hook-relay.ts | 3 +- src/shared/agent-hook-types.ts | 3 +- src/shared/agent-resume-argv.ts | 4 + ...ent-session-resume-runtime-capabilities.ts | 2 + src/shared/agent-session-resume.ts | 5 +- src/shared/ai-vault-resume-command.ts | 7 +- src/shared/ai-vault-types.ts | 6 +- 37 files changed, 1234 insertions(+), 16 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-kiro-source.ts create mode 100644 src/main/ai-vault/session-scanner-kiro-fixtures.ts create mode 100644 src/main/ai-vault/session-scanner-kiro-parser.test.ts create mode 100644 src/main/ai-vault/session-scanner-kiro-parser.ts create mode 100644 src/main/kiro/hook-service.test.ts create mode 100644 src/main/kiro/hook-service.ts create mode 100644 src/main/kiro/hook-settings.ts create mode 100644 src/shared/agent-hook-listener/providers/kiro-events.test.ts create mode 100644 src/shared/agent-hook-listener/providers/kiro-events.ts create mode 100644 src/shared/agent-hook-listener/providers/kiro-tool-fields.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index be0414e96118..521c86eadd66 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -257,6 +257,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/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..e07b535d4450 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' @@ -87,7 +88,8 @@ 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) => kiroHookService.installRemote(sftp, remoteHome)] ] /** 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..3ab04287d41f 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -14,6 +14,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 +69,7 @@ const DEVIN_TRANSCRIPTS_DIR = join( ), 'transcripts' ) +const KIRO_SESSIONS_DIR = join(homedir(), '.kiro', 'sessions', 'cli') const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') const CLINE_SESSIONS_DIR = @@ -228,6 +233,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..01f0eddea449 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kiro-parser.ts @@ -0,0 +1,187 @@ +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/sessions/cli; per-session subdirectories hold task state only. +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/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts new file mode 100644 index 000000000000..c357ad9967a1 --- /dev/null +++ b/src/main/kiro/hook-service.test.ts @@ -0,0 +1,128 @@ +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 { 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 + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-kiro-hook-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = 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 + } + 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') + // 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('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..91d7d29ab2d4 --- /dev/null +++ b/src/main/kiro/hook-service.ts @@ -0,0 +1,253 @@ +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') +} + +const NO_AGENTS_DETAIL = + "No custom Kiro agents in ~/.kiro/agents. 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', NO_AGENTS_DETAIL) + } + 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', NO_AGENTS_DETAIL) + } + 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): Promise { + const agentsDir = getKiroRemoteAgentsDir(remoteHome) + 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', NO_AGENTS_DETAIL) + } + 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..41f26a7c418d --- /dev/null +++ b/src/main/kiro/hook-settings.ts @@ -0,0 +1,153 @@ +import { homedir } from 'node:os' +import { join, posix as pathPosix } from 'node:path' +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(homedir(), '.kiro', 'agents') +} + +export function getKiroRemoteAgentsDir(remoteHome: string): string { + return 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/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/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 79bbad552317..fbe5f36ed8ed 100644 --- a/src/shared/agent-resume-argv.ts +++ b/src/shared/agent-resume-argv.ts @@ -76,5 +76,9 @@ 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 ba1f5574ab1f..9300e8831c92 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 = { From 08f0a6ffa9c511b3a57b80c8309366503c40e1f5 Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 15:34:03 -0300 Subject: [PATCH 16/20] fix(kiro): honor KIRO_HOME for agent hooks and session history Kiro CLI uses $KIRO_HOME in place of ~/.kiro for both its agent configs and its sessions (verified on kiro-cli 2.28), so a relocated home left Kiro panes without status hooks and its conversations out of history. Resolve both paths through one shared helper; SSH hosts keep the CLI's default since their environment is not visible over SFTP. --- .../ai-vault/session-scanner-agent-sources.ts | 3 ++- .../ai-vault/session-scanner-kiro-parser.ts | 3 ++- src/main/kiro/hook-service.test.ts | 17 +++++++++++++++++ src/main/kiro/hook-settings.ts | 5 +++-- src/shared/kiro-home.test.ts | 19 +++++++++++++++++++ src/shared/kiro-home.ts | 8 ++++++++ 6 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/shared/kiro-home.test.ts create mode 100644 src/shared/kiro-home.ts diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 3ab04287d41f..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, @@ -69,7 +70,7 @@ const DEVIN_TRANSCRIPTS_DIR = join( ), 'transcripts' ) -const KIRO_SESSIONS_DIR = join(homedir(), '.kiro', 'sessions', 'cli') +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 = diff --git a/src/main/ai-vault/session-scanner-kiro-parser.ts b/src/main/ai-vault/session-scanner-kiro-parser.ts index 01f0eddea449..8a219f60e44b 100644 --- a/src/main/ai-vault/session-scanner-kiro-parser.ts +++ b/src/main/ai-vault/session-scanner-kiro-parser.ts @@ -27,7 +27,8 @@ type ParserSessionOptions = { } // Kiro CLI keeps each session as .json (metadata) beside .jsonl (transcript), -// flat under ~/.kiro/sessions/cli; per-session subdirectories hold task state only. +// 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 diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index c357ad9967a1..f15c8078c2a5 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -10,6 +10,7 @@ import { getKiroManagedCommandMatcher, KIRO_HOOK_EVENTS } from './hook-settings' let home: string let originalHome: string | undefined let originalUserProfile: string | undefined +let originalKiroHome: string | undefined beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'orca-kiro-hook-')) @@ -17,6 +18,8 @@ beforeEach(() => { originalUserProfile = process.env.USERPROFILE process.env.HOME = home process.env.USERPROFILE = home + originalKiroHome = process.env.KIRO_HOME + delete process.env.KIRO_HOME }) afterEach(() => { @@ -30,6 +33,11 @@ afterEach(() => { } 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 }) }) @@ -119,6 +127,15 @@ describe('KiroHookService', () => { 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('ignores non-JSON files such as the shipped example config', () => { mkdirSync(agentsDir(), { recursive: true }) writeFileSync(join(agentsDir(), 'agent_config.json.example'), '{}') diff --git a/src/main/kiro/hook-settings.ts b/src/main/kiro/hook-settings.ts index 41f26a7c418d..e6da26b11bb6 100644 --- a/src/main/kiro/hook-settings.ts +++ b/src/main/kiro/hook-settings.ts @@ -1,5 +1,5 @@ -import { homedir } from 'node:os' import { join, posix as pathPosix } from 'node:path' +import { resolveKiroHomeDir } from '../../shared/kiro-home' import { createManagedCommandMatcher, getSharedManagedScriptPath, @@ -30,9 +30,10 @@ const KIRO_TOOL_EVENTS: ReadonlySet = new Set(['preToolUse', 'postToolUs * `kiro_default` agent has none to edit. So Orca patches every global agent the user owns. */ export function getKiroAgentsDir(): string { - return join(homedir(), '.kiro', 'agents') + return join(resolveKiroHomeDir(), 'agents') } +// Why: a remote $KIRO_HOME is unknown over SFTP; default matches the CLI's own fallback. export function getKiroRemoteAgentsDir(remoteHome: string): string { return pathPosix.join(remoteHome.replace(/\/$/, ''), '.kiro', 'agents') } 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')) +} From 2baadd75f8ac0e4e2edce5926c195d527e15c016 Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 15:58:18 -0300 Subject: [PATCH 17/20] test(usage): mock the Kiro fetcher in the Cursor and OpenCode Go service suites The shared rate-limit test harness resets fetchKiroRateLimits on every run, so a suite that does not mock kiro-usage-fetcher fails before its first assertion with 'mockResolvedValue is not a function'. These two suites lost the mock in an earlier rebase of the Kiro usage branch. --- src/main/rate-limits/service-cursor-usage.test.ts | 1 + src/main/rate-limits/service-opencode-go-credentials.test.ts | 1 + 2 files changed, 2 insertions(+) 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-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() })) From edd4fc333d47a1431777763895ab614103220a34 Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 16:08:04 -0300 Subject: [PATCH 18/20] fix(kiro): install SSH hooks under the remote host's KIRO_HOME On an SSH host the Kiro installer always patched /.kiro/agents, so a host that sets KIRO_HOME got hooks on inactive configs and none on the agents Kiro actually loads. The host-side runtime now reads KIRO_HOME from the login shell, the same probe it already runs for GROK_HOME (both run in parallel), and the installer and its status use that one directory. Adding the test exposed that the host-side SFTP stand-in returned an empty readdir, so the remote install could never see any Kiro agent. It now returns entry names, matching the WSL adapter. --- .../managed-hook-local-filesystem.ts | 14 ++++-- .../agent-hooks/managed-hook-runtime.test.ts | 22 ++++++++- src/main/agent-hooks/managed-hook-runtime.ts | 45 ++++++++++++------- .../remote-managed-hook-installers.ts | 8 +++- src/main/kiro/hook-service.test.ts | 32 +++++++++++++ src/main/kiro/hook-service.ts | 8 +++- src/main/kiro/hook-settings.ts | 8 ++-- 7 files changed, 111 insertions(+), 26 deletions(-) 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-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index e07b535d4450..d496f062cdc9 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -30,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 @@ -89,7 +91,11 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)], ['dsh', (sftp, remoteHome) => dshHookService.installRemote(sftp, remoteHome)], ['jcode', (sftp, remoteHome) => jcodeHookService.installRemote(sftp, remoteHome)], - ['kiro', (sftp, remoteHome) => kiroHookService.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/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index f15c8078c2a5..15d9d4055547 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync 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' @@ -136,6 +137,37 @@ describe('KiroHookService', () => { 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'), '{}') diff --git a/src/main/kiro/hook-service.ts b/src/main/kiro/hook-service.ts index 91d7d29ab2d4..9dbd91e3bdab 100644 --- a/src/main/kiro/hook-service.ts +++ b/src/main/kiro/hook-service.ts @@ -195,8 +195,12 @@ export class KiroHookService { } /** Install on an SSH execution host, where Kiro runs hooks through sh. */ - async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - const agentsDir = getKiroRemoteAgentsDir(remoteHome) + 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)) ?? []) diff --git a/src/main/kiro/hook-settings.ts b/src/main/kiro/hook-settings.ts index e6da26b11bb6..0405e6d36f06 100644 --- a/src/main/kiro/hook-settings.ts +++ b/src/main/kiro/hook-settings.ts @@ -33,9 +33,11 @@ export function getKiroAgentsDir(): string { return join(resolveKiroHomeDir(), 'agents') } -// Why: a remote $KIRO_HOME is unknown over SFTP; default matches the CLI's own fallback. -export function getKiroRemoteAgentsDir(remoteHome: string): string { - return pathPosix.join(remoteHome.replace(/\/$/, ''), '.kiro', '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 { From 1dfca61ce87802448f49d7b64c2ba8cf4ffcc023 Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 16:22:35 -0300 Subject: [PATCH 19/20] fix(kiro): name the actual agents directory in the no-agents hook status The detail always said ~/.kiro/agents, which points users to the wrong place when KIRO_HOME is set locally or probed on an SSH host. --- src/main/kiro/hook-service.test.ts | 1 + src/main/kiro/hook-service.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/kiro/hook-service.test.ts b/src/main/kiro/hook-service.test.ts index 15d9d4055547..b806ea9638ae 100644 --- a/src/main/kiro/hook-service.test.ts +++ b/src/main/kiro/hook-service.test.ts @@ -68,6 +68,7 @@ describe('KiroHookService', () => { 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) }) diff --git a/src/main/kiro/hook-service.ts b/src/main/kiro/hook-service.ts index 9dbd91e3bdab..8078d68d25c8 100644 --- a/src/main/kiro/hook-service.ts +++ b/src/main/kiro/hook-service.ts @@ -76,8 +76,9 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } -const NO_AGENTS_DETAIL = - "No custom Kiro agents in ~/.kiro/agents. 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" +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 } @@ -122,7 +123,7 @@ function readLocalAgentFiles(agentsDir: string): AgentFileRead[] | null { function buildStatus(agentsDir: string, files: AgentFileRead[]): AgentHookInstallStatus { if (files.length === 0) { - return status(agentsDir, 'not_installed', NO_AGENTS_DETAIL) + return status(agentsDir, 'not_installed', noAgentsDetail(agentsDir)) } const isManaged = getKiroManagedCommandMatcher() const problems: string[] = [] @@ -177,7 +178,7 @@ export class KiroHookService { // 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', NO_AGENTS_DETAIL) + return status(agentsDir, 'not_installed', noAgentsDetail(agentsDir)) } const command = getKiroManagedCommand(scriptPath) const isManaged = getKiroManagedCommandMatcher() @@ -207,7 +208,7 @@ export class KiroHookService { .filter(isKiroAgentConfigFileName) .sort() if (fileNames.length === 0) { - return status(agentsDir, 'not_installed', NO_AGENTS_DETAIL) + return status(agentsDir, 'not_installed', noAgentsDetail(agentsDir)) } await writeManagedScriptRemote(sftp, scriptPath, getManagedScript('posix')) const command = getKiroRemoteManagedCommand(scriptPath) From 9cc7e7c1a6e90f03ab80b6bb3c79e1a7b3fc29ad Mon Sep 17 00:00:00 2001 From: Willian Felipe Date: Thu, 8 Oct 2026 16:36:27 -0300 Subject: [PATCH 20/20] fix(usage): detect KIRO_CLI_PATH and reject truncated Kiro usage output Carries forward the fixes from #26390 that the #14001 head does not have: - Local agent detection validates a configured KIRO_CLI_PATH with the same resolution the usage fetcher uses, so a Kiro CLI outside PATH keeps its meter and menu toggle; an invalid configured command is not detected, and WSL detection is unaffected. - The usage command is killed at the 1 MiB output cap and truncated output is rejected instead of being parsed as a valid total. - Reset descriptions that are not ISO dates (for example "01/01") are kept as display text instead of failing to parse. --- .../ipc/preflight-agent-detection.test.ts | 42 +++++++++++++++++++ src/main/preflight/agent-detection.ts | 15 +++++-- .../rate-limits/kiro-usage-fetcher.test.ts | 16 ++++++- src/main/rate-limits/kiro-usage-fetcher.ts | 17 ++++---- 4 files changed, 77 insertions(+), 13 deletions(-) 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/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 index d1d48a1ec8c9..aa55952b854e 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.test.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.test.ts @@ -41,6 +41,17 @@ Credits (1233.74 of 2000 covered in plan) }) }) + 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, @@ -74,6 +85,7 @@ Credits (1233.74 of 2000 covered in plan) args: ['chat', '/usage', '--no-interactive', '--wrap', 'never'], timeoutMs: 20_000, maxOutputBytes: 1024 * 1024, + killOnOutputLimit: true, signal }) ) @@ -83,7 +95,9 @@ Credits (1233.74 of 2000 covered in plan) it.each([ { code: 1, signal: null, timedOut: false }, { code: 0, signal: null, timedOut: true }, - { code: null, signal: 'SIGTERM' as const, timedOut: false } + { 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({ diff --git a/src/main/rate-limits/kiro-usage-fetcher.ts b/src/main/rate-limits/kiro-usage-fetcher.ts index dc891693ee45..d43939c1003c 100644 --- a/src/main/rate-limits/kiro-usage-fetcher.ts +++ b/src/main/rate-limits/kiro-usage-fetcher.ts @@ -1,6 +1,6 @@ -import { runProcess } from '../../shared/child-process/run-process' 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 @@ -57,9 +57,10 @@ const runCommand: CommandRunner = async (command, args, signal) => { env: withCliRuntimeOnPath(command, { ...process.env }), timeoutMs: CLI_TIMEOUT_MS, maxOutputBytes: 1024 * 1024, + killOnOutputLimit: true, signal }) - if (result.code !== 0 || result.timedOut || result.signal !== null) { + if (result.code !== 0 || result.timedOut || result.signal || result.outputTruncated) { throw new Error('Kiro usage command failed') } return { stdout: result.stdout, stderr: result.stderr } @@ -67,9 +68,8 @@ const runCommand: CommandRunner = async (command, args, signal) => { export function parseKiroUsageOutput(output: string): ProviderRateLimits { const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '') - const header = readable.match( - /Estimated Usage\s*\|\s*resets on (\d{4}-\d{2}-\d{2})\s*\|\s*([^\n]+)/i - ) + 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 @@ -80,7 +80,7 @@ export function parseKiroUsageOutput(output: string): ProviderRateLimits { : percent ? Math.min(100, Math.max(0, Number(percent[1]))) : null - if (!header || usedPercent === null || !Number.isFinite(usedPercent)) { + if (!header || !resetDescription || usedPercent === null || !Number.isFinite(usedPercent)) { return result('error', 'Could not parse Kiro usage output', null, null, 'parse') } return result( @@ -89,10 +89,9 @@ export function parseKiroUsageOutput(output: string): ProviderRateLimits { { usedPercent, windowMinutes: MONTHLY_WINDOW_MINUTES, - // Kiro reports a calendar date without a time zone or time of day. Keep it as - // display metadata instead of inventing a UTC-midnight countdown. + // Kiro supplies no exact reset time or zone, so keep its text as display metadata. resetsAt: null, - resetDescription: header[1] + resetDescription }, header[2].trim() )