diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 23:37:01 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 23:37:01 +0900 |
| commit | 1f4776e6891348d2dbdcbbf704c0a5901b008ecf (patch) | |
| tree | d406c2ba412b5393b16a3f9253acea34807d0dda | |
| parent | 29e4c2db4555bb764f458971f0c591c092e30ed6 (diff) | |
| download | dispatch-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.toml | 12 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 45 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ClaudeReset.svelte | 238 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 157 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 5 |
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"} |
