Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
adc7455
feat(usage): add Kiro subscription credits
guanbear Aug 12, 2026
1bc0fa1
fix(usage): distinguish compact Kiro label
guanbear Aug 12, 2026
04eb691
fix(usage): use compact Kiro Q label
guanbear Aug 12, 2026
bba016d
fix(usage): avoid inventing Kiro reset time
guanbear Aug 14, 2026
10affdc
fix(usage): follow split shared UI types
guanbear Aug 14, 2026
136ae26
fix(usage): keep non-date Kiro reset descriptions
guanbear Aug 14, 2026
3fcd012
fix(usage): launch Kiro .cmd shims through cmd.exe
guanbear Aug 14, 2026
9996044
fix(usage): align provider search with current lint gates
guanbear Aug 15, 2026
2118b79
test(usage): mock the Kiro fetcher in the Antigravity service test
guanbear Aug 24, 2026
e685ce3
fix(usage): thread the reset-countdown clock into the Kiro reset label
guanbear Aug 31, 2026
651b4a6
fix(usage): resolve Kiro review findings
guanbear Sep 2, 2026
b28b50e
fix(usage): complete Kiro rebase integration
guanbear Sep 28, 2026
1fcf401
Merge remote-tracking branch 'upstream/main' into codex/pr-14001-repair
guanbear Sep 28, 2026
2a1ab9d
fix(usage): narrow Kiro CLI errors without assertions
guanbear Sep 28, 2026
0149ead
Merge remote-tracking branch 'upstream/main' into codex/pr-14001-repair
guanbear Sep 29, 2026
8b1d748
fix: adapt Kiro usage to latest provider refreshes
guanbear Oct 8, 2026
b9af9b9
fix(kiro): isolate reset label coverage and preserve upstream formatting
guanbear Oct 8, 2026
3f3495b
fix(kiro): reconcile usage meter with current main
guanbear Oct 9, 2026
7811b84
Merge latest main into Kiro usage provider
guanbear Oct 12, 2026
bf0bb45
fix(usage): detect KIRO_CLI_PATH and reject truncated Kiro usage output
w-felipe360 Oct 8, 2026
399748b
Adapt Kiro usage to current process host and refresh checks
guanbear Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/main/ipc/preflight-agent-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,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') {
Expand Down
15 changes: 12 additions & 3 deletions src/main/preflight/agent-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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 = {
Expand Down Expand Up @@ -140,15 +141,23 @@ async function detectCommandRuntime(
}

export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise<string[]> {
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(
Expand Down
137 changes: 137 additions & 0 deletions src/main/rate-limits/kiro-usage-fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from 'vitest'
import { runProcess } from '@orca/process-host'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fetchKiroRateLimits, parseKiroUsageOutput, resolveKiroCommand } from './kiro-usage-fetcher'

vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() }))

const KIRO_USAGE_OUTPUT =
'Estimated Usage | resets on 2026-09-01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%'

describe('Kiro usage fetcher', () => {
it('uses the shared CLI resolver for version-manager installs', () => {
const homePath = mkdtempSync(join(tmpdir(), 'orca-kiro-command-'))
const command = join(homePath, '.volta', 'bin', 'kiro-cli')
mkdirSync(join(homePath, '.volta', 'bin'), { recursive: true })
writeFileSync(command, '#!/bin/sh\n')
chmodSync(command, 0o755)
vi.stubEnv('KIRO_CLI_PATH', '')
try {
expect(resolveKiroCommand({ platform: 'darwin', pathEnv: '', homePath })).toBe(command)
} finally {
vi.unstubAllEnvs()
rmSync(homePath, { recursive: true, force: true })
}
})

it('parses the official CLI usage command output', () => {
const result = parseKiroUsageOutput(`
\u001b[1mEstimated Usage | resets on 2026-09-01 | KIRO PRO+\u001b[0m
Credits (1233.74 of 2000 covered in plan)
\u001b[32m████ 61%\u001b[0m
`)

expect(result).toMatchObject({ provider: 'kiro', status: 'ok', planType: 'KIRO PRO+' })
expect(result.monthly?.usedPercent).toBeCloseTo(61.687)
expect(result.monthly).toMatchObject({
resetsAt: null,
resetDescription: '2026-09-01'
})
})

it('accepts a Kiro reset description without an ISO date', () => {
const output =
'Estimated Usage | resets on 01/01 | KIRO PRO\nCredits (10 of 100 covered in plan)\n10%'

expect(parseKiroUsageOutput(output)).toMatchObject({
status: 'ok',
planType: 'KIRO PRO',
monthly: { usedPercent: 10, resetsAt: null, resetDescription: '01/01' }
})
})

it('runs the local non-model usage command', async () => {
const runner = vi.fn().mockResolvedValue({
stdout: KIRO_USAGE_OUTPUT,
stderr: ''
})

const result = await fetchKiroRateLimits({ command: '/tmp/kiro-cli', runner })

expect(runner).toHaveBeenCalledWith(
'/tmp/kiro-cli',
['chat', '/usage', '--no-interactive', '--wrap', 'never'],
undefined
)
expect(result.status).toBe('ok')
})

it('uses the shared process wrapper with bounded timeout and cancellation', async () => {
vi.mocked(runProcess).mockResolvedValue({
code: 0,
signal: null,
stdout: KIRO_USAGE_OUTPUT,
stderr: '',
timedOut: false
})
const signal = new AbortController().signal
const command = 'C:\\Users\\me\\bin\\kiro-cli.cmd'
const result = await fetchKiroRateLimits({ command, signal })
expect(runProcess).toHaveBeenCalledWith(
expect.objectContaining({
program: command,
args: ['chat', '/usage', '--no-interactive', '--wrap', 'never'],
timeoutMs: 20_000,
maxOutputBytes: 1024 * 1024,
killOnOutputLimit: true,
signal
})
)
expect(result.status).toBe('ok')
})

it.each([
{ code: 1, signal: null, timedOut: false },
{ code: 0, signal: null, timedOut: true },
{ code: null, signal: 'SIGTERM' as const, timedOut: false },
// Why: a capped read can end on a valid-looking line that is not the real total.
{ code: 0, signal: null, timedOut: false, outputTruncated: true }
])('rejects unsuccessful process results even with valid output: %j', async (failure) => {
vi.mocked(runProcess).mockResolvedValue({ ...failure, stdout: KIRO_USAGE_OUTPUT, stderr: '' })
expect(await fetchKiroRateLimits({ command: '/tmp/kiro-cli' })).toMatchObject({
status: 'error',
usageMetadata: { failureKind: 'usage-unavailable' }
})
})

it('fails closed on unexpected output', () => {
expect(parseKiroUsageOutput('Please sign in first')).toMatchObject({
provider: 'kiro',
status: 'error',
usageMetadata: { failureKind: 'parse' }
})
})

it('reports a missing CLI without exposing process details', async () => {
const error = Object.assign(new Error('spawn /secret/path ENOENT'), { code: 'ENOENT' })
const result = await fetchKiroRateLimits({ runner: vi.fn().mockRejectedValue(error) })

expect(result).toMatchObject({ status: 'unavailable', error: 'Kiro CLI is not installed' })
expect(JSON.stringify(result)).not.toContain('/secret/path')
})

it('distinguishes a failed usage command from a missing CLI', async () => {
const result = await fetchKiroRateLimits({
runner: vi.fn().mockRejectedValue(new Error('command timed out'))
})

expect(result).toMatchObject({
status: 'error',
error: 'Kiro usage command failed',
usageMetadata: { failureKind: 'usage-unavailable' }
})
expect(JSON.stringify(result)).not.toContain('command timed out')
})
})
127 changes: 127 additions & 0 deletions src/main/rate-limits/kiro-usage-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences'
import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import { runProcess } from '@orca/process-host'
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<CommandResult>

function result(
status: ProviderRateLimits['status'],
error: string | null,
monthly: RateLimitWindow | null = null,
planType: string | null = null,
failureKind?: 'cli-unavailable' | 'parse' | 'usage-unavailable'
): ProviderRateLimits {
return {
provider: 'kiro',
session: null,
weekly: null,
monthly,
planType,
updatedAt: Date.now(),
error,
status,
usageMetadata: {
source: 'cli',
...(failureKind ? { failureKind } : {})
}
}
}

export function resolveKiroCommand(
options: {
pathEnv?: string | null
platform?: NodeJS.Platform
homePath?: string
} = {}
): string {
const configured = process.env.KIRO_CLI_PATH?.trim()
if (configured) {
return configured
}
return resolveCliCommand('kiro-cli', options)
}

const runCommand: CommandRunner = async (command, args, signal) => {
const result = await runProcess({
program: command,
args,
env: withCliRuntimeOnPath(command, { ...process.env }),
timeoutMs: CLI_TIMEOUT_MS,
maxOutputBytes: 1024 * 1024,
killOnOutputLimit: true,
signal
})
if (result.code !== 0 || result.timedOut || result.signal || result.outputTruncated) {
throw new Error('Kiro usage command failed')
}
return { stdout: result.stdout, stderr: result.stderr }
}

export function parseKiroUsageOutput(output: string): ProviderRateLimits {
const readable = stripAnsiEscapeSequences(output).replace(/\r/g, '')
const header = readable.match(/Estimated Usage\s*\|\s*resets on\s+([^|\n]+?)\s*\|\s*([^\n]+)/i)
const resetDescription = header?.[1].trim()
const credits = readable.match(/Credits\s*\(([\d,.]+)\s+of\s+([\d,.]+)\s+covered in plan\)/i)
const percent = readable.match(/(?:^|\s)(\d+(?:\.\d+)?)%\s*$/m)
const used = credits ? Number(credits[1].replaceAll(',', '')) : Number.NaN
const limit = credits ? Number(credits[2].replaceAll(',', '')) : Number.NaN
const usedPercent =
Number.isFinite(used) && Number.isFinite(limit) && limit > 0
? Math.min(100, Math.max(0, (used / limit) * 100))
: percent
? Math.min(100, Math.max(0, Number(percent[1])))
: null
if (!header || !resetDescription || usedPercent === null || !Number.isFinite(usedPercent)) {
return result('error', 'Could not parse Kiro usage output', null, null, 'parse')
}
return result(
'ok',
null,
{
usedPercent,
windowMinutes: MONTHLY_WINDOW_MINUTES,
// Kiro supplies no exact reset time or zone, so keep its text as display metadata.
resetsAt: null,
resetDescription
},
header[2].trim()
)
}

export async function fetchKiroRateLimits(
options: { signal?: AbortSignal; runner?: CommandRunner; command?: string } = {}
): Promise<ProviderRateLimits> {
try {
const { stdout, stderr } = await (options.runner ?? runCommand)(
options.command ?? resolveKiroCommand(),
['chat', '/usage', '--no-interactive', '--wrap', 'never'],
options.signal
)
return parseKiroUsageOutput(`${stdout}\n${stderr}`)
} catch (error) {
const code =
error !== null &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string'
? error.code
: null
const unavailable = code === 'ENOENT'
return result(
unavailable ? 'unavailable' : 'error',
unavailable ? 'Kiro CLI is not installed' : 'Kiro usage command failed',
null,
null,
unavailable ? 'cli-unavailable' : 'usage-unavailable'
)
}
}
3 changes: 3 additions & 0 deletions src/main/rate-limits/rate-limit-service-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { readGrokAuthSession } from './grok-auth'
import { fetchCursorRateLimits } from './cursor-fetcher'
import { readCursorAuthSession } from './cursor-auth'
import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection'
import { fetchKiroRateLimits } from './kiro-usage-fetcher'
import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
Expand Down Expand Up @@ -94,6 +95,7 @@ export function mockFreshBackgroundProviderFetches(): void {
vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0))
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor'))
vi.mocked(fetchKiroRateLimits).mockImplementation(async () => unavailableProvider('kiro'))
vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode'))
vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () =>
unavailableProvider('antigravity')
Expand All @@ -116,6 +118,7 @@ export function resetRateLimitProviderMocks(): void {
status: 'unavailable'
})
vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor'))
vi.mocked(fetchKiroRateLimits).mockResolvedValue(unavailableProvider('kiro'))
vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode'))
vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity'))
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
Expand Down
4 changes: 4 additions & 0 deletions src/main/rate-limits/service-account-target-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({
fetchOpenCodeGoUsage: vi.fn()
}))

vi.mock('./kiro-usage-fetcher', () => ({
fetchKiroRateLimits: vi.fn()
}))

vi.mock('./zcode-usage-fetcher', () => ({
fetchZcodeRateLimits: vi.fn()
}))
Expand Down
Loading