diff --git a/README.md b/README.md
index b3e312ef..6366bf31 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
|
| Codex (OpenAI) | Yes | [codex.md](docs/providers/codex.md) |
|
| Cursor | Yes | [cursor.md](docs/providers/cursor.md) |
|
| cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) |
+|
| Devin | Yes | [devin.md](docs/providers/devin.md) |
|
| Forge | Yes | [forge.md](docs/providers/forge.md) |
|
| Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) |
|
| Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) |
diff --git a/assets/providers/devin.png b/assets/providers/devin.png
new file mode 100644
index 00000000..c9a68730
Binary files /dev/null and b/assets/providers/devin.png differ
diff --git a/docs/providers/README.md b/docs/providers/README.md
index 109c47a4..ec1e0510 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -14,6 +14,7 @@ For the architectural picture, see `../architecture.md`.
| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` |
| [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` |
| [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` |
+| [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` |
| [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` |
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` |
diff --git a/docs/providers/devin.md b/docs/providers/devin.md
new file mode 100644
index 00000000..04c1cb43
--- /dev/null
+++ b/docs/providers/devin.md
@@ -0,0 +1,175 @@
+# Devin
+
+Cognition Devin CLI local usage tracking.
+
+- **Source:** `src/providers/devin.ts`
+- **Loading:** eager (`src/providers/index.ts`)
+- **Test:** `tests/providers/devin.test.ts` (336 lines)
+
+## Where it reads from
+
+Devin CLI data lives under:
+
+```text
+~/.local/share/devin/cli/
+```
+
+The MVP usage source is transcript JSON:
+
+```text
+~/.local/share/devin/cli/transcripts/*.json
+```
+
+The provider also reads:
+
+```text
+~/.local/share/devin/cli/sessions.db
+```
+
+`sessions.db` is enrichment only. It supplies project path/name, model fallback,
+timestamp fallback, and hidden-session filtering. It is not the source of usage
+or billing.
+
+## Configuration
+
+Devin reports spend in ACUs. CodeBurn reports provider cost through `costUSD`,
+so Devin stays disabled until a positive finite ACU-to-USD rate is configured:
+
+```json
+{
+ "devin": {
+ "acuUsdRate": 2.25
+ }
+}
+```
+
+The config file is:
+
+```text
+~/.config/codeburn/config.json
+```
+
+The macOS Settings window writes this value from the Devin tab. There is no
+environment-variable override and no default rate. Do not hardcode a universal
+ACU price; Devin ACU pricing is account/contract dependent.
+
+When the rate is missing or invalid, `discoverSessions()` returns `[]` and the
+parser yields no calls. Devin remains registered as a provider, but it does not
+appear in CLI/UI results until configured.
+
+## Storage format
+
+Transcript root is a JSON object following the [ATIF-v1.4 trajectory schema][atif],
+with Devin-specific additions such as per-step `metadata`. The parser does not
+validate `schema_version`; it only requires a parseable object with `steps[]`.
+
+Core fields include `session_id`, `agent.model_name`, and `steps[]`.
+
+Each counted step can provide:
+
+- `step_id`
+- `metadata.committed_acu_cost`
+- `metadata.metrics.input_tokens`
+- `metadata.metrics.output_tokens`
+- `metadata.metrics.cache_creation_tokens`
+- `metadata.metrics.cache_read_tokens`
+- `metadata.created_at`
+- `metadata.generation_model`
+- `metadata.request_id`
+- `tool_calls[].function_name`
+
+User-input steps (`metadata.is_user_input === true`) are skipped. Non-user
+steps are included only if they have positive ACU usage or positive token usage.
+
+## Pricing
+
+`metadata.committed_acu_cost` is per step, not cumulative. The provider converts
+each step with:
+
+```text
+costUSD = committed_acu_cost * devin.acuUsdRate
+```
+
+Token-only steps are still included when they have positive token metrics, but
+their `costUSD` is `0` if `committed_acu_cost` is absent.
+
+`src/parser.ts` preserves Devin's provider-supplied `costUSD` instead of
+re-pricing it through LiteLLM.
+
+## sessions.db enrichment
+
+The provider currently reads these columns from `sessions`:
+
+| Column | Use |
+| ------------------- | ----------------------------------------------------------------------------------------------------------- |
+| `id` | join key with transcript `session_id` during parsing; discovery uses the transcript filename before `.json` |
+| `working_directory` | `projectPath` and derived project name |
+| `model` | model fallback |
+| `title` | project name fallback |
+| `created_at` | timestamp fallback |
+| `last_activity_at` | preferred session timestamp fallback |
+| `hidden` | skip hidden sessions |
+
+`message_nodes`, `prompt_history`, and `tool_call_state` are not parsed yet.
+
+## Timestamps
+
+Step timestamps come from `metadata.created_at`, falling back to
+`sessions.last_activity_at`, then `sessions.created_at`.
+
+Transcript step timestamps are passed through as ATIF string timestamps.
+Numeric normalization is only applied to `sessions.db` timestamps:
+
+- less than `10_000_000_000`: seconds
+- otherwise: milliseconds
+
+## Model Resolution
+
+Model names resolve in this order:
+
+1. `step.metadata.generation_model`
+2. `step.model_name`
+3. `transcript.agent.model_name`
+4. `sessions.model`
+5. `devin`
+
+## Caching
+
+No provider-level cache.
+
+The normal session cache stores parsed provider calls, but Devin is always
+reparsed by `src/parser.ts` because `sessions.db` can change without the
+transcript JSON fingerprint changing.
+
+## Deduplication
+
+`devin::`
+
+The provider name is part of the key via the `devin:` prefix.
+
+## Quirks
+
+- The transcript directory has usage; `sessions.db` is enrichment only.
+- `committed_acu_cost` is per-generation/per-step ACU usage. Never treat it as cumulative.
+- There is no default ACU-to-USD rate. Missing config intentionally hides Devin.
+- Hidden sessions from `sessions.db` are skipped in discovery and parsing.
+- Tool names come directly from `tool_calls[].function_name`; the provider assumes valid ATIF tool-call records.
+- If SQLite is unavailable or `sessions.db` cannot be opened, the provider still parses transcripts without enrichment.
+
+## When fixing a bug here
+
+1. First check whether `~/.config/codeburn/config.json` contains a valid
+ `devin.acuUsdRate`. Without it, no Devin sessions should appear.
+2. For usage total bugs, compare against:
+
+ ```bash
+ jq '[.steps[] | select(.metadata.committed_acu_cost != null) | .metadata.committed_acu_cost] | add' ~/.local/share/devin/cli/transcripts/.json
+ ```
+
+3. If project/model/timestamp metadata is wrong, inspect `sessions.db`, not the transcript.
+4. If a hidden session appears, check the `hidden` column. Discovery can only
+ hide sessions whose transcript filename matches `sessions.id`; parsing uses
+ the transcript `session_id` when present.
+5. Run `tests/providers/devin.test.ts` after parser changes. It covers ACU conversion, disabled-until-configured behavior, timestamp parsing, deduplication, hidden sessions, and `sessions.db` enrichment.
+
+[atif]: https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md
diff --git a/gnome/indicator.js b/gnome/indicator.js
index 533f6441..19c13ac5 100644
--- a/gnome/indicator.js
+++ b/gnome/indicator.js
@@ -35,6 +35,7 @@ const PROVIDERS = [
{ id: 'codex', label: 'Codex' },
{ id: 'cursor', label: 'Cursor' },
{ id: 'copilot', label: 'Copilot' },
+ { id: 'devin', label: 'Devin' },
{ id: 'opencode', label: 'OpenCode' },
{ id: 'pi', label: 'Pi' },
{ id: 'droid', label: 'Droid' },
@@ -70,6 +71,7 @@ const PROVIDER_PATHS = {
codex: '.codex/sessions',
cursor: '.config/Cursor/User/globalStorage/state.vscdb',
copilot: '.copilot/session-state',
+ devin: '.local/share/devin/cli',
kimi: '.kimi/sessions',
pi: '.pi/agent/sessions',
};
diff --git a/gnome/prefs.js b/gnome/prefs.js
index 08d4b824..dafc1a2f 100644
--- a/gnome/prefs.js
+++ b/gnome/prefs.js
@@ -8,6 +8,7 @@ const PROVIDERS = [
{ id: 'codex', label: 'Codex' },
{ id: 'copilot', label: 'Copilot' },
{ id: 'cursor', label: 'Cursor' },
+ { id: 'devin', label: 'Devin' },
{ id: 'droid', label: 'Droid' },
{ id: 'gemini', label: 'Gemini' },
{ id: 'goose', label: 'Goose' },
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index e6cf9aa2..aab72636 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -1025,6 +1025,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case cursor = "Cursor"
case cursorAgent = "Cursor Agent"
case copilot = "Copilot"
+ case devin = "Devin"
case droid = "Droid"
case gemini = "Gemini"
case ibmBob = "IBM Bob"
@@ -1067,6 +1068,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .cursor: "cursor"
case .cursorAgent: "cursor-agent"
case .copilot: "copilot"
+ case .devin: "devin"
case .droid: "droid"
case .gemini: "gemini"
case .ibmBob: "ibm-bob"
diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift
index c27acc18..5fc907a6 100644
--- a/mac/Sources/CodeBurnMenubar/CurrencyState.swift
+++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift
@@ -207,3 +207,74 @@ enum CLICurrencyConfig {
}
}
}
+
+struct CodeburnCLIConfigStore {
+ let homeDirectory: String
+
+ init(homeDirectory: String = NSHomeDirectory()) {
+ self.homeDirectory = homeDirectory
+ }
+
+ private var configDir: String {
+ (homeDirectory as NSString).appendingPathComponent(".config/codeburn")
+ }
+ private var configPath: String {
+ (configDir as NSString).appendingPathComponent("config.json")
+ }
+ private var lockPath: String {
+ (configDir as NSString).appendingPathComponent(".config.lock")
+ }
+
+ func loadDevinAcuUsdRate() -> Double? {
+ guard
+ let data = try? SafeFile.read(from: configPath),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let devin = json["devin"] as? [String: Any],
+ let rate = devin["acuUsdRate"] as? Double,
+ rate.isFinite,
+ rate > 0
+ else {
+ return nil
+ }
+ return rate
+ }
+
+ func persistDevinAcuUsdRate(_ rate: Double) {
+ guard rate.isFinite, rate > 0 else { return }
+ do {
+ try SafeFile.withExclusiveLock(at: lockPath) {
+ var existing: [String: Any] = [:]
+ if let data = try? SafeFile.read(from: configPath),
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ existing = parsed
+ }
+
+ var devin = existing["devin"] as? [String: Any] ?? [:]
+ devin["acuUsdRate"] = rate
+ existing["devin"] = devin
+
+ guard let data = try? JSONSerialization.data(
+ withJSONObject: existing,
+ options: [.prettyPrinted, .sortedKeys]
+ ) else {
+ return
+ }
+ try SafeFile.write(data, to: configPath, mode: 0o600)
+ }
+ } catch {
+ NSLog("CodeBurn: failed to persist Devin ACU config: \(error)")
+ }
+ }
+}
+
+enum CLIDevinConfig {
+ private static let store = CodeburnCLIConfigStore()
+
+ static func loadAcuUsdRate() -> Double? {
+ store.loadDevinAcuUsdRate()
+ }
+
+ static func persistAcuUsdRate(_ rate: Double) {
+ store.persistDevinAcuUsdRate(rate)
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
index d86cf36d..0c0a81f7 100644
--- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
@@ -490,6 +490,7 @@ extension ProviderFilter {
case .cursor: return Theme.categoricalCursor
case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0)
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
+ case .devin: return Color(red: 0x25/255.0, green: 0xA0/255.0, blue: 0x8D/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 .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0)
diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
index 73a16be9..53c1755e 100644
--- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
@@ -18,10 +18,13 @@ struct SettingsView: View {
CodexSettingsTab()
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
+ DevinSettingsTab()
+ .tabItem { Label("Devin", systemImage: "flame.fill") }
+
AboutSettingsTab()
.tabItem { Label("About", systemImage: "info.circle") }
}
- .frame(width: 520, height: 400)
+ .frame(width: 520, height: 430)
}
}
@@ -468,6 +471,81 @@ private struct CodexConnectionRow: View {
}
}
+// MARK: - Devin
+
+private struct DevinSettingsTab: View {
+ @State private var rateText: String = ""
+ @State private var statusText: String = ""
+
+ private var parsedRate: Double? {
+ let trimmed = rateText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let value = Double(trimmed), value.isFinite, value > 0 else { return nil }
+ return value
+ }
+
+ var body: some View {
+ Form {
+ Section("ACU Conversion") {
+ HStack(alignment: .center, spacing: 10) {
+ Text("USD per ACU")
+ Spacer()
+ TextField("", text: $rateText)
+ .textFieldStyle(.roundedBorder)
+ .multilineTextAlignment(.trailing)
+ .frame(width: 96)
+ .accessibilityLabel("USD per ACU")
+ Text("USD")
+ .foregroundStyle(.secondary)
+ .frame(width: 36, alignment: .leading)
+ }
+
+ Button("Save") {
+ saveRate()
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(parsedRate == nil)
+
+ if !statusText.isEmpty {
+ Text(statusText)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section {
+ Text("CodeBurn reads Devin ACU usage from local transcripts only after this rate is configured, then multiplies each step by the rate before reporting cost.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ } header: {
+ Text("How it works")
+ }
+ }
+ .formStyle(.grouped)
+ .padding()
+ .onAppear {
+ if let rate = CLIDevinConfig.loadAcuUsdRate() {
+ rateText = Self.format(rate)
+ }
+ }
+ }
+
+ private func saveRate() {
+ guard let rate = parsedRate else { return }
+ CLIDevinConfig.persistAcuUsdRate(rate)
+ rateText = Self.format(rate)
+ statusText = "Saved. Refresh CodeBurn to recalculate Devin cost."
+ }
+
+ private static func format(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.numberStyle = .decimal
+ formatter.minimumFractionDigits = 0
+ formatter.maximumFractionDigits = 6
+ return formatter.string(from: NSNumber(value: value)) ?? String(value)
+ }
+}
+
// MARK: - About
private struct AboutSettingsTab: View {
diff --git a/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift
new file mode 100644
index 00000000..69e9f552
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/CLIDevinConfigTests.swift
@@ -0,0 +1,97 @@
+import Foundation
+import Testing
+@testable import CodeBurnMenubar
+
+@Suite("CLI Devin config", .serialized)
+struct CLIDevinConfigTests {
+ private func withTemporaryStore(_ body: (URL, CodeburnCLIConfigStore) throws -> Void) throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codeburn-devin-config-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer {
+ try? FileManager.default.removeItem(at: root)
+ }
+ try body(root, CodeburnCLIConfigStore(homeDirectory: root.path))
+ }
+
+ private func configURL(in home: URL) -> URL {
+ home
+ .appendingPathComponent(".config", isDirectory: true)
+ .appendingPathComponent("codeburn", isDirectory: true)
+ .appendingPathComponent("config.json")
+ }
+
+ private func writeConfig(_ object: [String: Any], in home: URL) throws {
+ let url = configURL(in: home)
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
+ try data.write(to: url)
+ }
+
+ private func readConfig(in home: URL) throws -> [String: Any] {
+ let data = try Data(contentsOf: configURL(in: home))
+ return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ }
+
+ @Test("missing config has no ACU rate")
+ func missingConfigHasNoRate() throws {
+ try withTemporaryStore { _, store in
+ #expect(store.loadDevinAcuUsdRate() == nil)
+ }
+ }
+
+ @Test("persists and loads ACU rate")
+ func persistsAndLoadsRate() throws {
+ try withTemporaryStore { _, store in
+ store.persistDevinAcuUsdRate(2.25)
+
+ #expect(store.loadDevinAcuUsdRate() == 2.25)
+ }
+ }
+
+ @Test("preserves existing config while adding Devin rate")
+ func preservesExistingConfig() throws {
+ try withTemporaryStore { home, store in
+ try writeConfig([
+ "currency": [
+ "code": "EUR",
+ "symbol": "\u{20AC}"
+ ]
+ ], in: home)
+
+ store.persistDevinAcuUsdRate(3.5)
+
+ let json = try readConfig(in: home)
+ let currency = try #require(json["currency"] as? [String: Any])
+ let devin = try #require(json["devin"] as? [String: Any])
+ #expect(currency["code"] as? String == "EUR")
+ #expect(devin["acuUsdRate"] as? Double == 3.5)
+ }
+ }
+
+ @Test("ignores invalid rates")
+ func ignoresInvalidRates() throws {
+ try withTemporaryStore { _, store in
+ store.persistDevinAcuUsdRate(1.75)
+ store.persistDevinAcuUsdRate(0)
+ store.persistDevinAcuUsdRate(-2)
+ store.persistDevinAcuUsdRate(.infinity)
+
+ #expect(store.loadDevinAcuUsdRate() == 1.75)
+ }
+ }
+
+ @Test("loads only positive finite numeric rates")
+ func loadsOnlyPositiveFiniteNumericRates() throws {
+ try withTemporaryStore { home, store in
+ try writeConfig(["devin": ["acuUsdRate": 0]], in: home)
+ #expect(store.loadDevinAcuUsdRate() == nil)
+
+ try writeConfig(["devin": ["acuUsdRate": "2.25"]], in: home)
+ #expect(store.loadDevinAcuUsdRate() == nil)
+ }
+ }
+}
diff --git a/package.json b/package.json
index 73224026..f4e7208e 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,8 @@
"bundle-litellm": "node scripts/bundle-litellm.mjs",
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
"dev": "tsx src/cli.ts",
+ "dev:mac:menubar": "npm run build && cd mac && CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN=\"node $(pwd)/../dist/cli.js\" swift run",
+ "local:mac:menubar": "scripts/install-local-mac-menubar.sh",
"test": "vitest",
"prepublishOnly": "npm run build"
},
@@ -22,6 +24,7 @@
"cursor",
"codex",
"kimi",
+ "devin",
"ibm-bob",
"opencode",
"pi",
diff --git a/scripts/install-local-mac-menubar.sh b/scripts/install-local-mac-menubar.sh
new file mode 100755
index 00000000..46667a44
--- /dev/null
+++ b/scripts/install-local-mac-menubar.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+# Build and install a local CodeBurn CLI tarball, then launch the menubar app
+# built from this checkout. Useful when upstream macOS releases lag behind a
+# fork/branch you want to test.
+
+set -euo pipefail
+
+REPLACE_APP=0
+SYSTEM_APP=0
+MIN_NODE_VERSION="22.13.0"
+
+usage() {
+ cat <<'USAGE'
+Usage:
+ npm run local:mac:menubar -- [--replace-app] [--system-app]
+
+Options:
+ --replace-app Replace the installed app with the locally built app before launching.
+ --system-app Install to /Applications/CodeBurnMenubar.app instead of ~/Applications/CodeBurnMenubar.app.
+ -h, --help Show this help.
+USAGE
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --replace-app)
+ REPLACE_APP=1
+ shift
+ ;;
+ --system-app)
+ SYSTEM_APP=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+repo_root() {
+ git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/.." && pwd)
+}
+
+require_node_version() {
+ if ! command -v node >/dev/null 2>&1; then
+ echo "Node ${MIN_NODE_VERSION}+ is required, but node was not found." >&2
+ exit 1
+ fi
+
+ local node_version
+ node_version="$(node -p "process.versions.node")"
+ if ! node -e "
+const current = process.versions.node.split('.').map(Number)
+const minimum = '${MIN_NODE_VERSION}'.split('.').map(Number)
+const ok = current[0] > minimum[0]
+ || (current[0] === minimum[0] && current[1] > minimum[1])
+ || (current[0] === minimum[0] && current[1] === minimum[1] && current[2] >= minimum[2])
+process.exit(ok ? 0 : 1)
+"; then
+ echo "Node ${MIN_NODE_VERSION}+ is required, but found ${node_version}." >&2
+ exit 1
+ fi
+}
+
+ROOT="$(repo_root)"
+PACK_DIR="${TMPDIR:-/tmp}/codeburn-local-pack"
+SUPPORT_DIR="${HOME}/Library/Application Support/CodeBurn"
+WRAPPER_PATH="${SUPPORT_DIR}/codeburn-menubar-cli"
+PERSISTED_CLI_PATH="${SUPPORT_DIR}/codeburn-cli-path.v1"
+if [[ "${SYSTEM_APP}" -eq 1 ]]; then
+ INSTALLED_APP_PATH="/Applications/CodeBurnMenubar.app"
+else
+ INSTALLED_APP_PATH="${HOME}/Applications/CodeBurnMenubar.app"
+fi
+
+cd "${ROOT}"
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+ echo "This script is for the macOS menubar app." >&2
+ exit 1
+fi
+
+require_node_version
+
+echo "==> Building CLI"
+npm run build
+NODE_PATH="$(node -p "process.execPath")"
+if [[ ! -x "${NODE_PATH}" ]]; then
+ echo "Node executable was not found at ${NODE_PATH}." >&2
+ exit 1
+fi
+NODE_BIN_DIR="$(dirname "${NODE_PATH}")"
+
+echo "==> Packing CLI"
+rm -rf "${PACK_DIR}"
+mkdir -p "${PACK_DIR}"
+TARBALL_NAME="$(npm pack --silent --pack-destination "${PACK_DIR}")"
+TARBALL_PATH="${PACK_DIR}/${TARBALL_NAME}"
+
+echo "==> Installing global CLI from ${TARBALL_PATH}"
+npm install -g "${TARBALL_PATH}"
+
+CLI_PATH="$(command -v codeburn || true)"
+if [[ -z "${CLI_PATH}" ]]; then
+ echo "Global codeburn command was not found after npm install -g." >&2
+ exit 1
+fi
+
+echo "==> Writing menubar CLI wrapper"
+mkdir -p "${SUPPORT_DIR}"
+{
+ printf '%s\n' '#!/bin/sh'
+ printf 'export PATH=%s:"${PATH:-}"\n' "$(printf '%q' "${NODE_BIN_DIR}")"
+ printf 'exec %s "$@"\n' "$(printf '%q' "${CLI_PATH}")"
+} > "${WRAPPER_PATH}"
+chmod 755 "${WRAPPER_PATH}"
+
+echo "==> Persisting CLI path: ${WRAPPER_PATH}"
+printf '%s\n' "${WRAPPER_PATH}" > "${PERSISTED_CLI_PATH}"
+chmod 600 "${PERSISTED_CLI_PATH}"
+
+VERSION="$(node -p "require('./package.json').version")-local"
+echo "==> Building menubar app (${VERSION})"
+mac/Scripts/package-app.sh "v${VERSION}"
+
+APP_PATH="${ROOT}/mac/.build/dist/CodeBurnMenubar.app"
+if [[ ! -d "${APP_PATH}" ]]; then
+ echo "Menubar app was not built at ${APP_PATH}" >&2
+ exit 1
+fi
+
+if [[ "${REPLACE_APP}" -eq 1 ]]; then
+ echo "==> Replacing ${INSTALLED_APP_PATH}"
+ pkill -f CodeBurnMenubar 2>/dev/null || true
+ if [[ "${SYSTEM_APP}" -eq 1 ]]; then
+ sudo mkdir -p "$(dirname "${INSTALLED_APP_PATH}")"
+ sudo rm -rf "${INSTALLED_APP_PATH}"
+ sudo cp -R "${APP_PATH}" "${INSTALLED_APP_PATH}"
+ sudo chown -R root:wheel "${INSTALLED_APP_PATH}"
+ else
+ mkdir -p "$(dirname "${INSTALLED_APP_PATH}")"
+ rm -rf "${INSTALLED_APP_PATH}"
+ cp -R "${APP_PATH}" "${INSTALLED_APP_PATH}"
+ fi
+ APP_PATH="${INSTALLED_APP_PATH}"
+fi
+
+echo "==> Restarting menubar app"
+pkill -f CodeBurnMenubar 2>/dev/null || true
+open "${APP_PATH}"
+
+echo ""
+echo "Ready."
+echo "CLI: ${CLI_PATH}"
+echo "App: ${APP_PATH}"
diff --git a/src/config.ts b/src/config.ts
index eca65f5a..d94be82b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -24,6 +24,9 @@ export type CodeburnConfig = {
code: string
symbol?: string
}
+ devin?: {
+ acuUsdRate?: number
+ }
plan?: Plan
plans?: PlanConfigMap
modelAliases?: Record
diff --git a/src/parser.ts b/src/parser.ts
index 55eb17d9..9dfe3c9d 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -1641,7 +1641,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
webSearchRequests: call.webSearchRequests,
cacheCreationOneHourTokens: 0,
},
- costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity') ? call.costUSD : undefined,
+ costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin') ? call.costUSD : undefined,
speed: call.speed,
timestamp: call.timestamp,
tools: call.tools,
@@ -1812,6 +1812,11 @@ function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string
// A 0-turn cache entry may just mean the server was unavailable last run.
if (providerName === 'antigravity') return shouldReparseAntigravitySource(sourcePath, cached.turns.length)
+ // Devin transcript usage is enriched from sessions.db. The cache fingerprint
+ // only tracks the transcript JSON, so reparse to pick up DB-side project,
+ // title, model, and timestamp changes.
+ if (providerName === 'devin') return true
+
if (providerName !== 'gemini') return false
return cached.turns.some(turn =>
diff --git a/src/providers/devin.ts b/src/providers/devin.ts
new file mode 100644
index 00000000..7fd6bec2
--- /dev/null
+++ b/src/providers/devin.ts
@@ -0,0 +1,395 @@
+import { readdir, stat } from "fs/promises";
+import { basename, join } from "path";
+import { homedir } from "os";
+
+import { getShortModelName } from "../models.js";
+import { booleanValue, openDatabase } from "../sqlite.js";
+import { readConfig } from "../config.js";
+import type {
+ Provider,
+ SessionParser,
+ SessionSource,
+ ParsedProviderCall,
+} from "./types.js";
+import { readSessionFile } from "../fs-utils.js";
+
+type AgentTrajectory = {
+ schema_version: string;
+ session_id?: string;
+ agent: Agent;
+ steps: T[];
+};
+
+type Agent = {
+ name: string;
+ version: string;
+ model_name?: string;
+};
+
+type ToolCall = {
+ tool_call_id: string;
+ function_name: string;
+ arguments: unknown;
+};
+
+type DevinMetadata = {
+ created_at?: string;
+ committed_acu_cost?: number;
+ generation_model?: string;
+ is_user_input?: boolean;
+ num_tokens?: number;
+ request_id?: string;
+ finish_reason?: string;
+ metrics?: {
+ input_tokens?: number;
+ output_tokens?: number;
+ cache_creation_tokens?: number;
+ cache_read_tokens?: number;
+ tokens_per_sec?: number;
+ total_time_ms?: number;
+ ttft_ms?: number;
+ tpot_ms?: number;
+ };
+};
+
+type Step = {
+ step_id: number;
+ source: string;
+ model_name?: string;
+ message: string;
+ tool_calls?: Array;
+};
+
+type DevinStep = Step & { metadata?: DevinMetadata };
+
+type DevinAgentTrajectory = AgentTrajectory;
+
+type DevinSessionMetadata = {
+ id: string;
+ workingDirectory: string;
+ model: string;
+ title?: string;
+ createdAt: string;
+ lastActivityAt: string;
+ hidden: boolean;
+};
+
+type DevinUsage = {
+ committedAcuCost: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheCreationInputTokens: number;
+ cacheReadInputTokens: number;
+};
+
+const DEFAULT_DEVIN_CLI_DIR = join(
+ homedir(),
+ ".local",
+ "share",
+ "devin",
+ "cli",
+);
+
+const DEFAULT_MODEL_NAME = "devin";
+const DEVIN_PROVIDER_NAME = "devin";
+const DEVIN_PROVIDER_DISPLAY_NAME = "Devin";
+const DEVIN_TRASNCRIPTS_SUBDIR = "transcripts";
+const DEVIN_SESSIONS_DB = "sessions.db";
+
+function parseTranscript(raw: string): DevinAgentTrajectory | null {
+ try {
+ return JSON.parse(raw) as DevinAgentTrajectory;
+ } catch {
+ return null;
+ }
+}
+
+function parseNumericTimestamp(value: number): string {
+ const millis = value < 10_000_000_000 ? value * 1000 : value;
+ return new Date(millis).toISOString();
+}
+
+function getUsage(
+ metadata: DevinMetadata | undefined | null,
+): DevinUsage | null {
+ if (!metadata) return null;
+ const metrics = metadata.metrics;
+
+ const hasAnyUsage = [
+ metadata.committed_acu_cost,
+ metrics?.input_tokens,
+ metrics?.output_tokens,
+ metrics?.cache_creation_tokens,
+ metrics?.cache_read_tokens,
+ ].some((x) => x !== undefined && x !== null && x > 0);
+
+ if (!hasAnyUsage) return null;
+
+ return {
+ committedAcuCost: metadata.committed_acu_cost ?? 0,
+ inputTokens: metrics?.input_tokens ?? 0,
+ outputTokens: metrics?.output_tokens ?? 0,
+ cacheCreationInputTokens: metrics?.cache_creation_tokens ?? 0,
+ cacheReadInputTokens: metrics?.cache_read_tokens ?? 0,
+ };
+}
+
+function getSessionId(
+ source: SessionSource,
+ transcript: DevinAgentTrajectory,
+): string {
+ const fromTranscript = transcript.session_id?.trim();
+ return fromTranscript || basename(source.path, ".json");
+}
+
+function projectNameFromPath(path: string): string {
+ const normalized = path.trim().replace(/[/\\]+$/, "");
+ return normalized.split(/[/\\]/).filter(Boolean).pop() ?? path;
+}
+
+function getProjectName(
+ source: SessionSource,
+ session: DevinSessionMetadata | null,
+): string {
+ if (session?.workingDirectory)
+ return projectNameFromPath(session.workingDirectory);
+ if (session?.title) return session.title;
+ return source.project;
+}
+
+function getProjectPath(
+ session: DevinSessionMetadata | null,
+): string | undefined {
+ return session?.workingDirectory;
+}
+
+function getTimestamp(
+ step: DevinStep,
+ session: DevinSessionMetadata | null,
+): string | undefined {
+ return [
+ step.metadata?.created_at,
+ session?.lastActivityAt,
+ session?.createdAt,
+ ]
+ .filter(Boolean)
+ .shift();
+}
+
+function getModelName(
+ transcript: DevinAgentTrajectory,
+ step: DevinStep,
+ session: DevinSessionMetadata | null,
+): string {
+ return (
+ [
+ step.metadata?.generation_model,
+ step.model_name,
+ transcript.agent?.model_name,
+ session?.model,
+ ]
+ .filter(Boolean)
+ .shift() || DEFAULT_MODEL_NAME
+ );
+}
+
+function getToolNames(step: DevinStep): string[] {
+ return (step.tool_calls ?? []).map((call) => call.function_name);
+}
+
+function getFirstUserMessageBeforeStep(
+ steps: DevinStep[],
+ index: number,
+): string | null {
+ for (let i = index - 1; i >= 0; i--) {
+ const step = steps[i];
+ if (!step?.metadata?.is_user_input) continue;
+ const message = step.message?.trim();
+ if (message) return message;
+ }
+ return null;
+}
+
+function loadSessionMetadata(
+ dbPath: string,
+): Map {
+ const sessions = new Map();
+ let db: ReturnType | null = null;
+ try {
+ db = openDatabase(dbPath);
+ const rows = db.query<{
+ id: string;
+ working_directory: string;
+ model: string;
+ title: string | null;
+ created_at: number;
+ last_activity_at: number;
+ hidden: number;
+ }>(
+ `SELECT id, working_directory, model, title, created_at, last_activity_at, hidden
+ FROM sessions`,
+ );
+ for (const row of rows) {
+ if (!row.id) continue;
+ sessions.set(row.id, {
+ id: row.id,
+ workingDirectory: row.working_directory,
+ model: row.model,
+ title: row.title ?? undefined,
+ createdAt: parseNumericTimestamp(row.created_at),
+ lastActivityAt: parseNumericTimestamp(row.last_activity_at),
+ hidden: booleanValue(row.hidden),
+ });
+ }
+ } catch {
+ return sessions;
+ } finally {
+ db?.close();
+ }
+ return sessions;
+}
+
+function isValidAcuUsdRate(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
+}
+
+async function getCostFactor(): Promise {
+ const configRate = (await readConfig()).devin?.acuUsdRate;
+ return isValidAcuUsdRate(configRate) ? configRate : null;
+}
+
+class DevinSessionParser implements SessionParser {
+ constructor(
+ private source: SessionSource,
+ private seenKeys: Set,
+ private sessionMetadata: Map,
+ ) {}
+
+ async *parse(): AsyncGenerator {
+ const raw = await readSessionFile(this.source.path);
+ if (!raw) return;
+
+ const transcript = parseTranscript(raw);
+ if (!transcript?.steps) return;
+
+ const sessionId = getSessionId(this.source, transcript);
+ const session = this.sessionMetadata.get(sessionId) ?? null;
+ if (session?.hidden) return;
+
+ const project = getProjectName(this.source, session);
+ const projectPath = getProjectPath(session);
+ const costFactor = await getCostFactor();
+ if (costFactor === null) return;
+
+ for (let index = 0; index < transcript.steps.length; index++) {
+ const step = transcript.steps[index];
+ if (step.metadata?.is_user_input) continue;
+
+ const usage = getUsage(step.metadata);
+ if (!usage) continue;
+
+ const timestamp = getTimestamp(step, session) ?? "";
+
+ const deduplicationKey = `devin:${sessionId}:${step.step_id}`;
+
+ if (this.seenKeys.has(deduplicationKey)) continue;
+ this.seenKeys.add(deduplicationKey);
+
+ const model = getModelName(transcript, step, session);
+ const tools = getToolNames(step);
+ const userMessage =
+ getFirstUserMessageBeforeStep(transcript.steps, index) ?? "";
+
+ yield {
+ provider: DEVIN_PROVIDER_NAME,
+ model,
+ inputTokens: usage.inputTokens,
+ outputTokens: usage.outputTokens,
+ cacheCreationInputTokens: usage.cacheCreationInputTokens,
+ cacheReadInputTokens: usage.cacheReadInputTokens,
+ cachedInputTokens: usage.cacheReadInputTokens,
+ reasoningTokens: 0,
+ webSearchRequests: 0,
+ costUSD: usage.committedAcuCost * costFactor,
+ tools,
+ bashCommands: [],
+ timestamp,
+ speed: "standard",
+ deduplicationKey,
+ userMessage,
+ sessionId,
+ project,
+ projectPath,
+ };
+ }
+ }
+}
+
+export function createDevinProvider(cliDir: string): Provider {
+ const sessionsDbPath = join(cliDir, DEVIN_SESSIONS_DB);
+ let sessionMetadata: Map | null = null;
+
+ const getSessionMetadata = () => {
+ if (!sessionMetadata) sessionMetadata = loadSessionMetadata(sessionsDbPath);
+ return sessionMetadata;
+ };
+
+ return {
+ name: DEVIN_PROVIDER_NAME,
+ displayName: DEVIN_PROVIDER_DISPLAY_NAME,
+
+ modelDisplayName(model: string): string {
+ return getShortModelName(model);
+ },
+
+ toolDisplayName(rawTool: string): string {
+ return rawTool;
+ },
+
+ async discoverSessions(): Promise {
+ if ((await getCostFactor()) === null) return [];
+
+ const transcriptsDir = join(cliDir, DEVIN_TRASNCRIPTS_SUBDIR);
+ const entries = await readdir(transcriptsDir).catch(() => []);
+ const metadata = getSessionMetadata();
+ const sources: SessionSource[] = [];
+
+ for (const entry of entries) {
+ if (!entry.endsWith(".json")) continue;
+
+ const filePath = join(transcriptsDir, entry);
+ const pathStats = await stat(filePath).catch(() => null);
+
+ if (!pathStats?.isFile()) continue;
+
+ const session = metadata.get(basename(filePath, ".json")) ?? null;
+ if (session?.hidden) continue;
+
+ const tmpSource: SessionSource = {
+ path: filePath,
+ project: DEVIN_PROVIDER_NAME,
+ provider: DEVIN_PROVIDER_NAME,
+ };
+
+ const project = getProjectName(tmpSource, session);
+
+ sources.push({
+ path: filePath,
+ project,
+ provider: DEVIN_PROVIDER_NAME,
+ });
+ }
+
+ return sources;
+ },
+
+ createSessionParser(
+ source: SessionSource,
+ seenKeys: Set,
+ ): SessionParser {
+ return new DevinSessionParser(source, seenKeys, getSessionMetadata());
+ },
+ };
+}
+
+export const devin = createDevinProvider(DEFAULT_DEVIN_CLI_DIR);
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 98980a62..cadcb086 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -4,6 +4,7 @@ import { codebuff } from './codebuff.js'
import { codex } from './codex.js'
import { copilot } from './copilot.js'
import { droid } from './droid.js'
+import { devin } from './devin.js'
import { gemini } from './gemini.js'
import { ibmBob } from './ibm-bob.js'
import { kiloCode } from './kilo-code.js'
@@ -136,7 +137,7 @@ async function loadCrush(): Promise {
}
}
-const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode]
+const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise {
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp] = await Promise.all([loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp()])
diff --git a/src/session-cache.ts b/src/session-cache.ts
index 53f7a78c..85a5171a 100644
--- a/src/session-cache.ts
+++ b/src/session-cache.ts
@@ -375,4 +375,3 @@ export async function cleanupOrphanedTempFiles(): Promise {
}
} catch {}
}
-
diff --git a/src/sqlite.ts b/src/sqlite.ts
index 3fb3c6a8..eee5443d 100644
--- a/src/sqlite.ts
+++ b/src/sqlite.ts
@@ -137,3 +137,7 @@ export function openDatabase(path: string): SqliteDatabase {
},
}
}
+
+export function booleanValue(value: number): boolean {
+ return value === 1
+}
diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts
index f310a564..c531d6ae 100644
--- a/tests/provider-registry.test.ts
+++ b/tests/provider-registry.test.ts
@@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index.
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
- expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
+ expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
})
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
diff --git a/tests/providers/devin.test.ts b/tests/providers/devin.test.ts
new file mode 100644
index 00000000..5197140f
--- /dev/null
+++ b/tests/providers/devin.test.ts
@@ -0,0 +1,336 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
+import { join } from 'path'
+import { tmpdir } from 'os'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import { isSqliteAvailable } from '../../src/sqlite.js'
+import { createDevinProvider } from '../../src/providers/devin.js'
+import type { ParsedProviderCall } from '../../src/providers/types.js'
+
+let tmpDir: string
+const originalHome = process.env['HOME']
+
+beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), 'devin-provider-'))
+ process.env['HOME'] = tmpDir
+})
+
+afterEach(async () => {
+ if (originalHome === undefined) delete process.env['HOME']
+ else process.env['HOME'] = originalHome
+ await rm(tmpDir, { recursive: true, force: true })
+})
+
+async function configureDevinRate(rate = 1): Promise {
+ await mkdir(join(tmpDir, '.config', 'codeburn'), { recursive: true })
+ await writeFile(join(tmpDir, '.config', 'codeburn', 'config.json'), JSON.stringify({
+ devin: { acuUsdRate: rate },
+ }))
+}
+
+async function writeTranscript(name: string, transcript: unknown): Promise {
+ const transcriptsDir = join(tmpDir, 'transcripts')
+ await mkdir(transcriptsDir, { recursive: true })
+ const filePath = join(transcriptsDir, name)
+ await writeFile(filePath, JSON.stringify(transcript))
+ return filePath
+}
+
+async function parseTranscript(filePath: string, project = 'devin'): Promise {
+ const provider = createDevinProvider(tmpDir)
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser({ path: filePath, project, provider: 'devin' }, new Set()).parse()) {
+ calls.push(call)
+ }
+ return calls
+}
+
+function createSessionsDb(): void {
+ const { DatabaseSync: Database } = require('node:sqlite')
+ const db = new Database(join(tmpDir, 'sessions.db'))
+ db.exec(`
+ CREATE TABLE 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,
+ hidden INTEGER NOT NULL DEFAULT 0
+ )
+ `)
+ db.prepare(`
+ INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).run('db-session', '/Users/example/work/codeburn', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'CodeBurn', 0)
+ db.prepare(`
+ INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).run('hidden-session', '/Users/example/work/hidden', 'claude-opus-4-6', 1_800_000_000, 1_800_000_010, 'Hidden', 1)
+ db.close()
+}
+
+describe('devin provider', () => {
+ it('discovers Devin CLI transcript json files', async () => {
+ await configureDevinRate()
+ const filePath = await writeTranscript('glimmer-platinum.json', { steps: [] })
+ await writeFile(join(tmpDir, 'transcripts', 'ignore.txt'), '{}')
+
+ const provider = createDevinProvider(tmpDir)
+ const sources = await provider.discoverSessions()
+
+ expect(sources).toEqual([
+ { path: filePath, project: 'devin', provider: 'devin' },
+ ])
+ })
+
+ it('stays disabled until the Devin ACU rate is configured', async () => {
+ await writeTranscript('glimmer-platinum.json', {
+ session_id: 'session-123',
+ steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }],
+ })
+
+ const provider = createDevinProvider(tmpDir)
+ expect(await provider.discoverSessions()).toEqual([])
+ expect(await parseTranscript(join(tmpDir, 'transcripts', 'glimmer-platinum.json'))).toEqual([])
+ })
+
+ it('parses per-step ACUs, tokens, tools, and model resolution', async () => {
+ await configureDevinRate()
+ const filePath = await writeTranscript('glimmer-platinum.json', {
+ schema_version: '1',
+ session_id: 'session-123',
+ agent: { model_name: 'agent-model' },
+ steps: [
+ {
+ step_id: 1,
+ message: 'please inspect the repo',
+ metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
+ },
+ {
+ step_id: 2,
+ model_name: 'step-model',
+ metadata: {
+ created_at: '2027-01-15T08:00:01.000Z',
+ committed_acu_cost: 0.02076149918138981,
+ generation_model: 'claude-opus-4-6',
+ metrics: {
+ input_tokens: 100,
+ output_tokens: 20,
+ cache_creation_tokens: 10,
+ cache_read_tokens: 5,
+ },
+ },
+ tool_calls: [{ function_name: 'read_file' }],
+ },
+ {
+ step_id: 3,
+ model_name: 'claude-sonnet-4-6',
+ metadata: {
+ created_at: '2027-01-15T08:00:02.000Z',
+ committed_acu_cost: 0.005421000067144632,
+ metrics: { input_tokens: 1 },
+ },
+ tool_calls: [{ function_name: 'str_replace' }],
+ },
+ ],
+ })
+
+ const calls = await parseTranscript(filePath)
+
+ expect(calls).toHaveLength(2)
+ expect(calls.reduce((sum, call) => sum + call.costUSD, 0)).toBeCloseTo(0.026182499248534442, 15)
+ expect(calls[0]).toMatchObject({
+ provider: 'devin',
+ model: 'claude-opus-4-6',
+ inputTokens: 100,
+ outputTokens: 20,
+ cacheCreationInputTokens: 10,
+ cacheReadInputTokens: 5,
+ cachedInputTokens: 5,
+ costUSD: 0.02076149918138981,
+ tools: ['read_file'],
+ timestamp: '2027-01-15T08:00:01.000Z',
+ deduplicationKey: 'devin:session-123:2',
+ userMessage: 'please inspect the repo',
+ sessionId: 'session-123',
+ })
+ expect(calls[1]).toMatchObject({
+ model: 'claude-sonnet-4-6',
+ timestamp: '2027-01-15T08:00:02.000Z',
+ tools: ['str_replace'],
+ deduplicationKey: 'devin:session-123:3',
+ })
+ })
+
+ it('includes token-only steps and skips user-input or empty steps', async () => {
+ await configureDevinRate()
+ const filePath = await writeTranscript('token-only.json', {
+ session_id: 'token-session',
+ agent: { model_name: 'agent-model' },
+ steps: [
+ {
+ step_id: 'user-cost',
+ metadata: {
+ is_user_input: true,
+ committed_acu_cost: 99,
+ metrics: { input_tokens: 99 },
+ },
+ },
+ { step_id: 'empty', metadata: { created_at: '2026-06-05T10:00:00.000Z' } },
+ {
+ step_id: 'tokens',
+ metadata: {
+ created_at: '2026-06-05T10:00:01.000Z',
+ metrics: { output_tokens: 42 },
+ },
+ },
+ ],
+ })
+
+ const calls = await parseTranscript(filePath)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.model).toBe('agent-model')
+ expect(calls[0]!.outputTokens).toBe(42)
+ expect(calls[0]!.costUSD).toBe(0)
+ })
+
+ it('converts ACUs to costUSD using the configured Devin rate', async () => {
+ await configureDevinRate(2.5)
+ const filePath = await writeTranscript('configured-rate.json', {
+ session_id: 'configured-rate',
+ agent: { model_name: 'agent-model' },
+ steps: [
+ { step_id: 's1', metadata: { committed_acu_cost: 0.4 } },
+ ],
+ })
+
+ const calls = await parseTranscript(filePath)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.costUSD).toBeCloseTo(1, 12)
+ })
+
+ it('falls back to filename session id and deduplicates by step id', async () => {
+ await configureDevinRate()
+ const filePath = await writeTranscript('fallback-session.json', {
+ steps: [
+ {
+ step_id: 1,
+ metadata: {
+ request_id: 'req-1',
+ committed_acu_cost: 0.1,
+ },
+ },
+ {
+ step_id: 2,
+ metadata: {
+ created_at: '2026-06-05T10:00:00.000Z',
+ committed_acu_cost: 0.2,
+ },
+ },
+ ],
+ })
+
+ const calls = await parseTranscript(filePath)
+
+ expect(calls.map(c => c.sessionId)).toEqual(['fallback-session', 'fallback-session'])
+ expect(calls.map(c => c.model)).toEqual(['devin', 'devin'])
+ expect(calls.map(c => c.deduplicationKey)).toEqual([
+ 'devin:fallback-session:1',
+ 'devin:fallback-session:2',
+ ])
+ })
+
+ it('ignores array-root and malformed transcripts', async () => {
+ await configureDevinRate()
+ const arrayPath = await writeTranscript('array.json', [])
+ const malformedPath = join(tmpDir, 'transcripts', 'bad.json')
+ await writeFile(malformedPath, '{')
+
+ expect(await parseTranscript(arrayPath)).toEqual([])
+ expect(await parseTranscript(malformedPath)).toEqual([])
+ })
+
+ it('deduplicates calls with a shared seen key set', async () => {
+ await configureDevinRate()
+ const filePath = await writeTranscript('dupe.json', {
+ session_id: 'dupe-session',
+ steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }],
+ })
+ const provider = createDevinProvider(tmpDir)
+ const seenKeys = new Set()
+ const source = { path: filePath, project: 'devin', provider: 'devin' }
+
+ const first: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call)
+ const second: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call)
+
+ expect(first).toHaveLength(1)
+ expect(second).toHaveLength(0)
+ })
+})
+
+const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
+
+skipUnlessSqlite('devin provider sessions.db enrichment', () => {
+ it('uses sessions.db to enrich project, projectPath, model, and timestamp fallbacks', async () => {
+ await configureDevinRate()
+ createSessionsDb()
+ const filePath = await writeTranscript('db-session.json', {
+ session_id: 'db-session',
+ steps: [
+ {
+ step_id: 's1',
+ metadata: {
+ committed_acu_cost: 0.25,
+ metrics: { input_tokens: 10 },
+ },
+ },
+ ],
+ })
+
+ const calls = await parseTranscript(filePath, 'fallback-project')
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toMatchObject({
+ model: 'claude-sonnet-4-6',
+ project: 'codeburn',
+ projectPath: '/Users/example/work/codeburn',
+ timestamp: '2027-01-15T08:00:10.000Z',
+ costUSD: 0.25,
+ })
+ })
+
+ it('uses sessions.db project labels during discovery when transcript filename matches the session id', async () => {
+ await configureDevinRate()
+ createSessionsDb()
+ const filePath = await writeTranscript('db-session.json', { session_id: 'db-session', steps: [] })
+
+ const provider = createDevinProvider(tmpDir)
+ const sources = await provider.discoverSessions()
+
+ expect(sources).toEqual([
+ { path: filePath, project: 'codeburn', provider: 'devin' },
+ ])
+ })
+
+ it('skips sessions hidden in sessions.db', async () => {
+ await configureDevinRate()
+ createSessionsDb()
+ await writeTranscript('hidden-session.json', {
+ session_id: 'hidden-session',
+ steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.25 } }],
+ })
+
+ const provider = createDevinProvider(tmpDir)
+ expect(await provider.discoverSessions()).toEqual([])
+
+ const calls = await parseTranscript(join(tmpDir, 'transcripts', 'hidden-session.json'))
+ expect(calls).toEqual([])
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index e1536101..3f1cfb9f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,7 +11,8 @@
"rootDir": "src",
"declaration": true,
"sourceMap": true,
- "resolveJsonModule": true
+ "resolveJsonModule": true,
+ "types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]