From 1f4776e6891348d2dbdcbbf704c0a5901b008ecf Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 20 May 2026 23:37:01 +0900 Subject: 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 --- dispatch.toml | 12 +- packages/api/src/routes/models.ts | 45 ++++ .../frontend/src/lib/components/ClaudeReset.svelte | 238 +++++++++++++++++++++ .../frontend/src/lib/components/KeyUsage.svelte | 157 +++++++++----- .../src/lib/components/SidebarPanel.svelte | 5 +- 5 files changed, 397 insertions(+), 60 deletions(-) create mode 100644 packages/frontend/src/lib/components/ClaudeReset.svelte 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 @@ + + +
+
Claude Wake Schedule
+ + +
+ AM +
+
+ {#each amRow1 as hour} + + {/each} +
+
+ {#each amRow2 as hour} + + {/each} +
+
+
+ + +
+ PM +
+
+ {#each pmRow1 as hour} + + {/each} +
+
+ {#each pmRow2 as hour} + + {/each} +
+
+
+ + + {#if scheduledHours.length > 0} +
+ {#each scheduledHours as hour} +
+ {formatHour(hour)}:15 + Wake scheduled for {formatWakeTime(schedule[hour] ?? 0)} +
+ {/each} +
+ {:else} +

No wake times scheduled. Click a block to schedule.

+ {/if} +
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(); + 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 @@

No keys available.

{:else}
- {#each entries as entry (entry.keyId)} + + {#if claudeLoading} +
+
+ Claude + anthropic +
+
+ + Loading... +
+
+ {:else if claudeAccounts.length > 0} +
+
+ Claude + anthropic +
+ {#each claudeAccounts as acct, idx (acct.source)} + {#if idx > 0} +
+ {/if} +
+
+ {acct.label} + {#if acct.subscriptionType} + {acct.subscriptionType} + {/if} +
+ {#if acct.error} +

{acct.error}

+ {/if} + {#if hasBucketData(acct.fiveHour)} + {@const b = acct.fiveHour!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} +
+
+ 5-Hour + {p}% +
+ + {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} +
+ {/if} + {#if hasBucketData(acct.sevenDay)} + {@const b = acct.sevenDay!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} +
+
+ Weekly + {p}% +
+ + {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} +
+ {/if} +
+ {/each} +
+ {/if} + + + {#each nonClaudeEntries as entry (entry.keyId)}
{entry.keyId} @@ -110,60 +211,6 @@ {:else if !entry.data}

No data.

- {:else if entry.data.provider === "anthropic"} - - {#if entry.data.accounts} - {#each entry.data.accounts as acct, idx (acct.source)} - {#if idx > 0} -
- {/if} -
-
- {acct.label} - {#if acct.subscriptionType} - {acct.subscriptionType} - {/if} -
- - {#if acct.error} -

{acct.error}

- {/if} - - {#if hasBucketData(acct.fiveHour)} - {@const b = acct.fiveHour!} - {@const u = b.utilization ?? 0} - {@const p = Math.round(u * 100)} -
-
- 5-Hour - {p}% -
- - {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - - {#if hasBucketData(acct.sevenDay)} - {@const b = acct.sevenDay!} - {@const u = b.utilization ?? 0} - {@const p = Math.round(u * 100)} -
-
- Weekly - {p}% -
- - {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} -
- {/each} - {/if} - {:else if entry.data.provider === "opencode-go"} {#if entry.data.unavailable}

Check console for usage.

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"];
@@ -41,6 +42,8 @@
{#if selected === "Key Usage"} + {:else if selected === "Claude Reset"} + {:else if selected === "Model Status"} {:else if selected === "Tasks"} -- cgit v1.2.3