feat(providers): add Zed agent provider (#480)

Reads Zed's agent threads from the single threads.db SQLite store: each
row's zstd-compressed JSON carries the thread model and per-request
Anthropic-shaped token usage, so calls are emitted per request with
exact input/output/cache fields and priced through the existing engine.
In-progress threads with an empty per-request map fall back to the
cumulative counter. zstd comes from node:zlib, which ships it from
22.15; on older Nodes the provider skips with a notice instead of
failing. On-disk format documented by @chatzinikolakisk in #480 and the
schema verified against a real Zed install.
This commit is contained in:
AgentSeal 2026-07-02 05:41:16 +02:00
parent 9b20dfd7dc
commit 1f45c1541b
3 changed files with 424 additions and 3 deletions

View file

@ -171,11 +171,26 @@ async function loadZcode(): Promise<Provider | null> {
}
}
let zedProvider: Provider | null = null
let zedLoadAttempted = false
async function loadZed(): Promise<Provider | null> {
if (zedLoadAttempted) return zedProvider
zedLoadAttempted = true
try {
const { zed } = await import('./zed.js')
zedProvider = zed
return zed
} catch {
return null
}
}
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, rooCode, zerostack, grok]
// Lazily loaded providers, listed by name so --provider validation works even
// when an optional module fails to load. Must stay in sync with getAllProviders.
const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode']
const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'zed']
// Canonical set of every provider name (core + lazy), used to validate the
// --provider CLI flag. Computed lazily so importing this module never depends on
@ -190,8 +205,8 @@ export function allProviderNames(): readonly string[] {
}
export async function getAllProviders(): Promise<Provider[]> {
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc] = await Promise.all([
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(),
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc, zd] = await Promise.all([
loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(), loadZed(),
])
const all = [...coreProviders]
if (ag) all.push(ag)
@ -204,6 +219,7 @@ export async function getAllProviders(): Promise<Provider[]> {
if (warp) all.push(warp)
if (vercelGw) all.push(vercelGw)
if (zc) all.push(zc)
if (zd) all.push(zd)
return all
}
@ -263,5 +279,9 @@ export async function getProvider(name: string): Promise<Provider | undefined> {
const z = await loadZcode()
return z ?? undefined
}
if (name === 'zed') {
const z = await loadZed()
return z ?? undefined
}
return coreProviders.find(p => p.name === name)
}

209
src/providers/zed.ts Normal file
View file

@ -0,0 +1,209 @@
import { existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import zlib from 'zlib'
import { calculateCost } from '../models.js'
import { getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
// Zed's built-in agent stores one row per thread in a single SQLite database;
// the `data` blob is zstd-compressed JSON carrying `request_token_usage`
// (per-request Anthropic-shaped token counts) and the thread's model.
// Format documented in issue #480.
// zstd landed in node:zlib in 22.15 / 23.8; the package floor is 22.13, so the
// provider degrades with a notice instead of assuming the export exists.
const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync
function getZedThreadsDbPath(): string {
if (process.platform === 'darwin') {
return join(homedir(), 'Library', 'Application Support', 'Zed', 'threads', 'threads.db')
}
if (process.platform === 'win32') {
return join(homedir(), 'AppData', 'Local', 'Zed', 'threads', 'threads.db')
}
return join(homedir(), '.local', 'share', 'zed', 'threads', 'threads.db')
}
const THREADS_QUERY = `
SELECT id, summary, updated_at, data_type, data
FROM threads
ORDER BY updated_at ASC
`
type ThreadRow = {
id: string
summary: string | null
updated_at: string | null
data_type: string | null
data: Uint8Array | null
}
type TokenUsage = {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
type ThreadJson = {
model?: { provider?: string; model?: string }
request_token_usage?: Record<string, TokenUsage>
cumulative_token_usage?: TokenUsage
}
function num(value: number | undefined): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
}
function usageIsEmpty(usage: TokenUsage): boolean {
return (
num(usage.input_tokens) === 0 &&
num(usage.output_tokens) === 0 &&
num(usage.cache_creation_input_tokens) === 0 &&
num(usage.cache_read_input_tokens) === 0
)
}
function buildCall(opts: {
threadId: string
requestKey: string
usage: TokenUsage
model: string
timestamp: string
userMessage: string
}): ParsedProviderCall {
const input = num(opts.usage.input_tokens)
const output = num(opts.usage.output_tokens)
const cacheWrite = num(opts.usage.cache_creation_input_tokens)
const cacheRead = num(opts.usage.cache_read_input_tokens)
return {
provider: 'zed',
model: opts.model,
inputTokens: input,
outputTokens: output,
cacheCreationInputTokens: cacheWrite,
cacheReadInputTokens: cacheRead,
cachedInputTokens: cacheRead,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD: calculateCost(opts.model, input, output, cacheWrite, cacheRead, 0),
tools: [],
bashCommands: [],
timestamp: opts.timestamp,
speed: 'standard',
deduplicationKey: `zed:${opts.threadId}:${opts.requestKey}`,
userMessage: opts.userMessage,
sessionId: opts.threadId,
}
}
function parseThreads(db: SqliteDatabase, seenKeys: Set<string>): ParsedProviderCall[] {
const calls: ParsedProviderCall[] = []
let skipped = 0
let rows: ThreadRow[]
try {
rows = db.query<ThreadRow>(THREADS_QUERY)
} catch {
return calls
}
for (const row of rows) {
try {
if (!row.id || !row.data || row.data_type !== 'zstd') {
if (row.data != null) skipped++
continue
}
const parsedAt = new Date(row.updated_at ?? '')
if (Number.isNaN(parsedAt.getTime())) continue
const timestamp = parsedAt.toISOString()
const thread = JSON.parse(zstdDecompress!(Buffer.from(row.data)).toString('utf-8')) as ThreadJson
const model = thread.model?.model || 'unknown'
const userMessage = row.summary ?? ''
const requests = Object.entries(thread.request_token_usage ?? {}).filter(([, usage]) => usage != null && !usageIsEmpty(usage))
// In-progress threads can have an empty per-request map while the
// cumulative counter already carries tokens; fall back so they count.
const entries: Array<[string, TokenUsage]> = requests.length > 0
? requests
: thread.cumulative_token_usage && !usageIsEmpty(thread.cumulative_token_usage)
? [['cumulative', thread.cumulative_token_usage]]
: []
for (const [requestKey, usage] of entries) {
const call = buildCall({ threadId: row.id, requestKey, usage, model, timestamp, userMessage })
if (seenKeys.has(call.deduplicationKey)) continue
seenKeys.add(call.deduplicationKey)
calls.push(call)
}
} catch {
skipped++
}
}
if (skipped > 0) {
process.stderr.write(`codeburn: skipped ${skipped} unreadable Zed threads\n`)
}
return calls
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
if (!isSqliteAvailable()) {
process.stderr.write(getSqliteLoadError() + '\n')
return
}
if (!zstdDecompress) {
process.stderr.write('codeburn: Zed threads need Node >= 22.15 (zstd support); skipping Zed usage.\n')
return
}
let db: SqliteDatabase
try {
db = openDatabase(source.path)
} catch (err) {
process.stderr.write(`codeburn: cannot open Zed database: ${err instanceof Error ? err.message : err}\n`)
return
}
try {
for (const call of parseThreads(db, seenKeys)) {
yield call
}
} finally {
db.close()
}
},
}
}
export function createZedProvider(dbPathOverride?: string): Provider {
return {
name: 'zed',
displayName: 'Zed',
modelDisplayName(model: string): string {
return model
},
toolDisplayName(rawTool: string): string {
return rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []
const dbPath = dbPathOverride ?? getZedThreadsDbPath()
if (!existsSync(dbPath)) return []
return [{ path: dbPath, project: 'zed', provider: 'zed' }]
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const zed = createZedProvider()

192
tests/providers/zed.test.ts Normal file
View file

@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { createRequire } from 'node:module'
import zlib from 'zlib'
import { createZedProvider } from '../../src/providers/zed.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
const requireForTest = createRequire(import.meta.url)
const zstd = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync
const skipReason = !isSqliteAvailable()
? 'node:sqlite not available — needs Node 22+; skipping'
: !zstd
? 'zlib zstd not available — needs Node 22.15+; skipping'
: null
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'zed-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function buildDb(fn: (db: {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}) => void): string {
const dbPath = join(tmpDir, 'threads.db')
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
db.exec(`CREATE TABLE threads (
id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
updated_at TEXT NOT NULL,
data_type TEXT NOT NULL,
data BLOB NOT NULL,
parent_id TEXT, folder_paths TEXT, folder_paths_order TEXT, created_at TEXT
)`)
fn(db)
db.close()
return dbPath
}
function insertThread(db: {
prepare(sql: string): { run(...params: unknown[]): void }
}, opts: {
id: string
summary?: string
updatedAt?: string
dataType?: string
thread?: unknown
rawData?: Buffer
}): void {
const data = opts.rawData ?? zstd!(Buffer.from(JSON.stringify(opts.thread ?? {})))
db.prepare('INSERT INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)').run(
opts.id,
opts.summary ?? 'a thread',
opts.updatedAt ?? '2026-06-20T10:00:00Z',
opts.dataType ?? 'zstd',
data,
)
}
async function collectCalls(dbPath: string, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> {
const provider = createZedProvider(dbPath)
const sources = await provider.discoverSessions()
const calls: ParsedProviderCall[] = []
for (const source of sources) {
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
calls.push(call)
}
}
return calls
}
describe.skipIf(skipReason !== null)('zed provider (#480)', () => {
it('emits one call per request with exact Anthropic-shaped token fields', async () => {
const dbPath = buildDb((db) => {
insertThread(db, {
id: 'thread-1',
summary: 'refactor the parser',
updatedAt: '2026-06-21T09:30:00Z',
thread: {
model: { provider: 'anthropic', model: 'claude-opus-4-8' },
request_token_usage: {
'req-1': { input_tokens: 1200, output_tokens: 300, cache_creation_input_tokens: 5000, cache_read_input_tokens: 90000 },
'req-2': { input_tokens: 800, output_tokens: 150, cache_creation_input_tokens: 0, cache_read_input_tokens: 95000 },
},
cumulative_token_usage: { input_tokens: 2000, output_tokens: 450 },
},
})
})
const calls = await collectCalls(dbPath)
expect(calls.length).toBe(2)
const first = calls.find(c => c.deduplicationKey === 'zed:thread-1:req-1')
expect(first).toBeDefined()
expect(first!.inputTokens).toBe(1200)
expect(first!.outputTokens).toBe(300)
expect(first!.cacheCreationInputTokens).toBe(5000)
expect(first!.cacheReadInputTokens).toBe(90000)
expect(first!.model).toBe('claude-opus-4-8')
expect(first!.costUSD).toBeGreaterThan(0)
expect(first!.sessionId).toBe('thread-1')
expect(first!.userMessage).toBe('refactor the parser')
expect(first!.timestamp).toBe('2026-06-21T09:30:00.000Z')
// Cumulative must not be counted on top of per-request entries.
expect(calls.reduce((s, c) => s + c.inputTokens, 0)).toBe(2000)
})
it('falls back to cumulative_token_usage when the per-request map is empty', async () => {
const dbPath = buildDb((db) => {
insertThread(db, {
id: 'thread-2',
thread: {
model: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
request_token_usage: {},
cumulative_token_usage: { input_tokens: 5400, output_tokens: 900, cache_read_input_tokens: 20000 },
},
})
})
const calls = await collectCalls(dbPath)
expect(calls.length).toBe(1)
expect(calls[0]!.deduplicationKey).toBe('zed:thread-2:cumulative')
expect(calls[0]!.inputTokens).toBe(5400)
expect(calls[0]!.cacheReadInputTokens).toBe(20000)
})
it('skips non-zstd rows and malformed blobs without dropping healthy threads', async () => {
const dbPath = buildDb((db) => {
insertThread(db, { id: 'bad-type', dataType: 'json', rawData: Buffer.from('{}') })
insertThread(db, { id: 'bad-blob', rawData: Buffer.from('not zstd at all') })
insertThread(db, {
id: 'good',
thread: {
model: { model: 'claude-opus-4-8' },
request_token_usage: { 'req-1': { input_tokens: 10, output_tokens: 5 } },
},
})
})
const calls = await collectCalls(dbPath)
expect(calls.length).toBe(1)
expect(calls[0]!.sessionId).toBe('good')
})
it('dedupes across repeat parses via the shared seenKeys set', async () => {
const dbPath = buildDb((db) => {
insertThread(db, {
id: 'thread-3',
thread: {
model: { model: 'claude-opus-4-8' },
request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } },
},
})
})
const seen = new Set<string>()
expect((await collectCalls(dbPath, seen)).length).toBe(1)
expect((await collectCalls(dbPath, seen)).length).toBe(0)
})
it('skips threads whose usage is entirely zero instead of emitting empty calls', async () => {
const dbPath = buildDb((db) => {
insertThread(db, {
id: 'thread-4',
thread: {
model: { model: 'claude-opus-4-8' },
request_token_usage: { 'req-1': { input_tokens: 0, output_tokens: 0 } },
cumulative_token_usage: { input_tokens: 0, output_tokens: 0 },
},
})
})
expect((await collectCalls(dbPath)).length).toBe(0)
})
it('discovers nothing when the database does not exist', async () => {
const provider = createZedProvider(join(tmpDir, 'missing.db'))
expect(await provider.discoverSessions()).toEqual([])
})
})