summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 15:44:00 +0900
committerAdam Malczewski <[email protected]>2026-05-21 15:44:00 +0900
commit1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (patch)
tree57aec6c0d039760aa37fab10e83e1cea7a23081e
parentc957e89e3ec46f1db64dcb1416f5ade7fb6e617e (diff)
downloaddispatch-1f309ccca20aabbd0ee3fb8fbb3c8192124edd95.tar.gz
dispatch-1f309ccca20aabbd0ee3fb8fbb3c8192124edd95.zip
fix: wake scheduler persistence/retry, credential filtering, usage cache and display names
- Wake scheduler: fix Bun timer leak, make recurring daily, persist to disk, retry failed wakes every 5min for 30min, start at boot - Key usage: localStorage cache survives page refresh, spinner during all refreshes, show cached data immediately - Credential filtering: key-usage and wake only use configured credentials_file, exclude unconfigured accounts - Display: remove counter suffix from Claude labels, format opencode/copilot key names
-rw-r--r--dispatch.toml2
-rw-r--r--packages/api/src/app.ts5
-rw-r--r--packages/api/src/routes/models.ts178
-rw-r--r--packages/core/src/credentials/claude.ts6
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte116
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte15
6 files changed, 260 insertions, 62 deletions
diff --git a/dispatch.toml b/dispatch.toml
index 6fcf964..7e6fa5a 100644
--- a/dispatch.toml
+++ b/dispatch.toml
@@ -14,7 +14,7 @@ fallback = ["claude-pro", "claude-max", "opencode-1", "opencode-2", "copilot"]
id = "claude-pro"
provider = "anthropic"
base_url = "https://api.anthropic.com/v1"
-# Reads from ~/.claude/.credentials.json (default)
+credentials_file = "/root/.claude/.credentials-1.json"
[[keys]]
id = "claude-max"
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 3591281..ce26aaa 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -4,7 +4,7 @@ import { AgentManager } from "./agent-manager.js";
import { PermissionManager } from "./permission-manager.js";
import { configRoutes } from "./routes/config.js";
import { skillsRoutes } from "./routes/skills.js";
-import { modelsRoutes } from "./routes/models.js";
+import { modelsRoutes, startWakeScheduler } from "./routes/models.js";
export const permissionManager = new PermissionManager();
export const agentManager = new AgentManager(permissionManager);
@@ -60,3 +60,6 @@ app.post("/chat", async (c) => {
app.route("/config", configRoutes);
app.route("/skills", skillsRoutes);
app.route("/models", modelsRoutes);
+
+// Start the wake scheduler on boot (restores persisted schedule)
+startWakeScheduler();
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index f29d236..2f11268 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -1,3 +1,5 @@
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
import type { ModelRegistry, ModelResolver } from "@dispatch/core";
import {
ANTHROPIC_MODELS_FALLBACK,
@@ -259,11 +261,16 @@ modelsRoutes.get("/key-usage", async (c) => {
try {
if (provider === "anthropic") {
- const accounts = discoverClaudeAccounts();
+ const allAccounts = discoverClaudeAccounts();
+ 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
if (accounts.length === 0) {
return c.json({ error: "no Claude accounts available" }, 502);
}
- // Fetch usage for all discovered accounts
+ // Fetch usage for matched accounts
const accountResults = await Promise.all(
accounts.map(async (acct) => {
const report = await getAccountUsage(acct);
@@ -341,7 +348,25 @@ modelsRoutes.get("/key-usage", async (c) => {
async function wakeAllClaudeAccounts(): Promise<
Array<{ label: string; ok: boolean; error?: string }>
> {
- const accounts = discoverClaudeAccounts();
+ // Only wake accounts referenced by configured anthropic keys
+ const allAccounts = discoverClaudeAccounts();
+ const registry = getRegistry();
+ const configuredCredFiles = 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);
+ }
+ }
+ }
+ }
+ const accounts = configuredCredFiles.size > 0
+ ? allAccounts.filter((a) => configuredCredFiles.has(a.source))
+ : allAccounts;
if (accounts.length === 0) {
return [{ label: "(none)", ok: false, error: "no Claude accounts available" }];
}
@@ -389,37 +414,147 @@ modelsRoutes.post("/wake", async (c) => {
// ─── Wake scheduler (runs on backend, survives frontend close) ─
-type WakeSchedule = Record<number, number>; // hour → target timestamp (ms)
+type WakeSchedule = Record<number, number>; // hour → next wake timestamp (ms)
-let wakeSchedule: WakeSchedule = {};
+interface PendingRetry {
+ retriesLeft: number; // starts at 6 (5 min × 6 = 30 min)
+ 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);
+ target.setHours(hour, 15, 0, 0);
+ if (target.getTime() <= Date.now()) {
+ target.setDate(target.getDate() + 1);
+ }
+ return target.getTime();
+}
+
+function loadScheduleFromDisk(): 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;
+ }
+ }
+ if (needsPersist) {
+ try {
+ writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule), "utf-8");
+ } catch {
+ // Ignore write errors
+ }
+ }
+ return schedule;
+ }
+ } catch {
+ // File doesn't exist or is corrupt — start fresh
+ }
+ return {};
+}
+
+function persistSchedule(): void {
+ try {
+ writeFileSync(SCHEDULE_FILE, JSON.stringify(wakeSchedule), "utf-8");
+ } catch {
+ // Ignore write errors
+ }
+}
+
+let wakeSchedule: WakeSchedule = loadScheduleFromDisk();
+let pendingRetries: PendingRetry[] = [];
// HMR-safe: clear previous tick before starting a new one
(globalThis as Record<string, unknown>)._dispatchWakeTimer ??= undefined;
const timerKey = "_dispatchWakeTimer";
+let isTickRunning = false;
async function schedulerTick(): Promise<void> {
- const now = Date.now();
- const hours = Object.keys(wakeSchedule).map(Number);
-
- for (const hour of hours) {
- const ts = wakeSchedule[hour];
- if (ts !== undefined && ts <= now) {
- // Delete BEFORE wake to prevent duplicate triggers
- delete wakeSchedule[hour];
- wakeAllClaudeAccounts().catch(() => {});
+ // Prevent concurrent tick execution (e.g. toggle called mid-tick)
+ if (isTickRunning) return;
+ isTickRunning = true;
+
+ try {
+ const now = Date.now();
+ const hours = Object.keys(wakeSchedule).map(Number);
+
+ for (const hour of hours) {
+ const ts = wakeSchedule[hour];
+ if (ts !== undefined && ts <= now) {
+ // Reschedule for next day (recurring daily)
+ wakeSchedule[hour] = nextOccurrenceAt15(hour);
+ persistSchedule();
+
+ // Wake accounts and track failures for retry
+ try {
+ const results = await wakeAllClaudeAccounts();
+ const anyFailed = results.some((r) => !r.ok);
+ if (anyFailed) {
+ pendingRetries.push({
+ retriesLeft: 6,
+ nextRetryAt: now + 5 * 60 * 1000,
+ });
+ }
+ } catch {
+ // Total failure — schedule retry
+ pendingRetries.push({
+ retriesLeft: 6,
+ nextRetryAt: now + 5 * 60 * 1000,
+ });
+ }
+ }
}
- }
- // Schedule next tick
- if (Object.keys(wakeSchedule).length > 0) {
- (globalThis as Record<string, unknown>)[timerKey] = setTimeout(schedulerTick, 30_000);
+ // Process pending retries (iterate backwards for safe splicing)
+ for (let i = pendingRetries.length - 1; i >= 0; i--) {
+ const retry = pendingRetries[i];
+ if (!retry || retry.nextRetryAt > now) continue;
+
+ try {
+ const results = await wakeAllClaudeAccounts();
+ const anyFailed = results.some((r) => !r.ok);
+ if (!anyFailed || retry.retriesLeft <= 1) {
+ // All succeeded or out of retries — remove
+ pendingRetries.splice(i, 1);
+ } else {
+ retry.retriesLeft--;
+ retry.nextRetryAt = now + 5 * 60 * 1000;
+ }
+ } catch {
+ if (retry.retriesLeft <= 1) {
+ pendingRetries.splice(i, 1);
+ } else {
+ retry.retriesLeft--;
+ retry.nextRetryAt = now + 5 * 60 * 1000;
+ }
+ }
+ }
+
+ // Schedule next tick while there's work to monitor
+ if (Object.keys(wakeSchedule).length > 0 || pendingRetries.length > 0) {
+ (globalThis as Record<string, unknown>)[timerKey] = setTimeout(schedulerTick, 30_000);
+ }
+ } finally {
+ isTickRunning = false;
}
}
export function startWakeScheduler(): void {
- // Clear any previous interval (HMR-safe)
+ // Clear any previous timer (HMR-safe — works with Bun's Timer objects)
const prev = (globalThis as Record<string, unknown>)[timerKey];
- if (typeof prev === "number") clearTimeout(prev);
+ if (prev != null) clearTimeout(prev as ReturnType<typeof setTimeout>);
schedulerTick();
}
@@ -442,7 +577,8 @@ modelsRoutes.post("/wake-schedule/toggle", async (c) => {
wakeSchedule[hour] = ts;
}
- // Restart the tick loop (handles empty → non-empty or vice versa)
+ // Persist and restart the tick loop
+ persistSchedule();
startWakeScheduler();
return c.json({ schedule: wakeSchedule });
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index e4cc1f9..6639afc 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -137,14 +137,10 @@ async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials
}
function buildAccountLabels(accounts: ClaudeAccount[]): void {
- const counts = new Map<string, number>();
for (const acct of accounts) {
- const base = acct.credentials.subscriptionType
+ acct.label = acct.credentials.subscriptionType
? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}`
: "Claude";
- const count = (counts.get(base) ?? 0) + 1;
- counts.set(base, count);
- acct.label = count > 1 ? `${base} ${count}` : base;
}
}
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index 5e86f36..a2b735e 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -17,8 +17,45 @@
loading: boolean;
}
- // Module-level cache survives remounts during the same page session
- const usageCache = new Map<string, KeyUsageData>();
+ // 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();
+
+ function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
+ return keyList.map((k) => {
+ const cached = usageCache.get(k.id);
+ return {
+ keyId: k.id,
+ provider: k.provider,
+ data: cached ?? null,
+ error: null,
+ loading: true, // always show spinner during refresh
+ };
+ });
+ }
let entries = $state<KeyUsageEntry[]>([]);
@@ -36,6 +73,7 @@
} else {
const fresh = await res.json() as KeyUsageData;
usageCache.set(key.id, fresh);
+ persistCache(usageCache);
updateEntry(key.id, {
data: fresh,
error: null,
@@ -59,26 +97,23 @@
);
}
+ // Sync entries with keys reactively — runs before DOM update so
+ // cached data renders on first paint without a flash of empty state.
+ $effect.pre(() => {
+ entries = buildEntries(keys);
+ });
+
+ // Fetch data and set up 90s auto-refresh
$effect(() => {
- // Show cached data immediately if available, then refresh in background
- entries = keys.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true,
- };
- });
+ const currentKeys = keys;
// Fire all fetches in parallel
- for (const key of keys) {
+ for (const key of currentKeys) {
fetchOne(key);
}
// Refresh every 90s
const interval = setInterval(() => {
- for (const key of keys) {
+ for (const key of currentKeys) {
updateEntry(key.id, { loading: true });
fetchOne(key);
}
@@ -115,6 +150,9 @@
const claudeLoading = $derived(
entries.some((e) => e.provider === "anthropic" && e.loading),
);
+ const claudeError = $derived(
+ entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
+ );
const nonClaudeEntries = $derived(
entries.filter((e) => e.provider !== "anthropic"),
@@ -160,6 +198,14 @@
});
}
+ function formatKeyId(id: string): string {
+ if (/^github-copilot/i.test(id)) return "Copilot";
+ return id.split("-").map((part) => {
+ if (part.toLowerCase() === "opencode") return "OpenCode";
+ return part.charAt(0).toUpperCase() + part.slice(1);
+ }).join(" ");
+ }
+
function hasBucketData(bucket: UsageBucket | undefined): boolean {
return bucket !== undefined && bucket.utilization !== undefined;
}
@@ -171,7 +217,7 @@
{:else}
<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
<!-- Claude (all accounts merged under one card) -->
- {#if claudeAccounts.length > 0 || claudeLoading}
+ {#if claudeAccounts.length > 0 || claudeLoading || claudeError}
<div class="bg-base-200 rounded-lg p-2">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="text-xs font-semibold">Claude</span>
@@ -181,12 +227,17 @@
{/if}
</div>
- {#if claudeAccounts.length === 0 && claudeLoading}
- <div class="flex items-center gap-1.5 py-1">
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else}
- {#each claudeAccounts as acct, idx (acct.source)}
+ {#if claudeAccounts.length === 0 && claudeLoading}
+ <div class="flex items-center gap-1.5 py-1">
+ <span class="text-xs text-base-content/50">Loading...</span>
+ </div>
+ {:else if claudeAccounts.length === 0 && claudeError}
+ <div role="alert" class="text-xs text-error/80">{claudeError}</div>
+ {:else}
+ {#if claudeError}
+ <div role="alert" class="text-xs text-error/80 mb-1">{claudeError}</div>
+ {/if}
+ {#each claudeAccounts as acct, idx (acct.source)}
{#if idx > 0}
<div class="border-t border-base-300 my-1.5"></div>
{/if}
@@ -240,26 +291,26 @@
{#each nonClaudeEntries as entry (entry.keyId)}
<div class="bg-base-200 rounded-lg p-2">
<div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">{entry.keyId}</span>
+ <span class="text-xs font-semibold">{formatKeyId(entry.keyId)}</span>
<span class="badge badge-xs badge-ghost">{entry.provider}</span>
{#if entry.loading}
<span class="loading loading-spinner loading-xs"></span>
{/if}
</div>
- {#if entry.loading}
+ {#if entry.loading && !entry.data}
<div class="flex items-center gap-1.5 py-1">
<span class="loading loading-spinner loading-xs"></span>
<span class="text-xs text-base-content/50">Loading...</span>
</div>
+ {:else}
+ {#if entry.error}
+ <div role="alert" class="text-xs text-error/80 mb-1">{entry.error}</div>
+ {/if}
+ {#if !entry.data}
+ <p class="text-xs text-base-content/50">No data.</p>
- {:else if entry.error}
- <div role="alert" class="text-xs text-error/80">{entry.error}</div>
-
- {:else if !entry.data}
- <p class="text-xs text-base-content/50">No data.</p>
-
- {:else if entry.data.provider === "opencode-go"}
+ {:else if entry.data.provider === "opencode-go"}
{#if entry.data.unavailable}
<p class="text-xs text-base-content/70">Check console for usage.</p>
{#if entry.data.consoleUrl}
@@ -335,7 +386,8 @@
<span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span>
{/if}
</div>
- {/if}
+ {/if}
+ {/if}
</div>
{/each}
</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index aebc74a..6edbdbf 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -50,11 +50,22 @@
function addPanel() {
panels = [...panels, { id: nextId++, selected: "Select a view" }];
}
+
+ function panelClass(selected: string): string {
+ const base = "bg-base-200 rounded-lg p-3 flex flex-col min-h-0";
+ const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks";
+ return fill ? base + " flex-1" : base;
+ }
+
+ function contentClass(selected: string): string {
+ const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks";
+ return fill ? "mt-2 flex-1 min-h-0" : "mt-2";
+ }
</script>
<div class="flex flex-col gap-2">
{#each panels as panel, idx (panel.id)}
- <div class="bg-base-200 rounded-lg p-3 flex flex-col">
+ <div class={panelClass(panel.selected)}>
<div class="flex items-center gap-1">
<select
class="select select-bordered select-sm flex-1"
@@ -82,7 +93,7 @@
{/if}
</div>
- <div class="mt-2">
+ <div class={contentClass(panel.selected)}>
{#if panel.selected === "Current Model"}
<ModelSelector
{keys}