summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
committerAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
commitd6b208342edf97bafa5b1dcc986b782f9879d141 (patch)
treec6d8f9bffa86f78e3b1369b811bef9642df788d0
parent1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (diff)
downloaddispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.tar.gz
dispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.zip
feat: SQLite database for all credentials, keys, wake schedule, and usage cache
- Add SQLite database at ~/.local/share/dispatch/dispatch.db with tables: credentials, api_keys, wake_schedule, usage_cache - Store Claude OAuth credentials in DB with import button in Model Status UI - Store OpenCode/Copilot API keys in DB with paste-to-import modal - Store OpenCode cookie and workspace IDs in DB - Migrate wake schedule from .wake-schedule.json to DB - Migrate usage cache from in-memory Map + localStorage to DB - Remove all env var and file fallbacks — DB is the single source of truth - Add seed scripts: bin/import-credentials.ts, bin/seed-opencode-keys.ts - Docker: container runs as host UID/GID with matching home directory - Clean up dispatch.toml: remove env fields, update comments - Progress bar time markers for usage cycle tracking
-rwxr-xr-xbin/import-credentials.ts26
-rw-r--r--bin/seed-opencode-keys.ts28
-rwxr-xr-xbin/up5
-rwxr-xr-xbin/up-backend8
-rw-r--r--dispatch.toml14
-rw-r--r--docker-compose.yml10
-rw-r--r--docker/entrypoint.dev.sh52
-rw-r--r--packages/api/src/agent-manager.ts29
-rw-r--r--packages/api/src/routes/models.ts202
-rw-r--r--packages/core/src/config/schema.ts8
-rw-r--r--packages/core/src/credentials/api-keys.ts71
-rw-r--r--packages/core/src/credentials/claude.ts116
-rw-r--r--packages/core/src/credentials/index.ts17
-rw-r--r--packages/core/src/credentials/opencode.ts10
-rw-r--r--packages/core/src/credentials/store.ts177
-rw-r--r--packages/core/src/db/index.ts93
-rw-r--r--packages/core/src/index.ts3
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte141
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte200
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte2
20 files changed, 1028 insertions, 184 deletions
diff --git a/bin/import-credentials.ts b/bin/import-credentials.ts
new file mode 100755
index 0000000..f99f20a
--- /dev/null
+++ b/bin/import-credentials.ts
@@ -0,0 +1,26 @@
+#!/usr/bin/env bun
+/**
+ * Import Claude credentials into the Dispatch SQLite database.
+ * Reads from ~/.claude/.credentials-{1,2}.json and stores them
+ * for the configured keys (claude-pro, claude-max).
+ */
+import { importCredentialsFromFile, getDatabasePath } from "../packages/core/src/index.js";
+
+const imports = [
+ { keyId: "claude-pro", provider: "anthropic", file: `${process.env.HOME}/.claude/.credentials-1.json` },
+ { keyId: "claude-max", provider: "anthropic", file: `${process.env.HOME}/.claude/.credentials-2.json` },
+];
+
+console.log(`Database: ${getDatabasePath()}\n`);
+
+for (const { keyId, provider, file } of imports) {
+ process.stdout.write(`Importing ${keyId} from ${file} ... `);
+ const result = importCredentialsFromFile(keyId, provider, file);
+ if (result.success) {
+ console.log("OK");
+ } else {
+ console.log(`FAILED: ${result.error}`);
+ }
+}
+
+console.log("\nDone.");
diff --git a/bin/seed-opencode-keys.ts b/bin/seed-opencode-keys.ts
new file mode 100644
index 0000000..c461592
--- /dev/null
+++ b/bin/seed-opencode-keys.ts
@@ -0,0 +1,28 @@
+#!/usr/bin/env bun
+/**
+ * Seed API keys from environment variables into the SQLite database.
+ */
+import { setApiKey, getDatabasePath } from "../packages/core/src/index.js";
+
+console.log(`Database: ${getDatabasePath()}\n`);
+
+const keys = [
+ { keyId: "opencode-1", provider: "opencode-go", envVar: "OPENCODE_KEY_1" },
+ { keyId: "opencode-2", provider: "opencode-go", envVar: "OPENCODE_KEY_2" },
+ { keyId: "copilot", provider: "github-copilot", envVar: "COPILOT_TOKEN" },
+ { keyId: "opencode-cookie", provider: "opencode-go", envVar: "OPENCODE_COOKIE" },
+ { keyId: "opencode-ws1", provider: "opencode-go", envVar: "OPENCODE_WS1_ID" },
+ { keyId: "opencode-ws2", provider: "opencode-go", envVar: "OPENCODE_WS2_ID" },
+];
+
+for (const { keyId, provider, envVar } of keys) {
+ const value = process.env[envVar];
+ if (value) {
+ setApiKey(keyId, provider, value);
+ console.log(`${keyId}: imported from $${envVar}`);
+ } else {
+ console.log(`${keyId}: SKIPPED ($${envVar} not set)`);
+ }
+}
+
+console.log("\nDone.");
diff --git a/bin/up b/bin/up
index 03df1b7..2cccfa8 100755
--- a/bin/up
+++ b/bin/up
@@ -10,6 +10,11 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Load secrets from gopass
OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)"
+# Pass host user identity so the container runs as the same UID/GID
+export HOST_UID="$(id -u)"
+export HOST_GID="$(id -g)"
+export HOST_USER="$(whoami)"
+
# Start all services
OPENCODE_API_KEY="$OPENCODE_API_KEY" \
docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@"
diff --git a/bin/up-backend b/bin/up-backend
index 722e5eb..894fc01 100755
--- a/bin/up-backend
+++ b/bin/up-backend
@@ -10,6 +10,12 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Load secrets from gopass
OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)"
+# Pass host user identity so the container runs as the same UID/GID
+export HOST_UID="$(id -u)"
+export HOST_GID="$(id -g)"
+export HOST_USER="$(whoami)"
+
# Start API service only
-sudo OPENCODE_API_KEY="$OPENCODE_API_KEY" \
+sudo -E OPENCODE_API_KEY="$OPENCODE_API_KEY" \
+ HOST_UID="$HOST_UID" HOST_GID="$HOST_GID" HOST_USER="$HOST_USER" \
docker compose -f "$PROJECT_DIR/docker-compose.yml" up api "$@"
diff --git a/dispatch.toml b/dispatch.toml
index 7e6fa5a..6789e95 100644
--- a/dispatch.toml
+++ b/dispatch.toml
@@ -1,11 +1,8 @@
# Dispatch — Model & Key Configuration
-# Keys reference env var names in .env.dispatch
+# Credentials and API keys are stored in the SQLite database.
+# Use the Model Status panel to import credentials, or run bin/import-credentials.ts.
# ─── Fallback Order (highest priority first) ────────────────────
-# Exhaust claude-max first, then opencode-1, then opencode-2, then copilot.
-# When all keys are exhausted the agent enters wait-for-refresh.
-# Must be declared BEFORE any [[keys]] / [[models]] blocks.
-
fallback = ["claude-pro", "claude-max", "opencode-1", "opencode-2", "copilot"]
# ─── API Keys ───────────────────────────────────────────────────
@@ -14,30 +11,27 @@ fallback = ["claude-pro", "claude-max", "opencode-1", "opencode-2", "copilot"]
id = "claude-pro"
provider = "anthropic"
base_url = "https://api.anthropic.com/v1"
-credentials_file = "/root/.claude/.credentials-1.json"
+credentials_file = "/home/tradam/.claude/.credentials-1.json"
[[keys]]
id = "claude-max"
provider = "anthropic"
base_url = "https://api.anthropic.com/v1"
-credentials_file = "/root/.claude/.credentials-2.json"
+credentials_file = "/home/tradam/.claude/.credentials-2.json"
[[keys]]
id = "opencode-1"
provider = "opencode-go"
-env = "OPENCODE_KEY_1"
base_url = "https://opencode.ai/zen/go/v1"
[[keys]]
id = "opencode-2"
provider = "opencode-go"
-env = "OPENCODE_KEY_2"
base_url = "https://opencode.ai/zen/go/v1"
[[keys]]
id = "copilot"
provider = "github-copilot"
-env = "COPILOT_TOKEN"
base_url = "https://api.githubcopilot.com"
# ─── Models ─────────────────────────────────────────────────────
diff --git a/docker-compose.yml b/docker-compose.yml
index 7a67505..3a019e8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,10 +8,14 @@ services:
- "3000:3000"
volumes:
- .:/app
- - ~/.claude:/root/.claude
+ - ${HOME}/.claude:/home/${HOST_USER:-dispatch}/.claude
+ - ${HOME}/.local/share/dispatch:/home/${HOST_USER:-dispatch}/.local/share/dispatch
env_file:
- .env.dispatch
environment:
+ HOST_UID: ${HOST_UID:-1000}
+ HOST_GID: ${HOST_GID:-1000}
+ HOST_USER: ${HOST_USER:-dispatch}
DISPATCH_WORKING_DIR: /app
frontend:
@@ -23,3 +27,7 @@ services:
- "5173:5173"
volumes:
- .:/app
+ environment:
+ HOST_UID: ${HOST_UID:-1000}
+ HOST_GID: ${HOST_GID:-1000}
+ HOST_USER: ${HOST_USER:-dispatch}
diff --git a/docker/entrypoint.dev.sh b/docker/entrypoint.dev.sh
index b28b44f..8378585 100644
--- a/docker/entrypoint.dev.sh
+++ b/docker/entrypoint.dev.sh
@@ -1,11 +1,47 @@
#!/bin/bash
set -euo pipefail
-# Install/update dependencies.
-# Source code is bind-mounted from the host; node_modules lives on the host
-# filesystem via the bind mount so it persists across container restarts
-# and is shared between the api and frontend services.
-bun install
-
-# Execute the main command
-exec "$@"
+# ─── Match host user inside container ────────────────────────────
+# Ensures the process runs as the host UID/GID with a matching
+# home directory so volume mounts and config paths are consistent.
+
+HOST_UID="${HOST_UID:-1000}"
+HOST_GID="${HOST_GID:-1000}"
+HOST_USER="${HOST_USER:-dispatch}"
+
+USER_HOME="/home/$HOST_USER"
+
+# Create group if it doesn't exist
+if ! getent group "$HOST_GID" > /dev/null 2>&1; then
+ groupadd -g "$HOST_GID" "$HOST_USER"
+fi
+
+# Ensure user with this UID has the correct home directory
+if id -u "$HOST_UID" > /dev/null 2>&1; then
+ USER_NAME=$(getent passwd "$HOST_UID" | cut -d: -f1)
+ usermod -d "$USER_HOME" "$USER_NAME" 2>/dev/null || true
+else
+ useradd -u "$HOST_UID" -g "$HOST_GID" -d "$USER_HOME" -m -s /bin/bash "$HOST_USER"
+ USER_NAME="$HOST_USER"
+fi
+
+# Ensure home and data directories exist with correct ownership
+mkdir -p "$USER_HOME" "$USER_HOME/.local/share/dispatch"
+chown "$HOST_UID:$HOST_GID" "$USER_HOME"
+chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.local/share/dispatch"
+
+# Ensure .claude is accessible
+if [ -d "$USER_HOME/.claude" ]; then
+ chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.claude" 2>/dev/null || true
+fi
+
+# Ensure node_modules is writable (created as root during build)
+if [ -d /app/node_modules ]; then
+ chown -R "$HOST_UID:$HOST_GID" /app/node_modules
+fi
+
+# Install/update dependencies as the target user
+su -s /bin/bash - "$USER_NAME" -c "export HOME=$USER_HOME && cd /app && bun install"
+
+# Execute the main command as the target user
+exec su -s /bin/bash - "$USER_NAME" -c "export HOME=$USER_HOME && cd /app && exec $*"
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 4f45781..92396b6 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -20,9 +20,10 @@ import {
TaskList,
createTaskListTool,
type ClaudeAccount,
- discoverClaudeAccounts,
+ getClaudeAccountsFromDB,
refreshAccountCredentials,
refreshAccountCredentialsAsync,
+ resolveApiKey,
} from "@dispatch/core";
import type { PermissionManager } from "./permission-manager.js";
import { setConfigGetter } from "./routes/config.js";
@@ -124,7 +125,7 @@ export class AgentManager {
private _refreshClaudeAccounts(): void {
try {
- this.claudeAccounts = discoverClaudeAccounts();
+ this.claudeAccounts = getClaudeAccountsFromDB();
if (this.claudeAccounts.length > 0) {
console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`);
}
@@ -187,8 +188,8 @@ export class AgentManager {
const ruleset = configToRuleset(this.config);
// Try to resolve model from registry, fall back to env vars
- let apiKey = process.env.OPENCODE_API_KEY ?? "";
- let model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
+ let apiKey = "";
+ let model = "deepseek-v4-flash";
let baseURL = "https://opencode.ai/zen/go/v1";
let provider: string | undefined;
let claudeCredentials: { accessToken: string } | undefined;
@@ -203,9 +204,8 @@ export class AgentManager {
if (key.provider === "anthropic") {
// Anthropic provider: resolve credentials from Claude accounts
const credFile = key.credentials_file;
- const account = credFile
- ? this.claudeAccounts.find((a) => a.source === credFile)
- : this.claudeAccounts[0];
+ const account = this.claudeAccounts.find((a) => a.id === effectiveKeyId)
+ ?? (credFile ? this.claudeAccounts.find((a) => a.source === credFile) : this.claudeAccounts[0]);
if (account) {
const creds = refreshAccountCredentials(account);
if (creds && creds.expiresAt > Date.now() + 60_000) {
@@ -247,7 +247,7 @@ export class AgentManager {
}
} else {
// Standard key: resolve from env var
- const envKey = key.env ? process.env[key.env] : undefined;
+ const envKey = resolveApiKey(key.id);
if (envKey) {
apiKey = envKey;
baseURL = key.base_url;
@@ -284,9 +284,8 @@ export class AgentManager {
// Check if resolved key is anthropic
if (resolved.key.provider === "anthropic") {
const credFile = resolved.key.credentials_file;
- const account = credFile
- ? this.claudeAccounts.find((a) => a.source === credFile)
- : this.claudeAccounts[0];
+ const account = this.claudeAccounts.find((a) => a.id === resolved.key.id)
+ ?? (credFile ? this.claudeAccounts.find((a) => a.source === credFile) : this.claudeAccounts[0]);
if (account) {
let creds = refreshAccountCredentials(account);
if (!creds || creds.expiresAt <= Date.now() + 60_000) {
@@ -304,14 +303,14 @@ export class AgentManager {
console.warn(`dispatch: no Claude credentials found for key "${resolved.key.id}"`);
}
} else {
- const envKey = process.env[resolved.key.env!];
+ const envKey = resolveApiKey(resolved.key.id);
if (envKey) {
apiKey = envKey;
} else {
- console.warn(`dispatch: env var "${resolved.key.env}" not set for key "${resolved.key.id}", falling back to env vars`);
- model = process.env.DISPATCH_MODEL ?? "deepseek-v4-flash";
+ console.warn(`dispatch: env var not set for key "${resolved.key.id}", falling back to defaults`);
+ model = "deepseek-v4-flash";
baseURL = "https://opencode.ai/zen/go/v1";
- apiKey = process.env.OPENCODE_API_KEY ?? "";
+ apiKey = "";
}
}
} else {
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 2f11268..ca58e38 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -1,16 +1,20 @@
-import { existsSync, readFileSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
import type { ModelRegistry, ModelResolver } from "@dispatch/core";
import {
ANTHROPIC_MODELS_FALLBACK,
type ClaudeAccount,
- discoverClaudeAccounts,
fetchAnthropicModels,
+ getClaudeAccountsFromDB,
fetchCopilotUsage,
fetchOpencodeUsage,
getAccountUsage,
getAnthropicHeaders,
+ getDatabase,
+ importCredentialsFromFile,
+ listApiKeys,
+ listStoredCredentials,
refreshAccountCredentialsAsync,
+ resolveApiKey,
+ setApiKey,
validateAccountCredentials,
} from "@dispatch/core";
import { Hono } from "hono";
@@ -31,6 +35,11 @@ export function setAccountsGetter(getter: () => ClaudeAccount[]): void {
getAccounts = getter;
}
+/** Load Claude accounts from the database. */
+function resolveClaudeAccounts(): ClaudeAccount[] {
+ return getClaudeAccountsFromDB();
+}
+
export const modelsRoutes = new Hono();
modelsRoutes.get("/", (c) => {
@@ -108,8 +117,9 @@ modelsRoutes.get("/available", async (c) => {
// Anthropic provider: validate credentials and fetch models dynamically
if (key.definition.provider === "anthropic") {
const credFile = key.definition.credentials_file;
- const accounts = discoverClaudeAccounts();
- const account = credFile ? accounts.find((a) => a.source === credFile) : accounts[0];
+ const accounts = resolveClaudeAccounts();
+ const account = accounts.find((a) => a.id === keyId)
+ ?? (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]);
if (!account) {
return c.json({ error: "no Claude credentials found" }, 500);
@@ -139,9 +149,9 @@ modelsRoutes.get("/available", async (c) => {
});
}
- const apiKeyValue = key.definition.env ? process.env[key.definition.env] : undefined;
+ const apiKeyValue = resolveApiKey(keyId!);
if (!apiKeyValue) {
- return c.json({ error: `env var not set: ${key.definition.env}` }, 500);
+ return c.json({ error: `no API key found for ${keyId}` }, 500);
}
const baseUrl = key.definition.base_url.replace(/\/+$/, "");
@@ -181,7 +191,7 @@ modelsRoutes.get("/available", async (c) => {
// List available Claude accounts with validated credentials
modelsRoutes.get("/claude-accounts", async (c) => {
- const candidates = discoverClaudeAccounts();
+ const candidates = resolveClaudeAccounts();
// Validate each account's credentials; only include ones with a working token
const validated: Array<{
@@ -214,7 +224,7 @@ modelsRoutes.get("/claude-accounts", async (c) => {
modelsRoutes.get("/claude-usage", async (c) => {
const accountId = c.req.query("accountId");
const accounts = getAccounts();
- const accountAccounts = discoverClaudeAccounts();
+ const accountAccounts = resolveClaudeAccounts();
const allAccounts = accounts.length > 0 ? accounts : accountAccounts;
let account: ClaudeAccount | undefined;
@@ -261,12 +271,15 @@ modelsRoutes.get("/key-usage", async (c) => {
try {
if (provider === "anthropic") {
- const allAccounts = discoverClaudeAccounts();
+ const allAccounts = resolveClaudeAccounts();
const credFile = key.definition.credentials_file;
- // Only show the account matching this key's credentials_file
- const accounts = credFile
- ? allAccounts.filter((a) => a.source === credFile)
- : allAccounts.slice(0, 1); // no credentials_file → default account only
+ // Match by key ID (DB accounts) or source file (file accounts)
+ const accounts = allAccounts.filter(
+ (a) => a.id === keyId || (credFile && a.source === credFile),
+ );
+ if (accounts.length === 0 && allAccounts[0]) {
+ accounts.push(allAccounts[0]);
+ }
if (accounts.length === 0) {
return c.json({ error: "no Claude accounts available" }, 502);
}
@@ -315,12 +328,9 @@ modelsRoutes.get("/key-usage", async (c) => {
},
});
} else if (provider === "github-copilot") {
- if (!key.definition.env) {
- return c.json({ error: "no env var configured for this key" }, 502);
- }
- const token = process.env[key.definition.env];
+ const token = resolveApiKey(keyId);
if (!token) {
- return c.json({ error: `env var ${key.definition.env} not set` }, 502);
+ return c.json({ error: `no API key found for ${keyId}` }, 502);
}
const report = await fetchCopilotUsage(token, key.definition.base_url);
if (!report) {
@@ -343,29 +353,106 @@ modelsRoutes.get("/key-usage", async (c) => {
}
});
+// ─── API key management ───────────────────────────────────────
+
+modelsRoutes.post("/set-api-key", async (c) => {
+ const body = await c.req.json<{ keyId?: string; apiKey?: string }>();
+ if (typeof body.keyId !== "string" || !body.keyId) {
+ return c.json({ error: "keyId is required" }, 400);
+ }
+ if (typeof body.apiKey !== "string" || !body.apiKey) {
+ return c.json({ error: "apiKey is required" }, 400);
+ }
+
+ const registry = getRegistry();
+ if (!registry) {
+ return c.json({ error: "registry not available" }, 502);
+ }
+
+ const keys = registry.getKeys();
+ const key = keys.find((k) => k.definition.id === body.keyId);
+ if (!key) {
+ return c.json({ error: `key not found: ${body.keyId}` }, 404);
+ }
+
+ setApiKey(body.keyId, key.definition.provider, body.apiKey);
+ return c.json({ success: true, keyId: body.keyId });
+});
+
+modelsRoutes.get("/api-keys-status", (c) => {
+ const stored = listApiKeys();
+ return c.json({ keys: stored });
+});
+
+// ─── Credential import ────────────────────────────────────────
+
+modelsRoutes.post("/import-credentials", async (c) => {
+ const body = await c.req.json<{ keyId?: string }>();
+ const keyId = body.keyId;
+ if (typeof keyId !== "string" || !keyId) {
+ return c.json({ error: "keyId is required" }, 400);
+ }
+
+ const registry = getRegistry();
+ if (!registry) {
+ return c.json({ error: "registry not available" }, 502);
+ }
+
+ const keys = registry.getKeys();
+ const key = keys.find((k) => k.definition.id === keyId);
+ if (!key) {
+ return c.json({ error: `key not found: ${keyId}` }, 404);
+ }
+
+ if (key.definition.provider !== "anthropic") {
+ return c.json({ error: "credential import is only supported for anthropic keys" }, 400);
+ }
+
+ const credFile = key.definition.credentials_file;
+ if (!credFile) {
+ return c.json({ error: "no credentials_file configured for this key" }, 400);
+ }
+
+ const result = importCredentialsFromFile(keyId, key.definition.provider, credFile);
+ if (!result.success) {
+ return c.json({ error: result.error ?? "import failed" }, 400);
+ }
+
+ return c.json({ success: true, keyId });
+});
+
+modelsRoutes.get("/credentials-status", (c) => {
+ const stored = listStoredCredentials();
+ const status = stored.map((cred) => ({
+ keyId: cred.keyId,
+ provider: cred.provider,
+ subscriptionType: cred.subscriptionType,
+ sourceFile: cred.sourceFile,
+ importedAt: cred.importedAt,
+ updatedAt: cred.updatedAt,
+ expired: cred.expiresAt < Date.now(),
+ }));
+ return c.json({ credentials: status });
+});
+
// ─── Shared wake function ─────────────────────────────────────
async function wakeAllClaudeAccounts(): Promise<
Array<{ label: string; ok: boolean; error?: string }>
> {
// Only wake accounts referenced by configured anthropic keys
- const allAccounts = discoverClaudeAccounts();
+ const allAccounts = resolveClaudeAccounts();
const registry = getRegistry();
- const configuredCredFiles = new Set<string>();
+ const configuredKeyIds = new Set<string>();
if (registry) {
for (const ks of registry.getKeys()) {
if (ks.definition.provider === "anthropic") {
- if (ks.definition.credentials_file) {
- configuredCredFiles.add(ks.definition.credentials_file);
- } else if (allAccounts[0]) {
- // Key without explicit credentials_file uses default account
- configuredCredFiles.add(allAccounts[0].source);
- }
+ configuredKeyIds.add(ks.definition.id);
}
}
}
- const accounts = configuredCredFiles.size > 0
- ? allAccounts.filter((a) => configuredCredFiles.has(a.source))
+ const accounts = configuredKeyIds.size > 0
+ ? allAccounts.filter((a) => configuredKeyIds.has(a.id))
: allAccounts;
if (accounts.length === 0) {
return [{ label: "(none)", ok: false, error: "no Claude accounts available" }];
@@ -421,8 +508,6 @@ interface PendingRetry {
nextRetryAt: number; // timestamp for next retry attempt
}
-const SCHEDULE_FILE = join(process.cwd(), ".wake-schedule.json");
-
function nextOccurrenceAt15(hour: number): number {
const now = new Date();
const target = new Date(now);
@@ -433,47 +518,44 @@ function nextOccurrenceAt15(hour: number): number {
return target.getTime();
}
-function loadScheduleFromDisk(): WakeSchedule {
+function loadScheduleFromDB(): WakeSchedule {
try {
- if (existsSync(SCHEDULE_FILE)) {
- const raw = readFileSync(SCHEDULE_FILE, "utf-8");
- const parsed = JSON.parse(raw) as Record<string, number>;
- const schedule: WakeSchedule = {};
- let needsPersist = false;
- for (const [key, value] of Object.entries(parsed)) {
- const hour = Number(key);
- if (value > Date.now()) {
- schedule[hour] = value;
- } else {
- // Timestamp has passed — recompute for next occurrence
- schedule[hour] = nextOccurrenceAt15(hour);
- needsPersist = true;
- }
+ const db = getDatabase();
+ const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{ hour: number; next_wake_at: number }>;
+ const schedule: WakeSchedule = {};
+ let needsUpdate = false;
+ for (const row of rows) {
+ if (row.next_wake_at > Date.now()) {
+ schedule[row.hour] = row.next_wake_at;
+ } else {
+ schedule[row.hour] = nextOccurrenceAt15(row.hour);
+ needsUpdate = true;
}
- if (needsPersist) {
- try {
- writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule), "utf-8");
- } catch {
- // Ignore write errors
- }
- }
- return schedule;
}
+ if (needsUpdate) {
+ persistSchedule(schedule);
+ }
+ return schedule;
} catch {
- // File doesn't exist or is corrupt — start fresh
+ return {};
}
- return {};
}
-function persistSchedule(): void {
+function persistSchedule(scheduleToSave?: WakeSchedule): void {
try {
- writeFileSync(SCHEDULE_FILE, JSON.stringify(wakeSchedule), "utf-8");
+ const db = getDatabase();
+ const data = scheduleToSave ?? wakeSchedule;
+ db.run("DELETE FROM wake_schedule");
+ const insert = db.query("INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)");
+ for (const [hour, nextWakeAt] of Object.entries(data)) {
+ insert.run({ $hour: Number(hour), $nextWakeAt: nextWakeAt });
+ }
} catch {
- // Ignore write errors
+ // Ignore DB errors
}
}
-let wakeSchedule: WakeSchedule = loadScheduleFromDisk();
+let wakeSchedule: WakeSchedule = loadScheduleFromDB();
let pendingRetries: PendingRetry[] = [];
// HMR-safe: clear previous tick before starting a new one
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
index 57d0b7b..83e2695 100644
--- a/packages/core/src/config/schema.ts
+++ b/packages/core/src/config/schema.ts
@@ -149,16 +149,12 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi
};
}
- // Other providers require env
- if (typeof raw["env"] !== "string") {
- errors.push({ path: `${path}.env`, message: "must be a string" });
- return null;
- }
+ // Other providers: env is optional (keys can be stored in DB)
return {
id: raw["id"] as string,
provider: raw["provider"] as string,
- env: raw["env"] as string,
base_url: raw["base_url"] as string,
+ ...(typeof raw["env"] === "string" ? { env: raw["env"] } : {}),
};
}
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
new file mode 100644
index 0000000..5f92ffa
--- /dev/null
+++ b/packages/core/src/credentials/api-keys.ts
@@ -0,0 +1,71 @@
+import { getDatabase } from "../db/index.js";
+
+export interface StoredApiKey {
+ keyId: string;
+ provider: string;
+ apiKey: string;
+ importedAt: number;
+ updatedAt: number;
+}
+
+/**
+ * Store or update an API key in the database.
+ */
+export function setApiKey(keyId: string, provider: string, apiKey: string): void {
+ const db = getDatabase();
+ const now = Date.now();
+ db.query(
+ `INSERT INTO api_keys (key_id, provider, api_key, imported_at, updated_at)
+ VALUES ($keyId, $provider, $apiKey, $now, $now)
+ ON CONFLICT(key_id) DO UPDATE SET
+ api_key = $apiKey,
+ updated_at = $now`,
+ ).run({
+ $keyId: keyId,
+ $provider: provider,
+ $apiKey: apiKey,
+ $now: now,
+ });
+}
+
+/**
+ * Get a stored API key by key ID. Returns the key string or null.
+ */
+export function getApiKey(keyId: string): string | null {
+ const db = getDatabase();
+ const row = db.query(
+ "SELECT api_key FROM api_keys WHERE key_id = $keyId",
+ ).get({ $keyId: keyId }) as { api_key: string } | null;
+ return row?.api_key ?? null;
+}
+
+/**
+ * Resolve an API key from the database. Returns null if not found.
+ */
+export function resolveApiKey(keyId: string): string | null {
+ return getApiKey(keyId);
+}
+
+/**
+ * Delete a stored API key.
+ */
+export function deleteApiKey(keyId: string): void {
+ const db = getDatabase();
+ db.query("DELETE FROM api_keys WHERE key_id = $keyId").run({ $keyId: keyId });
+}
+
+/**
+ * List all stored API keys with metadata (key value excluded for security).
+ */
+export function listApiKeys(): Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> {
+ const db = getDatabase();
+ const rows = db.query(
+ "SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id",
+ ).all() as Array<Record<string, unknown>>;
+ return rows.map((row) => ({
+ keyId: row.key_id as string,
+ provider: row.provider as string,
+ importedAt: row.imported_at as number,
+ updatedAt: row.updated_at as number,
+ }));
+}
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 6639afc..1b9d148 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -1,4 +1,6 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs";
+import { getStoredCredentials, updateStoredTokens, listStoredCredentials } from "./store.js";
+import { getDatabase } from "../db/index.js";
import { dirname, join, basename } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
@@ -144,6 +146,34 @@ function buildAccountLabels(accounts: ClaudeAccount[]): void {
}
}
+/**
+ * Load Claude accounts from the SQLite database.
+ * Returns accounts for all stored anthropic credentials.
+ * This is the preferred path — file-based discovery is the fallback.
+ */
+export function getClaudeAccountsFromDB(): ClaudeAccount[] {
+ const stored = listStoredCredentials();
+ const accounts: ClaudeAccount[] = [];
+
+ for (const cred of stored) {
+ if (cred.provider !== "anthropic") continue;
+ accounts.push({
+ id: cred.keyId,
+ label: "",
+ source: `db:${cred.keyId}`,
+ credentials: {
+ accessToken: cred.accessToken,
+ refreshToken: cred.refreshToken,
+ expiresAt: cred.expiresAt,
+ subscriptionType: cred.subscriptionType ?? undefined,
+ },
+ });
+ }
+
+ buildAccountLabels(accounts);
+ return accounts;
+}
+
export function discoverClaudeAccounts(): ClaudeAccount[] {
const accounts: ClaudeAccount[] = [];
@@ -194,10 +224,22 @@ export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredent
return cached.creds;
}
- // Re-read from file to pick up external updates
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
+ // Re-read credentials: from DB for DB-backed accounts, from file otherwise
+ if (account.source.startsWith("db:")) {
+ const stored = getStoredCredentials(account.id);
+ if (stored) {
+ account.credentials = {
+ accessToken: stored.accessToken,
+ refreshToken: stored.refreshToken,
+ expiresAt: stored.expiresAt,
+ subscriptionType: stored.subscriptionType ?? undefined,
+ };
+ }
+ } else {
+ const onDisk = readCredentialsFile(account.source);
+ if (onDisk) {
+ account.credentials = onDisk;
+ }
}
if (account.credentials.expiresAt > now + 60_000) {
@@ -222,10 +264,22 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr
return cached.creds;
}
- // Re-read from file
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
+ // Re-read credentials: from DB for DB-backed accounts, from file otherwise
+ if (account.source.startsWith("db:")) {
+ const stored = getStoredCredentials(account.id);
+ if (stored) {
+ account.credentials = {
+ accessToken: stored.accessToken,
+ refreshToken: stored.refreshToken,
+ expiresAt: stored.expiresAt,
+ subscriptionType: stored.subscriptionType ?? undefined,
+ };
+ }
+ } else {
+ const onDisk = readCredentialsFile(account.source);
+ if (onDisk) {
+ account.credentials = onDisk;
+ }
}
if (account.credentials.expiresAt > now + 60_000) {
@@ -238,7 +292,12 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr
const refreshed = await refreshViaOAuth(account.credentials.refreshToken);
if (refreshed && refreshed.expiresAt > now + 60_000) {
account.credentials = refreshed;
- writeCredentialsFile(account.source, refreshed);
+ // Update DB if this is a DB-backed account, otherwise write to file
+ if (account.source.startsWith("db:")) {
+ updateStoredTokens(account.id, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
+ } else {
+ writeCredentialsFile(account.source, refreshed);
+ }
accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now });
return refreshed;
}
@@ -458,15 +517,46 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
}
}
-const usageCacheMap = new Map<string, ClaudeUsageReport>();
+function getCachedUsage(keyId: string): ClaudeUsageReport | null {
+ try {
+ const db = getDatabase();
+ const row = db.query(
+ "SELECT report_json FROM usage_cache WHERE key_id = $keyId",
+ ).get({ $keyId: keyId }) as { report_json: string } | null;
+ if (!row) return null;
+ return JSON.parse(row.report_json) as ClaudeUsageReport;
+ } catch {
+ return null;
+ }
+}
+
+function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageReport): void {
+ try {
+ const db = getDatabase();
+ db.query(
+ `INSERT INTO usage_cache (key_id, provider, cached_at, report_json)
+ VALUES ($keyId, $provider, $cachedAt, $reportJson)
+ ON CONFLICT(key_id) DO UPDATE SET
+ cached_at = $cachedAt,
+ report_json = $reportJson`,
+ ).run({
+ $keyId: keyId,
+ $provider: provider,
+ $cachedAt: Date.now(),
+ $reportJson: JSON.stringify(report),
+ });
+ } catch {
+ // Ignore DB errors
+ }
+}
export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
const creds = await refreshAccountCredentialsAsync(account);
- if (!creds) return usageCacheMap.get(account.id) ?? null;
+ if (!creds) return getCachedUsage(account.id);
const report = await fetchClaudeUsage(creds.accessToken);
if (report) {
- usageCacheMap.set(account.id, report);
+ setCachedUsage(account.id, "anthropic", report);
return report;
}
- return usageCacheMap.get(account.id) ?? null;
+ return getCachedUsage(account.id);
} \ No newline at end of file
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index 0ae4edb..8cbedd9 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -7,6 +7,7 @@ export {
type ClaudeUsageBucket,
type ClaudeUsageReport,
discoverClaudeAccounts,
+ getClaudeAccountsFromDB,
fetchAnthropicModels,
getAccountUsage,
getAnthropicBetas,
@@ -25,3 +26,19 @@ export {
type OpencodeUsageBucket,
type OpencodeUsageReport,
} from "./opencode.js";
+export {
+ type StoredCredential,
+ importCredentialsFromFile,
+ getStoredCredentials,
+ updateStoredTokens,
+ deleteStoredCredentials,
+ listStoredCredentials,
+} from "./store.js";
+export {
+ type StoredApiKey,
+ setApiKey,
+ getApiKey,
+ resolveApiKey,
+ deleteApiKey,
+ listApiKeys,
+} from "./api-keys.js";
diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts
index 7a74486..b8a9d4d 100644
--- a/packages/core/src/credentials/opencode.ts
+++ b/packages/core/src/credentials/opencode.ts
@@ -1,3 +1,5 @@
+import { resolveApiKey } from "./api-keys.js";
+
// ─── OpenCode Usage Tracking ──────────────────────────────────
// OpenCode has no public usage API. We scrape usage from the
// SolidStart SSR-rendered workspace page using a session cookie.
@@ -16,14 +18,14 @@ export interface OpencodeUsageReport {
}
function getWorkspaceId(keyId: string): string | undefined {
- // Match ai-usage convention: opencode-1 → OPENCODE_WS1_ID, opencode-2 → OPENCODE_WS2_ID
+ // Check DB for workspace ID: stored as "opencode-ws1", "opencode-ws2", or "opencode-ws"
const match = keyId.match(/opencode-(\d+)$/i);
if (match) {
const num = match[1];
- const specific = process.env[`OPENCODE_WS${num}_ID`];
+ const specific = resolveApiKey(`opencode-ws${num}`);
if (specific) return specific;
}
- return process.env.OPENCODE_WS_ID;
+ return resolveApiKey("opencode-ws") ?? undefined;
}
function parseOcDouble(html: string, key: string): number {
@@ -71,7 +73,7 @@ function parseOcBucket(
export async function fetchOpencodeUsage(
keyId: string,
): Promise<OpencodeUsageReport | null> {
- const cookie = process.env.OPENCODE_COOKIE;
+ const cookie = resolveApiKey("opencode-cookie");
const wsId = getWorkspaceId(keyId);
if (!cookie || !wsId) {
diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts
new file mode 100644
index 0000000..6c814f9
--- /dev/null
+++ b/packages/core/src/credentials/store.ts
@@ -0,0 +1,177 @@
+import { getDatabase } from "../db/index.js";
+import type { ClaudeCredentials } from "./claude.js";
+import { existsSync, readFileSync } from "node:fs";
+
+export interface StoredCredential {
+ keyId: string;
+ provider: string;
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: number;
+ subscriptionType: string | null;
+ sourceFile: string | null;
+ importedAt: number;
+ updatedAt: number;
+}
+
+function parseCredentialsFile(raw: string): ClaudeCredentials | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
+
+ const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
+ const creds = data as Record<string, unknown>;
+
+ if (creds.mcpOAuth && !creds.accessToken) return null;
+
+ if (
+ typeof creds.accessToken !== "string" ||
+ typeof creds.refreshToken !== "string" ||
+ typeof creds.expiresAt !== "number"
+ ) {
+ return null;
+ }
+
+ return {
+ accessToken: creds.accessToken as string,
+ refreshToken: creds.refreshToken as string,
+ expiresAt: creds.expiresAt as number,
+ subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
+ };
+}
+
+/**
+ * Import credentials from a file into the database for a specific key.
+ * Reads the credential file, parses it, and upserts into the credentials table.
+ */
+export function importCredentialsFromFile(
+ keyId: string,
+ provider: string,
+ filePath: string,
+): { success: boolean; error?: string } {
+ if (!existsSync(filePath)) {
+ return { success: false, error: `File not found: ${filePath}` };
+ }
+
+ let raw: string;
+ try {
+ raw = readFileSync(filePath, "utf-8").trim();
+ } catch (e) {
+ return { success: false, error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}` };
+ }
+
+ if (!raw) {
+ return { success: false, error: "File is empty" };
+ }
+
+ const creds = parseCredentialsFile(raw);
+ if (!creds) {
+ return { success: false, error: "Invalid credentials format" };
+ }
+
+ const db = getDatabase();
+ const now = Date.now();
+
+ db.query(
+ `INSERT INTO credentials (key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at)
+ VALUES ($keyId, $provider, $accessToken, $refreshToken, $expiresAt, $subscriptionType, $sourceFile, $now, $now)
+ ON CONFLICT(key_id) DO UPDATE SET
+ access_token = $accessToken,
+ refresh_token = $refreshToken,
+ expires_at = $expiresAt,
+ subscription_type = $subscriptionType,
+ source_file = $sourceFile,
+ updated_at = $now`,
+ ).run({
+ $keyId: keyId,
+ $provider: provider,
+ $accessToken: creds.accessToken,
+ $refreshToken: creds.refreshToken,
+ $expiresAt: creds.expiresAt,
+ $subscriptionType: creds.subscriptionType ?? null,
+ $sourceFile: filePath,
+ $now: now,
+ });
+
+ return { success: true };
+}
+
+/**
+ * Get stored credentials for a specific key from the database.
+ */
+export function getStoredCredentials(keyId: string): StoredCredential | null {
+ const db = getDatabase();
+ const row = db.query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
+ ).get({ $keyId: keyId }) as Record<string, unknown> | null;
+
+ if (!row) return null;
+
+ return {
+ keyId: row.key_id as string,
+ provider: row.provider as string,
+ accessToken: row.access_token as string,
+ refreshToken: row.refresh_token as string,
+ expiresAt: row.expires_at as number,
+ subscriptionType: row.subscription_type as string | null,
+ sourceFile: row.source_file as string | null,
+ importedAt: row.imported_at as number,
+ updatedAt: row.updated_at as number,
+ };
+}
+
+/**
+ * Update tokens in the database after a refresh.
+ */
+export function updateStoredTokens(
+ keyId: string,
+ accessToken: string,
+ refreshToken: string,
+ expiresAt: number,
+): void {
+ const db = getDatabase();
+ db.query(
+ `UPDATE credentials SET access_token = $accessToken, refresh_token = $refreshToken, expires_at = $expiresAt, updated_at = $now WHERE key_id = $keyId`,
+ ).run({
+ $keyId: keyId,
+ $accessToken: accessToken,
+ $refreshToken: refreshToken,
+ $expiresAt: expiresAt,
+ $now: Date.now(),
+ });
+}
+
+/**
+ * Delete stored credentials for a key.
+ */
+export function deleteStoredCredentials(keyId: string): void {
+ const db = getDatabase();
+ db.query("DELETE FROM credentials WHERE key_id = $keyId").run({ $keyId: keyId });
+}
+
+/**
+ * List all keys that have imported credentials, with their status.
+ */
+export function listStoredCredentials(): StoredCredential[] {
+ const db = getDatabase();
+ const rows = db.query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
+ ).all() as Array<Record<string, unknown>>;
+
+ return rows.map((row) => ({
+ keyId: row.key_id as string,
+ provider: row.provider as string,
+ accessToken: row.access_token as string,
+ refreshToken: row.refresh_token as string,
+ expiresAt: row.expires_at as number,
+ subscriptionType: row.subscription_type as string | null,
+ sourceFile: row.source_file as string | null,
+ importedAt: row.imported_at as number,
+ updatedAt: row.updated_at as number,
+ }));
+}
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
new file mode 100644
index 0000000..818aff0
--- /dev/null
+++ b/packages/core/src/db/index.ts
@@ -0,0 +1,93 @@
+import { Database } from "bun:sqlite";
+import { existsSync, mkdirSync } from "node:fs";
+import { isAbsolute, join } from "node:path";
+import { homedir } from "node:os";
+
+/**
+ * Returns the directory for persistent Dispatch data, following XDG Base
+ * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to
+ * `~/.local/share/dispatch`).
+ */
+function getDataDir(): string {
+ const xdg = process.env.XDG_DATA_HOME;
+ const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share");
+ return join(base, "dispatch");
+}
+
+let _db: Database | null = null;
+
+/**
+ * Get (or create) the singleton SQLite database.
+ *
+ * - Creates the data directory if it doesn't exist.
+ * - Creates `dispatch.db` if it doesn't exist.
+ * - Enables WAL journal mode for concurrent read performance.
+ */
+export function getDatabase(): Database {
+ if (_db) return _db;
+
+ const dir = getDataDir();
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+
+ const dbPath = join(dir, "dispatch.db");
+ _db = new Database(dbPath, { create: true });
+
+ // WAL mode: better concurrent read performance, safe for single-writer
+ _db.run("PRAGMA journal_mode = WAL;");
+ // Recommended for WAL: normal synchronous is safe and faster
+ _db.run("PRAGMA synchronous = NORMAL;");
+ // Enable foreign keys
+ _db.run("PRAGMA foreign_keys = ON;");
+
+ // Create tables
+ _db.run(`CREATE TABLE IF NOT EXISTS credentials (
+ key_id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT NOT NULL,
+ expires_at INTEGER NOT NULL,
+ subscription_type TEXT,
+ source_file TEXT,
+ imported_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
+ hour INTEGER PRIMARY KEY CHECK (hour BETWEEN 0 AND 23),
+ next_wake_at INTEGER NOT NULL
+ )`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
+ key_id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ cached_at INTEGER NOT NULL,
+ report_json TEXT NOT NULL
+ )`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS api_keys (
+ key_id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ api_key TEXT NOT NULL,
+ imported_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )`);
+
+ return _db;
+}
+
+/** Close the database connection (e.g. on shutdown). */
+export function closeDatabase(): void {
+ if (_db) {
+ _db.close();
+ _db = null;
+ }
+}
+
+/** Returns the path where the database file lives (or will live). */
+export function getDatabasePath(): string {
+ if (_db) return _db.filename;
+ const dir = getDataDir();
+ return join(dir, "dispatch.db");
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ae826dc..90cafbe 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -29,3 +29,6 @@ export { ModelRegistry, ModelResolver } from "./models/index.js";
// Credentials
export * from "./credentials/index.js";
+
+// Database
+export { getDatabase, closeDatabase, getDatabasePath } from "./db/index.js";
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index a2b735e..f5f7d6d 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -17,32 +17,8 @@
loading: boolean;
}
- // localStorage-backed cache: survives page refreshes
- const CACHE_STORAGE_KEY = "dispatch-key-usage-cache";
-
- function loadPersistedCache(): Map<string, KeyUsageData> {
- try {
- const raw = localStorage.getItem(CACHE_STORAGE_KEY);
- if (raw) {
- const parsed = JSON.parse(raw) as Record<string, KeyUsageData>;
- return new Map(Object.entries(parsed));
- }
- } catch {
- // Ignore parse errors
- }
- return new Map();
- }
-
- function persistCache(cache: Map<string, KeyUsageData>): void {
- try {
- const obj = Object.fromEntries(cache.entries());
- localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj));
- } catch {
- // Ignore storage errors (e.g. quota exceeded)
- }
- }
-
- const usageCache = loadPersistedCache();
+ // In-memory cache for the current session (backend DB is the persistent cache)
+ const usageCache = new Map<string, KeyUsageData>();
function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
return keyList.map((k) => {
@@ -73,7 +49,6 @@
} else {
const fresh = await res.json() as KeyUsageData;
usageCache.set(key.id, fresh);
- persistCache(usageCache);
updateEntry(key.id, {
data: fresh,
error: null,
@@ -206,6 +181,18 @@
}).join(" ");
}
+ // Cycle durations in ms
+ const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
+ const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
+
+ function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
+ if (!resetsAt) return -1;
+ const timeRemaining = resetsAt - Date.now();
+ const elapsed = cycleDurationMs - timeRemaining;
+ return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
+ }
+
function hasBucketData(bucket: UsageBucket | undefined): boolean {
return bucket !== undefined && bucket.utilization !== undefined;
}
@@ -255,34 +242,46 @@
{@const b = acct.fiveHour!}
{@const u = b.utilization ?? 0}
{@const p = Math.round(u * 100)}
+ {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between">
<span class="text-xs text-base-content/50">5-Hour</span>
<span class="text-xs font-mono">{p}%</span>
</div>
- <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(acct.sevenDay)}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress>
+ {#if tp >= 0}
+ <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
+ {/if}
+ </div>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+ {#if hasBucketData(acct.sevenDay)}
{@const b = acct.sevenDay!}
{@const u = b.utilization ?? 0}
{@const p = Math.round(u * 100)}
+ {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between">
<span class="text-xs text-base-content/50">Weekly</span>
<span class="text-xs font-mono">{p}%</span>
</div>
- <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress>
+ {#if tp >= 0}
+ <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
+ {/if}
+ </div>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
{/if}
</div>
{/if}
@@ -323,48 +322,66 @@
{@const b = entry.data.fiveHour!}
{@const u = b.utilization ?? 0}
{@const p = Math.round(u * 100)}
+ {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between">
<span class="text-xs text-base-content/50">5-Hour</span>
<span class="text-xs font-mono">{p}%</span>
</div>
- <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(entry.data.weekly)}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress>
+ {#if tp >= 0}
+ <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
+ {/if}
+ </div>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+ {#if hasBucketData(entry.data.weekly)}
{@const b = entry.data.weekly!}
{@const u = b.utilization ?? 0}
{@const p = Math.round(u * 100)}
+ {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between">
<span class="text-xs text-base-content/50">Weekly</span>
<span class="text-xs font-mono">{p}%</span>
</div>
- <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(entry.data.monthly)}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress>
+ {#if tp >= 0}
+ <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
+ {/if}
+ </div>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+ {#if hasBucketData(entry.data.monthly)}
{@const b = entry.data.monthly!}
{@const u = b.utilization ?? 0}
{@const p = Math.round(u * 100)}
+ {@const tp = cycleElapsedPct(b.resetsAt, THIRTY_DAY_MS)}
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between">
<span class="text-xs text-base-content/50">Monthly</span>
<span class="text-xs font-mono">{p}%</span>
</div>
- <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress>
+ {#if tp >= 0}
+ <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
+ {/if}
+ </div>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
{/if}
+ {/if}
{:else if entry.data.provider === "github-copilot"}
{@const p = Math.round(entry.data.percentUsed ?? 0)}
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte
index 34a0563..b2b6902 100644
--- a/packages/frontend/src/lib/components/ModelStatus.svelte
+++ b/packages/frontend/src/lib/components/ModelStatus.svelte
@@ -13,16 +13,27 @@
tags: string[];
}
+ interface CredentialStatus {
+ keyId: string;
+ provider: string;
+ subscriptionType: string | null;
+ importedAt: number;
+ updatedAt: number;
+ expired: boolean;
+ }
+
const {
models = [],
keys = [],
tags = [],
currentModel,
+ apiBase = "",
}: {
models?: ModelInfo[];
keys?: KeyInfo[];
tags?: string[];
currentModel?: string;
+ apiBase?: string;
} = $props();
const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
@@ -33,6 +44,102 @@
const uniqueTags = $derived([...new Set(tags)]);
+ let credentialStatus = $state<Record<string, CredentialStatus>>({});
+ let importingKey = $state<string | null>(null);
+ let importError = $state<string | null>(null);
+ let importSuccess = $state<string | null>(null);
+
+ let apiKeyStatus = $state<Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>>({});
+ let showKeyModal = $state<string | null>(null); // keyId or null
+ let keyModalValue = $state("");
+ let keyModalError = $state<string | null>(null);
+ let keyModalSaving = $state(false);
+
+ async function loadCredentialStatus(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/credentials-status`);
+ if (!res.ok) return;
+ const data = await res.json() as { credentials: CredentialStatus[] };
+ const map: Record<string, CredentialStatus> = {};
+ for (const cred of data.credentials) {
+ map[cred.keyId] = cred;
+ }
+ credentialStatus = map;
+ } catch {
+ // ignore
+ }
+ }
+
+ async function importCredentials(keyId: string): Promise<void> {
+ importingKey = keyId;
+ importError = null;
+ importSuccess = null;
+ try {
+ const res = await fetch(`${apiBase}/models/import-credentials`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId }),
+ });
+ const data = await res.json() as { success?: boolean; error?: string };
+ if (!res.ok || !data.success) {
+ importError = data.error ?? "Import failed";
+ } else {
+ importSuccess = keyId;
+ await loadCredentialStatus();
+ setTimeout(() => { importSuccess = null; }, 3000);
+ }
+ } catch (e) {
+ importError = e instanceof Error ? e.message : "Network error";
+ } finally {
+ importingKey = null;
+ }
+ }
+
+ async function loadApiKeyStatus(): Promise<void> {
+ try {
+ const res = await fetch(`${apiBase}/models/api-keys-status`);
+ if (!res.ok) return;
+ const data = await res.json() as { keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> };
+ const map: Record<string, typeof data.keys[0]> = {};
+ for (const k of data.keys) {
+ map[k.keyId] = k;
+ }
+ apiKeyStatus = map;
+ } catch {
+ // ignore
+ }
+ }
+
+ async function saveApiKey(): Promise<void> {
+ if (!showKeyModal || !keyModalValue.trim()) return;
+ keyModalSaving = true;
+ keyModalError = null;
+ try {
+ const res = await fetch(`${apiBase}/models/set-api-key`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }),
+ });
+ const data = await res.json() as { success?: boolean; error?: string };
+ if (!res.ok || !data.success) {
+ keyModalError = data.error ?? "Failed to save";
+ } else {
+ showKeyModal = null;
+ keyModalValue = "";
+ await loadApiKeyStatus();
+ }
+ } catch (e) {
+ keyModalError = e instanceof Error ? e.message : "Network error";
+ } finally {
+ keyModalSaving = false;
+ }
+ }
+
+ $effect(() => {
+ void loadCredentialStatus();
+ void loadApiKeyStatus();
+ });
+
function timeAgo(ts: number | null): string {
if (ts === null) return "";
const diffMs = Date.now() - ts;
@@ -46,7 +153,7 @@
function truncate(str: string | null, max: number): string {
if (!str) return "";
- return str.length > max ? str.slice(0, max) + "…" : str;
+ return str.length > max ? str.slice(0, max) + "..." : str;
}
</script>
@@ -96,12 +203,21 @@
</div>
{/if}
+ <!-- Import error/success banners -->
+ {#if importError}
+ <div role="alert" class="text-xs text-error/80">{importError}</div>
+ {/if}
+ {#if importSuccess}
+ <div class="text-xs text-success/80">Imported credentials for {importSuccess}</div>
+ {/if}
+
<!-- Keys -->
{#if keys.length > 0}
<div class="flex flex-col gap-1">
<p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p>
<ul class="flex flex-col gap-1">
{#each keys as key (key.id)}
+ {@const cred = credentialStatus[key.id]}
<li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors">
<div class="flex items-center gap-1.5">
<span
@@ -126,10 +242,88 @@
{/if}
</div>
{/if}
- </li>
- {/each}
+ <!-- Credential import for anthropic keys -->
+ {#if key.provider === "anthropic"}
+ <div class="pl-2 flex items-center gap-1.5 mt-0.5">
+ {#if cred}
+ <span class="badge badge-xs {cred.expired ? 'badge-warning' : 'badge-info'}">
+ {cred.expired ? "expired" : "imported"}
+ </span>
+ {#if cred.subscriptionType}
+ <span class="text-xs text-base-content/40">{cred.subscriptionType}</span>
+ {/if}
+ {/if}
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost btn-outline"
+ disabled={importingKey === key.id}
+ onclick={() => importCredentials(key.id)}
+ >
+ {#if importingKey === key.id}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ {cred ? "Re-import" : "Import Credentials"}
+ {/if}
+ </button>
+ </div>
+ {/if}
+ <!-- API key import for env-based keys (opencode, copilot, etc) -->
+ {#if key.provider !== "anthropic"}
+ <div class="pl-2 flex items-center gap-1.5 mt-0.5">
+ {#if apiKeyStatus[key.id]}
+ <span class="badge badge-xs badge-info">imported</span>
+ {/if}
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost btn-outline"
+ onclick={() => { showKeyModal = key.id; keyModalValue = ""; keyModalError = null; }}
+ >
+ {apiKeyStatus[key.id] ? "Update Key" : "Import Key"}
+ </button>
+ </div>
+ {/if}
+ </li>
+ {/each}
</ul>
</div>
{/if}
{/if}
+<!-- Import Key Modal -->
+{#if showKeyModal}
+ <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
+ <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
+ <h3 class="text-sm font-semibold">Import Key: {showKeyModal}</h3>
+ <input
+ type="password"
+ class="input input-bordered input-sm w-full"
+ placeholder="Paste API key..."
+ bind:value={keyModalValue}
+ />
+ {#if keyModalError}
+ <p class="text-xs text-error">{keyModalError}</p>
+ {/if}
+ <div class="flex justify-end gap-2">
+ <button
+ type="button"
+ class="btn btn-sm btn-ghost"
+ onclick={() => { showKeyModal = null; keyModalValue = ""; keyModalError = null; }}
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ class="btn btn-sm btn-primary"
+ disabled={!keyModalValue.trim() || keyModalSaving}
+ onclick={saveApiKey}
+ >
+ {#if keyModalSaving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 6edbdbf..79eb73b 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -109,7 +109,7 @@
{:else if panel.selected === "Claude Reset"}
<ClaudeReset {apiBase} />
{:else if panel.selected === "Model Status"}
- <ModelStatus {models} {keys} {tags} />
+ <ModelStatus {models} {keys} {tags} {apiBase} />
{:else if panel.selected === "Tasks"}
<TaskListPanel {tasks} />
{:else if panel.selected === "Config"}