diff --git a/CHANGELOG.md b/CHANGELOG.md
index 835f4557..69ca40ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Added
+- **Amp support.** CodeBurn reads Sourcegraph Amp's local thread mirror at `~/.local/share/amp/threads/` (`AMP_DATA_DIR` honored, comma-separated): one JSON document per thread with a per-request usage ledger (`tokens.input`/`output`, cache tokens joined from the billed message through `toMessageId`) and a per-message usage fallback for older threads. Every call is priced from the underlying model's token rates and marked estimated; Amp's own credits field is not converted; thread files carry no cwd, so sessions group under the provider name. Reported in #1572.
- **`codeburn import cursor ` replaces the local Cursor estimates with Cursor's own usage export.** The local database has no per-request token counts and none of the cache reads Cursor re-sends, so on one real month CodeBurn saw about 7M Cursor tokens where the dashboard showed 644M. Export CSV at cursor.com/dashboard/usage and import it: for the time the export covers, its events replace the local Cursor, Cursor Agent and (when the export holds Grok Bot events) Grok Bot estimates, and outside it the estimates stay. Dollar amounts in the export are kept as billed; included usage is priced at API rates like every other local call. Pass `--from`/`--to` with the range you exported, re-import overlapping exports without double counting, and `--remove` to go back. Stored locally, nothing is uploaded. Reported in #1545.
### Removed
diff --git a/README.md b/README.md
index b4b10372..3205e123 100644
--- a/README.md
+++ b/README.md
@@ -156,7 +156,7 @@ That registers a local MCP server over stdio. Your agent can then answer "where
It reads the same files on disk that the CLI reads. The server answers from that local data and makes no network call of its own, and project names are pseudonymized unless the agent asks for them.
-## Works with 40 tools
+## Works with 41 tools
CodeBurn detects the tools you already use. There is nothing to configure and no folder to point it at. If a tool is installed and has sessions on disk, it shows up. Each logo links to that tool's page.
@@ -198,11 +198,11 @@ CodeBurn detects the tools you already use. There is nothing to configure and no
-All 40 tools
+All 41 tools
Each page lists where that tool keeps its data, the format it uses, and the quirks CodeBurn works around.
-[Antigravity](docs/providers/antigravity.md) · [Claude Code](docs/providers/claude.md) · [Cline](docs/providers/cline.md) · [Cline CLI](docs/providers/cline-cli.md) · [Codebuff](docs/providers/codebuff.md) · [Codex](docs/providers/codex.md) ·
+[Amp](docs/providers/amp.md) · [Antigravity](docs/providers/antigravity.md) · [Claude Code](docs/providers/claude.md) · [Cline](docs/providers/cline.md) · [Cline CLI](docs/providers/cline-cli.md) · [Codebuff](docs/providers/codebuff.md) · [Codex](docs/providers/codex.md) ·
[CodeWhale](docs/providers/codewhale.md) · [Copilot](docs/providers/copilot.md) · [Crush](docs/providers/crush.md) · [Cursor](docs/providers/cursor.md) · [Cursor Agent](docs/providers/cursor-agent.md) · [DeepSeek Harness](docs/providers/dsh.md) ·
[Devin](docs/providers/devin.md) · [Droid](docs/providers/droid.md) · [Forge](docs/providers/forge.md) · [Gemini CLI](docs/providers/gemini.md) · [Goose](docs/providers/goose.md) · [Grok Bot](docs/providers/grokbot.md) ·
[Grok Build](docs/providers/grok.md) · [Hermes Agent](docs/providers/hermes.md) · [IBM Bob](docs/providers/ibm-bob.md) · [KiloCode](docs/providers/kilo-code.md) · [Kimi](docs/providers/kimi.md) · [Kimi Code](docs/providers/kimicode.md) ·
diff --git a/app/package.json b/app/package.json
index c461c89f..baf44ae8 100644
--- a/app/package.json
+++ b/app/package.json
@@ -274,6 +274,7 @@
"$HOME/.local/share/opencode",
"$HOME/.local/share/crush",
"$HOME/.local/share/goose/sessions",
+ "$HOME/.local/share/amp/threads",
"$HOME/.local/share/kilo",
"$HOME/.local/share/zerostack/sessions"
]
diff --git a/docs/architecture.md b/docs/architecture.md
index 94b1ebc6..b39b5077 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -193,7 +193,7 @@ type Provider = {
`src/providers/index.ts` registers providers across two tiers:
-- **Eager**: `claude`, `cline`, `cline-cli`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `dsh`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `kimicode`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `openclaude`, `open-design`, `pi`, `omp`, `qwen`, `quickdesk`, `zerostack`, `grok`. Imported at module load.
+- **Eager**: `claude`, `cline`, `cline-cli`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `dsh`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `kimicode`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `openclaude`, `open-design`, `pi`, `omp`, `qwen`, `quickdesk`, `zerostack`, `grok`, `grokbot`, `amp`. Imported at module load.
- **Lazy**: `antigravity`, `forge`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`, `warp`, `vercel-gateway`, `zcode`, `zed`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf, network clients) do not touch users who do not have those tools installed.
Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run.
diff --git a/docs/providers/README.md b/docs/providers/README.md
index c60f1c93..88b2e0f3 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -10,6 +10,7 @@ For the architectural picture, see `../architecture.md`.
| Provider | Storage | Source | Test |
|---|---|---|---|
+| [Amp](amp.md) | JSON (per-thread) | `src/providers/amp.ts` | `tests/providers/amp.test.ts` |
| [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) |
| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` |
| [Cline CLI](cline-cli.md) | JSON | `src/providers/cline-cli.ts` | `tests/providers/cline-cli.test.ts` |
diff --git a/docs/providers/amp.md b/docs/providers/amp.md
new file mode 100644
index 00000000..3cf28cd3
--- /dev/null
+++ b/docs/providers/amp.md
@@ -0,0 +1,48 @@
+# Amp
+
+Amp, Sourcegraph's coding agent ([ampcode.com](https://ampcode.com)), as a CLI or editor extension. Reported in #1572.
+
+- **Source:** `src/providers/amp.ts`
+- **Loading:** eager (`src/providers/index.ts`)
+- **Test:** `tests/providers/amp.test.ts`
+
+## Where it reads from
+
+`$AMP_DATA_DIR/threads/` (or `~/.local/share/amp/threads/`). `AMP_DATA_DIR` is Amp's own override; CodeBurn accepts a comma-separated list the way Amp does. One JSON document per thread, discovered recursively under `threads/**/*.json`.
+
+## Storage format
+
+A thread file is `{ id, messages: [...], usageLedger?: { events: [...] } }`. `usageLedger.events` is the per-request record: `{ id, timestamp, model, tokens: { input, output, total }, toMessageId, credits }`. Cache tokens are not on the event - they live on the thread message the event billed for, joined through `toMessageId` -> `messages[].messageId` -> `usage.cacheCreationInputTokens` / `usage.cacheReadInputTokens`. Threads without a ledger fall back to per-assistant-message usage blocks (`{ inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens, totalTokens, timestamp, model }`, with `timestamp` and `model` allowed on the message itself). The format is mapped against ccusage's amp adapter, which parses real thread files.
+
+## Token model
+
+**Ledger-first.** When a thread carries a usage ledger, each event is one call. Any positive remainder of `tokens.total` after subtracting input, output, and both cache-token counts is added to output. A total-only `tokens` record (`{"total": N}`) without cache tokens therefore bills as `N` output tokens. Cache tokens come from the billed message via `toMessageId`; a record whose `toMessageId` does not resolve prices without cache. No reasoning field exists, so `reasoningTokens` is 0.
+
+**Fallback.** Without a ledger, every assistant message with a usable usage block is one call.
+
+A record with all four token fields empty or malformed contributes nothing; untrusted numbers are clamped to finite non-negative before they can reach the aggregate.
+
+## Pricing
+
+Every call is priced from the model's token rates in `src/models.ts` and carries `costIsEstimated`; Amp's own `credits` field is not converted. Amp's model ids are the underlying OpenAI/Anthropic/Google ids and price through the standard catalog.
+
+## Caching
+
+Cache-read and cache-creation counts arrive through the ledger join (or the message's usage block). Token-priced costs are not cached; the cached call is re-priced on read, so a pricing update reaches history.
+
+## Deduplication
+
+Per `amp::` from the ledger, or `amp:::` on the message fallback path.
+
+## Quirks
+
+- **No project attribution.** Thread files carry no cwd, so Amp sessions group under the provider name (`Amp`) instead of a repository.
+- **No tool capture.** Thread files expose no tool-call records CodeBurn could read, so `tools` and `bashCommands` are empty.
+- **Estimated costs by design.** Every call is marked `costIsEstimated` (see Pricing). Token counts themselves are provider-recorded.
+- **`credits` is not converted.** CodeBurn estimates cost from tokens rather than summing Amp's recorded credits.
+
+## When fixing a bug here
+
+1. Discovery: check the recursive `threads/**/*.json` walk, the `.json` extension filter (without reading file contents), and the `AMP_DATA_DIR` resolution (comma-separated).
+2. Token accounting: see `parseThreadFile` (ledger join through `toMessageId` -> `messages[].messageId`, then the per-message fallback).
+3. Add a fixture-format thread under `tests/providers/amp.test.ts`; do not mock the filesystem.
diff --git a/gnome/prefs.js b/gnome/prefs.js
index 2a9a3608..c977dbf2 100644
--- a/gnome/prefs.js
+++ b/gnome/prefs.js
@@ -7,6 +7,7 @@ const PROVIDERS = [
{ id: 'claude', label: 'Claude' },
{ id: 'codex', label: 'Codex' },
{ id: 'copilot', label: 'Copilot' },
+ { id: 'amp', label: 'Amp' },
{ id: 'cursor', label: 'Cursor' },
{ id: 'devin', label: 'Devin' },
{ id: 'droid', label: 'Droid' },
diff --git a/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift
index 03003783..d411465c 100644
--- a/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift
@@ -117,6 +117,13 @@ enum UsageDataChangeGuard {
add(path(xdgData, "kilo"), scanFirstLevelDirectories: false)
add(expand(environment["GOOSE_PATH_ROOT"] ?? path(xdgData, "goose"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
add(expand(environment["CRUSH_GLOBAL_DATA"] ?? path(xdgData, "crush"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
+ let ampDataDirectories = (environment["AMP_DATA_DIR"] ?? "")
+ .split(separator: ",")
+ .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ for ampDataDirectory in ampDataDirectories.isEmpty ? [path(homeDirectory, ".local", "share", "amp")] : ampDataDirectories {
+ add(ampDataDirectory)
+ }
// Warp's default location is never watched. Its database lives in
// ~/Library/Group Containers/2BBY89MBSN.dev.warp (src/providers/warp.ts),
// and that tree is behind the "access data from other apps" consent: a
diff --git a/mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift b/mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift
index f6ad38c1..e517dbe6 100644
--- a/mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift
+++ b/mac/Tests/CodeBurnMenubarTests/UsageDataChangeGuardTests.swift
@@ -100,6 +100,41 @@ struct UsageDataChangeGuardTests {
#expect(!set.modificationDates.keys.contains { $0.contains("Group Containers") })
}
+ @Test("Amp watches the default root and comma-separated overrides")
+ func ampDataRootsAreWatched() throws {
+ let home = URL(fileURLWithPath: NSTemporaryDirectory())
+ .appendingPathComponent("CodeBurnMenubarTests.\(UUID().uuidString)", isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let defaultRoot = home.appendingPathComponent(".local/share/amp")
+ let first = home.appendingPathComponent("amp-a")
+ let second = home.appendingPathComponent("amp-b")
+ for root in [defaultRoot, first, second] {
+ try FileManager.default.createDirectory(
+ at: root.appendingPathComponent("threads"), withIntermediateDirectories: true
+ )
+ }
+ let environment = ["CLAUDE_CONFIG_DIR": home.path]
+ let unset = UsageDataChangeGuard.snapshot(environment: environment, homeDirectory: home.path)
+ #expect(unset.modificationDates[defaultRoot.path] != nil)
+ #expect(unset.modificationDates[defaultRoot.appendingPathComponent("threads").path] != nil)
+
+ let set = UsageDataChangeGuard.snapshot(
+ environment: environment.merging(["AMP_DATA_DIR": " \(first.path), ,\(second.path), "]) { _, new in new },
+ homeDirectory: home.path
+ )
+ for root in [first, second] {
+ #expect(set.modificationDates[root.path] != nil)
+ #expect(set.modificationDates[root.appendingPathComponent("threads").path] != nil)
+ }
+ #expect(set.modificationDates[defaultRoot.path] == nil)
+
+ let empty = UsageDataChangeGuard.snapshot(
+ environment: environment.merging(["AMP_DATA_DIR": " , , "]) { _, new in new },
+ homeDirectory: home.path
+ )
+ #expect(empty.modificationDates[defaultRoot.path] != nil)
+ }
+
private func makeSnapshot(_ seconds: TimeInterval) -> UsageDataSnapshot {
UsageDataSnapshot(modificationDates: ["provider-root": Date(timeIntervalSince1970: seconds)])
}
diff --git a/src/providers/amp.ts b/src/providers/amp.ts
new file mode 100644
index 00000000..b3fdd1e4
--- /dev/null
+++ b/src/providers/amp.ts
@@ -0,0 +1,331 @@
+import { readdir } from 'fs/promises'
+import { join } from 'path'
+import { homedir } from 'os'
+
+import { readSessionFile } from '../fs-utils.js'
+import { calculateCost, getShortModelName } from '../models.js'
+import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
+
+// Amp (Sourcegraph's coding agent, ampcode.com) mirrors every thread it
+// serves as one JSON document per thread at /threads/.json,
+// where amp-data is $AMP_DATA_DIR (comma-separated for extra roots) or
+// ~/.local/share/amp. Mapped against the format ccusage's amp adapter reads
+// (rust/adapters/amp/src/parser.rs) - the same layout its own tests fixture:
+//
+// { id, messages: [...], usageLedger?: { events: [...] } }
+//
+// usageLedger.events is the authoritative per-request record: { id, timestamp,
+// model, tokens: { input, output, total }, toMessageId, credits }. Cache
+// tokens are not on the event - they live on the thread message the event
+// billed for (toMessageId -> message.messageId ->
+// usage.cacheCreationInputTokens / usage.cacheReadInputTokens). Threads
+// without a ledger fall back to the per-assistant-message usage blocks
+// ({ inputTokens, outputTokens, cacheCreationInputTokens,
+// cacheReadInputTokens, totalTokens, timestamp, model }). When a record
+// publishes only tokens.total, the total becomes the output count.
+//
+// Costs are priced from tokens; Amp's own credits field is not converted.
+// Every cost uses CodeBurn's model pricing (models.ts) and carries
+// costIsEstimated. Thread files carry no project/cwd, so sessions group under
+// the provider name. Amp's model ids are the underlying OpenAI/Anthropic/
+// Google ids and price through the standard catalog.
+
+// Amp's data dir can be relocated via AMP_DATA_DIR (its own env var,
+// comma-separated for multiple roots, matching Amp's semantics).
+function ampDataDirs(): string[] {
+ const override = process.env['AMP_DATA_DIR']
+ if (override) {
+ const dirs = override.split(',').map(part => part.trim()).filter(Boolean)
+ if (dirs.length > 0) return [...new Set(dirs)]
+ }
+ return [join(homedir(), '.local/share/amp')]
+}
+
+function threadsDirFor(ampDataDir: string): string {
+ return join(ampDataDir, 'threads')
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+// Thread files are third-party input that moves with Amp releases. Keep the
+// check local so bad usage fields become absent rather than leaking NaN,
+// negative tokens, or a throwing arithmetic operation into the aggregate.
+function finiteNonNegative(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
+ return Math.min(value, Number.MAX_SAFE_INTEGER)
+ }
+ return undefined
+}
+
+function readNumber(record: Record, field: string): number {
+ return finiteNonNegative(record[field]) ?? 0
+}
+
+function nonEmptyString(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+// User text feeds the `userMessage` column. Content may be a plain string or
+// a block array; only the text blocks are usable.
+function messageText(message: Record): string {
+ const content = message['content']
+ if (typeof content === 'string') return content
+ if (Array.isArray(content)) {
+ return content
+ .filter(isRecord)
+ .map(block => nonEmptyString(block['text']) ?? '')
+ .filter(Boolean)
+ .join(' ')
+ }
+ return nonEmptyString(message['text']) ?? ''
+}
+
+type AmpCallRecord = {
+ model: string
+ timestamp: string
+ inputTokens: number
+ outputTokens: number
+ cacheCreationInputTokens: number
+ cacheReadInputTokens: number
+ // Ledger-only: the event id dedups re-reads of the same thread file.
+ eventId: string | undefined
+ // Index into the thread's message array the record billed for (ledger
+ // toMessageId, or the message's own index on the fallback path) - anchors
+ // userMessage attribution.
+ messageIndex: number | undefined
+}
+
+type ParsedThread = {
+ threadId: string
+ calls: AmpCallRecord[]
+ userMessages: { index: number; text: string }[]
+}
+
+function parseThreadFile(content: string): ParsedThread | null {
+ let thread: Record
+ try {
+ thread = JSON.parse(content) as Record
+ } catch {
+ return null
+ }
+ if (!isRecord(thread)) return null
+ const threadId = nonEmptyString(thread['id'])
+ if (!threadId) return null
+
+ const messages = Array.isArray(thread['messages']) ? thread['messages'].filter(isRecord) : []
+ const userMessages = messages
+ .map((message, index) => ({ index, text: messageText(message) }))
+ .filter(entry => entry.text.length > 0)
+
+ // Cache tokens per billed message id: ledger events join
+ // toMessageId -> messages[].messageId -> usage.cache*InputTokens. The
+ // message's array index travels with it to anchor userMessage attribution.
+ const cacheTokensByMessageId = new Map()
+ messages.forEach((message, index) => {
+ if (message['role'] !== 'assistant') return
+ const usage = message['usage']
+ if (!isRecord(usage)) return
+ const messageId = message['messageId']
+ if (typeof messageId !== 'number' || !Number.isInteger(messageId)) return
+ cacheTokensByMessageId.set(messageId, {
+ index,
+ creation: readNumber(usage, 'cacheCreationInputTokens'),
+ read: readNumber(usage, 'cacheReadInputTokens'),
+ })
+ })
+
+ const calls: AmpCallRecord[] = []
+
+ // Preferred path: the usage ledger, one event per billed request.
+ const ledger = thread['usageLedger']
+ const events = isRecord(ledger) && Array.isArray(ledger['events']) ? ledger['events'] : []
+ for (const raw of events) {
+ if (!isRecord(raw)) continue
+ const timestamp = nonEmptyString(raw['timestamp'])
+ const model = nonEmptyString(raw['model'])
+ const tokens = raw['tokens']
+ if (!timestamp || !model || !isRecord(tokens)) continue
+
+ const inputTokens = readNumber(tokens, 'input')
+ const outputTokens = readNumber(tokens, 'output')
+ const total = finiteNonNegative(tokens['total'])
+ const toMessageId = raw['toMessageId']
+ const billed = typeof toMessageId === 'number' && Number.isInteger(toMessageId)
+ ? cacheTokensByMessageId.get(toMessageId)
+ : undefined
+ // Attribute any positive remainder to output, after accounting for the
+ // explicit input/output counts and both joined cache-token counts.
+ const accountedTokens = inputTokens + outputTokens + (billed?.creation ?? 0) + (billed?.read ?? 0)
+ const billedOutput = outputTokens + Math.max(0, (total ?? 0) - accountedTokens)
+
+ calls.push({
+ model,
+ timestamp,
+ inputTokens,
+ outputTokens: billedOutput,
+ cacheCreationInputTokens: billed?.creation ?? 0,
+ cacheReadInputTokens: billed?.read ?? 0,
+ eventId: nonEmptyString(raw['id']) ?? (typeof raw['id'] === 'number' ? String(raw['id']) : undefined),
+ messageIndex: billed?.index,
+ })
+ }
+
+ if (calls.length > 0) return { threadId, calls, userMessages }
+
+ // Fallback: per-assistant-message usage blocks (threads without a ledger).
+ messages.forEach((message, index) => {
+ if (message['role'] !== 'assistant') return
+ const usage = message['usage']
+ if (!isRecord(usage)) return
+ const timestamp = nonEmptyString(usage['timestamp']) ?? nonEmptyString(message['timestamp'])
+ const model = nonEmptyString(usage['model']) ?? nonEmptyString(message['model'])
+ if (!timestamp || !model) return
+
+ const inputTokens = readNumber(usage, 'inputTokens')
+ const outputTokens = readNumber(usage, 'outputTokens')
+ const cacheCreationInputTokens = readNumber(usage, 'cacheCreationInputTokens')
+ const cacheReadInputTokens = readNumber(usage, 'cacheReadInputTokens')
+ const total = finiteNonNegative(usage['totalTokens'])
+ const hasParts = inputTokens > 0 || outputTokens > 0 || cacheCreationInputTokens > 0 || cacheReadInputTokens > 0
+ const billedOutput = hasParts ? outputTokens : total ?? 0
+
+ calls.push({
+ model,
+ timestamp,
+ inputTokens,
+ outputTokens: billedOutput,
+ cacheCreationInputTokens,
+ cacheReadInputTokens,
+ eventId: undefined,
+ messageIndex: index,
+ })
+ })
+
+ return { threadId, calls, userMessages }
+}
+
+function userMessageFor(parsed: ParsedThread, record: AmpCallRecord): string {
+ // Nearest user message at or before the billed message; the thread's first
+ // user text when the record carries no anchor.
+ const anchor = record.messageIndex ?? Number.MAX_SAFE_INTEGER
+ for (let i = parsed.userMessages.length - 1; i >= 0; i--) {
+ if (parsed.userMessages[i].index < anchor) return parsed.userMessages[i].text
+ }
+ return parsed.userMessages[0]?.text ?? ''
+}
+
+function hasPositiveTokens(record: AmpCallRecord): boolean {
+ return record.inputTokens > 0
+ || record.outputTokens > 0
+ || record.cacheCreationInputTokens > 0
+ || record.cacheReadInputTokens > 0
+}
+
+function createParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return {
+ async *parse(): AsyncGenerator {
+ const content = await readSessionFile(source.path)
+ if (content === null) return
+ const parsed = parseThreadFile(content)
+ if (!parsed) return
+
+ const threadId = parsed.threadId
+
+ for (const record of parsed.calls) {
+ if (!hasPositiveTokens(record)) continue
+
+ const deduplicationKey = record.eventId
+ ? `${source.provider}:${threadId}:${record.eventId}`
+ : `${source.provider}:${threadId}:${record.timestamp}:${record.model}:${record.inputTokens}x${record.outputTokens}`
+ if (seenKeys.has(deduplicationKey)) continue
+ seenKeys.add(deduplicationKey)
+
+ yield {
+ provider: source.provider,
+ model: record.model,
+ inputTokens: record.inputTokens,
+ outputTokens: record.outputTokens,
+ cacheCreationInputTokens: record.cacheCreationInputTokens,
+ cacheReadInputTokens: record.cacheReadInputTokens,
+ cachedInputTokens: record.cacheReadInputTokens,
+ reasoningTokens: 0,
+ webSearchRequests: 0,
+ costUSD: calculateCost(
+ record.model,
+ record.inputTokens,
+ record.outputTokens,
+ record.cacheCreationInputTokens,
+ record.cacheReadInputTokens,
+ 0,
+ ),
+ // Priced from tokens; Amp's own credits field is not converted.
+ costIsEstimated: true,
+ tools: [],
+ bashCommands: [],
+ timestamp: record.timestamp,
+ speed: 'standard',
+ deduplicationKey,
+ userMessage: userMessageFor(parsed, record),
+ sessionId: threadId,
+ project: source.project,
+ }
+ }
+ },
+ }
+}
+
+async function discoverThreads(roots: string[]): Promise {
+ const sources: SessionSource[] = []
+
+ for (const root of roots) {
+ const threadsDir = threadsDirFor(root)
+ let entries: string[]
+ try {
+ entries = await readdir(threadsDir, { recursive: true })
+ } catch {
+ continue
+ }
+
+ // Discovery only walks names. The parser reads and validates each thread
+ // once, skipping malformed JSON and files without a thread id.
+ for (const name of entries) {
+ if (!name.endsWith('.json')) continue
+ sources.push({ path: join(threadsDir, name), project: 'Amp', provider: 'amp' })
+ }
+ }
+
+ return sources
+}
+
+export function createAmpProvider(overrideDirs?: string[]): Provider {
+ const roots = overrideDirs ?? ampDataDirs()
+
+ return {
+ name: 'amp',
+ displayName: 'Amp',
+
+ async probeRoots(): Promise {
+ return roots.map(root => ({ path: threadsDirFor(root), label: 'threads' }))
+ },
+
+ modelDisplayName(model: string): string {
+ return getShortModelName(model)
+ },
+
+ toolDisplayName(rawTool: string): string {
+ return rawTool
+ },
+
+ async discoverSessions(): Promise {
+ return discoverThreads(roots)
+ },
+
+ createSessionParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return createParser(source, seenKeys)
+ },
+ }
+}
+
+export const amp = createAmpProvider()
diff --git a/src/providers/index.ts b/src/providers/index.ts
index a8a06f82..9b58a5eb 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -1,3 +1,4 @@
+import { amp } from './amp.js'
import { claude } from './claude.js'
import { cline } from './cline.js'
import { clineCli } from './cline-cli.js'
@@ -194,7 +195,7 @@ async function loadZed(): Promise {
}
}
-const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, zerostack, grok, grokbot]
+const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, zerostack, grok, grokbot, amp]
// Lazily loaded providers, listed by name so --provider validation works even
// when an optional module fails to load. Must stay in sync with getAllProviders.
diff --git a/src/session-cache.ts b/src/session-cache.ts
index b8d22c1c..28756358 100644
--- a/src/session-cache.ts
+++ b/src/session-cache.ts
@@ -295,6 +295,7 @@ const UNREFERENCED_SHARD_MAX_AGE_MS = 60 * 60 * 1000
// readable. Hashing the policy here would instead discard the rows that make a
// shutdown/restart cycle lossless and force a full 9P re-parse after re-enable.
export const PROVIDER_ENV_VARS: Record = {
+ amp: ['AMP_DATA_DIR'],
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'APPDATA', 'LOCALAPPDATA'],
'cline-cli': ['CLINE_SESSION_DATA_DIR', 'CLINE_DATA_DIR', 'CLINE_DIR'],
codebuff: ['CODEBUFF_DATA_DIR'],
@@ -354,6 +355,9 @@ const FULL_LOAD_PROVIDER_NAMES: ReadonlySet = new Set(['hermes', 'quickd
// re-parse, which lands the flag too, and durable orphans now survive
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
export const PROVIDER_PARSE_VERSIONS: Record = {
+ // usage-ledger-v2: include positive ledger total remainders as output after
+ // subtracting input, output, and cache tokens joined through toMessageId.
+ amp: 'usage-ledger-v2',
// rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call
// LOC deltas / interruptions / userModified / toolErrors, and session-level
// title / prLinks / isSidechain. Forces one re-parse so cached sessions gain
diff --git a/tests/provider-env-declarations.test.ts b/tests/provider-env-declarations.test.ts
index c7894aa0..36f337df 100644
--- a/tests/provider-env-declarations.test.ts
+++ b/tests/provider-env-declarations.test.ts
@@ -27,6 +27,7 @@ import { getAllProviders } from '../src/providers/index.js'
// providers). A file that contains env reads and is missing here fails the
// guard: add it, with the provider(s) the reads serve.
const FILE_PROVIDERS: Record = {
+ 'amp.ts': ['amp'],
'claude.ts': ['claude'],
'cline-cli.ts': ['cline-cli'],
'codebuff.ts': ['codebuff'],
diff --git a/tests/provider-probe-roots-tier2.test.ts b/tests/provider-probe-roots-tier2.test.ts
index 5dc38283..c9e05e9a 100644
--- a/tests/provider-probe-roots-tier2.test.ts
+++ b/tests/provider-probe-roots-tier2.test.ts
@@ -4,6 +4,7 @@ import { homedir } from 'os'
import { createClineProvider, getClineDataPath } from '../src/providers/cline.js'
import { createKiloCodeProvider } from '../src/providers/kilo-code.js'
+import { createAmpProvider } from '../src/providers/amp.js'
import { createGrokProvider } from '../src/providers/grok.js'
import { createPiProvider, createOmpProvider } from '../src/providers/pi.js'
import { createKimiProvider } from '../src/providers/kimi.js'
@@ -83,6 +84,15 @@ describe('probeRoots mirrors discovery resolution (Tier 2, batch 1)', () => {
])
})
+ it('amp reports exactly its resolved threads dir', async () => {
+ expect(await createAmpProvider(['/tmp/amp-a']).probeRoots!()).toEqual([
+ { path: join('/tmp/amp-a', 'threads'), label: 'threads' },
+ ])
+ expect(await createAmpProvider().probeRoots!()).toEqual([
+ { path: join(homedir(), '.local', 'share', 'amp', 'threads'), label: 'threads' },
+ ])
+ })
+
it('pi and omp each report their own sessions dir', async () => {
expect(await createPiProvider('/tmp/pi-a').probeRoots!()).toEqual([
{ path: '/tmp/pi-a', label: 'sessions' },
diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts
index a0f47249..41f88c56 100644
--- a/tests/provider-registry.test.ts
+++ b/tests/provider-registry.test.ts
@@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
- expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'zerostack', 'grok', 'grokbot'])
+ expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'zerostack', 'grok', 'grokbot', 'amp'])
})
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
diff --git a/tests/providers/amp.test.ts b/tests/providers/amp.test.ts
new file mode 100644
index 00000000..1460d369
--- /dev/null
+++ b/tests/providers/amp.test.ts
@@ -0,0 +1,331 @@
+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 { createAmpProvider } from '../../src/providers/amp.js'
+import { calculateCost } from '../../src/models.js'
+
+let tmpDir: string
+
+beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), 'amp-test-'))
+})
+
+afterEach(async () => {
+ await rm(tmpDir, { recursive: true, force: true })
+})
+
+// Mirrors the real on-disk layout: /threads/.json, one
+// JSON document per thread. The shapes follow ccusage's amp adapter fixtures
+// (rust/adapters/amp/src/parser.rs), which parse real Amp thread files.
+async function writeThread(id: string, thread: unknown): Promise {
+ const dir = join(tmpDir, 'threads')
+ await mkdir(dir, { recursive: true })
+ const path = join(dir, `${id}.json`)
+ await writeFile(path, JSON.stringify(thread), 'utf-8')
+ return path
+}
+
+const LEDGER_THREAD = {
+ id: 'T-abc123',
+ messages: [
+ { role: 'user', content: 'Fix the flaky test in parser.ts' },
+ {
+ role: 'assistant',
+ messageId: 11,
+ usage: {
+ model: 'claude-opus-4-6',
+ inputTokens: 10,
+ outputTokens: 178,
+ cacheCreationInputTokens: 986,
+ cacheReadInputTokens: 11372,
+ timestamp: '2026-09-20T11:42:10.652Z',
+ },
+ },
+ { role: 'user', content: 'Now make it fast' },
+ {
+ role: 'assistant',
+ messageId: 14,
+ usage: {
+ model: 'claude-opus-4-6',
+ inputTokens: 5,
+ outputTokens: 42,
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 12000,
+ timestamp: '2026-09-20T11:43:00.000Z',
+ },
+ },
+ ],
+ usageLedger: {
+ events: [
+ {
+ id: 'evt-1',
+ timestamp: '2026-09-20T11:42:12.000Z',
+ model: 'claude-opus-4-6',
+ tokens: { input: 12368, output: 178 },
+ toMessageId: 11,
+ },
+ {
+ id: 'evt-2',
+ timestamp: '2026-09-20T11:43:02.000Z',
+ model: 'claude-opus-4-6',
+ tokens: { input: 12005, output: 42 },
+ toMessageId: 14,
+ },
+ ],
+ },
+}
+
+async function parseAll(source: Awaited['discoverSessions']>[number]>, seenKeys = new Set()) {
+ const provider = createAmpProvider([tmpDir])
+ const calls = []
+ for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call)
+ return calls
+}
+
+describe('amp provider', () => {
+ it('reports its identity', () => {
+ const provider = createAmpProvider([tmpDir])
+ expect(provider.name).toBe('amp')
+ expect(provider.displayName).toBe('Amp')
+ expect(provider.modelDisplayName('claude-opus-4-6')).toBeTruthy()
+ expect(provider.toolDisplayName('some_tool')).toBe('some_tool')
+ })
+
+ it('probes the threads dir it discovers', async () => {
+ const provider = createAmpProvider([tmpDir])
+ expect(await provider.probeRoots()).toEqual([{ path: join(tmpDir, 'threads'), label: 'threads' }])
+ })
+
+ it('resolves the default root from AMP_DATA_DIR, comma-separated', async () => {
+ const first = await mkdtemp(join(tmpdir(), 'amp-root-a-'))
+ const second = await mkdtemp(join(tmpdir(), 'amp-root-b-'))
+ try {
+ process.env['AMP_DATA_DIR'] = `${first}, ${second}`
+ const provider = createAmpProvider()
+ expect(await provider.probeRoots()).toEqual([
+ { path: join(first, 'threads'), label: 'threads' },
+ { path: join(second, 'threads'), label: 'threads' },
+ ])
+ } finally {
+ delete process.env['AMP_DATA_DIR']
+ await rm(first, { recursive: true, force: true })
+ await rm(second, { recursive: true, force: true })
+ }
+ })
+
+ it('discovers nothing when the data dir is absent', async () => {
+ const provider = createAmpProvider([join(tmpDir, 'missing')])
+ expect(await provider.discoverSessions()).toEqual([])
+ })
+
+ it('discovers JSON candidates without parsing and skips invalid threads during parsing', async () => {
+ await writeThread('T-good', LEDGER_THREAD)
+ await mkdir(join(tmpDir, 'threads'), { recursive: true })
+ await writeFile(join(tmpDir, 'threads', 'notes.txt'), 'not a thread', 'utf-8')
+ await writeFile(join(tmpDir, 'threads', 'T-broken.json'), '{not json', 'utf-8')
+ await writeFile(join(tmpDir, 'threads', 'T-noid.json'), JSON.stringify({ messages: [] }), 'utf-8')
+
+ const provider = createAmpProvider([tmpDir])
+ const sources = await provider.discoverSessions()
+ expect(sources.map(source => source.path).sort()).toEqual([
+ join(tmpDir, 'threads', 'T-broken.json'),
+ join(tmpDir, 'threads', 'T-good.json'),
+ join(tmpDir, 'threads', 'T-noid.json'),
+ ])
+ for (const source of sources) {
+ expect(source.provider).toBe('amp')
+ expect(source.project).toBe('Amp')
+ expect(await parseAll(source)).toHaveLength(source.path.endsWith('T-good.json') ? 2 : 0)
+ }
+ })
+
+ it('discovers and parses nested threads', async () => {
+ const dir = join(tmpDir, 'threads', 'sub', 'nested')
+ await mkdir(dir, { recursive: true })
+ const path = join(dir, 'T-2.json')
+ await writeFile(path, JSON.stringify({ ...LEDGER_THREAD, id: 'T-2' }), 'utf-8')
+ await writeFile(join(dir, 'notes.txt'), 'not a thread', 'utf-8')
+
+ const sources = await createAmpProvider([tmpDir]).discoverSessions()
+ expect(sources).toEqual([{ path, project: 'Amp', provider: 'amp' }])
+ const calls = await parseAll(sources[0])
+ expect(calls).toHaveLength(2)
+ expect(calls[0].sessionId).toBe('T-2')
+ })
+
+ it('parses ledger events with cache tokens joined from the billed message', async () => {
+ const path = await writeThread('T-abc123', LEDGER_THREAD)
+ const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
+
+ expect(calls).toHaveLength(2)
+
+ expect(calls[0].model).toBe('claude-opus-4-6')
+ expect(calls[0].inputTokens).toBe(12368)
+ expect(calls[0].outputTokens).toBe(178)
+ expect(calls[0].cacheCreationInputTokens).toBe(986)
+ expect(calls[0].cacheReadInputTokens).toBe(11372)
+ expect(calls[0].cachedInputTokens).toBe(11372)
+ expect(calls[0].reasoningTokens).toBe(0)
+ expect(calls[0].timestamp).toBe('2026-09-20T11:42:12.000Z')
+ expect(calls[0].sessionId).toBe('T-abc123')
+ expect(calls[0].deduplicationKey).toBe('amp:T-abc123:evt-1')
+ expect(calls[0].costIsEstimated).toBe(true)
+ expect(calls[0].costUSD).toBe(calculateCost('claude-opus-4-6', 12368, 178, 986, 11372, 0))
+ expect(calls[0].costUSD).toBeGreaterThan(0)
+ // The ledger event carries no text; attribution falls back to the
+ // thread's first user message.
+ expect(calls[0].userMessage).toBe('Fix the flaky test in parser.ts')
+
+ expect(calls[1].deduplicationKey).toBe('amp:T-abc123:evt-2')
+ expect(calls[1].cacheReadInputTokens).toBe(12000)
+ })
+
+ it('bills a total-only token record as output', async () => {
+ await writeThread('T-total', {
+ id: 'T-total',
+ messages: [],
+ usageLedger: {
+ events: [
+ { id: 'evt-t', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: { total: 345 } },
+ ],
+ },
+ })
+ const path = join(tmpDir, 'threads', 'T-total.json')
+ const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0].inputTokens).toBe(0)
+ expect(calls[0].outputTokens).toBe(345)
+ expect(calls[0].costUSD).toBeGreaterThan(0)
+ })
+
+ it.each([
+ { input: 10, output: 20, creation: 30, read: 40, total: 150, expectedOutput: 70 },
+ { input: 10, output: 20, creation: 30, read: 40, total: 100, expectedOutput: 20 },
+ { input: 10, output: 20, creation: 30, read: 40, total: 50, expectedOutput: 20 },
+ { input: 10, output: 20, creation: 0, read: 0, total: 50, expectedOutput: 40 },
+ { input: 0, output: 0, creation: 30, read: 40, total: 100, expectedOutput: 30 },
+ ])('accounts for ledger total remainders: %j', async ({ input, output, creation, read, total, expectedOutput }) => {
+ const path = await writeThread('T-remainder', {
+ id: 'T-remainder',
+ messages: [{
+ role: 'assistant',
+ messageId: 1,
+ usage: { cacheCreationInputTokens: creation, cacheReadInputTokens: read },
+ }],
+ usageLedger: {
+ events: [{
+ id: 'evt-remainder',
+ timestamp: '2026-09-20T12:00:00.000Z',
+ model: 'claude-opus-4-6',
+ tokens: { input, output, total },
+ toMessageId: 1,
+ }],
+ },
+ })
+ const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
+ expect(calls).toHaveLength(1)
+ expect(calls[0].inputTokens).toBe(input)
+ expect(calls[0].outputTokens).toBe(expectedOutput)
+ expect(calls[0].cacheCreationInputTokens).toBe(creation)
+ expect(calls[0].cacheReadInputTokens).toBe(read)
+ expect(calls[0].costUSD).toBe(calculateCost('claude-opus-4-6', input, expectedOutput, creation, read, 0))
+ })
+
+ it('falls back to per-message usage when the thread has no ledger', async () => {
+ await writeThread('T-msgs', {
+ id: 'T-msgs',
+ messages: [
+ { role: 'user', content: 'hi' },
+ {
+ role: 'assistant',
+ messageId: 3,
+ usage: {
+ model: 'claude-haiku-4-5-20251001',
+ inputTokens: 10,
+ outputTokens: 178,
+ cacheCreationInputTokens: 986,
+ cacheReadInputTokens: 11372,
+ timestamp: '2026-09-19T11:42:10.652Z',
+ },
+ },
+ {
+ role: 'assistant',
+ // Model and timestamp may live on the message when the usage block
+ // omits them.
+ model: 'gpt-5.6-codex',
+ timestamp: '2026-09-19T11:43:00.000Z',
+ usage: { inputTokens: 5, outputTokens: 42 },
+ },
+ ],
+ })
+ const path = join(tmpDir, 'threads', 'T-msgs.json')
+ const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
+
+ expect(calls).toHaveLength(2)
+ expect(calls[0].model).toBe('claude-haiku-4-5-20251001')
+ expect(calls[0].inputTokens).toBe(10)
+ expect(calls[0].outputTokens).toBe(178)
+ expect(calls[0].cacheCreationInputTokens).toBe(986)
+ expect(calls[0].cacheReadInputTokens).toBe(11372)
+ expect(calls[0].timestamp).toBe('2026-09-19T11:42:10.652Z')
+ expect(calls[0].userMessage).toBe('hi')
+ expect(calls[1].model).toBe('gpt-5.6-codex')
+ expect(calls[1].timestamp).toBe('2026-09-19T11:43:00.000Z')
+ })
+
+ it('skips records with no usable tokens', async () => {
+ await writeThread('T-empty', {
+ id: 'T-empty',
+ messages: [],
+ usageLedger: {
+ events: [
+ { id: 'evt-e', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: {} },
+ { timestamp: '2026-09-20T12:00:01.000Z', model: 'gpt-5.6-codex', tokens: { input: 1 } },
+ ],
+ },
+ })
+ const path = join(tmpDir, 'threads', 'T-empty.json')
+ const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0].inputTokens).toBe(1)
+ })
+
+ it('deduplicates through seenKeys', async () => {
+ const path = await writeThread('T-abc123', LEDGER_THREAD)
+ const source = { path, project: 'Amp', provider: 'amp' }
+
+ const seenKeys = new Set()
+ const first = await parseAll(source, seenKeys)
+ expect(first).toHaveLength(2)
+
+ const second = await parseAll(source, seenKeys)
+ expect(second).toHaveLength(0)
+ })
+
+ it('clamps malformed token fields instead of leaking NaN', async () => {
+ await writeThread('T-bad', {
+ id: 'T-bad',
+ messages: [],
+ usageLedger: {
+ events: [
+ { id: 'evt-n', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: { input: -5, output: 'many' } },
+ ],
+ },
+ })
+ const path = join(tmpDir, 'threads', 'T-bad.json')
+ const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
+
+ // Every field malformed or non-positive -> nothing usable, no call.
+ expect(calls).toHaveLength(0)
+ })
+
+ it('is registered in the provider registry', async () => {
+ const { getAllProviders } = await import('../../src/providers/index.js')
+ const providers = await getAllProviders()
+ expect(providers.find(p => p.name === 'amp')).toBeDefined()
+ })
+})
diff --git a/tests/setup/env-isolation-vars.ts b/tests/setup/env-isolation-vars.ts
index fb12137c..ad075dd4 100644
--- a/tests/setup/env-isolation-vars.ts
+++ b/tests/setup/env-isolation-vars.ts
@@ -22,6 +22,7 @@ export const REDIRECTED = [
export const CLEARED = [
// Provider session-discovery dirs
+ 'AMP_DATA_DIR',
'CLAUDE_CONFIG_DIR',
'CLAUDE_CONFIG_DIRS',
'CLINE_DIR',
diff --git a/windows/src-tauri/src/usage_guard.rs b/windows/src-tauri/src/usage_guard.rs
index 337cb014..6f4411e3 100644
--- a/windows/src-tauri/src/usage_guard.rs
+++ b/windows/src-tauri/src/usage_guard.rs
@@ -333,6 +333,9 @@ fn roots() -> Vec {
roots.push(leaf(
env_path("CRUSH_GLOBAL_DATA").unwrap_or_else(|| xdg_data().join("crush")),
));
+ roots.push(leaf(
+ env_path("AMP_DATA_DIR").unwrap_or_else(|| xdg_data().join("amp")),
+ ));
roots.push(leaf(
env_path("ZS_DATA_DIR").unwrap_or_else(|| xdg_data().join("zerostack")),
));
diff --git a/windows/src/components/AgentTabStrip.tsx b/windows/src/components/AgentTabStrip.tsx
index 48897858..c9427132 100644
--- a/windows/src/components/AgentTabStrip.tsx
+++ b/windows/src/components/AgentTabStrip.tsx
@@ -63,6 +63,7 @@ const PROVIDER_COLORS: Record = {
pi: '#B26B3D',
qwen: '#615EEB',
omp: '#8B5CB0',
+ amp: '#3A3A3C',
crush: '#E06C9F',
antigravity: '#FF7A45',
goose: '#B78D52',