Add Droid CLI provider

Discovers and parses sessions from ~/.factory/sessions/, reading JSONL
message logs and companion settings.json files for token usage tracking.

- Discovers sessions by scanning per-cwd subdirectories
- Skips internal .factory housekeeping sessions
- Extracts tools, bash commands, and user messages from JSONL
- Distributes session-level cumulative token counts across calls
- Normalizes Droid model wrappers before existing pricing lookup
- Derives clean project names from cwd paths
- Adds menubar provider filtering for Droid
This commit is contained in:
Dunccan de Weerdt 2026-04-28 16:59:24 +02:00
parent 607cb463c9
commit 26ebe75aa1
6 changed files with 555 additions and 2 deletions

View file

@ -230,6 +230,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case codex = "Codex"
case cursor = "Cursor"
case copilot = "Copilot"
case droid = "Droid"
case gemini = "Gemini"
case kiro = "Kiro"
case kiloCode = "KiloCode"
@ -259,6 +260,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .codex: "codex"
case .cursor: "cursor"
case .copilot: "copilot"
case .droid: "droid"
case .gemini: "gemini"
case .kiloCode: "kilo-code"
case .kiro: "kiro"

View file

@ -92,6 +92,7 @@ extension ProviderFilter {
case .codex: return Theme.categoricalCodex
case .cursor: return Theme.categoricalCursor
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)

401
src/providers/droid.ts Normal file
View file

@ -0,0 +1,401 @@
import { readdir, stat, readFile } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { readSessionFile, readSessionLines } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type {
Provider,
SessionSource,
SessionParser,
ParsedProviderCall,
} from './types.js'
const toolNameMap: Record<string, string> = {
Read: 'Read',
Create: 'Create',
Edit: 'Edit',
MultiEdit: 'MultiEdit',
LS: 'LS',
Glob: 'Glob',
Grep: 'Grep',
Execute: 'Bash',
AskUser: 'AskUser',
TodoWrite: 'TodoWrite',
Skill: 'Skill',
Task: 'Agent',
WebSearch: 'WebSearch',
FetchUrl: 'FetchUrl',
GenerateDroid: 'GenerateDroid',
ExitSpecMode: 'ExitSpecMode',
}
type DroidSettings = {
model?: string
tokenUsage?: {
inputTokens: number
outputTokens: number
cacheCreationTokens: number
cacheReadTokens: number
thinkingTokens: number
}
}
type DroidContent = {
type: string
text?: string
name?: string
input?: Record<string, unknown>
}
type DroidMessage = {
role: string
content?: DroidContent[]
}
type DroidJsonlEntry = {
type: string
id?: string
timestamp?: string
message?: DroidMessage
title?: string
cwd?: string
}
function getFactoryDir(): string {
return process.env['FACTORY_DIR'] ?? join(homedir(), '.factory')
}
// Strip Droid-specific wrapper to get the model's display name.
// e.g. "custom:GLM-5.1-[Proxy]-0" -> "GLM-5.1"
// Cost lookup is handled by codeburn's existing calculateCost/getCanonicalName
// which normalizes case and strips date suffixes automatically.
function stripModelPrefix(raw: string): string {
return raw
.replace(/^custom:/, '')
.replace(/\[.*?\]/g, '')
.replace(/-\d+$/, '')
.replace(/-+$/, '')
.replace(/^-/, '')
}
function parseModelForDisplay(raw: string): string {
const stripped = stripModelPrefix(raw)
const lower = stripped.toLowerCase()
if (lower.includes('opus')) return getShortModelName(stripped)
if (lower.includes('sonnet')) return getShortModelName(stripped)
if (lower.includes('haiku')) return getShortModelName(stripped)
if (lower.startsWith('gpt-')) return getShortModelName(stripped)
if (lower.startsWith('o3') || lower.startsWith('o4')) return getShortModelName(stripped)
if (lower.startsWith('gemini')) return getShortModelName(stripped)
return stripped
}
/**
* Extract meaningful shell command names from a Droid Execute call.
* Droid frequently passes multi-line scripts (python -c "...", heredocs, etc.)
* where splitting on ;/&&/| produces noise tokens like '}', 'await', 'import'.
* Instead, extract only the primary command from each logical line.
*/
function extractDroidBashCommands(command: string): string[] {
if (!command || !command.trim()) return []
const firstLine = command.split('\n')[0]!.trim()
return extractBashCommands(firstLine)
}
function createParser(
source: SessionSource,
seenKeys: Set<string>,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const content = await readSessionFile(source.path)
if (content === null) return
// Read the companion settings file for token usage
const settingsPath = source.path.replace(/\.jsonl$/, '.settings.json')
let settings: DroidSettings = {}
try {
const raw = await readFile(settingsPath, 'utf-8')
settings = JSON.parse(raw) as DroidSettings
} catch {
// No settings file or parse error
}
const lines = content.split('\n').filter(l => l.trim())
let sessionId = ''
let sessionModelDisplay = settings.model ? stripModelPrefix(settings.model) : 'unknown'
let currentUserMessage = ''
// Collect all assistant messages with their tools
const assistantCalls: Array<{
id: string
timestamp: string
tools: string[]
bashCommands: string[]
}> = []
let pendingTools: string[] = []
let pendingBashCommands: string[] = []
for (const line of lines) {
let entry: DroidJsonlEntry
try {
entry = JSON.parse(line) as DroidJsonlEntry
} catch {
continue
}
if (entry.type === 'session_start') {
sessionId = entry.id ?? ''
continue
}
if (entry.type !== 'message' || !entry.message) continue
const msg = entry.message
if (msg.role === 'user') {
// Extract user text from content
const texts = (msg.content ?? [])
.filter(c => c.type === 'text' && c.text)
.map(c => c.text!)
.filter(Boolean)
// Skip system-reminder-only messages
const nonSystemTexts = texts.filter(t => !t.startsWith('<system-reminder>'))
if (nonSystemTexts.length > 0) {
currentUserMessage = nonSystemTexts.join(' ').slice(0, 500)
}
continue
}
if (msg.role === 'assistant') {
const toolUses = (msg.content ?? []).filter(c => c.type === 'tool_use')
for (const tu of toolUses) {
const toolName = tu.name ?? ''
pendingTools.push(toolNameMap[toolName] ?? toolName)
if (toolName === 'Execute' && tu.input && typeof tu.input['command'] === 'string') {
pendingBashCommands.push(...extractDroidBashCommands(tu.input['command'] as string))
}
}
// Check if this assistant message has any text content (non-thinking)
const hasText = (msg.content ?? []).some(c => c.type === 'text' && c.text)
// Only emit a call entry if there are tools or substantial text
if (pendingTools.length > 0 || hasText) {
assistantCalls.push({
id: entry.id ?? `msg-${assistantCalls.length}`,
timestamp: entry.timestamp ?? '',
tools: [...pendingTools],
bashCommands: [...pendingBashCommands],
})
pendingTools = []
pendingBashCommands = []
}
continue
}
}
if (assistantCalls.length === 0) return
// Distribute session-level token usage across calls
const totalTokens = settings.tokenUsage
if (!totalTokens) return
const totalInput = totalTokens.inputTokens ?? 0
const totalOutput = totalTokens.outputTokens ?? 0
const totalCacheCreation = totalTokens.cacheCreationTokens ?? 0
const totalCacheRead = totalTokens.cacheReadTokens ?? 0
const totalThinking = totalTokens.thinkingTokens ?? 0
const numCalls = assistantCalls.length
// Distribute evenly across calls
const inputPerCall = Math.floor(totalInput / numCalls)
const outputPerCall = Math.floor(totalOutput / numCalls)
const cacheCreationPerCall = Math.floor(totalCacheCreation / numCalls)
const cacheReadPerCall = Math.floor(totalCacheRead / numCalls)
const thinkingPerCall = Math.floor(totalThinking / numCalls)
for (let i = 0; i < assistantCalls.length; i++) {
const call = assistantCalls[i]
// Assign remainder to the last call
const isLast = i === assistantCalls.length - 1
const inputTokens = isLast
? totalInput - inputPerCall * (numCalls - 1)
: inputPerCall
const outputTokens = isLast
? totalOutput - outputPerCall * (numCalls - 1)
: outputPerCall
const cacheCreationTokens = isLast
? totalCacheCreation - cacheCreationPerCall * (numCalls - 1)
: cacheCreationPerCall
const cacheReadTokens = isLast
? totalCacheRead - cacheReadPerCall * (numCalls - 1)
: cacheReadPerCall
const thinkingTokens = isLast
? totalThinking - thinkingPerCall * (numCalls - 1)
: thinkingPerCall
const dedupKey = `droid:${sessionId}:${call.id}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
const costUSD = calculateCost(
sessionModelDisplay.toLowerCase(),
inputTokens,
outputTokens + thinkingTokens,
cacheCreationTokens,
cacheReadTokens,
0,
)
// Use the call's timestamp, or session_start timestamp
const timestamp = call.timestamp || ''
yield {
provider: 'droid',
model: sessionModelDisplay,
inputTokens,
outputTokens,
cacheCreationInputTokens: cacheCreationTokens,
cacheReadInputTokens: cacheReadTokens,
cachedInputTokens: cacheReadTokens,
reasoningTokens: thinkingTokens,
webSearchRequests: 0,
costUSD,
tools: call.tools,
bashCommands: call.bashCommands,
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: i === 0 ? currentUserMessage : '',
sessionId,
}
}
},
}
}
function isInternalSession(cwd: string, factoryDir: string): boolean {
// Skip sessions whose cwd is the .factory directory itself (internal housekeeping)
const normalized = cwd.replace(/\/+$/, '')
return normalized === factoryDir
}
function deriveProjectName(cwd: string): string {
const normalized = cwd.replace(/\/+$/, '')
const home = homedir()
// Strip home directory prefix
let relative = normalized.startsWith(home)
? normalized.slice(home.length).replace(/^\/+/, '')
: normalized.replace(/^\/+/, '')
if (!relative) relative = '~'
// Walk from the right: use the "projects/<name>" segment if present,
// otherwise the last meaningful path component.
const parts = relative.split('/')
const projectsIdx = parts.lastIndexOf('projects')
if (projectsIdx !== -1 && projectsIdx + 1 < parts.length) {
return parts.slice(projectsIdx + 1).join('/')
}
return parts.join('/')
}
async function readFirstJsonlLine(filePath: string): Promise<string | null> {
for await (const line of readSessionLines(filePath)) {
return line
}
return null
}
async function discoverSessionsInDir(
sessionsDir: string,
factoryDir: string,
): Promise<SessionSource[]> {
const sources: SessionSource[] = []
let entries: string[]
try {
entries = await readdir(sessionsDir)
} catch {
return sources
}
for (const entry of entries) {
const subDir = join(sessionsDir, entry)
const s = await stat(subDir).catch(() => null)
if (!s?.isDirectory()) continue
const files = await readdir(subDir).catch(() => [] as string[])
for (const file of files) {
if (!file.endsWith('.jsonl')) continue
const filePath = join(subDir, file)
const firstLine = await readFirstJsonlLine(filePath)
if (!firstLine?.trim()) continue
let startEntry: DroidJsonlEntry
try {
startEntry = JSON.parse(firstLine) as DroidJsonlEntry
} catch {
continue
}
if (startEntry.type !== 'session_start') continue
const cwd = startEntry.cwd ?? entry
if (isInternalSession(cwd, factoryDir)) continue
sources.push({
path: filePath,
project: deriveProjectName(cwd),
provider: 'droid',
})
}
}
return sources
}
export function createDroidProvider(factoryDir?: string): Provider {
const base = factoryDir ?? getFactoryDir()
const sessionsDir = join(base, 'sessions')
return {
name: 'droid',
displayName: 'Droid',
modelDisplayName(model: string): string {
return parseModelForDisplay(model)
},
toolDisplayName(rawTool: string): string {
return toolNameMap[rawTool] ?? rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSessionsInDir(sessionsDir, base)
},
createSessionParser(
source: SessionSource,
seenKeys: Set<string>,
): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const droid = createDroidProvider()

View file

@ -1,6 +1,7 @@
import { claude } from './claude.js'
import { codex } from './codex.js'
import { copilot } from './copilot.js'
import { droid } from './droid.js'
import { gemini } from './gemini.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
@ -55,7 +56,7 @@ async function loadCursorAgent(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, codex, copilot, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise<Provider[]> {
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])

View file

@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
})
it('includes sqlite providers after async load', async () => {

View file

@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createDroidProvider } from '../../src/providers/droid.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let factoryDir: string
async function writeSession(opts: {
projectDir?: string
sessionId?: string
lines?: unknown[]
settings?: unknown
subdir?: string
}): Promise<string> {
const sessionId = opts.sessionId ?? 'session-1'
const projectDir = opts.projectDir ?? '/tmp/my-project'
const subdir = opts.subdir ?? '-tmp-my-project'
const dir = join(factoryDir, 'sessions', subdir)
await mkdir(dir, { recursive: true })
const jsonlPath = join(dir, `${sessionId}.jsonl`)
const lines = opts.lines ?? [
{ type: 'session_start', id: sessionId, cwd: projectDir, title: 'Test session' },
{ type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'build this' }] } },
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } },
]
await writeFile(jsonlPath, lines.map(line => JSON.stringify(line)).join('\n'))
if (opts.settings !== undefined) {
await writeFile(join(dir, `${sessionId}.settings.json`), JSON.stringify(opts.settings))
}
return jsonlPath
}
async function parseAll(filePath: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
const provider = createDroidProvider(factoryDir)
const parser = provider.createSessionParser({ path: filePath, project: 'proj', provider: 'droid' }, seen)
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) calls.push(call)
return calls
}
describe('droid provider', () => {
beforeEach(async () => {
factoryDir = await mkdtemp(join(tmpdir(), 'codeburn-droid-test-'))
})
afterEach(async () => {
await rm(factoryDir, { recursive: true, force: true })
})
it('discovers Droid JSONL sessions', async () => {
await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
const provider = createDroidProvider(factoryDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('droid')
expect(sessions[0]!.path.endsWith('session-1.jsonl')).toBe(true)
})
it('parses calls and distributes session-level token usage', async () => {
const path = await writeSession({
lines: [
{ type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' },
{ type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>x</system-reminder>' }, { type: 'text', text: 'build this' }] } },
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] } },
{ type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'second' }] } },
],
settings: { model: 'custom:gpt-5-[Proxy]-0', tokenUsage: { inputTokens: 101, outputTokens: 51, cacheCreationTokens: 7, cacheReadTokens: 11, thinkingTokens: 5 } },
})
const calls = await parseAll(path)
expect(calls).toHaveLength(2)
expect(calls[0]!.provider).toBe('droid')
expect(calls[0]!.model).toBe('gpt-5')
expect(calls[0]!.inputTokens).toBe(50)
expect(calls[1]!.inputTokens).toBe(51)
expect(calls[0]!.outputTokens).toBe(25)
expect(calls[1]!.outputTokens).toBe(26)
expect(calls[0]!.cacheReadInputTokens).toBe(5)
expect(calls[1]!.cacheReadInputTokens).toBe(6)
expect(calls[0]!.userMessage).toBe('build this')
expect(calls[0]!.sessionId).toBe('session-1')
})
it('extracts tools and meaningful bash command names', async () => {
const path = await writeSession({
lines: [
{ type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' },
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [
{ type: 'tool_use', name: 'Execute', input: { command: "python3 - <<'PY'\nimport os\n}\nPY" } },
{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/a' } },
{ type: 'tool_use', name: 'Task', input: { prompt: 'do work' } },
] } },
],
settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } },
})
const calls = await parseAll(path)
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Agent'])
expect(calls[0]!.bashCommands).toContain('python3')
expect(calls[0]!.bashCommands).not.toContain('import')
expect(calls[0]!.bashCommands).not.toContain('}')
})
it('deduplicates calls by session and message id', async () => {
const path = await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
const seen = new Set<string>()
expect(await parseAll(path, seen)).toHaveLength(1)
expect(await parseAll(path, seen)).toHaveLength(0)
})
it('strips Droid model wrappers for display', () => {
const provider = createDroidProvider(factoryDir)
expect(provider.modelDisplayName('custom:GLM-5.1-[Proxy]-0')).toBe('GLM-5.1')
expect(provider.modelDisplayName('custom:claude-sonnet-4-6-1')).toBe('Sonnet 4.6')
})
it('returns no calls when settings are missing', async () => {
const path = await writeSession({})
expect(await parseAll(path)).toHaveLength(0)
})
it('skips internal .factory sessions during discovery', async () => {
await writeSession({ projectDir: factoryDir, subdir: '-internal', settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
const provider = createDroidProvider(factoryDir)
expect(await provider.discoverSessions()).toHaveLength(0)
})
it('returns no calls for empty sessions', async () => {
const path = await writeSession({
lines: [{ type: 'session_start', id: 'empty', cwd: '/tmp/my-project' }],
settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } },
})
expect(await parseAll(path)).toHaveLength(0)
})
})