summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 23:37:01 +0900
committerAdam Malczewski <[email protected]>2026-05-20 23:37:01 +0900
commit1f4776e6891348d2dbdcbbf704c0a5901b008ecf (patch)
treed406c2ba412b5393b16a3f9253acea34807d0dda
parent29e4c2db4555bb764f458971f0c591c092e30ed6 (diff)
downloaddispatch-1f4776e6891348d2dbdcbbf704c0a5901b008ecf.tar.gz
dispatch-1f4776e6891348d2dbdcbbf704c0a5901b008ecf.zip
feat: Claude Reset scheduler + fix key config and Claude grouping
- Added claude-pro key pointing to default credentials, claude-max pointing to .credentials-2.json (docker path /root/.claude/) - POST /models/wake sends 'hi' to haiku for all Claude accounts - ClaudeReset.svelte: 2 AM rows + 2 PM rows of 6 hour blocks each (12-hour American format). Click blocks to schedule wake at :15 - Key Usage now groups all Claude accounts under one 'Claude' card instead of duplicating under claude-pro and claude-max cards
-rw-r--r--dispatch.toml12
-rw-r--r--packages/api/src/routes/models.ts45
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte238
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte157
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte5
5 files changed, 397 insertions, 60 deletions
diff --git a/dispatch.toml b/dispatch.toml
index 407c7c4..6fcf964 100644
--- a/dispatch.toml
+++ b/dispatch.toml
@@ -6,17 +6,21 @@
# When all keys are exhausted the agent enters wait-for-refresh.
# Must be declared BEFORE any [[keys]] / [[models]] blocks.
-fallback = ["claude-max", "opencode-1", "opencode-2", "copilot"]
+fallback = ["claude-pro", "claude-max", "opencode-1", "opencode-2", "copilot"]
# ─── API Keys ───────────────────────────────────────────────────
[[keys]]
+id = "claude-pro"
+provider = "anthropic"
+base_url = "https://api.anthropic.com/v1"
+# Reads from ~/.claude/.credentials.json (default)
+
+[[keys]]
id = "claude-max"
provider = "anthropic"
base_url = "https://api.anthropic.com/v1"
-# No env needed — credentials read from ~/.claude/.credentials.json
-# Optional: specify a specific credentials file for multi-account:
-# credentials_file = "/home/tradam/.claude/.credentials-2.json"
+credentials_file = "/root/.claude/.credentials-2.json"
[[keys]]
id = "opencode-1"
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 906ecc4..79f25cc 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -7,6 +7,8 @@ import {
fetchCopilotUsage,
fetchOpencodeUsage,
getAccountUsage,
+ getAnthropicHeaders,
+ refreshAccountCredentialsAsync,
validateAccountCredentials,
} from "@dispatch/core";
import { Hono } from "hono";
@@ -333,3 +335,46 @@ modelsRoutes.get("/key-usage", async (c) => {
return c.json({ error: `failed to fetch usage: ${message}` }, 502);
}
});
+
+// Wake all Claude accounts by sending "hi" to haiku
+modelsRoutes.post("/wake", async (c) => {
+ const accounts = discoverClaudeAccounts();
+ if (accounts.length === 0) {
+ return c.json({ error: "no Claude accounts available" }, 502);
+ }
+
+ const results: Array<{ label: string; ok: boolean; error?: string }> = [];
+
+ for (const acct of accounts) {
+ try {
+ const creds = await refreshAccountCredentialsAsync(acct);
+ if (!creds) {
+ results.push({ label: acct.label, ok: false, error: "token refresh failed" });
+ continue;
+ }
+
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ ...getAnthropicHeaders(creds.accessToken),
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ model: "claude-3-5-haiku-20241022",
+ max_tokens: 16,
+ messages: [{ role: "user", content: "hi" }],
+ }),
+ });
+
+ results.push({ label: acct.label, ok: res.ok });
+ } catch (err) {
+ results.push({
+ label: acct.label,
+ ok: false,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ return c.json({ results });
+});
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
new file mode 100644
index 0000000..58150fe
--- /dev/null
+++ b/packages/frontend/src/lib/components/ClaudeReset.svelte
@@ -0,0 +1,238 @@
+<script lang="ts">
+ const { apiBase = "" }: { apiBase?: string } = $props();
+
+ // Map of hour (0-23) → scheduled wake timestamp (ms)
+ let schedule = $state<Record<number, number>>({});
+
+ // Active timeout IDs keyed by hour
+ const timeoutIds: Record<number, ReturnType<typeof setTimeout>> = {};
+
+ function formatHour(h: number): string {
+ const display = h % 12;
+ return display === 0 ? "12" : String(display);
+ }
+
+ function loadSchedule(): Record<number, number> {
+ try {
+ const raw = localStorage.getItem("claude-reset-schedule");
+ if (!raw) return {};
+ return JSON.parse(raw) as Record<number, number>;
+ } catch {
+ return {};
+ }
+ }
+
+ function saveSchedule(s: Record<number, number>): void {
+ try {
+ localStorage.setItem("claude-reset-schedule", JSON.stringify(s));
+ } catch {
+ // localStorage unavailable — ignore
+ }
+ }
+
+ async function triggerWake(hour: number): Promise<void> {
+ try {
+ await fetch(`${apiBase}/models/wake`, { method: "POST" });
+ } catch {
+ // Ignore network errors
+ }
+ // Remove this hour from the schedule
+ const updated = { ...schedule };
+ delete updated[hour];
+ schedule = updated;
+ saveSchedule(schedule);
+ }
+
+ function scheduleTimeout(hour: number, ts: number): void {
+ const delay = ts - Date.now();
+ if (delay <= 0) {
+ // Already past — fire immediately
+ void triggerWake(hour);
+ return;
+ }
+ const id = setTimeout(() => {
+ void triggerWake(hour);
+ }, delay);
+ timeoutIds[hour] = id;
+ }
+
+ function clearHourTimeout(hour: number): void {
+ if (timeoutIds[hour] !== undefined) {
+ clearTimeout(timeoutIds[hour]);
+ delete timeoutIds[hour];
+ }
+ }
+
+ 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 toggleHour(hour: number): void {
+ if (schedule[hour] !== undefined) {
+ // Deschedule
+ clearHourTimeout(hour);
+ const updated = { ...schedule };
+ delete updated[hour];
+ schedule = updated;
+ saveSchedule(schedule);
+ } else {
+ // Schedule
+ const ts = nextOccurrenceAt15(hour);
+ schedule = { ...schedule, [hour]: ts };
+ saveSchedule(schedule);
+ scheduleTimeout(hour, ts);
+ }
+ }
+
+ $effect(() => {
+ // Load persisted schedule on mount
+ const loaded = loadSchedule();
+ const now = Date.now();
+ const cleaned: Record<number, number> = {};
+
+ for (const [k, ts] of Object.entries(loaded)) {
+ const hour = Number(k);
+ if (ts >= now) {
+ cleaned[hour] = ts;
+ }
+ }
+
+ schedule = cleaned;
+ saveSchedule(cleaned);
+
+ // Register timeouts for all future entries
+ for (const [k, ts] of Object.entries(cleaned)) {
+ scheduleTimeout(Number(k), ts);
+ }
+
+ // Cleanup on destroy
+ return () => {
+ for (const id of Object.values(timeoutIds)) {
+ clearTimeout(id);
+ }
+ };
+ });
+
+ // Compute "faded" hours: the 4 hours after each scheduled block
+ const fadedHours = $derived((): Set<number> => {
+ const result = new Set<number>();
+ for (const h of Object.keys(schedule).map(Number)) {
+ for (let i = 1; i <= 4; i++) {
+ const faded = (h + i) % 24;
+ if (schedule[faded] === undefined) {
+ result.add(faded);
+ }
+ }
+ }
+ return result;
+ });
+
+ const currentHour = $derived(new Date().getHours());
+
+ function blockClass(hour: number): string {
+ const isScheduled = schedule[hour] !== undefined;
+ const isCurrent = hour === currentHour;
+ const isFaded = fadedHours().has(hour);
+
+ let base = "flex items-center justify-center rounded cursor-pointer select-none text-[10px] font-mono transition-colors";
+
+ if (isScheduled) {
+ base += " bg-primary text-primary-content";
+ } else if (isFaded) {
+ base += " bg-primary/25 text-base-content";
+ } else {
+ base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
+ }
+
+ if (isCurrent) {
+ if (isScheduled) {
+ base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
+ } else {
+ base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
+ }
+ }
+
+ return base;
+ }
+
+ function formatWakeTime(ts: number): string {
+ return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ }
+
+ const scheduledHours = $derived(
+ Object.keys(schedule)
+ .map(Number)
+ .sort((a, b) => (schedule[a] ?? 0) - (schedule[b] ?? 0))
+ );
+
+ const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
+ const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
+ const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
+ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
+</script>
+
+<div class="flex flex-col gap-2">
+ <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Claude Wake Schedule</div>
+
+ <!-- AM rows -->
+ <div class="flex items-center gap-1">
+ <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">AM</span>
+ <div class="flex flex-col gap-0.5">
+ <div class="flex gap-0.5">
+ {#each amRow1 as hour}
+ <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 AM">
+ {formatHour(hour)}
+ </button>
+ {/each}
+ </div>
+ <div class="flex gap-0.5">
+ {#each amRow2 as hour}
+ <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 AM">
+ {formatHour(hour)}
+ </button>
+ {/each}
+ </div>
+ </div>
+ </div>
+
+ <!-- PM rows -->
+ <div class="flex items-center gap-1">
+ <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">PM</span>
+ <div class="flex flex-col gap-0.5">
+ <div class="flex gap-0.5">
+ {#each pmRow1 as hour}
+ <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 PM">
+ {formatHour(hour)}
+ </button>
+ {/each}
+ </div>
+ <div class="flex gap-0.5">
+ {#each pmRow2 as hour}
+ <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 PM">
+ {formatHour(hour)}
+ </button>
+ {/each}
+ </div>
+ </div>
+ </div>
+
+ <!-- Scheduled summary -->
+ {#if scheduledHours.length > 0}
+ <div class="flex flex-col gap-0.5 mt-1">
+ {#each scheduledHours as hour}
+ <div class="flex items-center gap-1.5 text-xs text-base-content/70">
+ <span class="badge badge-xs badge-primary">{formatHour(hour)}:15</span>
+ <span>Wake scheduled for {formatWakeTime(schedule[hour] ?? 0)}</span>
+ </div>
+ {/each}
+ </div>
+ {:else}
+ <p class="text-xs text-base-content/40 italic">No wake times scheduled. Click a block to schedule.</p>
+ {/if}
+</div>
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index 3587ff8..51e08c2 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -71,6 +71,39 @@
}
});
+ // Merge duplicate Claude entries — all anthropic keys return the same
+ // set of accounts, so collect and deduplicate under one "Claude" card.
+ const claudeAccounts = $derived.by(() => {
+ const seen = new Set<string>();
+ const accounts: Array<{
+ label: string;
+ source: string;
+ subscriptionType?: string;
+ fiveHour?: UsageBucket;
+ sevenDay?: UsageBucket;
+ error?: string;
+ }> = [];
+ const claudeEntries = entries.filter((e) => e.provider === "anthropic");
+ for (const e of claudeEntries) {
+ if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
+ for (const acct of e.data.accounts) {
+ if (!seen.has(acct.source)) {
+ seen.add(acct.source);
+ accounts.push(acct);
+ }
+ }
+ }
+ return accounts;
+ });
+
+ const claudeLoading = $derived(
+ entries.some((e) => e.provider === "anthropic" && e.loading),
+ );
+
+ const nonClaudeEntries = $derived(
+ entries.filter((e) => e.provider !== "anthropic"),
+ );
+
function progressClass(utilization: number): string {
if (utilization > 0.8) return "progress-error";
if (utilization >= 0.5) return "progress-warning";
@@ -91,7 +124,75 @@
<p class="text-xs text-base-content/50">No keys available.</p>
{:else}
<div class="flex flex-col gap-3 max-h-96 overflow-y-auto">
- {#each entries as entry (entry.keyId)}
+ <!-- Claude (all accounts merged under one card) -->
+ {#if claudeLoading}
+ <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>
+ <span class="badge badge-xs badge-ghost">anthropic</span>
+ </div>
+ <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>
+ </div>
+ {:else if claudeAccounts.length > 0}
+ <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>
+ <span class="badge badge-xs badge-ghost">anthropic</span>
+ </div>
+ {#each claudeAccounts as acct, idx (acct.source)}
+ {#if idx > 0}
+ <div class="border-t border-base-300 my-1.5"></div>
+ {/if}
+ <div class="flex flex-col gap-1 pl-1">
+ <div class="flex items-center gap-1">
+ <span class="text-xs font-medium">{acct.label}</span>
+ {#if acct.subscriptionType}
+ <span class="badge badge-xs">{acct.subscriptionType}</span>
+ {/if}
+ </div>
+ {#if acct.error}
+ <p class="text-xs text-error/70">{acct.error}</p>
+ {/if}
+ {#if hasBucketData(acct.fiveHour)}
+ {@const b = acct.fiveHour!}
+ {@const u = b.utilization ?? 0}
+ {@const p = Math.round(u * 100)}
+ <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)}
+ {@const b = acct.sevenDay!}
+ {@const u = b.utilization ?? 0}
+ {@const p = Math.round(u * 100)}
+ <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>
+ {/if}
+
+ <!-- Non-Claude keys -->
+ {#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>
@@ -110,60 +211,6 @@
{:else if !entry.data}
<p class="text-xs text-base-content/50">No data.</p>
- {:else if entry.data.provider === "anthropic"}
- <!-- Render each Claude account -->
- {#if entry.data.accounts}
- {#each entry.data.accounts as acct, idx (acct.source)}
- {#if idx > 0}
- <div class="border-t border-base-300 my-1.5"></div>
- {/if}
- <div class="flex flex-col gap-1 pl-1">
- <div class="flex items-center gap-1">
- <span class="text-xs font-medium">{acct.label}</span>
- {#if acct.subscriptionType}
- <span class="badge badge-xs">{acct.subscriptionType}</span>
- {/if}
- </div>
-
- {#if acct.error}
- <p class="text-xs text-error/70">{acct.error}</p>
- {/if}
-
- {#if hasBucketData(acct.fiveHour)}
- {@const b = acct.fiveHour!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- <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)}
- {@const b = acct.sevenDay!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- <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}
- {/if}
-
{:else if entry.data.provider === "opencode-go"}
{#if entry.data.unavailable}
<p class="text-xs text-base-content/70">Check console for usage.</p>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 2184818..ddd8ebd 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -5,6 +5,7 @@
import SkillsBrowser from "./SkillsBrowser.svelte";
import PermissionLog from "./PermissionLog.svelte";
import KeyUsage from "./KeyUsage.svelte";
+ import ClaudeReset from "./ClaudeReset.svelte";
import type { TaskItem, LogEntry, KeyInfo, ModelInfo } from "../types.js";
const {
@@ -25,7 +26,7 @@
let selected = $state("Tasks");
- const options = ["Key Usage", "Model Status", "Tasks", "Config", "Skills", "Permission Log"];
+ const options = ["Key Usage", "Claude Reset", "Model Status", "Tasks", "Config", "Skills", "Permission Log"];
</script>
<div class="bg-base-200 rounded-lg p-3">
@@ -41,6 +42,8 @@
<div class="mt-2">
{#if selected === "Key Usage"}
<KeyUsage {keys} {apiBase} />
+ {:else if selected === "Claude Reset"}
+ <ClaudeReset {apiBase} />
{:else if selected === "Model Status"}
<ModelStatus {models} {keys} {tags} />
{:else if selected === "Tasks"}