feat(usage): add Devin tracking backend

Backend Devin tracking implementation.

Co-authored-by: Eddie Jaoude <eddie@jaoudestudios.com>
This commit is contained in:
Neil
2026-09-19 00:19:51 -07:00
committed by GitHub
co-authored by Eddie Jaoude
parent b9f06230ad
commit cfe769e4bc
85 changed files with 3589 additions and 265 deletions
@@ -52,7 +52,7 @@ const CASES = [
agent: 'devin',
envVar: 'DEVIN_HOME',
absolute: '/srv/devin',
absoluteRoot: join('/srv/devin', 'transcripts'),
absoluteRoot: join('/srv/devin', 'cli', 'transcripts'),
defaultRoot: () => join(homedir(), '.local', 'share', 'devin', 'cli', 'transcripts')
},
{
@@ -16,6 +16,7 @@ import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './ses
import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts'
import type { AiVaultScanOptions } from './session-scanner-types'
import { normalizeAgentSessionsDir, primeAgentSessionsDirFromEnv } from './session-scanner-values'
import { resolveDevinTranscriptsDir } from '../devin/devin-cli-data-dir'
export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex')
const CODEX_SESSIONS_DIR = join(
@@ -42,14 +43,8 @@ const PI_SESSIONS_DIR = normalizeAgentSessionsDir(
// dedicated sessions-root override, so resolution differs from Pi/OMP in shape
// as well as in variable name.
const PRIME_AGENT_SESSIONS_DIR = primeAgentSessionsDirFromEnv()
// Why: Devin ATIF transcripts are stored under <DEVIN_HOME>/transcripts.
const DEVIN_TRANSCRIPTS_DIR = join(
resolveAbsoluteDirOverride(
process.env.DEVIN_HOME,
join(homedir(), '.local', 'share', 'devin', 'cli')
),
'transcripts'
)
// Devin transcripts share the same root as the usage scanner.
export const DEVIN_TRANSCRIPTS_DIR = resolveDevinTranscriptsDir()
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
const CLINE_SESSIONS_DIR =
+27
View File
@@ -0,0 +1,27 @@
import type { DevinAccountStatus, ProviderRateLimits } from '../../shared/rate-limit-types'
import { readDevinCredentials } from '../rate-limits/devin-credentials'
export function getDevinAccountStatus(limits: ProviderRateLimits | null): DevinAccountStatus {
const readResult = readDevinCredentials()
if (readResult.status === 'missing') {
return { signedIn: false, email: null, tokenFresh: false, plan: null, error: null }
}
if (readResult.status === 'error') {
return {
signedIn: false,
email: null,
tokenFresh: false,
plan: null,
error: readResult.error
}
}
const delegatedRefreshRequired =
limits?.usageMetadata?.failureKind === 'delegated-refresh-required'
return {
signedIn: true,
email: limits?.usageMetadata?.authProvenance ?? null,
tokenFresh: !delegatedRefreshRequired,
plan: limits?.planType ?? null,
error: delegatedRefreshRequired ? limits.error : null
}
}
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error'
const { wslGatedReaddir } = vi.hoisted(() => ({ wslGatedReaddir: vi.fn() }))
vi.mock('../native-chat/wsl-transcript-fs-access', () => ({
wslGatedReaddir,
wslGatedStat: vi.fn()
}))
import { listDevinTranscriptFiles } from './devin-transcript-discovery'
describe('listDevinTranscriptFiles', () => {
it('returns an empty list only when the transcripts dir is missing', async () => {
wslGatedReaddir.mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOENT' }))
await expect(listDevinTranscriptFiles()).resolves.toEqual([])
wslGatedReaddir.mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOTDIR' }))
await expect(listDevinTranscriptFiles()).resolves.toEqual([])
})
it('propagates transient filesystem failures so cached usage survives', async () => {
const eacces = Object.assign(new Error('denied'), { code: 'EACCES' })
wslGatedReaddir.mockRejectedValue(eacces)
await expect(listDevinTranscriptFiles()).rejects.toBe(eacces)
const eio = Object.assign(new Error('io'), { code: 'EIO' })
wslGatedReaddir.mockRejectedValue(eio)
await expect(listDevinTranscriptFiles()).rejects.toBe(eio)
})
it('propagates a WSL refusal instead of swallowing it as empty', async () => {
const refusal = new WslTranscriptFsError('timeout', 'slow share')
wslGatedReaddir.mockRejectedValue(refusal)
await expect(listDevinTranscriptFiles()).rejects.toBe(refusal)
})
})
@@ -0,0 +1,81 @@
import { join } from 'node:path'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import type { SessionSidecarObservation } from '../ai-vault/session-sidecar-stat'
import { resolveDevinTranscriptsDir } from '../devin/devin-cli-data-dir'
import type { DevinUsageProcessedFile } from './types'
// Why gated: a DEVIN_HOME override can point the transcripts root at a
// \\wsl$ UNC path, where a raw syscall on a stalled distro would hang the
// whole scan (STA-4049).
export async function listDevinTranscriptFiles(): Promise<string[]> {
const transcriptsDir = resolveDevinTranscriptsDir()
try {
const entries = await wslGatedReaddir(transcriptsDir, 'scan')
return entries
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'))
.map((entry) => join(transcriptsDir, entry.name))
.sort()
} catch (error) {
// Why: only a genuinely missing transcripts dir means "no data". A
// transient EACCES/EIO/WSL refusal must surface as a scan error so the
// store keeps the previous projection instead of caching an empty one.
if (isMissingFsError(error)) {
return []
}
throw error
}
}
export async function getProcessedFileInfo(filePath: string): Promise<DevinUsageProcessedFile> {
const fileStat = await wslGatedStat(filePath, 'scan')
return {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size
}
}
/**
* Observe the sessions.db a transcripts dir is indexed by, so a db-only change
* (working_directory edit, hidden toggle) re-attributes cached files. Only a
* genuinely missing db is 'none' — every other failure must retry next scan.
*/
export async function observeDevinSessionsDb(
transcriptsDir: string
): Promise<SessionSidecarObservation> {
const dbPath = join(transcriptsDir, '..', 'sessions.db')
// Why wal first: WAL-mode commits land in sessions.db-wal while the db's own
// stat stays put until checkpoint, so the wal is the fresher signal — same
// rule the AI Vault applies via devinSessionsDbDependencyPath.
try {
const walPath = `${dbPath}-wal`
const walStat = await wslGatedStat(walPath, 'scan')
if (walStat.isFile()) {
return { path: walPath, mtimeMs: walStat.mtimeMs, sizeBytes: walStat.size }
}
} catch (error) {
if (!isMissingFsError(error)) {
return 'unknown'
}
}
try {
const dbStat = await wslGatedStat(dbPath, 'scan')
return dbStat.isFile()
? { path: dbPath, mtimeMs: dbStat.mtimeMs, sizeBytes: dbStat.size }
: 'none'
} catch (error) {
return isMissingFsError(error) ? 'none' : 'unknown'
}
}
function isMissingFsError(error: unknown): boolean {
if (error instanceof WslTranscriptFsError) {
return false
}
const code =
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
return code === 'ENOENT' || code === 'ENOTDIR'
}
@@ -0,0 +1,259 @@
import { describe, expect, it } from 'vitest'
import type { DevinSessionIndexRow, DevinSessionsIndex } from '../devin/sessions-index'
import { parseDevinTranscriptForUsage } from './devin-transcript-parse'
function indexOf(rows: Record<string, Partial<DevinSessionIndexRow>>): DevinSessionsIndex {
const index: DevinSessionsIndex = new Map()
for (const [id, row] of Object.entries(rows)) {
index.set(id, {
workingDirectory: null,
title: null,
model: null,
createdAt: null,
lastActivityAt: null,
hidden: false,
...row
})
}
return index
}
describe('parseDevinTranscriptForUsage', () => {
it('treats ATIF cached_tokens as a subset of prompt_tokens', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/s1.json',
JSON.stringify({
session_id: 's1',
agent: { model_name: 'swe-2' },
steps: [
{
timestamp: '2026-09-01T10:00:00.123456Z',
metrics: { prompt_tokens: 100, cached_tokens: 30, completion_tokens: 20 }
}
]
}),
null
)
expect(parsed?.events).toHaveLength(1)
expect(parsed?.events[0]).toMatchObject({
sessionId: 's1',
model: 'swe-2',
inputTokens: 100,
cachedInputTokens: 30,
outputTokens: 20,
totalTokens: 120,
estimatedCostUsd: null
})
})
it('folds legacy additive cache buckets into input', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/s2.json',
JSON.stringify({
session_id: 's2',
steps: [
{
metadata: {
created_at: '2026-09-01T10:00:00Z',
metrics: {
input_tokens: 50,
output_tokens: 10,
cache_read_tokens: 30,
cache_creation_tokens: 5
}
}
}
]
}),
null
)
expect(parsed?.events[0]).toMatchObject({
inputTokens: 85,
cachedInputTokens: 30,
outputTokens: 10,
totalTokens: 95
})
})
it('folds current total_input_tokens with its separate cache buckets', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/current.json',
JSON.stringify({
session_id: 'current',
steps: [
{
timestamp: '2026-09-01T10:00:00Z',
metrics: {
total_input_tokens: 50,
cache_read_input_tokens: 30,
cache_creation_input_tokens: 5,
output_tokens: 10
}
}
]
}),
null
)
expect(parsed?.events[0]).toMatchObject({
inputTokens: 85,
cachedInputTokens: 30,
outputTokens: 10,
totalTokens: 95
})
})
it('keeps ATIF cache creation out of both cached input and prompt tokens', () => {
// Real transcripts carry cached_tokens (read) alongside a separate,
// larger cache_creation_input_tokens; prompt_tokens already includes both.
const parsed = parseDevinTranscriptForUsage(
'/transcripts/s2b.json',
JSON.stringify({
session_id: 's2b',
steps: [
{
timestamp: '2026-09-01T10:00:00Z',
metrics: {
prompt_tokens: 25866,
completion_tokens: 174,
cached_tokens: 12018,
cache_creation_input_tokens: 13844
}
}
]
}),
null
)
expect(parsed?.events[0]).toMatchObject({
inputTokens: 25866,
cachedInputTokens: 12018,
outputTokens: 174,
totalTokens: 26040
})
})
it('does not double-count metrics present in both metadata and step metrics', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/s3.json',
JSON.stringify({
session_id: 's3',
steps: [
{
timestamp: '2026-09-01T10:00:00Z',
metadata: { metrics: { prompt_tokens: 40, completion_tokens: 8 } },
metrics: { prompt_tokens: 40, cached_tokens: 12, completion_tokens: 8 }
}
]
}),
null
)
expect(parsed?.events[0]).toMatchObject({
inputTokens: 40,
cachedInputTokens: 12,
totalTokens: 48
})
})
it('uses sessions.db for cwd, model fallback, and hidden flag', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/s4.json',
JSON.stringify({
session_id: 's4',
steps: [
{
timestamp: '2026-09-01T10:00:00Z',
metrics: { prompt_tokens: 10, completion_tokens: 5 }
}
]
}),
indexOf({
s4: { workingDirectory: 'D:\\Project\\orca', model: 'swe-2-high', hidden: true }
})
)
expect(parsed?.hidden).toBe(true)
expect(parsed?.events[0]).toMatchObject({ cwd: 'D:\\Project\\orca', model: 'swe-2-high' })
})
it('prefers transcript working_directory over the db row', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/s5.json',
JSON.stringify({
session_id: 's5',
working_directory: '/home/user/repo',
steps: [
{
timestamp: '2026-09-01T10:00:00Z',
metrics: { prompt_tokens: 10, completion_tokens: 5 }
}
]
}),
indexOf({ s5: { workingDirectory: 'D:\\other', model: null, hidden: false } })
)
expect(parsed?.events[0].cwd).toBe('/home/user/repo')
})
it('skips steps with no tokens or no timestamp and rejects invalid JSON', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/s6.json',
JSON.stringify({
session_id: 's6',
steps: [
{ timestamp: '2026-09-01T10:00:00Z' },
{ metrics: { prompt_tokens: 10, completion_tokens: 1 } },
{
timestamp: '2026-09-01T11:00:00Z',
metrics: { prompt_tokens: 7, completion_tokens: 3 }
}
]
}),
null
)
expect(parsed?.events).toHaveLength(1)
expect(parsed?.events[0].totalTokens).toBe(10)
expect(parseDevinTranscriptForUsage('/t/x.json', '{not json', null)).toBeNull()
expect(parseDevinTranscriptForUsage('/t/x.json', '42', null)).toBeNull()
})
it('falls back to the filename for the session id', () => {
const parsed = parseDevinTranscriptForUsage(
'/transcripts/file-id.json',
JSON.stringify({
steps: [
{
timestamp: '2026-09-01T10:00:00Z',
metrics: { prompt_tokens: 1, completion_tokens: 1 }
}
]
}),
null
)
expect(parsed?.sessionId).toBe('file-id')
expect(parsed?.events[0].sessionId).toBe('file-id')
})
it('honors an explicit zero before lower-priority fallback metrics', () => {
const parsed = parseDevinTranscriptForUsage(
'zero.json',
JSON.stringify({
session_id: 'zero',
steps: [
{
timestamp: '2026-09-18T00:00:00Z',
metadata: { total_input_tokens: 0, output_tokens: 1 },
metrics: { prompt_tokens: 100, completion_tokens: 50 }
}
]
}),
null
)
expect(parsed?.events[0]).toMatchObject({ inputTokens: 0, outputTokens: 1, totalTokens: 1 })
})
})
@@ -0,0 +1,139 @@
import type { DevinSessionsIndex } from '../devin/sessions-index'
import { asRecord } from '../ai-vault/session-scanner-record-value'
import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator'
import { arrayValue, extractString } from '../ai-vault/session-scanner-values'
import type { DevinUsageParsedEvent } from './types'
export type DevinTranscriptUsageParse = {
sessionId: string
/** True when sessions.db marks the session hidden in Devin's own UI. */
hidden: boolean
events: DevinUsageParsedEvent[]
}
const DEVIN_INPUT_KEYS = ['total_input_tokens', 'input_tokens', 'prompt_tokens'] as const
const DEVIN_OUTPUT_KEYS = ['output_tokens', 'completion_tokens'] as const
const DEVIN_CACHE_READ_KEYS = [
'cache_read_tokens',
'cache_read_input_tokens',
'cached_tokens'
] as const
const DEVIN_CACHE_WRITE_KEYS = ['cache_creation_tokens', 'cache_creation_input_tokens'] as const
const DEVIN_REASONING_KEYS = ['reasoning_tokens', 'reasoning_output_tokens'] as const
/**
* Read one metric bucket from the first source that reports a non-negative integer,
* so a step carrying both legacy metadata metrics and ATIF step-level metrics
* never double-counts. Mirrors the AI Vault's devinStepTokenTotal sources.
*/
function firstDevinMetric(
sources: readonly (Record<string, unknown> | null)[],
keys: readonly string[]
): { value: number; key: string | null } {
for (const source of sources) {
if (!source) {
continue
}
for (const key of keys) {
const value = source[key]
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
return { value, key }
}
}
}
return { value: 0, key: null }
}
/**
* Fold one ATIF transcript into usage events. Devin reports metrics per LLM
* step: `prompt_tokens`/`cached_tokens`/`completion_tokens` (ATIF ≥1.7, where
* cached is a subset of prompt) or the legacy `input_tokens`/`output_tokens`/
* `cache_*` split (where cache is additive). Both are normalized so
* `cached ⊆ input` and `total = input + output` hold for every event.
*/
export function parseDevinTranscriptForUsage(
filePath: string,
content: string,
sessionsIndex: DevinSessionsIndex | null
): DevinTranscriptUsageParse | null {
let parsed: unknown
try {
parsed = JSON.parse(content)
} catch {
return null
}
const record = asRecord(parsed)
if (!record) {
return null
}
const sessionId =
extractString(record.session_id) ??
extractString(record.sessionId) ??
sessionIdFromFileName(filePath)
const dbRow = sessionsIndex?.get(sessionId) ?? sessionsIndex?.get(sessionIdFromFileName(filePath))
const agentRecord = asRecord(record.agent)
const sessionModel =
extractString(agentRecord?.model_name) ??
extractString(agentRecord?.model) ??
extractString(record.generation_model) ??
dbRow?.model ??
null
// Why: Windows transcripts carry no cwd; the sessions.db row is the
// attribution source there (same as the AI Vault merge).
const sessionCwd = extractString(record.working_directory) ?? dbRow?.workingDirectory ?? null
const events: DevinUsageParsedEvent[] = []
for (const step of arrayValue(record.steps)) {
const stepRecord = asRecord(step)
if (!stepRecord) {
continue
}
const metadata = asRecord(stepRecord.metadata)
const sources = [metadata, asRecord(metadata?.metrics), asRecord(stepRecord.metrics)]
const input = firstDevinMetric(sources, DEVIN_INPUT_KEYS)
const output = firstDevinMetric(sources, DEVIN_OUTPUT_KEYS)
const cacheRead = firstDevinMetric(sources, DEVIN_CACHE_READ_KEYS)
const cacheWrite = firstDevinMetric(sources, DEVIN_CACHE_WRITE_KEYS)
const reasoning = firstDevinMetric(sources, DEVIN_REASONING_KEYS)
// cachedInputTokens is the cache-read bucket only, matching the shared
// contract (Claude/Codex count reads, not cache writes); creation still
// bills as input below. ATIF `cached_tokens` is the read subset, not the
// combined cached figure — real transcripts carry a separate, larger
// cache_creation_input_tokens alongside it.
const cachedTokens = cacheRead.value
// ATIF prompt_tokens already includes cache, while total_input_tokens is
// the base input bucket used with separate cache read/write fields.
const inputTokens =
input.key === 'prompt_tokens' ? input.value : input.value + cacheRead.value + cacheWrite.value
const outputTokens = output.value
const totalTokens = inputTokens + outputTokens
if (totalTokens <= 0) {
continue
}
const timestamp = extractString(stepRecord.timestamp) ?? extractString(metadata?.created_at)
if (!timestamp) {
continue
}
const extra = asRecord(stepRecord.extra)
const metrics = asRecord(stepRecord.metrics)
events.push({
sessionId,
timestamp,
model:
extractString(stepRecord.model_name) ??
extractString(extra?.generation_model) ??
extractString(metadata?.generation_model) ??
extractString(metrics?.generation_model) ??
sessionModel,
cwd: sessionCwd,
estimatedCostUsd: null,
inputTokens,
cachedInputTokens: Math.min(cachedTokens, inputTokens),
outputTokens,
reasoningOutputTokens: reasoning.value,
totalTokens
})
}
return { sessionId, hidden: dbRow?.hidden === true, events }
}
@@ -0,0 +1,24 @@
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import type { DevinUsageAttributedEvent, DevinUsageMetric } from './types'
export const devinUsageAggregation = createUsageEventAggregation<
DevinUsageAttributedEvent,
DevinUsageMetric
>({
metric: {
empty: () => ({ estimatedCostUsd: null }),
fromEvent: (event) => ({ estimatedCostUsd: event.estimatedCostUsd }),
fold: (target, source) => {
if (target.estimatedCostUsd === null && source.estimatedCostUsd === null) {
return
}
target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + (source.estimatedCostUsd ?? 0)
}
},
cloneSessionForMerge: (session) => ({
...session,
locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })),
modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })),
locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry }))
})
})
@@ -0,0 +1,10 @@
import { attributeUsageEvent } from '../usage/usage-event-attribution'
import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import type { DevinUsageAttributedEvent, DevinUsageParsedEvent } from './types'
export function attributeDevinUsageEvent(
event: DevinUsageParsedEvent,
resolveWorktree: UsageWorktreeResolver
): DevinUsageAttributedEvent | null {
return attributeUsageEvent(event, resolveWorktree)
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { devinUsageAggregation } from './devin-usage-aggregation'
import {
buildDevinBreakdown,
buildDevinDaily,
buildDevinRecentSessions,
buildDevinSummary
} from './devin-usage-projections'
import type { DevinUsageAttributedEvent, DevinUsagePersistedState } from './types'
function event(overrides: Partial<DevinUsageAttributedEvent> = {}): DevinUsageAttributedEvent {
return {
sessionId: 's1',
timestamp: '2026-09-18T10:00:00Z',
day: '2026-09-18',
model: 'model-a',
cwd: '/repo/a',
projectKey: 'worktree:a',
projectLabel: 'Same name',
repoId: 'repo-a',
worktreeId: 'a',
inputTokens: 10,
cachedInputTokens: 3,
outputTokens: 5,
reasoningOutputTokens: 2,
totalTokens: 15,
estimatedCostUsd: null,
...overrides
}
}
function state(events: DevinUsageAttributedEvent[]): DevinUsagePersistedState {
return {
schemaVersion: 1,
worktreeFingerprint: null,
processedFiles: [],
...devinUsageAggregation.aggregate(events),
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
const mixedLocations = () =>
state([
event(),
event({
model: 'model-b',
cwd: '/outside',
projectKey: 'cwd:/outside',
projectLabel: 'Outside',
repoId: null,
worktreeId: null
}),
event({ sessionId: 's2', model: 'model-b' }),
event({ sessionId: 's2', model: 'model-b', timestamp: '2026-09-18T10:01:00Z' })
])
describe('Devin usage projections', () => {
it('does not pool distinct projects that have the same display name', () => {
const snapshot = state([
event({ totalTokens: 15 }),
event({ sessionId: 's2', projectKey: 'worktree:b', worktreeId: 'b', totalTokens: 15 }),
event({
sessionId: 's3',
projectKey: 'worktree:c',
worktreeId: 'c',
projectLabel: 'Winner',
totalTokens: 20
})
])
expect(buildDevinSummary(snapshot, 'all', 'all').topProject).toBe('Winner')
expect(buildDevinBreakdown(snapshot, 'all', 'all', 'project')).toHaveLength(3)
})
it('counts each scoped model once per session, excluding models used only elsewhere', () => {
expect(buildDevinBreakdown(mixedLocations(), 'orca', 'all', 'model')).toEqual([
expect.objectContaining({ key: 'model-b', sessions: 1, events: 2 }),
expect.objectContaining({ key: 'model-a', sessions: 1, events: 1 })
])
expect(buildDevinBreakdown(mixedLocations(), 'all', 'all', 'model')[0]).toMatchObject({
key: 'model-b',
sessions: 2,
events: 3
})
})
it('labels recent sessions using only models in the selected scope', () => {
expect(
buildDevinRecentSessions(mixedLocations(), 'orca', 'all', 10).find(
(row) => row.sessionId === 's1'
)
).toMatchObject({ model: 'model-a', totalTokens: 15 })
expect(
buildDevinRecentSessions(mixedLocations(), 'all', 'all', 10).find(
(row) => row.sessionId === 's1'
)
).toMatchObject({ model: 'Mixed models', totalTokens: 30 })
})
it('preserves reasoning tokens throughout the scoped projections', () => {
const snapshot = mixedLocations()
expect(buildDevinSummary(snapshot, 'orca', 'all').reasoningOutputTokens).toBe(6)
expect(buildDevinDaily(snapshot, 'orca', 'all')[0].reasoningOutputTokens).toBe(6)
expect(buildDevinBreakdown(snapshot, 'orca', 'all', 'model')[0].reasoningOutputTokens).toBe(4)
expect(buildDevinBreakdown(snapshot, 'orca', 'all', 'project')[0].reasoningOutputTokens).toBe(6)
expect(
buildDevinRecentSessions(snapshot, 'orca', 'all', 10).find((row) => row.sessionId === 's1')
?.reasoningOutputTokens
).toBe(2)
})
})
@@ -0,0 +1,209 @@
import { highestUsageKey } from '../usage/highest-usage-key'
import { filterUsageDaily, filterUsageSessions } from '../usage/usage-scope-filters'
import type {
DevinUsageBreakdownKind,
DevinUsageBreakdownRow,
DevinUsageDailyPoint,
DevinUsageRange,
DevinUsageScope,
DevinUsageSessionRow,
DevinUsageSummary
} from '../../shared/devin-usage-types'
import type { DevinUsagePersistedState } from './types'
function filteredDaily(
state: DevinUsagePersistedState,
scope: DevinUsageScope,
range: DevinUsageRange
) {
return filterUsageDaily(state.dailyAggregates, scope, range)
}
function filteredSessions(
state: DevinUsagePersistedState,
scope: DevinUsageScope,
range: DevinUsageRange
) {
return filterUsageSessions(state.sessions, scope, range)
}
export function buildDevinSummary(
state: DevinUsagePersistedState,
scope: DevinUsageScope,
range: DevinUsageRange
): DevinUsageSummary {
const daily = filteredDaily(state, scope, range)
const sessions = filteredSessions(state, scope, range)
let inputTokens = 0
let cachedInputTokens = 0
let outputTokens = 0
let reasoningOutputTokens = 0
let totalTokens = 0
let events = 0
const byModel = new Map<string, number>()
const byProject = new Map<string, number>()
const projectLabels = new Map<string, string>()
for (const row of daily) {
inputTokens += row.inputTokens
cachedInputTokens += row.cachedInputTokens
outputTokens += row.outputTokens
reasoningOutputTokens += row.reasoningOutputTokens
totalTokens += row.totalTokens
events += row.eventCount
const model = row.model ?? 'Unknown model'
byModel.set(model, (byModel.get(model) ?? 0) + row.totalTokens)
byProject.set(row.projectKey, (byProject.get(row.projectKey) ?? 0) + row.totalTokens)
projectLabels.set(row.projectKey, row.projectLabel)
}
const topProjectKey = highestUsageKey(byProject)
return {
scope,
range,
sessions: sessions.length,
events,
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens,
estimatedCostUsd: null,
topModel: highestUsageKey(byModel),
topProject: topProjectKey ? (projectLabels.get(topProjectKey) ?? topProjectKey) : null,
hasAnyDevinData: sessions.length > 0 || daily.length > 0
}
}
export function buildDevinDaily(
state: DevinUsagePersistedState,
scope: DevinUsageScope,
range: DevinUsageRange
): DevinUsageDailyPoint[] {
const rows = new Map<string, DevinUsageDailyPoint>()
for (const entry of filteredDaily(state, scope, range)) {
const row = rows.get(entry.day) ?? {
day: entry.day,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
row.inputTokens += entry.inputTokens
row.cachedInputTokens += entry.cachedInputTokens
row.outputTokens += entry.outputTokens
row.reasoningOutputTokens += entry.reasoningOutputTokens
row.totalTokens += entry.totalTokens
rows.set(entry.day, row)
}
return [...rows.values()].sort((a, b) => a.day.localeCompare(b.day))
}
export function buildDevinBreakdown(
state: DevinUsagePersistedState,
scope: DevinUsageScope,
range: DevinUsageRange,
kind: DevinUsageBreakdownKind
): DevinUsageBreakdownRow[] {
const rows = new Map<string, DevinUsageBreakdownRow>()
for (const entry of filteredDaily(state, scope, range)) {
const key = kind === 'model' ? (entry.model ?? 'unknown') : entry.projectKey
const row = rows.get(key) ?? {
key,
label: kind === 'model' ? (entry.model ?? 'Unknown model') : entry.projectLabel,
sessions: 0,
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
hasInferredPricing: false
}
row.events += entry.eventCount
row.inputTokens += entry.inputTokens
row.cachedInputTokens += entry.cachedInputTokens
row.outputTokens += entry.outputTokens
row.reasoningOutputTokens += entry.reasoningOutputTokens
row.totalTokens += entry.totalTokens
rows.set(key, row)
}
for (const session of filteredSessions(state, scope, range)) {
const seen = new Set<string>()
if (kind === 'model') {
for (const entry of session.locationModelBreakdown) {
if (scope === 'orca' && entry.worktreeId === null) {
continue
}
if (seen.has(entry.modelKey)) {
continue
}
const row = rows.get(entry.modelKey)
if (row) {
row.sessions++
}
seen.add(entry.modelKey)
}
continue
}
for (const entry of session.locationBreakdown) {
if (scope === 'orca' && entry.worktreeId === null) {
continue
}
if (seen.has(entry.locationKey)) {
continue
}
const row = rows.get(entry.locationKey)
if (row) {
row.sessions++
}
seen.add(entry.locationKey)
}
}
return [...rows.values()].sort((a, b) => b.totalTokens - a.totalTokens)
}
export function buildDevinRecentSessions(
state: DevinUsagePersistedState,
scope: DevinUsageScope,
range: DevinUsageRange,
limit: number
): DevinUsageSessionRow[] {
return filteredSessions(state, scope, range)
.slice(0, Number.isFinite(limit) ? Math.max(0, Math.min(100, Math.floor(limit))) : 10)
.map((session) => {
const locations = session.locationBreakdown.filter(
(entry) => scope === 'all' || entry.worktreeId !== null
)
const models = new Map(
session.locationModelBreakdown
.filter((entry) => scope === 'all' || entry.worktreeId !== null)
.map((entry) => [entry.modelKey, entry.modelLabel])
)
return {
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes: Math.max(
0,
Math.round(
(Date.parse(session.lastTimestamp) - Date.parse(session.firstTimestamp)) / 60_000
)
),
projectLabel:
locations.length > 1
? 'Multiple locations'
: (locations[0]?.projectLabel ?? session.primaryProjectLabel),
model: models.size > 1 ? 'Mixed models' : (models.values().next().value ?? null),
events: locations.reduce((sum, entry) => sum + entry.eventCount, 0),
inputTokens: locations.reduce((sum, entry) => sum + entry.inputTokens, 0),
cachedInputTokens: locations.reduce((sum, entry) => sum + entry.cachedInputTokens, 0),
outputTokens: locations.reduce((sum, entry) => sum + entry.outputTokens, 0),
reasoningOutputTokens: locations.reduce(
(sum, entry) => sum + entry.reasoningOutputTokens,
0
),
totalTokens: locations.reduce((sum, entry) => sum + entry.totalTokens, 0),
hasInferredPricing: false
}
})
}
@@ -0,0 +1,17 @@
import type { UsageProvider } from '../usage/usage-provider-contract'
import { scanDevinUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
import type { DevinUsageDailyAggregate, DevinUsagePersistedFile, DevinUsageSession } from './types'
export const DEVIN_USAGE_SCHEMA_VERSION = 2
export const devinUsageProvider = {
id: 'devin',
label: 'Devin',
schemaVersion: DEVIN_USAGE_SCHEMA_VERSION,
scan: scanDevinUsageFilesViaWorker
} satisfies UsageProvider<
'processedFiles',
DevinUsagePersistedFile,
DevinUsageSession,
DevinUsageDailyAggregate
>
@@ -0,0 +1,84 @@
import { z } from 'zod'
const count = z.number().finite().nonnegative()
const nullableText = z.string().nullable()
const tokens = {
eventCount: count,
inputTokens: count,
cachedInputTokens: count,
outputTokens: count,
reasoningOutputTokens: count,
totalTokens: count,
estimatedCostUsd: count.nullable()
}
const location = z.object({
...tokens,
locationKey: z.string(),
projectLabel: z.string(),
repoId: nullableText,
worktreeId: nullableText
})
const model = z.object({ ...tokens, modelKey: z.string(), modelLabel: z.string() })
const locationModel = model.extend({
locationKey: z.string(),
repoId: nullableText,
worktreeId: nullableText
})
const session = z.object({
sessionId: z.string(),
firstTimestamp: z.string(),
lastTimestamp: z.string(),
primaryModel: nullableText,
hasMixedModels: z.boolean(),
primaryProjectLabel: z.string(),
hasMixedLocations: z.boolean(),
primaryWorktreeId: nullableText,
primaryRepoId: nullableText,
eventCount: count,
totalInputTokens: count,
totalCachedInputTokens: count,
totalOutputTokens: count,
totalReasoningOutputTokens: count,
totalTokens: count,
estimatedCostUsd: count.nullable(),
locationBreakdown: z.array(location),
modelBreakdown: z.array(model),
locationModelBreakdown: z.array(locationModel)
})
const daily = z.object({
...tokens,
day: z.string(),
model: nullableText,
projectKey: z.string(),
projectLabel: z.string(),
repoId: nullableText,
worktreeId: nullableText
})
const sidecar = z.union([
z.literal('none'),
z.literal('unknown'),
z.object({ path: z.string(), mtimeMs: z.number().finite(), sizeBytes: count })
])
const file = z.object({
path: z.string(),
mtimeMs: z.number().finite(),
size: count,
sessionId: z.string(),
sessionsDb: sidecar.optional(),
sessions: z.array(session),
dailyAggregates: z.array(daily)
})
export const devinUsagePersistedStateSchema = z.object({
schemaVersion: z.number().int(),
worktreeFingerprint: nullableText,
processedFiles: z.array(file),
sessions: z.array(session),
dailyAggregates: z.array(daily),
scanState: z.object({
enabled: z.boolean(),
lastScanStartedAt: count.nullable(),
lastScanCompletedAt: count.nullable(),
lastScanError: nullableText
})
})
+337
View File
@@ -0,0 +1,337 @@
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import SyncDatabase from '../sqlite/sync-database'
import * as transcriptFs from '../native-chat/wsl-transcript-fs-access'
import { resetDevinSessionsIndexCacheForTests } from '../devin/sessions-index'
import { scanDevinUsageFiles } from './scanner'
let tempDirs: string[] = []
const originalDevinHome = process.env.DEVIN_HOME
afterEach(async () => {
process.env.DEVIN_HOME = originalDevinHome
resetDevinSessionsIndexCacheForTests()
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
tempDirs = []
})
async function makeTranscriptsDir(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'orca-devin-usage-'))
tempDirs.push(home)
process.env.DEVIN_HOME = home
const dir = join(home, 'cli', 'transcripts')
await mkdir(dir, { recursive: true })
return dir
}
type TranscriptOverrides = Record<string, unknown>
function transcript(overrides: TranscriptOverrides = {}): string {
return JSON.stringify({
session_id: 's1',
working_directory: '/repo/main',
agent: { model_name: 'swe-2' },
steps: [
{
timestamp: '2026-09-01T10:00:00Z',
metrics: { prompt_tokens: 100, cached_tokens: 25, completion_tokens: 40 }
},
{
timestamp: '2026-09-01T10:05:00Z',
metrics: { prompt_tokens: 50, completion_tokens: 10 }
}
],
...overrides
})
}
const WORKTREES = [{ repoId: 'r1', worktreeId: 'w1', path: '/repo/main', displayName: 'main' }]
// The Devin CLI sessions.db schema, written out in full because the reader
// probes every column it names.
const DEVIN_SESSIONS_SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
working_directory TEXT,
backend_type TEXT,
model TEXT,
agent_mode TEXT,
created_at INTEGER,
last_activity_at INTEGER,
title TEXT,
main_chain_id TEXT,
shell_last_seen_index INTEGER,
cogs_json TEXT,
workspace_dirs TEXT,
hidden INTEGER,
metadata TEXT
);
`
function writeDevinSessionsDb(
dbPath: string,
rows: readonly { id: string; working_directory?: string | null; hidden?: number | null }[]
): void {
const db = new SyncDatabase(dbPath)
try {
db.exec(DEVIN_SESSIONS_SCHEMA)
db.exec('DELETE FROM sessions')
const insert = db.prepare(
'INSERT INTO sessions (id, working_directory, hidden) VALUES (?, ?, ?)'
)
for (const row of rows) {
insert.run(row.id, row.working_directory ?? null, row.hidden ?? 0)
}
} finally {
db.close()
}
}
describe('scanDevinUsageFiles', () => {
it('aggregates events into sessions and daily aggregates with worktree attribution', async () => {
const dir = await makeTranscriptsDir()
await writeFile(join(dir, 's1.json'), transcript())
const result = await scanDevinUsageFiles(WORKTREES)
expect(result.processedFiles).toHaveLength(1)
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
sessionId: 's1',
primaryWorktreeId: 'w1',
primaryModel: 'swe-2',
eventCount: 2,
totalInputTokens: 150,
totalCachedInputTokens: 25,
totalOutputTokens: 50,
totalTokens: 200
})
expect(result.dailyAggregates).toHaveLength(1)
expect(result.dailyAggregates[0]).toMatchObject({
day: '2026-09-01',
worktreeId: 'w1',
eventCount: 2,
totalTokens: 200
})
})
it('reuses unchanged files on a rescan and dedupes copied transcripts', async () => {
const dir = await makeTranscriptsDir()
const file = join(dir, 's1.json')
await writeFile(file, transcript())
const first = await scanDevinUsageFiles(WORKTREES)
expect(first.sessions).toHaveLength(1)
// A duplicate transcript carrying the same session_id must not double-count.
await writeFile(
join(dir, 's1-copy.json'),
transcript({
steps: [
{ timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } }
]
})
)
const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles)
expect(second.sessions).toHaveLength(1)
expect(second.sessions[0].totalTokens).toBe(200)
expect(second.sessions[0].eventCount).toBe(2)
})
it('lets the canonical <session>.json claim ahead of a lexicographically earlier copy', async () => {
const dir = await makeTranscriptsDir()
// 's1-copy.json' sorts before 's1.json' ('-' < '.'), so a naive
// sorted-order claim would hand the session to the stale copy forever.
await writeFile(
join(dir, 's1-copy.json'),
transcript({
steps: [
{ timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } }
]
})
)
await writeFile(join(dir, 's1.json'), transcript())
const result = await scanDevinUsageFiles(WORKTREES)
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0].totalTokens).toBe(200)
})
it('hands the claim back to the canonical file after a copy owned it', async () => {
const dir = await makeTranscriptsDir()
const copy = join(dir, 's1-copy.json')
await writeFile(copy, transcript())
// Only the copy exists: it owns the session.
const first = await scanDevinUsageFiles(WORKTREES)
expect(first.sessions[0]?.totalTokens).toBe(200)
// The canonical transcript appears; the copy must not freeze the session.
await writeFile(join(dir, 's1.json'), transcript())
const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles)
expect(second.sessions[0]?.totalTokens).toBe(200)
expect(second.sessions).toHaveLength(1)
})
it('lets a deferred copy reclaim its session once the owner disappears', async () => {
const dir = await makeTranscriptsDir()
const canonical = join(dir, 's1.json')
await writeFile(canonical, transcript())
await writeFile(
join(dir, 's1-copy.json'),
transcript({
steps: [
{ timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } }
]
})
)
const first = await scanDevinUsageFiles(WORKTREES)
expect(first.sessions[0]?.totalTokens).toBe(200)
await rm(canonical)
const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles)
expect(second.sessions).toHaveLength(1)
expect(second.sessions[0]?.totalTokens).toBe(10)
})
it('lets a deferred copy reclaim its session when the owner stops parsing', async () => {
const dir = await makeTranscriptsDir()
const canonical = join(dir, 's1.json')
await writeFile(canonical, transcript())
await writeFile(
join(dir, 's1-copy.json'),
transcript({
steps: [
{ timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } }
]
})
)
const first = await scanDevinUsageFiles(WORKTREES)
expect(first.sessions[0]?.totalTokens).toBe(200)
// The owner stays listed but turns unreadable — without a reclaim pass
// the copy would sit deferred with the session lost forever.
await writeFile(canonical, '{ torn write')
const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles)
expect(second.sessions).toHaveLength(1)
expect(second.sessions[0]?.totalTokens).toBe(10)
})
it('skips a corrupt transcript without sinking the rest of the scan', async () => {
const dir = await makeTranscriptsDir()
await writeFile(join(dir, 'broken.json'), '{ not json')
await writeFile(join(dir, 's1.json'), transcript())
const result = await scanDevinUsageFiles(WORKTREES)
expect(result.processedFiles).toHaveLength(1)
expect(result.sessions[0]?.sessionId).toBe('s1')
})
it('returns an empty projection when no transcripts exist', async () => {
await makeTranscriptsDir()
const result = await scanDevinUsageFiles(WORKTREES)
expect(result.processedFiles).toEqual([])
expect(result.sessions).toEqual([])
expect(result.dailyAggregates).toEqual([])
})
it('re-attributes an unchanged transcript when only sessions.db moves', async () => {
const dir = await makeTranscriptsDir()
// No working_directory in the transcript (the Windows ATIF shape) — the db
// row is the only attribution source.
await writeFile(join(dir, 's1.json'), transcript({ working_directory: null }))
const dbPath = join(dir, '..', 'sessions.db')
writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/main' }])
const first = await scanDevinUsageFiles(WORKTREES)
expect(first.sessions[0]?.primaryWorktreeId).toBe('w1')
writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/other' }])
// Why: pin the mtime so the sidecar change is visible regardless of fs
// timestamp granularity — size alone stays identical across rewrites.
const later = new Date('2026-09-02T00:00:00Z')
await utimes(dbPath, later, later)
const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles)
expect(second.sessions[0]?.primaryWorktreeId).toBeNull()
expect(second.sessions[0]?.primaryProjectLabel).toBe('repo/other')
})
it('drops a session from the projection once sessions.db marks it hidden', async () => {
const dir = await makeTranscriptsDir()
await writeFile(join(dir, 's1.json'), transcript({ working_directory: null }))
const dbPath = join(dir, '..', 'sessions.db')
writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/main', hidden: 0 }])
const first = await scanDevinUsageFiles(WORKTREES)
expect(first.sessions).toHaveLength(1)
writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/main', hidden: 1 }])
const later = new Date('2026-09-02T00:00:00Z')
await utimes(dbPath, later, later)
const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles)
expect(second.sessions).toEqual([])
expect(second.dailyAggregates).toEqual([])
})
it('observes sessions.db-wal while the db runs in WAL mode', async () => {
const dir = await makeTranscriptsDir()
await writeFile(join(dir, 's1.json'), transcript({ working_directory: null }))
const dbPath = join(dir, '..', 'sessions.db')
const db = new SyncDatabase(dbPath)
try {
db.exec(DEVIN_SESSIONS_SCHEMA)
db.pragma('journal_mode = WAL')
db.prepare('INSERT INTO sessions (id, working_directory) VALUES (?, ?)').run(
's1',
'/repo/main'
)
// The open connection keeps sessions.db-wal on disk; the observation
// must ride the wal stat, not the untouched db file.
const result = await scanDevinUsageFiles(WORKTREES)
expect(result.sessions[0]?.primaryWorktreeId).toBe('w1')
expect(result.processedFiles[0]?.sessionsDb).toMatchObject({
path: `${dbPath}-wal`
})
} finally {
db.close()
}
})
it('reads no unchanged transcripts and only one changed file across a large cached scan', async () => {
const dir = await makeTranscriptsDir()
await Promise.all(
Array.from({ length: 250 }, (_, i) =>
writeFile(join(dir, `s${i}.json`), transcript({ session_id: `s${i}` }))
)
)
const read = vi.spyOn(transcriptFs, 'wslGatedReadFile')
try {
const first = await scanDevinUsageFiles(WORKTREES)
expect(read).toHaveBeenCalledTimes(250)
expect(first.sessions).toHaveLength(250)
read.mockClear()
const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles)
expect(read).not.toHaveBeenCalled()
await writeFile(
join(dir, 's1.json'),
transcript({ session_id: 's1', working_directory: '/changed' })
)
await scanDevinUsageFiles(WORKTREES, second.processedFiles)
expect(read).toHaveBeenCalledTimes(1)
} finally {
read.mockRestore()
}
})
})
+108
View File
@@ -0,0 +1,108 @@
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import { devinSessionsIndexForSidecar } from '../devin/sessions-index'
import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator'
import { sidecarUnchanged } from '../ai-vault/session-sidecar-stat'
import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import { resolveDevinTranscriptsDir } from '../devin/devin-cli-data-dir'
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import {
getProcessedFileInfo,
listDevinTranscriptFiles,
observeDevinSessionsDb
} from './devin-transcript-discovery'
import { parseDevinTranscriptForUsage } from './devin-transcript-parse'
import { attributeDevinUsageEvent } from './devin-usage-event-attribution'
import { devinUsageAggregation } from './devin-usage-aggregation'
import type { DevinUsageDailyAggregate, DevinUsagePersistedFile, DevinUsageSession } from './types'
export async function scanDevinUsageFiles(
worktrees: UsageScanWorktreeRef[],
previous: DevinUsagePersistedFile[] = [],
onFilesScanned?: (count: number) => void
): Promise<{
processedFiles: DevinUsagePersistedFile[]
sessions: DevinUsageSession[]
dailyAggregates: DevinUsageDailyAggregate[]
}> {
const filePaths = await listDevinTranscriptFiles()
const sessionsDb = await observeDevinSessionsDb(resolveDevinTranscriptsDir())
const { index, unreadable } = devinSessionsIndexForSidecar(sessionsDb)
if (unreadable) {
throw new Error('Unable to read Devin session metadata; retry the scan')
}
const previousByPath = new Map(previous.map((file) => [file.path, file]))
const resolveWorktree = await createUsageWorktreeResolver(worktrees)
const processedFiles: DevinUsagePersistedFile[] = []
const ownerBySession = new Map<string, DevinUsagePersistedFile>()
for (const [position, path] of filePaths.entries()) {
try {
const info = await getProcessedFileInfo(path)
const cached = previousByPath.get(path)
let file: DevinUsagePersistedFile
if (
cached &&
typeof cached.sessionId === 'string' &&
cached.mtimeMs === info.mtimeMs &&
cached.size === info.size &&
sidecarUnchanged(cached.sessionsDb, sessionsDb)
) {
file = cached
} else {
const parsed = parseDevinTranscriptForUsage(
path,
await wslGatedReadFile(path, 'utf-8', 'scan'),
index
)
if (!parsed) {
continue
}
const events = parsed.hidden
? []
: parsed.events
.map((event) => attributeDevinUsageEvent(event, resolveWorktree))
.filter((event) => event !== null)
file = {
...info,
sessionId: parsed.sessionId,
sessionsDb,
...devinUsageAggregation.aggregate(events)
}
}
processedFiles.push(file)
// Cache every file's aggregates; choose one copy per session only when projecting.
if (!ownerBySession.has(file.sessionId) || sessionIdFromFileName(path) === file.sessionId) {
ownerBySession.set(file.sessionId, file)
}
} catch (error) {
if (
!(
error &&
typeof error === 'object' &&
'code' in error &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
)
) {
throw error
}
} finally {
onFilesScanned?.(1)
if ((position + 1) % 10 === 0) {
await yieldToEventLoop()
}
}
}
const sessions = new Map<string, DevinUsageSession>()
const daily = new Map<string, DevinUsageDailyAggregate>()
for (const file of ownerBySession.values()) {
devinUsageAggregation.mergeSessions(sessions, file.sessions)
devinUsageAggregation.mergeDailyAggregates(daily, file.dailyAggregates)
}
return {
processedFiles,
sessions: devinUsageAggregation.finalizeSessions(sessions),
dailyAggregates: devinUsageAggregation.sortDailyAggregates(daily)
}
}
+50
View File
@@ -0,0 +1,50 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn() }))
vi.mock('electron', () => ({ app: { getPath: getPathMock } }))
vi.mock('../usage/usage-scan-worker-spawn', () => ({ scanDevinUsageFilesViaWorker: vi.fn() }))
import { DEVIN_USAGE_SCHEMA_VERSION } from './devin-usage-provider'
import { DevinUsageStore, initDevinUsagePath, normalizeDevinUsageState } from './store'
describe('DevinUsageStore persisted state', () => {
let userData: string
beforeEach(() => {
userData = mkdtempSync(join(tmpdir(), 'orca-devin-store-'))
getPathMock.mockReturnValue(userData)
initDevinUsagePath()
})
afterEach(() => rmSync(userData, { recursive: true, force: true }))
it('drops malformed projections without losing the enabled preference', () => {
const state = normalizeDevinUsageState({
schemaVersion: DEVIN_USAGE_SCHEMA_VERSION,
scanState: { enabled: true },
sessions: [{ locationBreakdown: null, modelBreakdown: [], locationModelBreakdown: [] }],
dailyAggregates: [{ day: 'bad' }],
processedFiles: []
})
expect(state.scanState.enabled).toBe(true)
expect(state.sessions).toEqual([])
expect(state.dailyAggregates).toEqual([])
})
it('migrates an old schema by clearing aggregates while preserving opt-in state', () => {
writeFileSync(
join(userData, 'orca-devin-usage.json'),
JSON.stringify({
schemaVersion: 1,
scanState: { enabled: true },
sessions: [{ locationBreakdown: [], modelBreakdown: [], locationModelBreakdown: [] }]
})
)
const store = new DevinUsageStore({ getRepos: () => [], getAllWorktreeMeta: () => ({}) })
expect(store.getScanState().enabled).toBe(true)
expect(store.getScanState().hasAnyDevinData).toBe(false)
})
})
+113
View File
@@ -0,0 +1,113 @@
import { app } from 'electron'
import { asRecord } from '../ai-vault/session-scanner-record-value'
import { devinUsagePersistedStateSchema } from './persisted-state-schema'
import { join } from 'node:path'
import type {
DevinUsageBreakdownKind,
DevinUsageBreakdownRow,
DevinUsageDailyPoint,
DevinUsageRange,
DevinUsageScope,
DevinUsageSessionRow,
DevinUsageSnapshot,
DevinUsageSummary
} from '../../shared/devin-usage-types'
import type { Store } from '../persistence'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
import { DEVIN_USAGE_SCHEMA_VERSION, devinUsageProvider } from './devin-usage-provider'
import {
buildDevinBreakdown,
buildDevinDaily,
buildDevinRecentSessions,
buildDevinSummary
} from './devin-usage-projections'
import type { DevinUsagePersistedState } from './types'
let usageFile: string | null = null
function defaultState(): DevinUsagePersistedState {
return {
schemaVersion: DEVIN_USAGE_SCHEMA_VERSION,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
export function normalizeDevinUsageState(value: unknown): DevinUsagePersistedState {
const parsed = devinUsagePersistedStateSchema.safeParse(value)
if (parsed.success && parsed.data.schemaVersion === DEVIN_USAGE_SCHEMA_VERSION) {
return parsed.data
}
const defaults = defaultState()
defaults.scanState.enabled = asRecord(asRecord(value)?.scanState)?.enabled === true
return defaults
}
export function initDevinUsagePath(): void {
usageFile = join(app.getPath('userData'), 'orca-devin-usage.json')
}
export class DevinUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
DevinUsagePersistedState,
'hasAnyDevinData'
> {
constructor(store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
super(store, {
logTag: '[devin-usage]',
resolveCacheFile: () => usageFile ?? join(app.getPath('userData'), 'orca-devin-usage.json'),
createDefaultState: defaultState,
normalizeState: (state) => normalizeDevinUsageState(state),
sourceKey: 'processedFiles',
dataPresenceKey: 'hasAnyDevinData',
scan: devinUsageProvider.scan
})
}
getSnapshot(scope: DevinUsageScope, range: DevinUsageRange, limit = 10): DevinUsageSnapshot {
return {
scanState: this.getScanState(),
summary: buildDevinSummary(this.state, scope, range),
daily: buildDevinDaily(this.state, scope, range),
modelBreakdown: buildDevinBreakdown(this.state, scope, range, 'model'),
projectBreakdown: buildDevinBreakdown(this.state, scope, range, 'project'),
recentSessions: buildDevinRecentSessions(this.state, scope, range, limit)
}
}
async getSummary(scope: DevinUsageScope, range: DevinUsageRange): Promise<DevinUsageSummary> {
await this.refresh(false)
return buildDevinSummary(this.state, scope, range)
}
async getDaily(scope: DevinUsageScope, range: DevinUsageRange): Promise<DevinUsageDailyPoint[]> {
await this.refresh(false)
return buildDevinDaily(this.state, scope, range)
}
async getBreakdown(
scope: DevinUsageScope,
range: DevinUsageRange,
kind: DevinUsageBreakdownKind
): Promise<DevinUsageBreakdownRow[]> {
await this.refresh(false)
return buildDevinBreakdown(this.state, scope, range, kind)
}
async getRecentSessions(
scope: DevinUsageScope,
range: DevinUsageRange,
limit = 12
): Promise<DevinUsageSessionRow[]> {
await this.refresh(false)
return buildDevinRecentSessions(this.state, scope, range, limit)
}
}
+64
View File
@@ -0,0 +1,64 @@
import type {
UsageDailyAggregate,
UsageLocationBreakdown,
UsageLocationModelBreakdown,
UsageModelBreakdown,
UsageSession
} from '../usage/usage-rollup-records'
import type { SessionSidecarObservation } from '../ai-vault/session-sidecar-stat'
export type DevinUsageMetric = { estimatedCostUsd: number | null }
export type DevinUsageProcessedFile = {
path: string
mtimeMs: number
size: number
}
export type DevinUsageSession = UsageSession<DevinUsageMetric>
export type DevinUsageDailyAggregate = UsageDailyAggregate<DevinUsageMetric>
export type DevinUsageLocationBreakdown = UsageLocationBreakdown<DevinUsageMetric>
export type DevinUsageModelBreakdown = UsageModelBreakdown<DevinUsageMetric>
export type DevinUsageLocationModelBreakdown = UsageLocationModelBreakdown<DevinUsageMetric>
export type DevinUsagePersistedFile = DevinUsageProcessedFile & {
sessionsDb?: SessionSidecarObservation
sessionId: string
sessions: DevinUsageSession[]
dailyAggregates: DevinUsageDailyAggregate[]
}
export type DevinUsagePersistedState = {
schemaVersion: number
worktreeFingerprint: string | null
processedFiles: DevinUsagePersistedFile[]
sessions: DevinUsageSession[]
dailyAggregates: DevinUsageDailyAggregate[]
scanState: {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
}
export type DevinUsageParsedEvent = {
sessionId: string
timestamp: string
model: string | null
cwd: string | null
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
estimatedCostUsd: number | null
}
export type DevinUsageAttributedEvent = DevinUsageParsedEvent & {
day: string
projectKey: string
projectLabel: string
repoId: string | null
worktreeId: string | null
}
+43
View File
@@ -0,0 +1,43 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveDevinCliDataDir } from './devin-cli-data-dir'
afterEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
describe('Devin CLI data root', () => {
it.each(['win32', 'linux', 'darwin'])('honors the root override on %s', (platform) => {
vi.stubGlobal('process', { ...process, platform })
vi.stubEnv('DEVIN_HOME', join(homedir(), 'custom-devin'))
expect(resolveDevinCliDataDir()).toBe(join(homedir(), 'custom-devin', 'cli'))
})
it.each(['win32', 'linux', 'darwin'])('uses the platform data directory on %s', (platform) => {
vi.stubGlobal('process', { ...process, platform })
vi.stubEnv('DEVIN_HOME', '')
vi.stubEnv('APPDATA', join(homedir(), 'appdata'))
vi.stubEnv('XDG_DATA_HOME', join(homedir(), 'xdg'))
expect(resolveDevinCliDataDir()).toBe(
join(
homedir(),
platform === 'win32'
? 'appdata'
: platform === 'darwin'
? 'Library/Application Support'
: 'xdg',
'devin',
'cli'
)
)
})
it('ignores relative environment paths', () => {
vi.stubGlobal('process', { ...process, platform: 'linux' })
vi.stubEnv('DEVIN_HOME', 'relative')
vi.stubEnv('XDG_DATA_HOME', 'relative')
expect(resolveDevinCliDataDir()).toBe(join(homedir(), '.local', 'share', 'devin', 'cli'))
})
})
+29
View File
@@ -0,0 +1,29 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override'
// DEVIN_HOME overrides the root containing credentials.toml and the cli directory.
export function resolveDevinCliDataDir(): string {
const platformDataDir =
process.platform === 'win32'
? process.env.APPDATA
: process.platform === 'darwin'
? join(homedir(), 'Library', 'Application Support')
: process.env.XDG_DATA_HOME
const resolvedPlatformDataDir = resolveAbsoluteDirOverride(
platformDataDir,
process.platform === 'win32'
? join(homedir(), 'AppData', 'Roaming')
: process.platform === 'darwin'
? join(homedir(), 'Library', 'Application Support')
: join(homedir(), '.local', 'share')
)
return join(
resolveAbsoluteDirOverride(process.env.DEVIN_HOME, join(resolvedPlatformDataDir, 'devin')),
'cli'
)
}
export function resolveDevinTranscriptsDir(): string {
return join(resolveDevinCliDataDir(), 'transcripts')
}
+140
View File
@@ -0,0 +1,140 @@
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open'
import type { SessionSidecarObservation } from '../ai-vault/session-sidecar-stat'
import { asRecord } from '../ai-vault/session-scanner-record-value'
import { numberValue } from '../ai-vault/session-scanner-token-values'
import { extractString } from '../ai-vault/session-scanner-values'
// Why: Devin CLI keeps a tiny `sessions` table in sessions.db beside the
// transcripts dir — the transcript holds no cwd on Windows installs, so the db
// is what lets a Devin session group under a workspace. One row per session_id
// (the transcript filename), timestamps in unix SECONDS.
export type DevinSessionIndexRow = {
workingDirectory: string | null
title: string | null
model: string | null
createdAt: string | null
lastActivityAt: string | null
// The user hid the session in Devin's own UI; the listing honors that.
hidden: boolean
}
export type DevinSessionsIndex = Map<string, DevinSessionIndexRow>
// Optional in older schemas; `id` is the only required column.
const DEVIN_SESSION_TABLE = 'sessions'
const DEVIN_SESSION_OPTIONAL_COLUMNS = [
'working_directory',
'title',
'model',
'created_at',
'last_activity_at',
'hidden'
] as const
// One index per observed db stat, so all transcripts under a root share a
// single open per scan and a db the transcript mtimes cannot see still
// re-merges when its own stat moves.
const INDEX_CACHE_LIMIT = 8
const indexCache = new Map<
string,
{ observationPath: string; mtimeMs: number; sizeBytes: number; index: DevinSessionsIndex }
>()
function devinSessionsDbPathForSidecarPath(sidecarPath: string): string {
return sidecarPath.endsWith('-wal') ? sidecarPath.slice(0, -'-wal'.length) : sidecarPath
}
export function devinSessionsIndexForSidecar(sidecar: SessionSidecarObservation | undefined): {
index: DevinSessionsIndex | null
unreadable: boolean
} {
if (sidecar === undefined || sidecar === 'none') {
return { index: null, unreadable: false }
}
if (sidecar === 'unknown') {
// The stat already failed this scan; an open would ride the same stalled
// share. Retry next scan instead of paying it per transcript.
return { index: null, unreadable: true }
}
const dbPath = devinSessionsDbPathForSidecarPath(sidecar.path)
const cached = indexCache.get(dbPath)
if (
cached &&
cached.observationPath === sidecar.path &&
cached.mtimeMs === sidecar.mtimeMs &&
cached.sizeBytes === sidecar.sizeBytes
) {
indexCache.delete(dbPath)
indexCache.set(dbPath, cached)
return { index: cached.index, unreadable: false }
}
try {
const index = readDevinSessionsIndex(dbPath)
if (indexCache.size >= INDEX_CACHE_LIMIT) {
const oldest = indexCache.keys().next().value
if (oldest !== undefined) {
indexCache.delete(oldest)
}
}
indexCache.set(dbPath, {
observationPath: sidecar.path,
mtimeMs: sidecar.mtimeMs,
sizeBytes: sidecar.sizeBytes,
index
})
return { index, unreadable: false }
} catch {
// Deliberately uncached: contention is transient, and caching a failure
// under an unchanged stat would refuse enrichment until the db moved.
return { index: null, unreadable: true }
}
}
export function resetDevinSessionsIndexCacheForTests(): void {
indexCache.clear()
}
function readDevinSessionsIndex(dbPath: string): DevinSessionsIndex {
return readOpenCodeDatabase({
dbPath,
read: (db) => {
const index: DevinSessionsIndex = new Map()
if (!tableExists(db, DEVIN_SESSION_TABLE) || !columnExists(db, DEVIN_SESSION_TABLE, 'id')) {
return index
}
const columns = DEVIN_SESSION_OPTIONAL_COLUMNS.filter((column) =>
columnExists(db, DEVIN_SESSION_TABLE, column)
)
const statement = db.prepare(
`SELECT id${columns.map((column) => `, ${column}`).join('')} FROM ${DEVIN_SESSION_TABLE}`
)
for (const row of statement.all()) {
const record = asRecord(row)
const id = record ? extractString(record.id) : null
if (!record || !id) {
continue
}
index.set(id, {
workingDirectory: extractString(record.working_directory),
title: extractString(record.title),
model: extractString(record.model),
createdAt: unixSecondsToIso(record.created_at),
lastActivityAt: unixSecondsToIso(record.last_activity_at),
hidden: numberValue(record.hidden) !== 0
})
}
return index
}
})
}
function unixSecondsToIso(value: unknown): string | null {
const seconds = numberValue(value)
if (seconds <= 0) {
return null
}
const date = new Date(seconds * 1000)
return Number.isFinite(date.getTime()) ? date.toISOString() : null
}
+9
View File
@@ -0,0 +1,9 @@
import { ipcMain } from 'electron'
import type { RateLimitService } from '../rate-limits/service'
import { getDevinAccountStatus } from '../devin-accounts/status'
export function registerDevinAccountHandlers(rateLimits: RateLimitService): void {
ipcMain.handle('devinAccounts:getStatus', () =>
getDevinAccountStatus(rateLimits.getState().devin ?? null)
)
}
+14 -6
View File
@@ -14,6 +14,7 @@ vi.mock('electron', () => ({
import { registerRateLimitHandlers } from './rate-limits'
import type { RateLimitService } from '../rate-limits/service'
import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory'
import type { RateLimitState } from '../../shared/rate-limit-types'
import type { CodexAccountService } from '../codex-accounts/service'
@@ -27,14 +28,10 @@ function makeCodexAccounts() {
}
}
function makeService(): {
service: RateLimitService
refresh: ReturnType<typeof vi.fn>
refreshGrok: ReturnType<typeof vi.fn>
consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>
} {
function makeService() {
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
const refreshGrok = vi.fn(() => Promise.resolve({} as RateLimitState))
const refreshDevin = vi.fn(() => Promise.resolve(createEmptyRateLimitState()))
const consumeCodexRateLimitResetCredit = vi.fn(() =>
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
)
@@ -42,6 +39,7 @@ function makeService(): {
getState: vi.fn(() => ({}) as RateLimitState),
refresh,
refreshGrok,
refreshDevin,
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
consumeCodexRateLimitResetCredit,
@@ -53,6 +51,7 @@ function makeService(): {
service: service as unknown as RateLimitService,
refresh,
refreshGrok,
refreshDevin,
consumeCodexRateLimitResetCredit
}
}
@@ -78,6 +77,7 @@ describe('registerRateLimitHandlers', () => {
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refreshGrok')).toBe(true)
expect(ipcState.handleHandlers.has('rateLimits:refreshDevin')).toBe(true)
})
it('registers a refreshGrok channel that delegates to refreshGrok()', async () => {
@@ -88,6 +88,14 @@ describe('registerRateLimitHandlers', () => {
await handler!({})
expect(refreshGrok).toHaveBeenCalledTimes(1)
})
it('registers a refreshDevin channel that delegates to refreshDevin()', async () => {
const { service, refreshDevin } = makeService()
registerRateLimitHandlers(service, makeCodexAccounts().service)
const handler = ipcState.handleHandlers.get('rateLimits:refreshDevin')
expect(handler).toBeDefined()
await handler!({})
expect(refreshDevin).toHaveBeenCalledTimes(1)
})
it('serializes desktop reset consumption through CodexAccountService', async () => {
const { service, consumeCodexRateLimitResetCredit } = makeService()
+1
View File
@@ -30,4 +30,5 @@ export function registerRateLimitHandlers(
)
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok())
ipcMain.handle('rateLimits:refreshDevin', () => rateLimits.refreshDevin())
}
@@ -38,6 +38,7 @@ const {
registerClaudeAccountHandlersMock,
registerMiniMaxCredentialsHandlersMock,
registerGrokAccountHandlersMock,
registerDevinAccountHandlersMock,
registerClipboardHandlersMock,
setTrustedClipboardRendererWebContentsIdMock,
registerUpdaterHandlersMock,
@@ -105,6 +106,7 @@ const {
registerClaudeAccountHandlersMock: vi.fn(),
registerMiniMaxCredentialsHandlersMock: vi.fn(),
registerGrokAccountHandlersMock: vi.fn(),
registerDevinAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn(),
@@ -343,6 +345,9 @@ vi.mock('../minimax-credentials', () => ({
vi.mock('../grok-accounts', () => ({
registerGrokAccountHandlers: registerGrokAccountHandlersMock
}))
vi.mock('../devin-accounts', () => ({
registerDevinAccountHandlers: registerDevinAccountHandlersMock
}))
vi.mock('../../window/attach-main-window-services', () => ({
registerUpdaterHandlers: registerUpdaterHandlersMock
@@ -469,6 +474,7 @@ describe('registerCoreHandlers', () => {
const stats = { marker: 'stats' }
const claudeUsage = { marker: 'claudeUsage' }
const codexUsage = { marker: 'codexUsage' }
const devinUsage = { marker: 'devinUsage' }
const openCodeUsage = { marker: 'openCodeUsage' }
const codexAccounts = { marker: 'codexAccounts', runtimeHomeService: { marker: 'runtimeHome' } }
const claudeAccounts = { marker: 'claudeAccounts' }
@@ -483,6 +489,8 @@ describe('registerCoreHandlers', () => {
stats as never,
claudeUsage as never,
codexUsage as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Registration only forwards this mock; no concrete store methods execute in this test.
devinUsage as never,
openCodeUsage as never,
codexAccounts as never,
claudeAccounts as never,
@@ -507,6 +515,7 @@ describe('registerCoreHandlers', () => {
expect(registerUsageProviderHandlersMock).toHaveBeenCalledWith({
claudeUsage,
codexUsage,
devinUsage,
openCodeUsage
})
expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch })
@@ -524,6 +533,7 @@ describe('registerCoreHandlers', () => {
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
expect(registerDevinAccountHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerLinearHandlersMock).toHaveBeenCalled()
@@ -643,6 +653,7 @@ describe('registerCoreHandlers', () => {
const stats2 = { marker: 'stats2' }
const claudeUsage2 = { marker: 'claudeUsage2' }
const codexUsage2 = { marker: 'codexUsage2' }
const devinUsage2 = { marker: 'devinUsage2' }
const openCodeUsage2 = { marker: 'openCodeUsage2' }
const codexAccounts2 = { marker: 'codexAccounts2' }
const claudeAccounts2 = { marker: 'claudeAccounts2' }
@@ -654,6 +665,8 @@ describe('registerCoreHandlers', () => {
stats2 as never,
claudeUsage2 as never,
codexUsage2 as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Registration only forwards this mock; no concrete store methods execute in this test.
devinUsage2 as never,
openCodeUsage2 as never,
codexAccounts2 as never,
claudeAccounts2 as never,
@@ -64,6 +64,7 @@ import { registerAgentTrustHandlers } from '../agent-trust'
import { registerClaudeAccountHandlers } from '../claude-accounts'
import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials'
import { registerGrokAccountHandlers } from '../grok-accounts'
import { registerDevinAccountHandlers } from '../devin-accounts'
import { registerUpdaterHandlers } from '../../window/attach-main-window-services'
import {
registerClipboardHandlers,
@@ -72,6 +73,7 @@ import {
import { isDashboardPopoutRenderer } from '../../window/dashboard-popout-window'
import type { ClaudeUsageStore } from '../../claude-usage/store'
import type { CodexUsageStore } from '../../codex-usage/store'
import type { DevinUsageStore } from '../../devin-usage/store'
import type { OpenCodeUsageStore } from '../../opencode-usage/store'
import type { RateLimitService } from '../../rate-limits/service'
import type { CodexAccountService } from '../../codex-accounts/service'
@@ -112,6 +114,7 @@ export function registerCoreHandlers(
stats: StatsCollector,
claudeUsage: ClaudeUsageStore,
codexUsage: CodexUsageStore,
devinUsage: DevinUsageStore,
openCodeUsage: OpenCodeUsageStore,
codexAccounts: CodexAccountService,
claudeAccounts: ClaudeAccountService,
@@ -142,7 +145,7 @@ export function registerCoreHandlers(
registerAppHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch })
registerCliHandlers()
registerPreflightHandlers()
registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage })
registerUsageProviderHandlers({ claudeUsage, codexUsage, devinUsage, openCodeUsage })
registerCodexAccountHandlers(codexAccounts, () => store.getSettings())
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
@@ -150,6 +153,7 @@ export function registerCoreHandlers(
registerClaudeAccountHandlers(claudeAccounts)
registerMiniMaxCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerDevinAccountHandlers(rateLimits)
registerRateLimitHandlers(rateLimits, codexAccounts)
registerGitHubHandlers(store, stats)
registerGitLabHandlers(store)
+5 -1
View File
@@ -21,13 +21,16 @@ describe('usage provider IPC handlers', () => {
const claudeUsage = createUsage()
const codexUsage = createUsage()
const openCodeUsage = createUsage()
const devinUsage = createUsage()
registerUsageProviderHandlers({
claudeUsage: claudeUsage as never,
codexUsage: codexUsage as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Registration only forwards this mock; no concrete store methods execute in this test.
devinUsage: devinUsage as never,
openCodeUsage: openCodeUsage as never
})
const prefixes = ['claudeUsage', 'codexUsage', 'openCodeUsage']
const prefixes = ['claudeUsage', 'codexUsage', 'devinUsage', 'openCodeUsage']
const suffixes = Object.keys(claudeUsage)
expect(handle.mock.calls.map(([channel]) => channel)).toEqual(
prefixes.flatMap((prefix) => suffixes.map((suffix) => `${prefix}:${suffix}`))
@@ -41,6 +44,7 @@ describe('usage provider IPC handlers', () => {
}
call('claudeUsage', 'getScanState')
call('codexUsage', 'getScanState')
call('devinUsage', 'getScanState')
call('openCodeUsage', 'getScanState')
call('claudeUsage', 'setEnabled', { enabled: true })
call('claudeUsage', 'refresh')
+3
View File
@@ -1,11 +1,13 @@
import { ipcMain } from 'electron'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import type { DevinUsageStore } from '../devin-usage/store'
import type { OpenCodeUsageStore } from '../opencode-usage/store'
type UsageProviderStores = {
claudeUsage: ClaudeUsageStore
codexUsage: CodexUsageStore
devinUsage: DevinUsageStore
openCodeUsage: OpenCodeUsageStore
}
@@ -64,5 +66,6 @@ function registerProviderHandlers<Scope, Range, BreakdownKind>(
export function registerUsageProviderHandlers(stores: UsageProviderStores): void {
registerProviderHandlers('claudeUsage', stores.claudeUsage)
registerProviderHandlers('codexUsage', stores.codexUsage)
registerProviderHandlers('devinUsage', stores.devinUsage)
registerProviderHandlers('openCodeUsage', stores.openCodeUsage)
}
@@ -0,0 +1,95 @@
import { mkdir, 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 { getDevinCredentialsPath, readDevinCredentials } from './devin-credentials'
const originalDevinHome = process.env.DEVIN_HOME
let tempDirs: string[] = []
afterEach(async () => {
process.env.DEVIN_HOME = originalDevinHome
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
tempDirs = []
})
async function makeDevinRoot(toml: string | null): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-devin-creds-'))
tempDirs.push(root)
process.env.DEVIN_HOME = root
if (toml !== null) {
await writeFile(join(root, 'credentials.toml'), toml)
}
return root
}
describe('readDevinCredentials', () => {
it('resolves credentials.toml at the devin root', async () => {
const root = await makeDevinRoot(null)
expect(getDevinCredentialsPath()).toBe(join(root, 'credentials.toml'))
})
it('reports missing when no credentials file exists', async () => {
await makeDevinRoot(null)
expect(readDevinCredentials()).toEqual({ status: 'missing' })
})
it('reads credentials from the legacy CLI-directory override layout', async () => {
const root = await makeDevinRoot(null)
await mkdir(join(root, 'cli'), { recursive: true })
await writeFile(join(root, 'cli', 'credentials.toml'), 'windsurf_api_key = "legacy-token"\n')
expect(readDevinCredentials()).toEqual({
status: 'ok',
credentials: {
sessionToken: 'devin-session-token$legacy-token',
apiServerUrl: 'https://server.codeium.com'
}
})
})
it('reports missing for a token-less file — signed out, not corrupt', async () => {
await makeDevinRoot('other_key = "value"\n')
expect(readDevinCredentials()).toEqual({ status: 'missing' })
})
it('normalizes the session token prefix and defaults the API server', async () => {
await makeDevinRoot('windsurf_api_key = "raw-token"\n')
expect(readDevinCredentials()).toEqual({
status: 'ok',
credentials: {
sessionToken: 'devin-session-token$raw-token',
apiServerUrl: 'https://server.codeium.com'
}
})
})
it('honours a custom https api_server_url', async () => {
await makeDevinRoot(
'windsurf_api_key = "devin-session-token$tok"\napi_server_url = "https://staging.example.com"\n'
)
expect(readDevinCredentials()).toEqual({
status: 'ok',
credentials: {
sessionToken: 'devin-session-token$tok',
apiServerUrl: 'https://staging.example.com'
}
})
})
it('rejects an http api_server_url — the token must never travel cleartext', async () => {
await makeDevinRoot('windsurf_api_key = "tok"\napi_server_url = "http://server.codeium.com"\n')
const result = readDevinCredentials()
expect(result.status).toBe('error')
expect(result).not.toEqual({ status: 'ok' })
})
it('rejects a non-URL api_server_url', async () => {
await makeDevinRoot('windsurf_api_key = "tok"\napi_server_url = "not a url"\n')
expect(readDevinCredentials().status).toBe('error')
})
it('reports a malformed credentials line as an error, not as signed out', async () => {
await makeDevinRoot('windsurf_api_key = unquoted\n')
expect(readDevinCredentials().status).toBe('error')
})
})
+108
View File
@@ -0,0 +1,108 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import { resolveDevinCliDataDir } from '../devin/devin-cli-data-dir'
// Why: the Devin CLI stores credentials.toml next to its cli data dir
// (%APPDATA%\devin\credentials.toml vs %APPDATA%\devin\cli on Windows;
// $XDG_DATA_HOME/devin/credentials.toml on posix).
export function getDevinCredentialsPath(): string {
return getDevinCredentialsPaths()[0]
}
/** Return the current location first, followed by the pre-CLI override layout. */
export function getDevinCredentialsPaths(): string[] {
const cliDir = resolveDevinCliDataDir()
const paths = [join(dirname(cliDir), 'credentials.toml'), join(cliDir, 'credentials.toml')]
if (process.platform === 'darwin' && !process.env.DEVIN_HOME && !process.env.XDG_DATA_HOME) {
paths.push(join(homedir(), '.local', 'share', 'devin', 'credentials.toml'))
}
return paths
}
export type DevinCredentials = {
sessionToken: string
/** Connect-RPC host the CLI calls (api_server_url); defaults to server.codeium.com. */
apiServerUrl: string
}
export type DevinCredentialsReadResult =
| { status: 'missing' }
| { status: 'error'; error: string }
| { status: 'ok'; credentials: DevinCredentials }
const DEFAULT_DEVIN_API_SERVER = 'https://server.codeium.com'
const DEVIN_SESSION_TOKEN_PREFIX = 'devin-session-token$'
function getDevinCredentialsReadError(err: unknown): string {
if (err instanceof SyntaxError) {
return 'Devin credentials file is invalid'
}
// Why: filesystem errors include the full path; renderer/mobile surfaces
// should not expose local usernames or a custom DEVIN_HOME.
return 'Unable to read Devin credentials file'
}
// credentials.toml is a flat `key = "value"` file written by the Devin CLI; a
// line parser avoids pulling a TOML dependency for two fields.
function parseCredentialsToml(raw: string): DevinCredentials | null {
let sessionToken: string | null = null
let apiServerUrl: string | null = null
for (const line of raw.split(/\r?\n/)) {
const match = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*"(.*)"\s*$/)
if (!match) {
// Why: a credential key that does not parse distinguishes a corrupt file
// (report 'error') from a signed-out one (report 'missing').
if (/^\s*(windsurf_api_key|api_server_url)\b/.test(line)) {
throw new SyntaxError('malformed credentials line')
}
continue
}
if (match[1] === 'windsurf_api_key' && match[2].length > 0) {
sessionToken = match[2]
} else if (match[1] === 'api_server_url' && match[2].length > 0) {
apiServerUrl = match[2]
}
}
if (!sessionToken) {
return null
}
let normalizedApiServerUrl: URL
try {
normalizedApiServerUrl = new URL(apiServerUrl ?? DEFAULT_DEVIN_API_SERVER)
} catch {
throw new SyntaxError('malformed api_server_url')
}
// Why: the session token rides in the request body — an http:// override
// would send it cleartext to whatever host the file names.
if (
normalizedApiServerUrl.protocol !== 'https:' ||
normalizedApiServerUrl.username ||
normalizedApiServerUrl.password ||
normalizedApiServerUrl.search ||
normalizedApiServerUrl.hash
) {
throw new SyntaxError('Devin API server must use HTTPS')
}
return {
sessionToken: sessionToken.startsWith(DEVIN_SESSION_TOKEN_PREFIX)
? sessionToken
: `${DEVIN_SESSION_TOKEN_PREFIX}${sessionToken}`,
apiServerUrl: normalizedApiServerUrl.href.replace(/\/+$/, '')
}
}
export function readDevinCredentials(): DevinCredentialsReadResult {
const path = getDevinCredentialsPaths().find((candidate) => existsSync(candidate))
if (!path) {
return { status: 'missing' }
}
try {
const credentials = parseCredentialsToml(readFileSync(path, 'utf-8'))
// Why: a token-less file means signed out, not a failure — 'error' would
// keep a status-bar alert visible for a user who simply logged out.
return credentials ? { status: 'ok', credentials } : { status: 'missing' }
} catch (err) {
return { status: 'error', error: getDevinCredentialsReadError(err) }
}
}
+310
View File
@@ -0,0 +1,310 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const netFetchMock = vi.hoisted(() => vi.fn())
const files = vi.hoisted<{
credentials: string | null
readError: Error | null
}>(() => ({ credentials: null, readError: null }))
vi.mock('electron', () => ({
net: { fetch: netFetchMock }
}))
vi.mock('node:fs', () => ({
existsSync: (path: string) => path.endsWith('credentials.toml') && files.credentials !== null,
readFileSync: (path: string) => {
if (files.readError) {
throw files.readError
}
if (path.endsWith('credentials.toml')) {
if (files.credentials === null) {
throw new Error('ENOENT')
}
return files.credentials
}
throw new Error('ENOENT')
}
}))
vi.mock('node:os', () => ({ homedir: () => '/home/test' }))
import { fetchDevinRateLimits } from './devin-fetcher'
function protoResponse(body: Uint8Array, status = 200): Response {
return new Response(Buffer.from(body), { status })
}
function encVarint(value: number): Uint8Array {
const bytes: number[] = []
let v = value >>> 0
while (v > 0x7f) {
bytes.push((v & 0x7f) | 0x80)
v >>>= 7
}
bytes.push(v)
return Uint8Array.from(bytes)
}
function concatBytes(parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.length, 0)
const out = new Uint8Array(total)
let offset = 0
for (const part of parts) {
out.set(part, offset)
offset += part.length
}
return out
}
function varField(num: number, value: number): Uint8Array {
return concatBytes([encVarint(num << 3), encVarint(value)])
}
function strField(num: number, value: string): Uint8Array {
const bytes = new TextEncoder().encode(value)
return concatBytes([encVarint((num << 3) | 2), encVarint(bytes.length), bytes])
}
function msgField(num: number, message: Uint8Array): Uint8Array {
return concatBytes([encVarint((num << 3) | 2), encVarint(message.length), message])
}
// Mirrors GetUserStatusResponse{user_status{email=7, plan_status=13{
// plan_info=1{plan_name=2}, daily=14, weekly=15, resets=17/18}}}.
function userStatusResponse(planStatus: Uint8Array, email = 'dev@example.com'): Uint8Array {
const userStatus = concatBytes([strField(7, email), msgField(13, planStatus)])
return msgField(1, userStatus)
}
function quotaPlanStatus(
overrides: { daily?: number; weekly?: number; dailyReset?: number; weeklyReset?: number } = {}
): Uint8Array {
const planInfo = strField(2, 'Pro')
return concatBytes([
msgField(1, planInfo),
varField(14, overrides.daily ?? 98),
varField(15, overrides.weekly ?? 47),
varField(17, overrides.dailyReset ?? 1_900_000_000),
varField(18, overrides.weeklyReset ?? 1_900_500_000)
])
}
function credentialsToml(extra = ''): string {
return `windsurf_api_key = "devin-session-token$test-token"\napi_server_url = "https://server.codeium.com"\n${extra}`
}
describe('fetchDevinRateLimits', () => {
beforeEach(() => {
netFetchMock.mockReset()
files.credentials = null
files.readError = null
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns unavailable when credentials.toml is missing', async () => {
const result = await fetchDevinRateLimits()
expect(result.provider).toBe('devin')
expect(result.status).toBe('unavailable')
expect(result.error).toMatch(/devin login/i)
expect(netFetchMock).not.toHaveBeenCalled()
})
it('returns unavailable when credentials.toml has no session token', async () => {
files.credentials = 'api_server_url = "https://server.codeium.com"\n'
const result = await fetchDevinRateLimits()
expect(result.status).toBe('unavailable')
expect(netFetchMock).not.toHaveBeenCalled()
})
it('returns error when the credentials file cannot be read', async () => {
files.credentials = credentialsToml()
files.readError = new Error('EACCES')
const result = await fetchDevinRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('Unable to read Devin credentials file')
expect(netFetchMock).not.toHaveBeenCalled()
})
it('maps daily and weekly quota windows from the user status response', async () => {
files.credentials = credentialsToml()
netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus())))
const result = await fetchDevinRateLimits()
expect(result.status).toBe('ok')
expect(result.error).toBeNull()
expect(result.session?.usedPercent).toBe(2)
expect(result.session?.windowMinutes).toBe(1440)
expect(result.session?.resetsAt).toBe(1_900_000_000_000)
expect(result.weekly?.usedPercent).toBe(53)
expect(result.weekly?.windowMinutes).toBe(10_080)
expect(result.weekly?.resetsAt).toBe(1_900_500_000_000)
expect(result.planType).toBe('Pro')
expect(result.usageMetadata).toEqual({
source: 'oauth',
authProvenance: 'dev@example.com',
credentialSource: 'credentials.toml'
})
})
it('posts the CLI identity tuple to the seat management endpoint', async () => {
files.credentials = credentialsToml()
netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus())))
await fetchDevinRateLimits()
expect(netFetchMock).toHaveBeenCalledWith(
'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/proto',
'Connect-Protocol-Version': '1'
})
})
)
const body: Buffer = netFetchMock.mock.calls[0][1].body
expect(body.includes('devin-session-token$test-token')).toBe(true)
expect(body.includes('devin-cli')).toBe(true)
expect(body.includes('chisel')).toBe(true)
expect(body.includes('3000.6.2')).toBe(true)
})
it('normalizes a session token missing the devin-session-token prefix', async () => {
files.credentials = 'windsurf_api_key = "raw-token"\n'
netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus())))
await fetchDevinRateLimits()
const body: Buffer = netFetchMock.mock.calls[0][1].body
expect(body.includes('devin-session-token$raw-token')).toBe(true)
})
it('uses the configured api_server_url', async () => {
files.credentials =
'windsurf_api_key = "devin-session-token$test-token"\napi_server_url = "https://devin.example.com/"\n'
netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus())))
await fetchDevinRateLimits()
expect(netFetchMock).toHaveBeenCalledWith(
'https://devin.example.com/exa.seat_management_pb.SeatManagementService/GetUserStatus',
expect.anything()
)
})
it.each([401, 403])(
'reports an expired session as delegated refresh on HTTP %i',
async (status) => {
files.credentials = credentialsToml()
netFetchMock.mockResolvedValueOnce(protoResponse(new Uint8Array(), status))
const result = await fetchDevinRateLimits()
expect(result.status).toBe('error')
expect(result.error).toMatch(/run devin on the computer running Orca/i)
expect(result.usageMetadata).toEqual({
failureKind: 'delegated-refresh-required',
source: 'oauth'
})
}
)
it('surfaces other HTTP failures as errors', async () => {
files.credentials = credentialsToml()
netFetchMock.mockResolvedValueOnce(protoResponse(new Uint8Array(), 500))
const result = await fetchDevinRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('Devin usage request failed (HTTP 500)')
})
it('returns error for an undecodable response', async () => {
files.credentials = credentialsToml()
netFetchMock.mockResolvedValueOnce(protoResponse(Uint8Array.from([0xff, 0xff, 0xff])))
const result = await fetchDevinRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('Devin usage response was not a valid user status')
})
it('reports plans without quota windows as unavailable', async () => {
files.credentials = credentialsToml()
// plan_status present but no dated quota fields — credit-billed plans omit them.
netFetchMock.mockResolvedValueOnce(
protoResponse(userStatusResponse(msgField(1, strField(2, 'Teams'))))
)
const result = await fetchDevinRateLimits()
expect(result.status).toBe('unavailable')
expect(result.session).toBeNull()
expect(result.weekly).toBeNull()
expect(result.error).toMatch(/did not report quota windows/i)
})
it('propagates fetch failures as errors', async () => {
files.credentials = credentialsToml()
netFetchMock.mockRejectedValueOnce(new Error('network down'))
const result = await fetchDevinRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('Devin usage request failed — check your connection and retry')
})
it('aborts the status request when the caller aborts', async () => {
files.credentials = credentialsToml()
const controller = new AbortController()
let requestSignal: AbortSignal | undefined
netFetchMock.mockImplementationOnce((_url, init: RequestInit) => {
requestSignal = init.signal ?? undefined
return new Promise((_resolve, reject) => {
requestSignal?.addEventListener('abort', () => reject(new Error('aborted')), {
once: true
})
})
})
const resultPromise = fetchDevinRateLimits({ signal: controller.signal })
await Promise.resolve()
expect(requestSignal?.aborted).toBe(false)
controller.abort()
expect(requestSignal?.aborted).toBe(true)
const result = await resultPromise
expect(result.status).toBe('error')
expect(result.error).toBe('Devin usage request failed — check your connection and retry')
})
it('rejects redirects before forwarding the credential-bearing body', async () => {
files.credentials = credentialsToml()
netFetchMock.mockRejectedValueOnce(new Error('redirect to https://private.invalid/secret'))
const result = await fetchDevinRateLimits()
expect(netFetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: 'error' })
)
expect(result.error).not.toContain('private.invalid')
})
it('cancels oversized responses before reading the body', async () => {
files.credentials = credentialsToml()
const cancel = vi.fn()
netFetchMock.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel }), {
headers: { 'content-length': '1048577' }
})
)
expect((await fetchDevinRateLimits()).status).toBe('error')
expect(cancel).toHaveBeenCalledOnce()
})
it('cancels unread HTTP error bodies', async () => {
files.credentials = credentialsToml()
const cancel = vi.fn()
netFetchMock.mockResolvedValueOnce(
new Response(new ReadableStream({ cancel }), { status: 401 })
)
expect((await fetchDevinRateLimits()).usageMetadata?.failureKind).toBe(
'delegated-refresh-required'
)
expect(cancel).toHaveBeenCalledOnce()
})
})
+197
View File
@@ -0,0 +1,197 @@
import { net } from 'electron'
import { readFetchResponseBytesWithinLimit } from '../../shared/fetch-response-body'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
import type {
ProviderRateLimits,
RateLimitWindow,
UsageRateLimitMetadata
} from '../../shared/rate-limit-types'
import {
readDevinCredentials,
type DevinCredentials,
type DevinCredentialsReadResult
} from './devin-credentials'
import {
decodeGetUserStatusQuota,
encodeGetUserStatusRequest,
type DevinUserStatusQuota
} from './devin-user-status-wire'
// Why: Devin has no REST usage endpoint — plan tier and the daily/weekly quota
// windows come from the same SeatManagementService/GetUserStatus unary
// Connect-RPC the CLI issues for `devin auth status`. The request body is raw
// (unframed) protobuf; see devin-user-status-wire.ts for the schema notes.
const GET_USER_STATUS_PATH = '/exa.seat_management_pb.SeatManagementService/GetUserStatus'
const API_TIMEOUT_MS = 10_000
const DAILY_WINDOW_MINUTES = 1440
const WEEKLY_WINDOW_MINUTES = 10_080
// Why: the backend gates GetUserStatus on a released-CLI identity tuple, and
// the CLI persists no version file Orca can read, so pin a known-good release.
const DEVIN_CLI_IDENTITY_VERSION = '3000.6.2'
function result(
status: ProviderRateLimits['status'],
error: string | null,
usageMetadata?: UsageRateLimitMetadata
): ProviderRateLimits {
return {
provider: 'devin',
session: null,
weekly: null,
updatedAt: Date.now(),
error,
status,
...(usageMetadata ? { usageMetadata } : {})
}
}
function parseResetDescription(unixSeconds: number | null): string | null {
if (unixSeconds === null || !Number.isFinite(unixSeconds)) {
return null
}
const date = new Date(unixSeconds * 1000)
const isToday = date.toDateString() === new Date().toDateString()
return isToday
? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
: date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' })
}
function mapQuotaWindow(
remainingPercent: number | null,
resetAtUnix: number | null,
windowMinutes: number
): RateLimitWindow | null {
if (remainingPercent === null) {
return null
}
const resetSeconds = resetAtUnix !== null && resetAtUnix <= 8_640_000_000_000 ? resetAtUnix : null
return {
usedPercent: Math.min(100, Math.max(0, 100 - remainingPercent)),
windowMinutes,
resetsAt: resetSeconds !== null ? resetSeconds * 1000 : null,
resetDescription: parseResetDescription(resetSeconds)
}
}
function quotaResult(quota: DevinUserStatusQuota): ProviderRateLimits {
const session = mapQuotaWindow(
quota.dailyQuotaRemainingPercent,
quota.dailyQuotaResetAtUnix,
DAILY_WINDOW_MINUTES
)
const weekly = mapQuotaWindow(
quota.weeklyQuotaRemainingPercent,
quota.weeklyQuotaResetAtUnix,
WEEKLY_WINDOW_MINUTES
)
if (!session && !weekly) {
// Why: a signed-in plan that reports no dated quota windows (credit-billed
// plans omit them) has no visible quota. 'unavailable' also clears the
// devinAuthConfigured probe in the apply step, so the bar hides instead
// of pinning a permanent "--" slot.
return {
...result('unavailable', 'Devin did not report quota windows for this account', {
source: 'oauth',
authProvenance: quota.email ?? 'Devin account',
credentialSource: 'credentials.toml'
}),
planType: quota.planName
}
}
return {
provider: 'devin',
session,
weekly,
planType: quota.planName,
updatedAt: Date.now(),
error: null,
status: 'ok',
usageMetadata: {
source: 'oauth',
authProvenance: quota.email ?? 'Devin account',
credentialSource: 'credentials.toml'
}
}
}
function expiredSessionResult(): ProviderRateLimits {
// Why: the session token lives in credentials.toml and is rotated by the
// Devin CLI on its next run — Orca must never refresh it. Report the
// delegated-refresh failure so the UI tells the user to run `devin`.
return result(
'error',
'Devin session expired — run devin on the computer running Orca, then retry usage.',
{ failureKind: 'delegated-refresh-required', source: 'oauth' }
)
}
async function fetchUserStatus(
credentials: DevinCredentials,
signal: AbortSignal | undefined
): Promise<
{ kind: 'quota'; quota: DevinUserStatusQuota } | { kind: 'result'; result: ProviderRateLimits }
> {
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)])
: AbortSignal.timeout(API_TIMEOUT_MS)
const requestBody = encodeGetUserStatusRequest(
credentials.sessionToken,
DEVIN_CLI_IDENTITY_VERSION
)
const res = await net.fetch(`${credentials.apiServerUrl}${GET_USER_STATUS_PATH}`, {
method: 'POST',
redirect: 'error',
headers: {
'Content-Type': 'application/proto',
'Connect-Protocol-Version': '1',
Accept: '*/*'
},
body: Buffer.from(requestBody),
signal: requestSignal
})
if (!res.ok) {
await cancelUnreadResponseBody(res)
}
if (res.status === 401 || res.status === 403) {
return { kind: 'result', result: expiredSessionResult() }
}
if (!res.ok) {
return {
kind: 'result',
result: result('error', `Devin usage request failed (HTTP ${res.status})`)
}
}
const quota = decodeGetUserStatusQuota(await readFetchResponseBytesWithinLimit(res, 1024 * 1024))
if (!quota) {
return {
kind: 'result',
result: result('error', 'Devin usage response was not a valid user status')
}
}
return { kind: 'quota', quota }
}
// Why read-only: the session token is written by `devin login`/the CLI's own
// session lifecycle; Orca only reads it and calls the same status endpoint the
// CLI does. A rejected token means the CLI must run again — never refresh it.
export async function fetchDevinRateLimits(
options: { signal?: AbortSignal; credentialsReadResult?: DevinCredentialsReadResult } = {}
): Promise<ProviderRateLimits> {
const readResult = options.credentialsReadResult ?? readDevinCredentials()
if (readResult.status === 'missing') {
return result('unavailable', 'Not signed in to Devin — run devin login')
}
if (readResult.status === 'error') {
return result('error', readResult.error)
}
try {
const outcome = await fetchUserStatus(readResult.credentials, options.signal)
if (outcome.kind === 'result') {
return outcome.result
}
return quotaResult(outcome.quota)
} catch {
return result('error', 'Devin usage request failed — check your connection and retry')
}
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { decodeGetUserStatusQuota } from './devin-user-status-wire'
describe('Devin protobuf admission', () => {
it.each([
{ bytes: [0, 0] },
{ bytes: [10, 255, 255, 255, 255, 255, 255, 255, 255, 255, 2] },
{ bytes: [10, 2, 128, 128] },
{ bytes: [10, 127] },
{ bytes: Array.from({ length: 100_000 }, () => 128) }
])('rejects malformed or oversized varints without scanning the entire input', ({ bytes }) => {
expect(decodeGetUserStatusQuota(Uint8Array.from(bytes))).toBeNull()
})
})
@@ -0,0 +1,219 @@
// Wire codec for the Devin CLI's SeatManagementService/GetUserStatus call.
//
// Why: Devin ships no REST quota endpoint. The CLI asks the Cascade backend
// (server.codeium.com) for user status via a unary Connect-RPC call whose body
// is raw (unframed) protobuf — `exa.seat_management_pb.GetUserStatusRequest`
// carrying a `codeium_common_pb.Metadata` with the CLI identity tuple and the
// session token from credentials.toml. The backend gates the CLI surface on
// that identity (ide_name=devin-cli, ide_type=chisel), so a bare token call is
// rejected. Field numbers below follow the published exa/codeium proto schema.
//
// Those numbers were confirmed on 2026-09-18 against a live
// GetUserStatus response (devin 3000.10.31) and against the CLI's own on-disk
// cache at ~/.cache/devin/cli/user_status.<digest>.bin (JSON envelope
// `{version, identity_digest, fetched_at_secs, payload: base64 UserStatus}`),
// which is a possible zero-network fallback for a future change.
const DEVIN_CLI_IDE_NAME = 'devin-cli'
const DEVIN_CLI_IDE_TYPE = 'chisel'
const DEVIN_CLI_EXTENSION_NAME = 'chisel'
const DEVIN_CLI_LOCALE = 'en'
export type DevinUserStatusQuota = {
/** 0100 remaining for the daily quota window, when reported. */
dailyQuotaRemainingPercent: number | null
/** 0100 remaining for the weekly quota window, when reported. */
weeklyQuotaRemainingPercent: number | null
/** Unix seconds when the daily window resets. */
dailyQuotaResetAtUnix: number | null
/** Unix seconds when the weekly window resets. */
weeklyQuotaResetAtUnix: number | null
/** Plan display name (e.g. "Pro") from plan_status.plan_info.plan_name. */
planName: string | null
/** Account email, for provenance labelling. */
email: string | null
}
type ProtoField =
| { num: number; wire: 0; value: bigint }
| { num: number; wire: 1 | 5; value: Uint8Array }
| { num: number; wire: 2; value: Uint8Array }
function encodeVarint(value: number): Uint8Array {
const bytes: number[] = []
let v = value >>> 0
while (v > 0x7f) {
bytes.push((v & 0x7f) | 0x80)
v >>>= 7
}
bytes.push(v)
return Uint8Array.from(bytes)
}
function encodeStringField(num: number, value: string): Uint8Array {
const bytes = new TextEncoder().encode(value)
const tag = encodeVarint((num << 3) | 2)
const len = encodeVarint(bytes.length)
const out = new Uint8Array(tag.length + len.length + bytes.length)
out.set(tag, 0)
out.set(len, tag.length)
out.set(bytes, tag.length + len.length)
return out
}
function concatBytes(parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.length, 0)
const out = new Uint8Array(total)
let offset = 0
for (const part of parts) {
out.set(part, offset)
offset += part.length
}
return out
}
// Builds GetUserStatusRequest{metadata{…}} — the only message member the
// request schema defines (field 1, length-delimited).
export function encodeGetUserStatusRequest(sessionToken: string, cliVersion: string): Uint8Array {
const metadata = concatBytes([
encodeStringField(1, DEVIN_CLI_IDE_NAME), // ide_name
encodeStringField(2, cliVersion), // extension_version
encodeStringField(3, sessionToken), // api_key — wire format carries the devin-session-token$ prefix
encodeStringField(4, DEVIN_CLI_LOCALE), // locale
encodeStringField(
5,
process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux'
), // os
encodeStringField(7, cliVersion), // ide_version
encodeStringField(12, DEVIN_CLI_EXTENSION_NAME), // extension_name
encodeStringField(28, DEVIN_CLI_IDE_TYPE) // ide_type — 'chisel' unlocks the Devin CLI surface
])
const tag = encodeVarint(0x0a) // field 1, wire 2
const len = encodeVarint(metadata.length)
return concatBytes([tag, len, metadata])
}
function readVarint(buf: Uint8Array, pos: number): [bigint, number] {
let result = 0n
let shift = 0n
while (pos < buf.length && shift < 70n) {
const byte = buf[pos++]
if (shift === 63n && byte > 1) {
throw new Error('invalid varint')
}
result |= BigInt(byte & 0x7f) << shift
if ((byte & 0x80) === 0) {
return [result, pos]
}
shift += 7n
}
throw new Error('truncated varint')
}
function readProtoFields(buf: Uint8Array): ProtoField[] {
const fields: ProtoField[] = []
let pos = 0
while (pos < buf.length) {
const [tag, afterTag] = readVarint(buf, pos)
pos = afterTag
if (tag >> 3n === 0n || tag >> 3n > 0x1fffffffn) {
throw new Error('invalid field number')
}
const num = Number(tag >> 3n)
const wire = Number(tag & 7n)
if (wire === 0) {
const [value, next] = readVarint(buf, pos)
pos = next
fields.push({ num, wire: 0, value })
} else if (wire === 2) {
const [len, afterLen] = readVarint(buf, pos)
if (len > BigInt(buf.length - afterLen)) {
throw new Error('truncated length-delimited field')
}
const length = Number(len)
pos = afterLen + length
if (pos > buf.length) {
throw new Error('truncated length-delimited field')
}
fields.push({ num, wire: 2, value: buf.subarray(afterLen, pos) })
} else if (wire === 1 || wire === 5) {
const size = wire === 1 ? 8 : 4
if (pos + size > buf.length) {
throw new Error('truncated fixed-width field')
}
fields.push({ num, wire, value: buf.subarray(pos, pos + size) })
pos += size
} else {
// Why: wire types 3/4 (groups) are unused in proto3 output; bail rather
// than silently misaligning the rest of the message.
throw new Error(`unsupported protobuf wire type ${wire}`)
}
}
return fields
}
function fieldBytes(fields: ProtoField[], num: number): Uint8Array | null {
const field = fields.find((f) => f.num === num)
return field?.wire === 2 ? field.value : null
}
function fieldVarint(fields: ProtoField[], num: number): number | null {
const field = fields.find((f) => f.num === num)
return field?.wire === 0 && field.value <= BigInt(Number.MAX_SAFE_INTEGER)
? Number(field.value)
: null
}
function fieldString(fields: ProtoField[], num: number): string | null {
const bytes = fieldBytes(fields, num)
return bytes ? new TextDecoder().decode(bytes) : null
}
// Parses GetUserStatusResponse → user_status.plan_status quota fields plus the
// plan/account labels the status bar shows. Returns null when the payload is
// not a decodable user-status message.
export function decodeGetUserStatusQuota(buf: Uint8Array): DevinUserStatusQuota | null {
let userStatusBytes: Uint8Array | null
try {
userStatusBytes = fieldBytes(readProtoFields(buf), 1)
} catch {
return null
}
if (!userStatusBytes) {
return null
}
let userStatus: ProtoField[]
let planStatusBytes: Uint8Array | null
try {
userStatus = readProtoFields(userStatusBytes)
planStatusBytes = fieldBytes(userStatus, 13)
} catch {
return null
}
if (!planStatusBytes) {
return null
}
let planStatus: ProtoField[]
try {
planStatus = readProtoFields(planStatusBytes)
} catch {
return null
}
const planInfoBytes = fieldBytes(planStatus, 1)
let planName: string | null = null
if (planInfoBytes) {
try {
planName = fieldString(readProtoFields(planInfoBytes), 2)
} catch {
planName = null
}
}
return {
dailyQuotaRemainingPercent: fieldVarint(planStatus, 14),
weeklyQuotaRemainingPercent: fieldVarint(planStatus, 15),
dailyQuotaResetAtUnix: fieldVarint(planStatus, 17),
weeklyQuotaResetAtUnix: fieldVarint(planStatus, 18),
planName,
email: fieldString(userStatus, 7)
}
}
@@ -7,6 +7,8 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { fetchDevinRateLimits } from './devin-fetcher'
import { readDevinCredentials } from './devin-credentials'
import { readGrokAuthSession } from './grok-auth'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
@@ -89,6 +91,7 @@ export function mockFreshBackgroundProviderFetches(): void {
vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0))
vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0))
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
vi.mocked(fetchDevinRateLimits).mockImplementation(async () => unavailableProvider('devin'))
}
/** Shared `beforeEach` body: healthy stubs for every provider the service polls. */
@@ -106,8 +109,10 @@ export function resetRateLimitProviderMocks(): void {
error: null,
status: 'unavailable'
})
vi.mocked(fetchDevinRateLimits).mockResolvedValue(unavailableProvider('devin'))
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
vi.mocked(readDevinCredentials).mockReturnValue({ status: 'missing' })
}
type RateLimitWindow = Parameters<RateLimitService['attach']>[0]
@@ -44,6 +44,14 @@ vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('./devin-fetcher', () => ({
fetchDevinRateLimits: vi.fn()
}))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -43,6 +43,14 @@ vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('./devin-fetcher', () => ({
fetchDevinRateLimits: vi.fn()
}))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import { RateLimitService } from './service'
import { fetchDevinRateLimits } from './devin-fetcher'
import { readDevinCredentials } from './devin-credentials'
import { deferred } from './rate-limit-service-test-harness'
vi.mock('./devin-fetcher', () => ({ fetchDevinRateLimits: vi.fn() }))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
const quota = (usedPercent: number): ProviderRateLimits => ({
provider: 'devin',
session: { usedPercent, windowMinutes: 1440, resetsAt: null, resetDescription: null },
weekly: null,
updatedAt: Date.now(),
error: null,
status: 'ok'
})
describe('Devin credential identity fencing', () => {
beforeEach(() => vi.clearAllMocks())
it('drops an in-flight Devin-only result when credentials rotate', async () => {
const oldCredentials = {
status: 'ok' as const,
credentials: { sessionToken: 'old', apiServerUrl: 'https://server.codeium.com' }
}
const newCredentials = {
status: 'ok' as const,
credentials: { sessionToken: 'new', apiServerUrl: 'https://server.codeium.com' }
}
let current = oldCredentials
const pending = deferred<ProviderRateLimits>()
vi.mocked(fetchDevinRateLimits).mockImplementationOnce(async () => {
current = newCredentials
return pending.promise
})
vi.mocked(readDevinCredentials).mockImplementation(() => current)
const service = new RateLimitService()
const refresh = service.refreshDevin()
pending.resolve(quota(12))
await refresh
await pending.promise
expect(service.getState().devin).toBeNull()
expect(service.getState().devinAuthConfigured).toBe(true)
})
it('does not carry the old account quota into a failed refresh for a new account', async () => {
vi.mocked(readDevinCredentials).mockReturnValue({
status: 'ok',
credentials: {
sessionToken: 'first',
apiServerUrl: 'https://server.codeium.com'
}
})
vi.mocked(fetchDevinRateLimits).mockResolvedValueOnce(quota(12))
const service = new RateLimitService()
await service.refreshDevin()
vi.mocked(readDevinCredentials).mockReturnValue({
status: 'ok',
credentials: {
sessionToken: 'second',
apiServerUrl: 'https://server.codeium.com'
}
})
vi.mocked(fetchDevinRateLimits).mockResolvedValueOnce({
...quota(12),
status: 'error',
session: null,
error: 'Offline'
})
await service.refreshDevin()
expect(service.getState().devin?.session).toBeNull()
})
})
@@ -51,6 +51,14 @@ vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('./devin-fetcher', () => ({
fetchDevinRateLimits: vi.fn()
}))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -48,6 +48,14 @@ vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('./devin-fetcher', () => ({
fetchDevinRateLimits: vi.fn()
}))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -45,6 +45,14 @@ vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('./devin-fetcher', () => ({
fetchDevinRateLimits: vi.fn()
}))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -8,6 +8,8 @@ import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { readGrokAuthSession } from './grok-auth'
import { fetchDevinRateLimits } from './devin-fetcher'
import { readDevinCredentials } from './devin-credentials'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import {
deferred,
@@ -15,7 +17,8 @@ import {
flushMicrotasks,
mockFreshBackgroundProviderFetches,
okProvider,
resetRateLimitProviderMocks
resetRateLimitProviderMocks,
unavailableProvider
} from './rate-limit-service-test-harness'
vi.mock('./claude-fetcher', () => ({
@@ -52,6 +55,14 @@ vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('./devin-fetcher', () => ({
fetchDevinRateLimits: vi.fn()
}))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -119,6 +130,25 @@ describe('RateLimitService', () => {
expect(service.getState().grok?.status).toBe('ok')
})
it('preserves credential presence when a signed-in plan reports no quota windows', async () => {
vi.mocked(readDevinCredentials).mockReturnValue({
status: 'ok',
credentials: { sessionToken: 'tok', apiServerUrl: 'https://server.codeium.com' }
})
vi.mocked(fetchDevinRateLimits).mockResolvedValue(
unavailableProvider('devin', 'Devin did not report quota windows for this account')
)
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0))
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0))
const service = new RateLimitService()
expect(service.getState().devinAuthConfigured).toBe(true)
await serviceInternals(service).fetchAll()
expect(service.getState().devinAuthConfigured).toBe(true)
expect(service.getState().devin?.status).toBe('unavailable')
})
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
const service = new RateLimitService()
const firstClaude = deferred<ProviderRateLimits>()
@@ -264,6 +294,57 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
})
it('runs a queued Devin refresh after an active Devin fetch settles', async () => {
const service = new RateLimitService()
const firstDevin = deferred<ProviderRateLimits>()
vi.mocked(fetchDevinRateLimits).mockImplementationOnce(() => firstDevin.promise)
const activeRefresh = service.refreshDevin()
await flushMicrotasks()
const queuedRefresh = service.refreshDevin()
firstDevin.resolve(okProvider('devin', 10))
await activeRefresh
await queuedRefresh
expect(fetchDevinRateLimits).toHaveBeenCalledTimes(2)
})
it('runs a queued provider refresh after an active Devin fetch settles', async () => {
const service = new RateLimitService()
const firstDevin = deferred<ProviderRateLimits>()
vi.mocked(fetchDevinRateLimits).mockImplementationOnce(() => firstDevin.promise)
const activeRefresh = service.refreshDevin()
await flushMicrotasks()
const queuedRefresh = service.refreshGrok()
firstDevin.resolve(okProvider('devin', 10))
await activeRefresh
await queuedRefresh
expect(fetchDevinRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1)
})
it('runs a queued full refresh after an active Devin fetch settles', async () => {
const service = new RateLimitService()
const firstDevin = deferred<ProviderRateLimits>()
vi.mocked(fetchDevinRateLimits).mockImplementationOnce(() => firstDevin.promise)
const activeRefresh = service.refreshDevin()
await flushMicrotasks()
const queuedRefresh = service.refresh()
firstDevin.resolve(okProvider('devin', 10))
await activeRefresh
await queuedRefresh
expect(fetchDevinRateLimits).toHaveBeenCalledTimes(2)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
})
it('publishes non-Grok provider results before a slow Grok fetch completes', async () => {
const service = new RateLimitService()
const grok = deferred<ProviderRateLimits>()
@@ -8,6 +8,7 @@ import { fetchKimiRateLimits } from './kimi-fetcher'
import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
import { fetchDevinRateLimits } from './devin-fetcher'
import {
asRateLimitWindow,
deferred,
@@ -53,6 +54,14 @@ vi.mock('./grok-auth', () => ({
readGrokAuthSession: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('./devin-fetcher', () => ({
fetchDevinRateLimits: vi.fn()
}))
vi.mock('./devin-credentials', () => ({
readDevinCredentials: vi.fn(() => ({ status: 'missing' }))
}))
vi.mock('../minimax/minimax-cookie-store', () => ({
hasMiniMaxSessionCookie: vi.fn(() => false)
}))
@@ -540,6 +549,36 @@ describe('RateLimitService', () => {
}
})
it('retries a failing Devin fetch on its own cycle without re-reading Claude', async () => {
vi.useFakeTimers()
try {
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12))
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24))
vi.mocked(fetchDevinRateLimits).mockResolvedValue(errorProvider('devin', 'HTTP 502'))
const service = new RateLimitService()
const window = new FakeRateLimitWindow()
service.attach(asRateLimitWindow(window))
service.start({ fetchImmediately: false })
await vi.advanceTimersByTimeAsync(1000)
expect(fetchDevinRateLimits).toHaveBeenCalledTimes(1)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(service.getState().devin?.status).toBe('error')
// Why: Devin has a dedicated fetch cycle, so recovering it must not pull
// Claude's tight-budget endpoint along the way the Kimi full-fetch does.
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchDevinRateLimits).toHaveBeenCalledTimes(2)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
service.stop()
} finally {
vi.useRealTimers()
}
})
it('debounces unavailable providers on active window events', async () => {
vi.useFakeTimers()
try {
@@ -28,6 +28,10 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna
await this.fetchGrokOnly({ force: true })
return this.getState()
}
async refreshDevin(): Promise<RateLimitState> {
await this.fetchDevinOnly({ force: true })
return this.getState()
}
invalidateMiniMaxCredentialState(): void {
this.minimaxFetchGeneration += 1
@@ -126,6 +126,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
minimaxApiKeyConfigured: hasMiniMaxApiKey(),
grokAuthConfigured: this.grokAuthConfigured,
devinAuthConfigured: this.devinAuthConfigured,
claudeTarget: this.claudeFetchTarget,
codexTarget: this.codexFetchTarget,
inactiveClaudeAccounts: this.buildInactiveArray(
@@ -7,7 +7,8 @@ export abstract class RateLimitServiceFetchControl extends RateLimitServiceState
!this.fullFetchQueued &&
!this.codexOnlyFetchQueued &&
!this.claudeOnlyFetchQueued &&
!this.grokOnlyFetchQueued
!this.grokOnlyFetchQueued &&
!this.devinOnlyFetchQueued
) {
return Promise.resolve()
}
@@ -23,7 +24,8 @@ export abstract class RateLimitServiceFetchControl extends RateLimitServiceState
this.fullFetchQueued ||
this.codexOnlyFetchQueued ||
this.claudeOnlyFetchQueued ||
this.grokOnlyFetchQueued
this.grokOnlyFetchQueued ||
this.devinOnlyFetchQueued
) {
return
}
@@ -68,6 +70,7 @@ export abstract class RateLimitServiceFetchControl extends RateLimitServiceState
this.codexOnlyFetchQueued = false
this.claudeOnlyFetchQueued = false
this.grokOnlyFetchQueued = false
this.devinOnlyFetchQueued = false
}
protected resolveAndClearFetchIdleWaiters(): void {
@@ -1,245 +1,114 @@
import { RateLimitServiceProviderCycles } from './service-provider-cycles'
type FetchKind = 'all' | 'codex' | 'claude' | 'grok' | 'devin'
export abstract class RateLimitServiceFetchQueue extends RateLimitServiceProviderCycles {
protected async fetchAll(options?: { force?: boolean }): Promise<void> {
protected fetchAll(options?: { force?: boolean }): Promise<void> {
return this.fetchProvider('all', options?.force ?? false)
}
protected fetchCodexOnly(options?: { force?: boolean }): Promise<void> {
return this.fetchProvider('codex', options?.force ?? false)
}
protected fetchClaudeOnly(options?: { force?: boolean }): Promise<void> {
return this.fetchProvider('claude', options?.force ?? false)
}
protected fetchGrokOnly(options?: { force?: boolean }): Promise<void> {
return this.fetchProvider('grok', options?.force ?? false)
}
protected fetchDevinOnly(options?: { force?: boolean }): Promise<void> {
return this.fetchProvider('devin', options?.force ?? false)
}
private async fetchProvider(kind: FetchKind, force: boolean): Promise<void> {
if (this.isFetching) {
if (options?.force) {
if (force) {
this.queueFetch(kind)
return this.waitForFetchIdle()
}
return
}
this.isFetching = true
try {
let next: FetchKind | null = kind
let cycleForce = force
while (next !== null) {
const current = next
const signal = await this.runWithFetchAbortSignal((signal) =>
this.runProviderCycle(current, signal, cycleForce)
)
if (signal.aborted) {
break
}
// Read live flags after every await; requests can arrive during any provider's cycle.
next = this.takeQueuedFetch()
cycleForce = true
}
} finally {
this.isFetching = false
this.resolveFetchIdleWaiters()
}
}
private runProviderCycle(kind: FetchKind, signal: AbortSignal, force: boolean): Promise<void> {
switch (kind) {
case 'all':
return this.runFetchAllCycle(signal, { force })
case 'codex':
return this.runFetchCodexOnlyCycle(signal)
case 'claude':
return this.runFetchClaudeOnlyCycle(signal, { force })
case 'grok':
return this.runFetchGrokOnlyCycle(signal)
case 'devin':
return this.runFetchDevinOnlyCycle(signal)
}
}
private queueFetch(kind: FetchKind): void {
switch (kind) {
case 'all':
this.fullFetchQueued = true
return this.waitForFetchIdle()
}
return
}
this.isFetching = true
try {
let shouldContinue = true
// Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them.
let cycleForce = options?.force ?? false
while (shouldContinue) {
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal, { force: cycleForce })
)
shouldContinue = false
cycleForce = true
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
shouldContinue = true
continue
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
if (codexSignal.aborted) {
break
}
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal, { force: true })
)
if (claudeSignal.aborted) {
break
}
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
if (grokSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
this.resolveFetchIdleWaiters()
}
}
protected async fetchCodexOnly(options?: { force?: boolean }): Promise<void> {
if (this.isFetching) {
if (options?.force) {
break
case 'codex':
this.codexOnlyFetchQueued = true
return this.waitForFetchIdle()
}
return
}
this.isFetching = true
try {
let shouldContinue = true
while (shouldContinue) {
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
shouldContinue = false
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal, { force: true })
)
if (fullSignal.aborted) {
break
}
continue
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
shouldContinue = true
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal, { force: true })
)
if (claudeSignal.aborted) {
break
}
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
if (grokSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
this.resolveFetchIdleWaiters()
}
}
protected async fetchClaudeOnly(options?: { force?: boolean }): Promise<void> {
if (this.isFetching) {
if (options?.force) {
break
case 'claude':
this.claudeOnlyFetchQueued = true
return this.waitForFetchIdle()
}
return
}
this.isFetching = true
try {
let shouldContinue = true
// Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them.
let cycleForce = options?.force ?? false
while (shouldContinue) {
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce })
)
shouldContinue = false
cycleForce = true
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal, { force: true })
)
if (fullSignal.aborted) {
break
}
continue
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
shouldContinue = true
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
if (codexSignal.aborted) {
break
}
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
if (grokSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
this.resolveFetchIdleWaiters()
break
case 'grok':
this.grokOnlyFetchQueued = true
break
case 'devin':
this.devinOnlyFetchQueued = true
break
}
}
protected async fetchGrokOnly(options?: { force?: boolean }): Promise<void> {
if (this.isFetching) {
if (options?.force) {
this.grokOnlyFetchQueued = true
return this.waitForFetchIdle()
}
return
private takeQueuedFetch(): FetchKind | null {
if (this.fullFetchQueued) {
this.fullFetchQueued = false
return 'all'
}
this.isFetching = true
try {
let shouldContinue = true
while (shouldContinue) {
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchGrokOnlyCycle(fetchSignal)
)
shouldContinue = false
if (signal.aborted) {
break
}
if (this.fullFetchQueued) {
this.fullFetchQueued = false
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchAllCycle(fetchSignal, { force: true })
)
if (fullSignal.aborted) {
break
}
continue
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
shouldContinue = true
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchCodexOnlyCycle(fetchSignal)
)
if (codexSignal.aborted) {
break
}
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
this.runFetchClaudeOnlyCycle(fetchSignal, { force: true })
)
if (claudeSignal.aborted) {
break
}
}
}
} finally {
this.isFetching = false
this.resolveFetchIdleWaiters()
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
return 'codex'
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
return 'claude'
}
if (this.grokOnlyFetchQueued) {
this.grokOnlyFetchQueued = false
return 'grok'
}
if (this.devinOnlyFetchQueued) {
this.devinOnlyFetchQueued = false
return 'devin'
}
return null
}
}
@@ -1,5 +1,6 @@
import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation'
import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror'
import { readDevinCredentials } from '../devin-credentials'
import type { ProviderRateLimits } from './service-types'
export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation {
@@ -34,7 +35,10 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
kimiResult,
miniMaxResult
],
grokResultPromise
grokResultPromise,
devinResultPromise,
devinCredentialFingerprint,
previousDevin
} = prepared
if (signal.aborted) {
return
@@ -211,5 +215,33 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
})
const devinResult = await devinResultPromise
if (signal.aborted) {
return
}
const latestDevinCredentials = readDevinCredentials()
if (devinCredentialFingerprint !== this.getDevinCredentialFingerprint(latestDevinCredentials)) {
this.devinAuthConfigured = latestDevinCredentials.status === 'ok'
this.updateState({ ...this.state, devin: null })
return
}
const devin =
devinResult.status === 'fulfilled'
? devinResult.value
: ({
provider: 'devin',
session: null,
weekly: null,
updatedAt: Date.now(),
error:
devinResult.reason instanceof Error ? devinResult.reason.message : 'Unknown error',
status: 'error'
} satisfies ProviderRateLimits)
this.trackActiveFailureStreak('devin', devin)
this.updateState({
...this.state,
devin: this.applyStalePolicy(devin, previousDevin)
})
}
}
@@ -3,6 +3,8 @@ import { fetchCodexRateLimits } from '../codex-fetcher'
import { fetchGeminiRateLimits } from '../gemini-usage-fetcher'
import { fetchGrokRateLimits } from '../grok-fetcher'
import { readGrokAuthSession } from '../grok-auth'
import { fetchDevinRateLimits } from '../devin-fetcher'
import { readDevinCredentials } from '../devin-credentials'
import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher'
import { RateLimitServiceFetchPolicy } from './service-fetch-policy'
@@ -41,6 +43,11 @@ export type FetchAllCyclePrepared = {
grokResultPromise: Promise<
{ status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown }
>
devinResultPromise: Promise<
{ status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown }
>
previousDevin: ProviderRateLimits | null
devinCredentialFingerprint: string
}
export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy {
@@ -86,6 +93,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
// Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead.
const grokAuthReadResult = readGrokAuthSession()
this.grokAuthConfigured = grokAuthReadResult.status === 'ok'
const devinCredentialsReadResult = readDevinCredentials()
this.devinAuthConfigured = devinCredentialsReadResult.status === 'ok'
const devinCredentialFingerprint = this.getDevinCredentialFingerprint(
devinCredentialsReadResult
)
const previousDevin = this.previousDevinSnapshot(devinCredentialFingerprint)
// Discard stale data on config change — it belongs to a different session/workspace.
const currentConfigHash = `${cookie}|${workspaceIdOverride}`
@@ -121,7 +134,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
minimax: miniMaxConfigChanged
? this.withFetchingStatus(null, 'minimax')
: this.withFetchingStatus(previousState.minimax, 'minimax'),
grok: this.withFetchingStatus(previousState.grok, 'grok')
grok: this.withFetchingStatus(previousState.grok, 'grok'),
devin: this.withFetchingStatus(previousDevin, 'devin')
})
const missingWslCodexHome =
@@ -133,6 +147,13 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
(value) => ({ status: 'fulfilled', value }) as const,
(reason) => ({ status: 'rejected', reason }) as const
)
const devinResultPromise = fetchDevinRateLimits({
signal,
credentialsReadResult: devinCredentialsReadResult
}).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 =
@@ -202,7 +223,10 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
kimiResult,
miniMaxResult
],
grokResultPromise
grokResultPromise,
devinResultPromise,
previousDevin,
devinCredentialFingerprint
}
}
}
@@ -78,7 +78,8 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue
kimi: this.state.kimi,
minimax: this.state.minimax,
grok: this.state.grok,
antigravity: this.state.antigravity
antigravity: this.state.antigravity,
devin: this.state.devin
}
return Object.entries(byProvider).map(([provider, limits]) => ({
provider: provider as ActiveRateLimitProvider,
@@ -171,6 +172,9 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue
if (plan.providers.includes('grok')) {
await this.fetchGrokOnly()
}
if (plan.providers.includes('devin')) {
await this.fetchDevinOnly()
}
}
protected async refreshIfWindowActive(): Promise<void> {
@@ -3,6 +3,8 @@ import { fetchClaudeRateLimits } from '../claude-fetcher'
import { fetchCodexRateLimits } from '../codex-fetcher'
import { fetchGrokRateLimits } from '../grok-fetcher'
import { readGrokAuthSession } from '../grok-auth'
import { fetchDevinRateLimits } from '../devin-fetcher'
import { readDevinCredentials } from '../devin-credentials'
import type { ProviderRateLimits } from './service-types'
export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFullCycleApplication {
@@ -180,4 +182,41 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul
grok: this.applyStalePolicy(grok, previousState.grok)
})
}
protected async runFetchDevinOnlyCycle(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return
}
const credentialsReadResult = readDevinCredentials()
const credentialFingerprint = this.getDevinCredentialFingerprint(credentialsReadResult)
const previousDevin = this.previousDevinSnapshot(credentialFingerprint)
this.devinAuthConfigured = credentialsReadResult.status === 'ok'
this.updateState({
...this.state,
devin: this.withFetchingStatus(previousDevin, 'devin')
})
const devin = await fetchDevinRateLimits({ signal, credentialsReadResult }).catch(
(err): ProviderRateLimits => ({
provider: 'devin',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
})
)
if (signal.aborted) {
return
}
const latestCredentials = readDevinCredentials()
if (credentialFingerprint !== this.getDevinCredentialFingerprint(latestCredentials)) {
this.devinAuthConfigured = latestCredentials.status === 'ok'
this.updateState({ ...this.state, devin: null })
return
}
this.trackActiveFailureStreak('devin', devin)
this.updateState({
...this.state,
devin: this.applyStalePolicy(devin, previousDevin)
})
}
}
@@ -92,6 +92,7 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch
| 'minimax'
| 'grok'
| 'antigravity'
| 'devin'
): ProviderRateLimits {
if (!current) {
return {
+23 -3
View File
@@ -21,6 +21,7 @@ import {
DEFAULT_POLL_MS
} from './service-types'
import { readGrokAuthSession } from '../grok-auth'
import { readDevinCredentials, type DevinCredentialsReadResult } from '../devin-credentials'
export abstract class RateLimitServiceState {
protected state: InternalRateLimitState = {
@@ -31,9 +32,26 @@ export abstract class RateLimitServiceState {
kimi: null,
antigravity: null,
minimax: null,
grok: null
grok: null,
devin: null
}
protected grokAuthConfigured = readGrokAuthSession().status === 'ok'
protected devinAuthConfigured = readDevinCredentials().status === 'ok'
protected devinSnapshotCredential: string | null = null
protected getDevinCredentialFingerprint(result: DevinCredentialsReadResult): string {
return result.status === 'ok'
? `ok\u0000${result.credentials.apiServerUrl}\u0000${result.credentials.sessionToken}`
: result.status
}
protected previousDevinSnapshot(fingerprint: string): ProviderRateLimits | null {
const previous = this.devinSnapshotCredential === fingerprint ? this.state.devin : null
this.devinSnapshotCredential = fingerprint
return previous
}
protected devinOnlyFetchQueued = false
protected pollInterval: number = DEFAULT_POLL_MS
protected timer: ReturnType<typeof setInterval> | null = null
protected deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | null = null
@@ -46,7 +64,8 @@ export abstract class RateLimitServiceState {
kimi: 0,
minimax: 0,
grok: 0,
antigravity: 0
antigravity: 0,
devin: 0
}
// Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result.
protected activeFailureStreakByProvider: Record<ActiveRateLimitProvider, number> = {
@@ -57,7 +76,8 @@ export abstract class RateLimitServiceState {
kimi: 0,
minimax: 0,
grok: 0,
antigravity: 0
antigravity: 0,
devin: 0
}
protected mainWindow: BrowserWindow | null = null
protected detachWindowListeners: (() => void) | null = null
@@ -83,7 +83,8 @@ export const MAX_ACTIVE_FAILURE_STREAK = 8
export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet<ActiveRateLimitProvider> = new Set([
'claude',
'codex',
'grok'
'grok',
'devin'
])
export const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped
// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited".
@@ -107,6 +108,7 @@ export type InternalRateLimitState = {
antigravity: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
devin: ProviderRateLimits | null
}
export function normalizePollingInterval(ms: number): number {
@@ -16,6 +16,7 @@ import { StatsCollector } from '../stats/collector'
import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder'
import { ClaudeUsageStore } from '../claude-usage/store'
import { CodexUsageStore } from '../codex-usage/store'
import { DevinUsageStore } from '../devin-usage/store'
import { OpenCodeUsageStore } from '../opencode-usage/store'
import { installRepoMaintenanceIdleGate } from '../repo-maintenance-idle-gate'
import { mainProcessState as state } from './main-process-state'
@@ -123,5 +124,6 @@ export function initializeMainProcessObservers(): void {
agentHookServer.subscribePaneStatusClear((clear) => agentSessionRecorder.onCleared(clear))
state.claudeUsage = new ClaudeUsageStore(store)
state.codexUsage = new CodexUsageStore(store)
state.devinUsage = new DevinUsageStore(store)
state.openCodeUsage = new OpenCodeUsageStore(store)
}
@@ -71,6 +71,7 @@ import { initStatsPath } from '../stats/collector'
import { initClaudeUsagePath } from '../claude-usage/store'
import { initCodexUsagePath } from '../codex-usage/store'
import { initOpenCodeUsagePath } from '../opencode-usage/store'
import { initDevinUsagePath } from '../devin-usage/store'
import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol'
import { startCrashpadCapture } from '../crash-reporting/crashpad-capture'
import { CrashReportStore } from '../crash-reporting/crash-report-store'
@@ -286,6 +287,7 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
initDevinUsagePath()
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
registerDocPreviewSchemePrivileges()
+1
View File
@@ -206,6 +206,7 @@ function installWillQuitHandler(): void {
const usageCacheFlush = Promise.all([
state.claudeUsage?.flush(),
state.codexUsage?.flush(),
state.devinUsage?.flush(),
state.openCodeUsage?.flush()
]).then(() => {})
const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts()
+3
View File
@@ -4,6 +4,7 @@ import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import type { DevinUsageStore } from '../devin-usage/store'
import type { OpenCodeUsageStore } from '../opencode-usage/store'
import type { CodexAccountService } from '../codex-accounts/service'
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
@@ -54,6 +55,8 @@ export const mainProcessState = {
stats: null as StatsCollector | null,
claudeUsage: null as ClaudeUsageStore | null,
codexUsage: null as CodexUsageStore | null,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Startup assigns the concrete store before usage handlers are registered.
devinUsage: null as DevinUsageStore | null,
openCodeUsage: null as OpenCodeUsageStore | null,
codexAccounts: null as CodexAccountService | null,
codexRuntimeHome: null as CodexRuntimeHomeService | null,
@@ -56,6 +56,7 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
stats: state.stats,
claudeUsage: state.claudeUsage,
codexUsage: state.codexUsage,
devinUsage: state.devinUsage,
openCodeUsage: state.openCodeUsage,
rateLimits: state.rateLimits,
automations: state.automations,
@@ -29,6 +29,7 @@ export function attachMainWindowCoreServices(
const stats = state.stats
const claudeUsage = state.claudeUsage
const codexUsage = state.codexUsage
const devinUsage = state.devinUsage
const openCodeUsage = state.openCodeUsage
const codexAccounts = state.codexAccounts
const claudeAccounts = state.claudeAccounts
@@ -43,6 +44,7 @@ export function attachMainWindowCoreServices(
!stats ||
!claudeUsage ||
!codexUsage ||
!devinUsage ||
!openCodeUsage ||
!codexAccounts ||
!claudeAccounts ||
@@ -60,6 +62,7 @@ export function attachMainWindowCoreServices(
stats,
claudeUsage,
codexUsage,
devinUsage,
openCodeUsage,
codexAccounts,
claudeAccounts,
@@ -8,6 +8,7 @@ const MAIN_WINDOW_SERVICE_REQUIREMENTS = [
['stats', 'Stats must be initialized before opening the main window'],
['claudeUsage', 'Claude usage store must be initialized before opening the main window'],
['codexUsage', 'Codex usage store must be initialized before opening the main window'],
['devinUsage', 'Devin usage store must be initialized before opening the main window'],
['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'],
['rateLimits', 'Rate limit service must be initialized before opening the main window'],
['automations', 'Automation service must be initialized before opening the main window'],
@@ -10,6 +10,11 @@ import type {
CodexUsagePersistedFile,
CodexUsageSession
} from '../codex-usage/types'
import type {
DevinUsageDailyAggregate,
DevinUsagePersistedFile,
DevinUsageSession
} from '../devin-usage/types'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedDatabase,
@@ -150,6 +155,19 @@ export async function scanCodexUsageOnWorker(
}
return value
}
export async function scanDevinUsageOnWorker(
scan: (body: UsageScanWorkerRequestBody) => Promise<UsageScanWorkerValue>,
worktrees: UsageScanWorktreeRef[],
previous: DevinUsagePersistedFile[]
): Promise<
ProviderScanResult<DevinUsagePersistedFile, DevinUsageSession, DevinUsageDailyAggregate>
> {
const value = await scan({ providerId: 'devin', worktrees, previous })
if (value.providerId !== 'devin') {
throw wrongProvider('devin', value.providerId)
}
return value
}
/**
* Scan OpenCode usage databases on the shared worker.
+10
View File
@@ -1,6 +1,7 @@
import { parentPort } from 'node:worker_threads'
import { scanClaudeUsageFiles } from '../claude-usage/scanner'
import { scanCodexUsageFiles } from '../codex-usage/scanner'
import { scanDevinUsageFiles } from '../devin-usage/scanner'
import { scanOpenCodeUsageDatabases } from '../opencode-usage/scanner'
import type {
UsageScanWorkerProgress,
@@ -79,6 +80,15 @@ async function runScan(
dailyAggregates: result.dailyAggregates
}
}
case 'devin': {
const result = await scanDevinUsageFiles(request.worktrees, request.previous, onFilesScanned)
return {
providerId: 'devin',
source: result.processedFiles,
sessions: result.sessions,
dailyAggregates: result.dailyAggregates
}
}
case 'opencode': {
const result = await scanOpenCodeUsageDatabases(
request.worktrees,
+13 -1
View File
@@ -8,6 +8,11 @@ import type {
CodexUsagePersistedFile,
CodexUsageSession
} from '../codex-usage/types'
import type {
DevinUsageDailyAggregate,
DevinUsagePersistedFile,
DevinUsageSession
} from '../devin-usage/types'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedDatabase,
@@ -26,7 +31,7 @@ import type { UsageScanWorktreeRef } from './usage-provider-contract'
* than `UsageProviderId`: a `plugin:` provider supplies its own scan function,
* which is not in this bundle and cannot be named on the wire.
*/
export type UsageScanWorkerProviderId = 'claude' | 'codex' | 'opencode'
export type UsageScanWorkerProviderId = 'claude' | 'codex' | 'devin' | 'opencode'
/** Request body per provider; `previous` is that provider's own per-source cache. */
export type UsageScanWorkerRequestBody =
@@ -36,6 +41,7 @@ export type UsageScanWorkerRequestBody =
previous: ClaudeUsagePersistedFile[]
}
| { providerId: 'codex'; worktrees: UsageScanWorktreeRef[]; previous: CodexUsagePersistedFile[] }
| { providerId: 'devin'; worktrees: UsageScanWorktreeRef[]; previous: DevinUsagePersistedFile[] }
| {
providerId: 'opencode'
worktrees: UsageScanWorktreeRef[]
@@ -62,6 +68,12 @@ export type UsageScanWorkerValue =
sessions: CodexUsageSession[]
dailyAggregates: CodexUsageDailyAggregate[]
}
| {
providerId: 'devin'
source: DevinUsagePersistedFile[]
sessions: DevinUsageSession[]
dailyAggregates: DevinUsageDailyAggregate[]
}
| {
providerId: 'opencode'
source: OpenCodeUsagePersistedDatabase[]
+25
View File
@@ -11,6 +11,11 @@ import type {
CodexUsagePersistedFile,
CodexUsageSession
} from '../codex-usage/types'
import type {
DevinUsageDailyAggregate,
DevinUsagePersistedFile,
DevinUsageSession
} from '../devin-usage/types'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedDatabase,
@@ -20,6 +25,7 @@ import type { UsageScanWorktreeRef } from './usage-provider-contract'
import {
scanClaudeUsageOnWorker,
scanCodexUsageOnWorker,
scanDevinUsageOnWorker,
scanOpenCodeUsageOnWorker,
UsageScanWorkerClient
} from './usage-scan-worker-client'
@@ -101,6 +107,25 @@ export async function scanCodexUsageFilesViaWorker(
dailyAggregates: value.dailyAggregates
}
}
export async function scanDevinUsageFilesViaWorker(
worktrees: UsageScanWorktreeRef[],
previous: DevinUsagePersistedFile[] = []
): Promise<{
processedFiles: DevinUsagePersistedFile[]
sessions: DevinUsageSession[]
dailyAggregates: DevinUsageDailyAggregate[]
}> {
const value = await scanDevinUsageOnWorker(
(body) => getSharedClient().scan(body),
worktrees,
previous
)
return {
processedFiles: value.source,
sessions: value.sessions,
dailyAggregates: value.dailyAggregates
}
}
/**
* Scan OpenCode usage databases through the shared worker client.
+10 -1
View File
@@ -2,6 +2,7 @@ import type {
ClaudeAccountsApi,
CodexAccountsApi,
CodexConfigSyncApi,
DevinAccountsApi,
GrokAccountsApi,
MinimaxCredentialsApi
} from './api/agent-account-api'
@@ -11,6 +12,7 @@ import type { AgentAwakeApi, AgentStatusApi, AgentTrustApi } from './api/agent-s
import type {
ClaudeUsageApi,
CodexUsageApi,
DevinUsageApi,
OpenCodeUsageApi,
RateLimitsApi
} from './api/agent-usage-api'
@@ -101,6 +103,7 @@ export type PreloadApi = {
keybindings: KeybindingsApi
codexAccounts: CodexAccountsApi
claudeAccounts: ClaudeAccountsApi
devinAccounts: DevinAccountsApi
cli: CliApi
codexConfigSync: CodexConfigSyncApi
agentTrust: AgentTrustApi
@@ -129,6 +132,7 @@ export type PreloadApi = {
memory: MemoryApi
claudeUsage: ClaudeUsageApi
codexUsage: CodexUsageApi
devinUsage: DevinUsageApi
openCodeUsage: OpenCodeUsageApi
aiVault: AiVaultApi
nativeChat: NativeChatApi
@@ -151,7 +155,12 @@ export type PreloadApi = {
speech: SpeechApi
}
export type { ClaudeUsageApi, CodexUsageApi, OpenCodeUsageApi } from './api/agent-usage-api'
export type {
ClaudeUsageApi,
CodexUsageApi,
DevinUsageApi,
OpenCodeUsageApi
} from './api/agent-usage-api'
export type { AiVaultApi } from './api/ai-vault-api'
export type { AutomationsApi, ExternalAutomationManagerResult } from './api/automation-api'
export type { AppApi } from './api/app-api'
+4 -1
View File
@@ -3,7 +3,7 @@ import type {
CodexRateLimitAccountsState
} from '../../shared/managed-account-types'
import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types'
import type { GrokAccountStatus } from '../../shared/rate-limit-types'
import type { DevinAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types'
export type CodexAccountsApi = {
list: () => Promise<CodexRateLimitAccountsState>
@@ -61,6 +61,9 @@ export type ClaudeAccountsApi = {
export type GrokAccountsApi = {
getStatus: () => Promise<GrokAccountStatus>
}
export type DevinAccountsApi = {
getStatus: () => Promise<DevinAccountStatus>
}
export type MinimaxCredentialsApi = {
// Why: cookie + API key each live in their own safeStorage file, so the
+3
View File
@@ -1,5 +1,6 @@
import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../../shared/claude-usage-types'
import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../../shared/codex-usage-types'
import type { DevinUsageBreakdownKind, DevinUsageSnapshot } from '../../shared/devin-usage-types'
import type {
OpenCodeUsageBreakdownKind,
OpenCodeUsageSnapshot
@@ -41,6 +42,7 @@ export type UsageProviderApi<Snapshot extends UsageProviderSnapshot, BreakdownKi
export type ClaudeUsageApi = UsageProviderApi<ClaudeUsageSnapshot, ClaudeUsageBreakdownKind>
export type CodexUsageApi = UsageProviderApi<CodexUsageSnapshot, CodexUsageBreakdownKind>
export type DevinUsageApi = UsageProviderApi<DevinUsageSnapshot, DevinUsageBreakdownKind>
export type OpenCodeUsageApi = UsageProviderApi<OpenCodeUsageSnapshot, OpenCodeUsageBreakdownKind>
@@ -53,6 +55,7 @@ export type RateLimitsApi = {
setPollingInterval: (ms: number) => Promise<void>
fetchInactiveClaudeAccounts: () => Promise<void>
fetchInactiveCodexAccounts: () => Promise<void>
refreshDevin: () => Promise<RateLimitState>
refreshMiniMax: () => Promise<RateLimitState>
refreshGrok: () => Promise<RateLimitState>
onUpdate: (callback: (state: RateLimitState) => void) => () => void
+7
View File
@@ -0,0 +1,7 @@
import { ipcRenderer } from 'electron'
import type { DevinAccountStatus } from '../../shared/rate-limit-types'
import type { PreloadApi } from '../api-types'
export const devinAccountsApi = {
getStatus: (): Promise<DevinAccountStatus> => ipcRenderer.invoke('devinAccounts:getStatus')
} satisfies PreloadApi['devinAccounts']
+8
View File
@@ -0,0 +1,8 @@
import { ipcRenderer } from 'electron'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const devinUsageApi = createUsageProviderApi(
ipcRenderer,
'devinUsage'
) satisfies PreloadApi['devinUsage']
+1
View File
@@ -23,6 +23,7 @@ export const rateLimitsApi = {
ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'),
refreshMiniMax: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshMiniMax'),
refreshGrok: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshGrok'),
refreshDevin: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshDevin'),
onUpdate: (callback: (state: RateLimitState) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state)
ipcRenderer.on('rateLimits:update', listener)
+4
View File
@@ -69,6 +69,7 @@ import { statsApi } from './api/stats-bridge'
import { memoryApi } from './api/memory-bridge'
import { claudeUsageApi } from './api/claude-usage-bridge'
import { codexUsageApi } from './api/codex-usage-bridge'
import { devinUsageApi } from './api/devin-usage-bridge'
import { openCodeUsageApi } from './api/open-code-usage-bridge'
import { aiVaultApi } from './api/ai-vault-bridge'
import { nativeChatApi } from './api/native-chat-bridge'
@@ -77,6 +78,7 @@ import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge'
import { rateLimitsApi } from './api/rate-limits-bridge'
import { minimaxCredentialsApi } from './api/minimax-credentials-bridge'
import { grokAccountsApi } from './api/grok-accounts-bridge'
import { devinAccountsApi } from './api/devin-accounts-bridge'
import { sshApi } from './api/ssh-bridge'
import { automationsApi } from './api/automations-bridge'
import { e2eApi } from './api/e2e-bridge'
@@ -167,6 +169,7 @@ const api = {
memory: memoryApi,
claudeUsage: claudeUsageApi,
codexUsage: codexUsageApi,
devinUsage: devinUsageApi,
openCodeUsage: openCodeUsageApi,
aiVault: aiVaultApi,
nativeChat: nativeChatApi,
@@ -175,6 +178,7 @@ const api = {
rateLimits: rateLimitsApi,
minimaxCredentials: minimaxCredentialsApi,
grokAccounts: grokAccountsApi,
devinAccounts: devinAccountsApi,
ssh: sshApi,
automations: automationsApi,
e2e: e2eApi,
+1 -1
View File
@@ -6,7 +6,7 @@ describe('usage provider preload API', () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const query = { scope: 'orca' as const, range: '30d' as const }
for (const prefix of ['claudeUsage', 'codexUsage', 'openCodeUsage'] as const) {
for (const prefix of ['claudeUsage', 'codexUsage', 'devinUsage', 'openCodeUsage'] as const) {
const api = createUsageProviderApi({ invoke } as never, prefix)
await api.getScanState()
await api.setEnabled({ enabled: true })
+1 -1
View File
@@ -1,7 +1,7 @@
import type { IpcRenderer } from 'electron'
import type { PreloadApi } from './api-types'
type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage'
type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'devinUsage' | 'openCodeUsage'
type UsageProviderApi = PreloadApi[UsageProviderApiKey]
type UsageRangeArgs = { scope: string; range: string }
@@ -82,6 +82,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string {
return 'M'
case 'grok':
return 'R'
case 'devin':
return 'D'
case 'codex':
return 'X'
}
@@ -18,8 +18,13 @@ export function getUsageProviderAccountsSectionId(
return 'accounts-minimax'
case 'grok':
return 'accounts-grok'
case 'devin':
// Why: the Devin section is read-only status plus a refresh, like Grok's —
// the deep link is where the "run devin login" instructions live.
return 'accounts-devin'
case 'kimi':
// Why: Orca must not mutate Kimi's CLI-owned credential lifecycle.
// Why: Orca must not mutate CLI-owned credential lifecycles; Kimi
// refreshes its own session file, so there is no accounts section.
return null
}
}
@@ -16,6 +16,7 @@ export function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimi
fetchInactiveCodexAccounts: () => Promise.resolve(),
refreshMiniMax: () => Promise.resolve(empty),
refreshGrok: () => Promise.resolve(empty),
refreshDevin: () => Promise.resolve(empty),
onUpdate: () => noopUnsubscribe
}
}
+75
View File
@@ -0,0 +1,75 @@
export type DevinUsageScope = 'orca' | 'all'
export type DevinUsageRange = '7d' | '30d' | '90d' | 'all'
export type DevinUsageBreakdownKind = 'model' | 'project'
export type DevinUsageScanState = {
enabled: boolean
isScanning: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
hasAnyDevinData: boolean
}
export type DevinUsageSummary = {
scope: DevinUsageScope
range: DevinUsageRange
sessions: number
events: number
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
estimatedCostUsd: null
topModel: string | null
topProject: string | null
hasAnyDevinData: boolean
}
export type DevinUsageDailyPoint = {
day: string
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
}
export type DevinUsageBreakdownRow = {
key: string
label: string
sessions: number
events: number
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
estimatedCostUsd: null
hasInferredPricing: false
}
export type DevinUsageSessionRow = {
sessionId: string
lastActiveAt: string
durationMinutes: number
projectLabel: string
model: string | null
events: number
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
hasInferredPricing: false
}
export type DevinUsageSnapshot = {
scanState: DevinUsageScanState
summary: DevinUsageSummary
daily: DevinUsageDailyPoint[]
modelBreakdown: DevinUsageBreakdownRow[]
projectBreakdown: DevinUsageBreakdownRow[]
recentSessions: DevinUsageSessionRow[]
}
+2
View File
@@ -118,6 +118,8 @@ export type PersistedUIState = {
_antigravityStatusBarDefaultAdded?: boolean
/** One-shot migration flag for adding the default-on Grok status item. */
_grokStatusBarDefaultAdded?: boolean
/** One-shot migration flag for adding the default-on Devin status item. */
_devinStatusBarDefaultAdded?: boolean
statusBarItems: StatusBarItem[]
statusBarVisible: boolean
/** Why: this is client-side presentation, not a provider/account or execution-host setting. */
+2
View File
@@ -11,9 +11,11 @@ export function createEmptyRateLimitState(overrides: Partial<RateLimitState> = {
antigravity: null,
minimax: null,
grok: null,
devin: null,
minimaxCookieConfigured: false,
minimaxApiKeyConfigured: false,
grokAuthConfigured: false,
devinAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
+2
View File
@@ -16,9 +16,11 @@ describe('RateLimitState', () => {
antigravity: null,
minimax: null,
grok: null,
devin: null,
minimaxCookieConfigured: false,
minimaxApiKeyConfigured: false,
grokAuthConfigured: false,
devinAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
+11
View File
@@ -55,6 +55,7 @@ export type ProviderRateLimits = {
| 'minimax'
| 'grok'
| 'antigravity'
| 'devin'
/** 5-hour session window, null if not available. */
session: RateLimitWindow | null
/** 7-day weekly window, null if not available. */
@@ -114,6 +115,13 @@ export type GrokAccountStatus = {
tokenFresh: boolean
error: string | null
}
export type DevinAccountStatus = {
signedIn: boolean
email: string | null
tokenFresh: boolean
plan: string | null
error: string | null
}
export type RateLimitState = {
claude: ProviderRateLimits | null
@@ -124,6 +132,7 @@ export type RateLimitState = {
antigravity: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
devin: 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
@@ -140,6 +149,8 @@ export type RateLimitState = {
minimaxApiKeyConfigured: boolean
/** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */
grokAuthConfigured: boolean
/** True when main finds a Devin CLI credentials file (credentials.toml under DEVIN_HOME). */
devinAuthConfigured: boolean
claudeTarget: RateLimitRuntimeTarget
codexTarget: RateLimitRuntimeTarget
inactiveClaudeAccounts: InactiveAccountUsage[]
@@ -69,6 +69,7 @@ export const StatusBarItem = z.enum([
'kimi',
'minimax',
'grok',
'devin',
'ssh',
'resource-usage',
'ports'
@@ -182,6 +183,7 @@ export const UiUpdateFields = z
_minimaxStatusBarDefaultAdded: z.boolean().optional(),
_antigravityStatusBarDefaultAdded: z.boolean().optional(),
_grokStatusBarDefaultAdded: z.boolean().optional(),
_devinStatusBarDefaultAdded: z.boolean().optional(),
statusBarVisible: z.boolean().optional(),
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
+1
View File
@@ -9,6 +9,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
'kimi',
'minimax',
'grok',
'devin',
'ssh',
'resource-usage',
'ports'
+1
View File
@@ -62,6 +62,7 @@ export type StatusBarItem =
| 'kimi'
| 'minimax'
| 'grok'
| 'devin'
| 'ssh'
| 'resource-usage'
| 'ports'