Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Click the menu bar glyph to open the popover. Tally refreshes on launch, on wake

**Manage accounts in OpenCode.** Tally opens OpenCode's credential database read-only. OpenCode and its auth plugins own sign-in and token refresh. Tally uses the stored access token for usage collection, model discovery, and warm-up; it never refreshes tokens or writes to OpenCode's database. Collection works while OpenCode is stopped and stored tokens remain usable.

**Switch accounts from Tally.** Native and web account cards show **Active in OpenCode** and a **Use in OpenCode** button for inactive accounts. The companion supports `{"action":"activate","accountId":"opaque-ID"}` after resolving the account with `accounts`. Switching requires the local OpenCode service and uses its activation API so OpenCode reloads provider state. It changes the selected account for that provider on the Mac running Tally. Pinning and collection remain independent of selection. Selection follows Tally's inventory refresh; an unavailable inventory shows selection as unknown.

**Quota bars turn red when you are burning too fast.** A blue bar means your current pace fits inside the window. Red means your average usage projects that you will hit the limit before the window resets, and Tally shows how long you have. The small tick mark is the even-pace marker: where you would be if you spread the window evenly.

**Pin what you care about.** Pinned accounts appear at the top and their percentages show directly in the menu bar. Everything else groups by provider below. Reorder accounts in Settings, and click an account's icon to change its color.
Expand Down
21 changes: 21 additions & 0 deletions Sources/TallyApp/TallyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ final class Runtime: ObservableObject {
startServer()
}

@Published var switchingAccount: String?

func activate(_ account: Account) async {
guard switchingAccount == nil else { return }
switchingAccount = account.id
defer { switchingAccount = nil }
do { snapshot = try await owner.activate(accountID: account.id); settingsError = nil }
catch let fault as Fault { settingsError = fault.message; snapshot = await owner.snapshot() }
catch { settingsError = "Account switch was not confirmed. Refresh Accounts before trying again." }
}

func pin(_ account: Account) async {
var ids = (snapshot?.accounts ?? []).filter(\.pinned).map(\.id)
if account.pinned { ids.removeAll { $0 == account.id } }
Expand Down Expand Up @@ -319,6 +330,16 @@ struct AccountCard: View {
Button { details.toggle() } label: { Image(systemName: details ? "chevron.up" : "chevron.down") }
.buttonStyle(.plain).accessibilityLabel("Details for \(account.name)")
}
HStack {
if account.active == true {
Label("Active in OpenCode", systemImage: "checkmark.circle").font(.caption)
} else {
Button(runtime.switchingAccount == account.id ? "Switching…" : "Use in OpenCode") {
Task { await runtime.activate(account) }
}.font(.caption).disabled(runtime.switchingAccount != nil || account.active == nil)
if account.active == nil { Text("Selection unavailable").font(.caption).foregroundStyle(.secondary) }
}
}
if let operation = runtime.resetOperations[account.id], operation.state != .pending,
!operation.acknowledgementRequired || resetExplanation {
VStack(alignment: .leading, spacing: 8) {
Expand Down
1 change: 1 addition & 0 deletions Sources/TallyCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ public struct Account: Codable, Sendable, Identifiable {
public var service = "opencode-go"
public var name: String
public var pinned = true
public var active: Bool? = nil
@Null public var pinOrder: Int? = nil
public var identityColorIndex = 0
public var pin = Pin()
Expand Down
10 changes: 7 additions & 3 deletions Sources/TallyCore/OpenCodeInventory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct StoredCredential: Sendable {
var refresh: String? = nil
var workspace: String? = nil
var expiresAt: Date? = nil
var active = false

var fingerprint: String { identityDigest(key) }
var preferenceKey: String { identityDigest("\(provider)\u{0}\(storedID)") }
Expand Down Expand Up @@ -63,7 +64,7 @@ public struct OpenCodeInventory: Sendable {
sqlite3_busy_timeout(database, 1000)
var statement: OpaquePointer?
// Selecting required columns validates their presence while accepting additive schema changes.
let sql = "SELECT id, label, value, integration_id, time_created FROM credential WHERE integration_id IN ('anthropic', 'openai', 'opencode-go', 'xai') ORDER BY time_created, id"
let sql = "SELECT id, label, value, integration_id, time_created, id = (SELECT chosen.id FROM credential AS chosen WHERE chosen.integration_id = credential.integration_id ORDER BY chosen.active DESC, chosen.time_created DESC, chosen.id DESC LIMIT 1) FROM credential WHERE integration_id IN ('anthropic', 'openai', 'opencode-go', 'xai') ORDER BY time_created, id"
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
throw Fault("inventory_schema_incompatible", "OpenCode's credential schema is not compatible with this Tally build.")
}
Expand Down Expand Up @@ -125,8 +126,11 @@ public struct OpenCodeInventory: Sendable {
throw Fault("credentials_unavailable", "A stored credential is invalid. Manage this Account in OpenCode.")
}
let credential = StoredCredential(storedID: try text(0), name: try text(1), key: secret, provider: provider,
refresh: refresh, workspace: workspace.flatMap { $0.isEmpty ? nil : $0 }, expiresAt: expiresAt)
if !credentials.contains(where: { $0.evidence.relation(to: credential.evidence) == .same }) { credentials.append(credential) }
refresh: refresh, workspace: workspace.flatMap { $0.isEmpty ? nil : $0 }, expiresAt: expiresAt,
active: sqlite3_column_int(statement, 5) == 1)
if let duplicate = credentials.firstIndex(where: { $0.evidence.relation(to: credential.evidence) == .same }) {
credentials[duplicate].active = credentials[duplicate].active || credential.active
} else { credentials.append(credential) }
}
guard try databaseIdentity() == identity else { throw Fault("inventory_unavailable", "OpenCode database changed during the read.") }
return InventoryRead(databaseIdentity: identity, credentials: credentials)
Expand Down
76 changes: 76 additions & 0 deletions Sources/TallyCore/OpenCodeSelection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

struct OpenCodeSelection: Sendable {
var environment = ProcessInfo.processInfo.environment
var home = FileManager.default.homeDirectoryForCurrentUser.path
var send: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) = { request in
let session = URLSession(configuration: .ephemeral, delegate: SelectionRedirects(), delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else { throw Fault("opencode_unavailable", "OpenCode returned an invalid response.") }
return (data, response)
}

func activate(_ credential: StoredCredential, databasePath: String) async throws {
struct Registration: Decodable { var url: URL; var password: String }
struct Configuration: Decodable { var env: [String: String]? }
let state = environment["XDG_STATE_HOME"] ?? home + "/.local/state"
let config = environment["XDG_CONFIG_HOME"] ?? home + "/.config"
let registration: Registration
do {
registration = try JSONDecoder().decode(Registration.self, from: Data(contentsOf: URL(fileURLWithPath: state + "/opencode/service.json")))
} catch { throw Fault("opencode_unavailable", "Start the local OpenCode service before switching Accounts.") }
var serviceEnvironment = environment
let configURL = URL(fileURLWithPath: config + "/opencode/service.json")
if FileManager.default.fileExists(atPath: configURL.path) {
do {
let configuration = try JSONDecoder().decode(Configuration.self, from: Data(contentsOf: configURL))
serviceEnvironment.merge(configuration.env ?? [:]) { _, new in new }
} catch { throw Fault("opencode_unavailable", "OpenCode's service configuration could not be read.") }
}
let expected = OpenCodeInventory.defaultPath(environment: serviceEnvironment, home: home)
guard try OpenCodeInventory(path: expected).databaseIdentity() == OpenCodeInventory(path: databasePath).databaseIdentity() else {
throw Fault("account_changed", "Tally must read the local OpenCode service's database to switch Accounts. Check the database path in Settings.")
}
guard registration.url.scheme == "http", let host = registration.url.host,
["127.0.0.1", "localhost", "[::1]", "::1"].contains(host),
registration.url.user == nil, registration.url.password == nil,
registration.url.query == nil, registration.url.fragment == nil else {
throw Fault("opencode_unavailable", "Account switching requires a local OpenCode service.")
}
func request(_ path: String, method: String = "GET") -> URLRequest {
var request = URLRequest(url: registration.url.appendingPathComponent(path), timeoutInterval: 10)
request.httpMethod = method
request.setValue("Basic " + Data("opencode:\(registration.password)".utf8).base64EncodedString(), forHTTPHeaderField: "Authorization")
return request
}
// Verify the service knows this stored row before sending the explicit selection command.
struct Integration: Decodable {
var connections: [Connection]
struct Connection: Decodable { var id: String }
}
struct IntegrationResponse: Decodable { var data: Integration }
do {
let (data, response) = try await send(request("api/integration/" + credential.provider))
guard response.statusCode == 200,
try JSONDecoder().decode(IntegrationResponse.self, from: data).data.connections.contains(where: { $0.id == credential.storedID }) else {
throw Fault("account_changed", "The Account is not available in the local OpenCode service. Refresh Accounts.")
}
} catch let fault as Fault { throw fault }
catch { throw Fault("opencode_unavailable", "Cannot read Accounts from OpenCode. Check that its local service is running.") }
do {
let (_, response) = try await send(request("api/credential/" + credential.storedID + "/activate", method: "POST"))
guard response.statusCode == 204 else {
throw Fault("account_switch_unconfirmed", "OpenCode did not confirm the switch. Refresh Accounts before trying again.")
}
} catch let fault as Fault { throw fault }
catch { throw Fault("account_switch_unconfirmed", "The switch response was lost. Refresh Accounts to check the selection before trying again.") }
}
}

private final class SelectionRedirects: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
completionHandler(nil)
}
}
42 changes: 42 additions & 0 deletions Sources/TallyCore/TallyOwner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ public actor TallyOwner {
private let warmupRunner = ProviderWarmup()
private var warmupTask: Task<Void, Never>?
private var warmingAccount: String?
private var switchingAccount = false
private var activateCredential: @Sendable (StoredCredential, String) async throws -> Void = { credential, path in
try await OpenCodeSelection().activate(credential, databasePath: path)
}
var warmupSend: (@Sendable (WarmupRequest) async throws -> Void)?
private var warmupModelList: (@Sendable (StoredCredential) async throws -> [WarmupModel])?
var warmupJitter: @Sendable () -> TimeInterval = { Double.random(in: 1...1200) }
Expand Down Expand Up @@ -69,6 +73,7 @@ public actor TallyOwner {
warmupSend: @escaping @Sendable (WarmupRequest) async throws -> Void = { _ in throw Fault("unexpected", "No test warm-up transport configured.") },
warmupJitter: @escaping @Sendable () -> TimeInterval = { 600 },
warmupModels: (@Sendable (StoredCredential) async throws -> [WarmupModel])? = nil,
activate: @escaping @Sendable (StoredCredential, String) async throws -> Void = { _, _ in throw Fault("unexpected", "No test Account switch configured.") },
quitWait: Duration = .seconds(15),
inventory: @escaping @Sendable () throws -> InventoryRead,
collections: (@Sendable (StoredCredential) -> [CollectionJob])? = nil,
Expand All @@ -87,6 +92,7 @@ public actor TallyOwner {
self.warmupSend = warmupSend
self.warmupJitter = warmupJitter
self.warmupModelList = warmupModels
self.activateCredential = activate
self.quitWait = quitWait
}

Expand All @@ -95,6 +101,7 @@ public actor TallyOwner {
var inventory = inventory; inventory.age(at: now)
let display = accounts.map { original in
var account = original
if inventory.stale { account.active = nil }
if let block = redemptions.block(accountID: account.id, target: credentials[account.id]?.evidence) {
account.command = CommandSummary(blockingOperationId: block.result.operationId, state: block.result.state.rawValue,
acknowledgementRequired: block.result.acknowledgementRequired)
Expand Down Expand Up @@ -136,6 +143,40 @@ public actor TallyOwner {

public func settingsError() -> Fault? { storageError }

public func activate(accountID: String) async throws -> AccountsResponse {
guard !stopping, !switchingAccount else {
throw Fault("account_switch_busy", "An Account switch is already running or Tally is shutting down.")
}
guard let selected = credentials[accountID] else { throw Fault("account_not_found", "Account not found. Refresh the inventory.") }
let namespace = databaseIdentity
inventory.stale = true
let current = try inventorySource()
guard current.databaseIdentity == namespace,
let credential = current.credentials.first(where: { $0.storedID == selected.storedID && $0.provider == selected.provider }),
credential.evidence.relation(to: selected.evidence) == .same else {
throw Fault("account_changed", "The Account changed. Refresh the inventory before switching.")
}
reconcile(current)
if credential.active { return snapshot() }
switchingAccount = true
inventory.stale = true
defer { switchingAccount = false }
do { try await activateCredential(credential, databasePath) }
catch {
// A lost response can follow a successful switch. Refresh state, but never replay the command.
if databaseIdentity == namespace, let latest = try? inventorySource(), latest.databaseIdentity == namespace { reconcile(latest) }
throw error
}
guard databaseIdentity == namespace else { throw Fault("account_changed", "Tally's database changed during the switch. Refresh the inventory.") }
let latest = try inventorySource()
guard latest.databaseIdentity == namespace else { throw Fault("account_changed", "OpenCode's database changed during the switch. Refresh the inventory.") }
reconcile(latest)
guard snapshot().accounts.first(where: { $0.id == accountID })?.active == true else {
throw Fault("account_switch_unconfirmed", "OpenCode accepted the switch, but the Account is no longer selected. Refresh Accounts before trying again.")
}
return snapshot()
}

public func warmupStatuses() -> [String: WarmupStatus] {
guard let databaseIdentity else { return [:] }
return store.state.namespaces[databaseIdentity]?.warmups ?? [:]
Expand Down Expand Up @@ -386,6 +427,7 @@ public actor TallyOwner {
namespace.records.append(IdentityRecord(evidence: credential.evidence, account: account, present: true))
}
account.name = credential.name
account.active = credential.active
// Token rotation can replace the Account ID. Warm-up follows the stored row and workspace,
// including its cooldown and interrupted-attempt state so rotation cannot replay a message.
let warmupKey = identityDigest("\(credential.preferenceKey)\u{0}\(credential.workspace ?? "")")
Expand Down
10 changes: 9 additions & 1 deletion Sources/TallyHTTP/TallyHTTP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ public struct TallyResponder: HTTPResponder {
}
do {
let parts = path.split(separator: "/", omittingEmptySubsequences: false)
if parts.count == 6, parts[1] == "api", parts[2] == "v1", parts[3] == "accounts", !parts[4].isEmpty, parts[5] == "activate" {
guard request.method == .post else { return failure(.methodNotAllowed, Fault("method_not_allowed", "Use POST to activate an Account.")) }
let buffer = try await request.body.collect(upTo: 16_384)
guard let object = try JSONSerialization.jsonObject(with: Data(buffer.readableBytesView)) as? [String: Any], object.isEmpty else {
throw Fault("invalid_request", "Account activation requires an empty JSON object.")
}
return try json(await owner.activate(accountID: String(parts[4])))
}
if parts.count == 6, parts[1] == "api", parts[2] == "v1", parts[3] == "accounts", !parts[4].isEmpty, parts[5] == "color" {
guard request.method == .put else { return failure(.methodNotAllowed, Fault("method_not_allowed", "Use PUT to save an Account color.")) }
let buffer = try await request.body.collect(upTo: 16_384)
Expand Down Expand Up @@ -174,7 +182,7 @@ public struct TallyResponder: HTTPResponder {
switch fault.code {
case "invalid_request", "warmup_model", "warmup_unnecessary": status = .badRequest
case "account_not_found", "operation_not_found": status = .notFound
case "operation_conflict", "account_blocked": status = .conflict
case "operation_conflict", "account_blocked", "account_switch_busy", "account_changed": status = .conflict
default: status = .serviceUnavailable
}
return failure(status, fault)
Expand Down
Loading
Loading