mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 07:09:41 +00:00
* feat: Command Code support Reads the cmd CLI's session transcripts at ~/.commandcode/projects/<slug>/<id>.jsonl (CODEBURN_COMMANDCODE_DIR moves the root). Checkpoint and prompt sidecars are skipped. usage.inputTokens includes cacheReadTokens, so fresh input is inputTokens - cacheReadTokens. The recorded costUsd is kept per call (costFromBilling); a call without one is priced from tokens and marked estimated. Forks copy parent message ids into a new file, so calls dedupe by message id across sessions. Desktop: Electron sandbox read path, macOS and Windows change watchers, Windows tab colour. No quota adapter. * fix(command-code): dedupe on meta.messageId, not the 8-hex line id The top-level id is randomUUID().slice(0, 8) and only unique within one file, so calls from unrelated sessions could collide and vanish. meta.messageId is a full uuid that survives forks. Fixture ids now use the real 8-hex shape. * test(command-code): check sidecar skipping by file name, not a posix path
206 lines
14 KiB
Swift
206 lines
14 KiB
Swift
import Foundation
|
|
|
|
struct UsageDataSnapshot: Equatable, Sendable {
|
|
let modificationDates: [String: Date]
|
|
}
|
|
|
|
/// Cheap change detection for the background menubar usage refresh. This is
|
|
/// deliberately not a recursive session scan: a 30s timer must not replace a
|
|
/// full Node parse with a full Swift walk of the same corpus.
|
|
enum UsageDataChangeGuard {
|
|
/// Unchanged-skips are honored for at most this long after the last
|
|
/// successful fetch. The snapshot below stats directories, so a session
|
|
/// appending to an existing transcript looks unchanged for as long as the
|
|
/// skip lasts; five minutes matches the resident serve child's own reuse
|
|
/// cap, which is what makes the refresh it forces cheap.
|
|
static let maxSkipIntervalSeconds: TimeInterval = 5 * 60
|
|
|
|
static func shouldSkip(
|
|
current: UsageDataSnapshot,
|
|
lastSuccessful: UsageDataSnapshot?,
|
|
lastSuccessAt: Date?,
|
|
now: Date = Date(),
|
|
force: Bool
|
|
) -> Bool {
|
|
guard !force, let lastSuccessful, let lastSuccessAt else { return false }
|
|
guard now.timeIntervalSince(lastSuccessAt) < maxSkipIntervalSeconds else { return false }
|
|
return current == lastSuccessful
|
|
}
|
|
|
|
static func snapshot(
|
|
fileManager: FileManager = .default,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
homeDirectory: String = NSHomeDirectory()
|
|
) -> UsageDataSnapshot {
|
|
var dates: [String: Date] = [:]
|
|
var roots: [UsageDataRoot] = []
|
|
|
|
func add(_ path: String, scanFirstLevelDirectories: Bool = true) {
|
|
let root = UsageDataRoot(path: path, scanFirstLevelDirectories: scanFirstLevelDirectories)
|
|
guard !path.isEmpty, !roots.contains(root) else { return }
|
|
roots.append(root)
|
|
}
|
|
|
|
// These are the exact configurable roots used by the menubar's CLI
|
|
// payload path. Claude's projects directories are the normal session
|
|
// roots; the desktop root is included because its project directories
|
|
// are discovered below a Claude-managed workspace hierarchy.
|
|
for configDir in claudeConfigDirectories(environment: environment, homeDirectory: homeDirectory) {
|
|
add(path(configDir, "projects"))
|
|
}
|
|
add(environment["CODEBURN_DESKTOP_SESSIONS_DIR"] ?? path(homeDirectory, "Library", "Application Support", "Claude", "local-agent-mode-sessions"))
|
|
|
|
let codexHome = expand(environment["CODEX_HOME"] ?? path(homeDirectory, ".codex"), homeDirectory: homeDirectory)
|
|
add(path(codexHome, "sessions"))
|
|
add(path(codexHome, "archived_sessions"))
|
|
|
|
let cursorUser = path(homeDirectory, "Library", "Application Support", "Cursor", "User")
|
|
add(path(cursorUser, "globalStorage", "state.vscdb"), scanFirstLevelDirectories: false)
|
|
add(path(cursorUser, "workspaceStorage"))
|
|
let cursorAgentHome = path(homeDirectory, ".cursor")
|
|
add(path(cursorAgentHome, "projects"))
|
|
add(path(cursorAgentHome, "ai-tracking", "ai-code-tracking.db"), scanFirstLevelDirectories: false)
|
|
|
|
let xdgData = environment["XDG_DATA_HOME"] ?? path(homeDirectory, ".local", "share")
|
|
let xdgConfig = environment["XDG_CONFIG_HOME"] ?? path(homeDirectory, ".config")
|
|
let applicationSupport = path(homeDirectory, "Library", "Application Support")
|
|
|
|
// Several providers use nested workspace/session layouts or SQLite
|
|
// files. Their stable top directories/files are cheap to stat, but this
|
|
// intentionally does not descend to individual transcript files; an
|
|
// in-place edit can therefore wait for the next directory-entry change
|
|
// or an interactive refresh. The tradeoff avoids a deep idle walk.
|
|
add(expand(environment["CODEWHALE_HOME"] ?? path(homeDirectory, ".codewhale"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".deepseek", "sessions"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".cline", "data"), scanFirstLevelDirectories: false)
|
|
let dshHome = expand(environment["DSH_HOME"] ?? path(homeDirectory, ".dsh"), homeDirectory: homeDirectory)
|
|
add(path(dshHome, "sessions"))
|
|
add(expand(environment["CODEBUFF_DATA_DIR"] ?? path(xdgConfig, "manicode"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
|
let factoryHome = expand(environment["FACTORY_DIR"] ?? path(homeDirectory, ".factory"), homeDirectory: homeDirectory)
|
|
add(path(factoryHome, "sessions"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".gemini", "tmp"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".gemini", "antigravity", "conversations"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".gemini", "antigravity-cli", "conversations"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".gemini", "antigravity-cli", "implicit"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".gemini", "antigravity-ide", "conversations"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".gemini", "antigravity-ide", "implicit"), scanFirstLevelDirectories: false)
|
|
let hermesHome = expand(environment["HERMES_HOME"] ?? path(homeDirectory, ".hermes"), homeDirectory: homeDirectory)
|
|
add(hermesHome, scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "IBM Bob", "User", "globalStorage", "ibm.bob-code"), scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "Bob-IDE", "User", "globalStorage", "ibm.bob-code"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".kiro"), scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "Kiro", "User", "globalStorage", "kiro.kiroagent"), scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "Kiro", "User", "workspaceStorage"), scanFirstLevelDirectories: false)
|
|
let kimiHome = expand(environment["KIMI_SHARE_DIR"] ?? path(homeDirectory, ".kimi"), homeDirectory: homeDirectory)
|
|
add(path(kimiHome, "sessions"), scanFirstLevelDirectories: false)
|
|
let kimiCodeHome = expand(environment["KIMI_CODE_HOME"] ?? path(homeDirectory, ".kimi-code"), homeDirectory: homeDirectory)
|
|
add(path(kimiCodeHome, "sessions"))
|
|
add(path(homeDirectory, ".lingtai"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".lingtai-tui"), scanFirstLevelDirectories: false)
|
|
let vibeHome = expand(environment["VIBE_HOME"] ?? path(homeDirectory, ".vibe"), homeDirectory: homeDirectory)
|
|
add(path(vibeHome, "logs", "session"), scanFirstLevelDirectories: false)
|
|
let muxHome = expand(environment["CODEBURN_MUX_DIR"] ?? environment["MUX_ROOT"] ?? path(homeDirectory, ".mux"), homeDirectory: homeDirectory)
|
|
add(muxHome, scanFirstLevelDirectories: false)
|
|
for name in [".openclaw", ".clawdbot", ".moltbot", ".moldbot"] {
|
|
add(path(homeDirectory, name, "agents"), scanFirstLevelDirectories: false)
|
|
}
|
|
let openClaudeRoot = expand(environment["CODEBURN_OPENCLAUDE_DIR"] ?? path(homeDirectory, ".openclaude"), homeDirectory: homeDirectory)
|
|
add(path(openClaudeRoot, "projects"), scanFirstLevelDirectories: true)
|
|
let commandCodeRoot = expand(environment["CODEBURN_COMMANDCODE_DIR"] ?? path(homeDirectory, ".commandcode"), homeDirectory: homeDirectory)
|
|
add(path(commandCodeRoot, "projects"), scanFirstLevelDirectories: true)
|
|
add(path(applicationSupport, "Open Design"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".pi", "agent", "sessions"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".omp", "agent", "sessions"), scanFirstLevelDirectories: false)
|
|
add(expand(environment["QWEN_DATA_DIR"] ?? path(homeDirectory, ".qwen", "projects"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
|
let grokHome = expand(environment["GROK_HOME"] ?? path(homeDirectory, ".grok"), homeDirectory: homeDirectory)
|
|
add(path(grokHome, "sessions"), scanFirstLevelDirectories: false)
|
|
add(expand(environment["ZS_DATA_DIR"] ?? path(applicationSupport, "zerostack"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
|
add(expand(environment["OPENCODE_DATA_DIR"] ?? path(xdgData, "opencode"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
|
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
|
|
// process without it blocks in the kernel rather than failing, on a bare
|
|
// access(2) as much as on open() (see the TCC probe in src/sqlite.ts).
|
|
// Stat-ing it from the 30s refresh would hang the refresh and re-ask for
|
|
// consent. Without it, Warp-only writes can be skipped for at most
|
|
// maxSkipIntervalSeconds, the same staleness the in-place-edit case above
|
|
// already accepts. (The entry here used to name group.warp, a path that
|
|
// has never existed, so it watched nothing either way.)
|
|
//
|
|
// WARP_DB_PATH is the exception, because it is a path the person named
|
|
// themselves — the CLI (src/providers/warp.ts) and the Windows guard both
|
|
// honour it, and a copy kept outside the group container is exactly what
|
|
// it is for. Pointed back at the group container it carries that tree's
|
|
// risk, which is the caller's to take.
|
|
if let warpDB = environment["WARP_DB_PATH"], !warpDB.isEmpty {
|
|
add(expand(warpDB, homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
|
}
|
|
add(path(homeDirectory, ".forge", ".forge.db"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".zcode", "cli", "db", "db.sqlite"), scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "Zed", "threads", "threads.db"), scanFirstLevelDirectories: false)
|
|
add(path(xdgConfig, "github-copilot"), scanFirstLevelDirectories: false)
|
|
add(path(homeDirectory, ".copilot", "session-state"), scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "Code", "User", "globalStorage", "github.copilot-chat", "agent-traces.db"), scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "Code - Insiders", "User", "globalStorage", "github.copilot-chat", "agent-traces.db"), scanFirstLevelDirectories: false)
|
|
add(path(applicationSupport, "VSCodium", "User", "globalStorage", "github.copilot-chat", "agent-traces.db"), scanFirstLevelDirectories: false)
|
|
|
|
// A changed menubar config can change the roots above, so keep its
|
|
// mtime in the snapshot even when the configured directory list is the
|
|
// same. Network-only providers have no local data root to fingerprint.
|
|
add(path(homeDirectory, ".config", "codeburn", "config.json"), scanFirstLevelDirectories: false)
|
|
|
|
for root in roots {
|
|
dates[root.path] = modificationDate(atPath: root.path, fileManager: fileManager)
|
|
guard root.scanFirstLevelDirectories,
|
|
dates[root.path] != nil,
|
|
let entries = try? fileManager.contentsOfDirectory(atPath: root.path) else { continue }
|
|
for entry in entries {
|
|
let child = path(root.path, entry)
|
|
var isDirectory = ObjCBool(false)
|
|
guard fileManager.fileExists(atPath: child, isDirectory: &isDirectory), isDirectory.boolValue else { continue }
|
|
dates[child] = modificationDate(atPath: child, fileManager: fileManager)
|
|
}
|
|
}
|
|
return UsageDataSnapshot(modificationDates: dates)
|
|
}
|
|
|
|
private struct UsageDataRoot: Hashable {
|
|
let path: String
|
|
let scanFirstLevelDirectories: Bool
|
|
}
|
|
|
|
private static func modificationDate(atPath path: String, fileManager: FileManager) -> Date? {
|
|
guard let attributes = try? fileManager.attributesOfItem(atPath: path) else { return nil }
|
|
return attributes[.modificationDate] as? Date
|
|
}
|
|
|
|
static func claudeConfigDirectories(environment: [String: String], homeDirectory: String) -> [String] {
|
|
if let multi = environment["CLAUDE_CONFIG_DIRS"], !multi.isEmpty {
|
|
return multi.split(separator: ":").map { expand(String($0), homeDirectory: homeDirectory) }
|
|
}
|
|
if let single = environment["CLAUDE_CONFIG_DIR"], !single.isEmpty {
|
|
return [expand(single, homeDirectory: homeDirectory)]
|
|
}
|
|
let configured = CLIClaudeConfig.load()
|
|
return configured.isEmpty ? [path(homeDirectory, ".claude")] : configured.map { expand($0, homeDirectory: homeDirectory) }
|
|
}
|
|
|
|
private static func expand(_ value: String, homeDirectory: String) -> String {
|
|
guard value == "~" || value.hasPrefix("~/") else { return value }
|
|
return value == "~" ? homeDirectory : path(homeDirectory, String(value.dropFirst(2)))
|
|
}
|
|
|
|
private static func path(_ base: String, _ components: String...) -> String {
|
|
components.reduce(base) { ($0 as NSString).appendingPathComponent($1) }
|
|
}
|
|
}
|