diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/api/src/routes | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/api/src/routes')
| -rw-r--r-- | packages/api/src/routes/agents.ts | 126 | ||||
| -rw-r--r-- | packages/api/src/routes/config.ts | 27 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 1073 | ||||
| -rw-r--r-- | packages/api/src/routes/notifications.ts | 88 | ||||
| -rw-r--r-- | packages/api/src/routes/skills.ts | 48 | ||||
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 229 |
6 files changed, 0 insertions, 1591 deletions
diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts deleted file mode 100644 index 10ca714..0000000 --- a/packages/api/src/routes/agents.ts +++ /dev/null @@ -1,126 +0,0 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import type { AgentDefinition } from "@dispatch/core"; -import { - deleteAgent, - getAgentDirs, - isReasoningEffort, - loadAgents, - saveAgent, -} from "@dispatch/core"; -import { Hono } from "hono"; - -const SAFE_SLUG_RE = /^[a-zA-Z0-9_-]+$/; - -function isValidSlug(slug: string): boolean { - return SAFE_SLUG_RE.test(slug) && slug.length > 0 && slug.length <= 100; -} - -const agentsRoutes = new Hono(); - -// GET /agents — list all agents (global + project-scoped) -// Query param: ?projectDir=... (optional, the working directory) -agentsRoutes.get("/", (c) => { - const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined; - const agents = loadAgents(projectDir); - const dirs = getAgentDirs(projectDir); - return c.json({ agents, dirs }); -}); - -// GET /agents/dirs — list available agent directories -agentsRoutes.get("/dirs", (c) => { - const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined; - const dirs = getAgentDirs(projectDir); - return c.json({ dirs }); -}); - -// POST /agents — create or update an agent -agentsRoutes.post("/", async (c) => { - try { - const body = await c.req.json<AgentDefinition>(); - // Validate required fields - if (!body.name || !body.slug || !body.scope) { - return c.json({ error: "name, slug, and scope are required" }, 400); - } - if (!isValidSlug(body.slug)) { - return c.json( - { error: "Invalid slug: must be alphanumeric with hyphens/underscores only" }, - 400, - ); - } - if (body.scope !== "global" && body.scope.includes("..")) { - return c.json({ error: "Invalid scope" }, 400); - } - // Ensure arrays exist - const agent: AgentDefinition = { - name: body.name, - description: body.description || "", - skills: body.skills || [], - tools: body.tools || [], - models: (body.models || []).map((m) => ({ - key_id: m.key_id, - model_id: m.model_id, - // Keep `effort` only when it's a recognised level; drop anything else. - ...(isReasoningEffort(m.effort) ? { effort: m.effort } : {}), - })), - scope: body.scope, - slug: body.slug, - ...(body.cwd ? { cwd: body.cwd } : {}), - ...(body.is_subagent ? { is_subagent: true } : {}), - }; - saveAgent(agent); - return c.json({ ok: true, agent }); - } catch (err) { - return c.json({ error: err instanceof Error ? err.message : "Failed to save agent" }, 500); - } -}); - -// DELETE /agents/:slug — delete an agent -// Query param: ?scope=... (required: "global" or directory path) -agentsRoutes.delete("/:slug", (c) => { - const slug = c.req.param("slug"); - const scope = c.req.query("scope"); - if (!scope) { - return c.json({ error: "scope query param is required" }, 400); - } - if (!isValidSlug(slug)) { - return c.json({ error: "Invalid slug" }, 400); - } - if (slug === "default" && scope === "global") { - return c.json({ error: "Cannot delete the default agent" }, 403); - } - if (scope !== "global" && scope.includes("..")) { - return c.json({ error: "Invalid scope" }, 400); - } - const deleted = deleteAgent(slug, scope); - if (!deleted) { - return c.json({ error: "Agent not found" }, 404); - } - return c.json({ ok: true }); -}); - -// GET /agents/check-dir?path=... — check if a directory exists -agentsRoutes.get("/check-dir", (c) => { - let dirPath = c.req.query("path"); - if (!dirPath) { - return c.json({ exists: false }); - } - // Expand ~ to home directory - if (dirPath === "~" || dirPath.startsWith("~/")) { - dirPath = path.join(os.homedir(), dirPath.slice(1)); - } - // Resolve relative paths against the project root - if (!path.isAbsolute(dirPath)) { - const projectDir = process.env.DISPATCH_WORKING_DIR || process.cwd(); - dirPath = path.resolve(projectDir, dirPath); - } - try { - const stat = fs.statSync(dirPath); - return c.json({ exists: stat.isDirectory(), resolved: dirPath }); - } catch { - return c.json({ exists: false, resolved: dirPath }); - } -}); - -export { agentsRoutes }; diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts deleted file mode 100644 index 65a1e2a..0000000 --- a/packages/api/src/routes/config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { DispatchConfig } from "@dispatch/core"; -import { Hono } from "hono"; - -let getConfig: () => DispatchConfig = () => ({ permissions: {} }); - -export function setConfigGetter(getter: () => DispatchConfig): void { - getConfig = getter; -} - -const configRoutes = new Hono(); - -configRoutes.get("/", (c) => { - const config = getConfig(); - - // Strip env field values from keys for security - const safeConfig: DispatchConfig = { - ...config, - keys: config.keys?.map((key) => ({ - ...key, - env: "***", - })), - }; - - return c.json({ config: safeConfig }); -}); - -export { configRoutes }; diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts deleted file mode 100644 index a1700b1..0000000 --- a/packages/api/src/routes/models.ts +++ /dev/null @@ -1,1073 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import type { ModelRegistry } from "@dispatch/core"; -import { - ANTHROPIC_MODELS_FALLBACK, - buildWakeProbeBody, - type ClaudeAccount, - fetchAnthropicModels, - fetchCopilotUsage, - fetchGoogleUsage, - fetchOpencodeUsage, - getAccountUsage, - getAnthropicHeaders, - getClaudeAccountsFromDB, - getDatabase, - importCredentialsFromFile, - listApiKeys, - listStoredCredentials, - refreshAccountCredentialsAsync, - resolveApiKey, - resolveContextLimit, - resolveModelCapabilities, - selectHaikuModel, - setApiKey, - validateAccountCredentials, -} from "@dispatch/core"; -import { Hono } from "hono"; -import { - CLAUDE_RESET_OFFSET_HOURS, - isProbeSlotMinute, - nextDailyAfter, - PROBE_SLOT_MINUTES, - type ProbeSlotMinute, - recoverScheduleEntry, -} from "../wake-scheduler.js"; - -let getRegistry: () => ModelRegistry | null = () => null; -let getAccounts: () => ClaudeAccount[] = () => []; - -export function setModelsGetter(registryGetter: () => ModelRegistry | null): void { - getRegistry = registryGetter; -} - -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) => { - const registry = getRegistry(); - if (!registry) { - return c.json({ keys: [] }); - } - - const keyStates = registry.getKeys(); - - const keys = keyStates.map((ks) => ({ - id: ks.definition.id, - provider: ks.definition.provider, - status: ks.status, - lastError: ks.lastError ?? null, - exhaustedAt: ks.exhaustedAt ?? null, - })); - - return c.json({ keys }); -}); - -// Fetch available models for a specific provider key. -modelsRoutes.get("/available", async (c) => { - const registry = getRegistry(); - if (!registry) { - return c.json({ error: "no registry configured" }, 500); - } - - const keyId = c.req.query("keyId"); - if (!keyId) { - return c.json({ error: "keyId query parameter is required" }, 400); - } - - const keyStates = registry.getKeys(); - const key = keyStates.find((ks) => ks.definition.id === keyId); - if (!key) { - return c.json({ error: `key not found: ${keyId}` }, 404); - } - - // Anthropic provider: validate credentials and fetch models dynamically - if (key.definition.provider === "anthropic") { - const credFile = key.definition.credentials_file; - 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); - } - - const profile = await validateAccountCredentials(account); - if (!profile) { - return c.json( - { - error: "Claude credentials are invalid or expired", - details: "Run `claude` to re-authenticate.", - }, - 401, - ); - } - - const creds = account.credentials; - let models = await fetchAnthropicModels(creds.accessToken); - if (models.length === 0) { - models = ANTHROPIC_MODELS_FALLBACK; - } - - return c.json({ - models, - subscriptionType: account.credentials.subscriptionType, - ...(profile.email ? { email: profile.email } : {}), - }); - } - - const apiKeyValue = resolveApiKey(keyId, key.definition.env); - if (!apiKeyValue) { - return c.json({ error: `no API key found for ${keyId}` }, 500); - } - - const baseUrl = key.definition.base_url.replace(/\/+$/, ""); - const url = `${baseUrl}/models`; - const headers: Record<string, string> = { - Authorization: `Bearer ${apiKeyValue}`, - }; - if (key.definition.provider === "github-copilot") { - headers["Copilot-Integration-Id"] = "vscode-chat"; - } - - let response: Response; - try { - response = await fetch(url, { headers }); - } catch (err) { - return c.json({ error: "provider API call failed", details: String(err) }, 502); - } - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return c.json( - { error: "provider API returned error", status: response.status, details: text }, - 502, - ); - } - - let data: { data: { id: string }[] }; - try { - data = await response.json(); - } catch (err) { - return c.json({ error: "failed to parse provider response", details: String(err) }, 502); - } - - const models = data.data.map((m) => m.id.replace(/^models\//, "")); - return c.json({ models }); -}); - -// Resolve a model's MAXIMUM context window (in tokens) from the models.dev -// catalog. Returns `{ contextLimit: number | null }`; `null` means the model's -// limit is unknown (unsupported provider, unknown model, or catalog offline), -// which the frontend renders without a denominator/percentage. -modelsRoutes.get("/context-limit", async (c) => { - const provider = c.req.query("provider"); - const modelId = c.req.query("modelId"); - if (!provider || !modelId) { - return c.json({ error: "provider and modelId query parameters are required" }, 400); - } - - const contextLimit = await resolveContextLimit(provider, modelId); - return c.json({ contextLimit }); -}); - -// Resolve a model's image / PDF INPUT capabilities from the models.dev catalog. -// Returns `{ capabilities: { image, pdf } | null }`. `null` means UNKNOWN — the -// provider is unmapped, the model is absent, the catalog predates the -// `modalities` field, or the catalog is offline. The frontend treats `null` as -// "can't verify" (optimistic allow) and a definitive `{ image: false }` as a -// hard block (no tokens spent). -modelsRoutes.get("/capabilities", async (c) => { - const provider = c.req.query("provider"); - const modelId = c.req.query("modelId"); - if (!provider || !modelId) { - return c.json({ error: "provider and modelId query parameters are required" }, 400); - } - - const capabilities = await resolveModelCapabilities(provider, modelId); - return c.json({ capabilities }); -}); - -// List available Claude accounts with validated credentials -modelsRoutes.get("/claude-accounts", async (c) => { - const candidates = resolveClaudeAccounts(); - - // Validate each account's credentials; only include ones with a working token - const validated: Array<{ - id: string; - label: string; - source: string; - subscriptionType: string; - expiresAt: number; - email?: string; - }> = []; - - for (const acct of candidates) { - const profile = await validateAccountCredentials(acct); - if (profile) { - validated.push({ - id: acct.id, - label: acct.label, - source: acct.source, - subscriptionType: acct.credentials.subscriptionType ?? "unknown", - expiresAt: acct.credentials.expiresAt, - ...(profile.email ? { email: profile.email } : {}), - }); - } - } - - return c.json({ accounts: validated }); -}); - -// Get usage for a specific Claude account -modelsRoutes.get("/claude-usage", async (c) => { - const accountId = c.req.query("accountId"); - const accounts = getAccounts(); - const accountAccounts = resolveClaudeAccounts(); - const allAccounts = accounts.length > 0 ? accounts : accountAccounts; - - let account: ClaudeAccount | undefined; - if (accountId) { - account = allAccounts.find((a) => a.id === accountId); - if (!account) { - return c.json({ error: `account not found: ${accountId}` }, 404); - } - } else { - account = allAccounts[0]; - } - - if (!account) { - return c.json({ error: "no Claude accounts available" }, 404); - } - - const report = await getAccountUsage(account); - if (!report) { - return c.json({ error: "failed to fetch usage data" }, 502); - } - - return c.json(report); -}); - -// Get usage for a specific key by ID -modelsRoutes.get("/key-usage", async (c) => { - const keyId = c.req.query("keyId"); - if (!keyId) { - return c.json({ error: "keyId query parameter 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); - } - - const provider = key.definition.provider; - - try { - if (provider === "anthropic") { - const allAccounts = resolveClaudeAccounts(); - const credFile = key.definition.credentials_file; - // 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); - } - // Fetch usage for matched accounts - const accountResults = await Promise.all( - accounts.map(async (acct) => { - const report = await getAccountUsage(acct); - return { - label: acct.label, - source: acct.source, - subscriptionType: acct.credentials.subscriptionType, - fiveHour: report?.fiveHour, - sevenDay: report?.sevenDay, - error: report ? undefined : "failed to fetch", - }; - }), - ); - return c.json({ - provider: "anthropic", - accounts: accountResults, - // Legacy single-account fields (first account) - fiveHour: accountResults[0]?.fiveHour, - sevenDay: accountResults[0]?.sevenDay, - }); - } else if (provider === "opencode-go") { - // Cookie-based HTML scraper. Uses OPENCODE_COOKIE env var plus - // OPENCODE_WS1_ID / OPENCODE_WS2_ID (keyed by the key's numeric suffix). - const report = await fetchOpencodeUsage(key.definition.id); - if (report) { - return c.json({ - provider: "opencode-go", - fiveHour: report.fiveHour, - weekly: report.weekly, - monthly: report.monthly, - }); - } - // Fall back: show limits info with link to console - return c.json({ - provider: "opencode-go", - unavailable: true, - consoleUrl: "https://opencode.ai/auth", - limits: { - fiveHour: "$12", - weekly: "$30", - monthly: "$60", - }, - }); - } else if (provider === "github-copilot") { - const token = resolveApiKey(keyId, key.definition.env); - if (!token) { - return c.json({ error: `no API key found for ${keyId}` }, 502); - } - const report = await fetchCopilotUsage(token, key.definition.base_url); - if (!report) { - return c.json({ error: "failed to fetch usage data" }, 502); - } - return c.json({ - provider: "github-copilot", - tokensConsumed: report.tokensConsumed, - tokensRemaining: report.tokensRemaining, - percentUsed: report.percentUsed, - resetAt: report.resetAt, - plan: report.plan, - }); - } else if (provider === "google") { - const token = resolveApiKey(keyId, key.definition.env); - if (!token) { - return c.json({ error: `no API key found for ${keyId}. Set GOOGLE_API_KEY env var.` }, 502); - } - const report = await fetchGoogleUsage(token, key.definition.base_url); - if (!report) { - return c.json({ error: "failed to fetch Google usage data" }, 502); - } - return c.json({ - provider: "google", - models: report.models, - currentUsage: report.currentUsage, - weeklyUsage: report.weeklyUsage, - }); - } else { - return c.json({ error: "usage tracking not supported for this provider" }, 400); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return c.json({ error: `failed to fetch usage: ${message}` }, 502); - } -}); - -// ─── 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 }); -}); - -// ─── Add key to dispatch.toml ───────────────────────────────── - -const VALID_PROVIDERS = ["anthropic", "opencode-go", "google"] as const; -type SupportedProvider = (typeof VALID_PROVIDERS)[number]; - -const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = { - anthropic: "https://api.anthropic.com/v1", - "opencode-go": "https://opencode.ai/zen/go/v1", - google: "https://generativelanguage.googleapis.com/v1beta/openai", -}; - -modelsRoutes.post("/add-key", async (c) => { - const body = await c.req.json<{ id?: unknown; provider?: unknown }>(); - - // Validate id - if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) { - return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400); - } - const id = body.id.trim(); - - // Validate provider - if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) { - return c.json({ error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, 400); - } - const provider = body.provider as SupportedProvider; - const base_url = PROVIDER_BASE_URLS[provider]; - - // Read current dispatch.toml - const tomlPath = `${process.cwd()}/dispatch.toml`; - let tomlContent: string; - try { - tomlContent = readFileSync(tomlPath, "utf-8"); - } catch (err) { - return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); - } - - // Check for duplicate key id - const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m"); - if (idPattern.test(tomlContent)) { - return c.json({ error: `key with id "${id}" already exists` }, 409); - } - - // Build the new [[keys]] block - let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`; - if (provider === "anthropic") { - const credPath = `${homedir()}/.claude/.credentials-${id}.json`; - newBlock += `\ncredentials_file = "${credPath}"`; - } else { - const envVar = - provider === "google" - ? "GOOGLE_API_KEY" - : `DISPATCH_${id.toUpperCase().replace(/-/g, "_")}_KEY`; - newBlock += `\nenv = "${envVar}"`; - } - newBlock += "\n"; - - // Insert before the # ─── Permissions section if it exists, otherwise at end - const permissionsMarker = /\n# [─-]+ Permissions/; - let newContent: string; - const permMatch = permissionsMarker.exec(tomlContent); - if (permMatch) { - const insertAt = permMatch.index; - newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt); - } else { - newContent = tomlContent + newBlock; - } - - try { - writeFileSync(tomlPath, newContent, "utf-8"); - } catch (err) { - return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); - } - - const key: { id: string; provider: string; base_url: string; credentials_file?: string } = { - id, - provider, - base_url, - }; - if (provider === "anthropic") { - key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`; - } - - return c.json({ success: true, key }); -}); - -// ─── Remove key from dispatch.toml ──────────────────────────── - -modelsRoutes.post("/remove-key", async (c) => { - const body = await c.req.json<{ id?: unknown }>(); - - if (typeof body.id !== "string" || !body.id.trim()) { - return c.json({ error: "id is required" }, 400); - } - const id = body.id.trim(); - - const tomlPath = `${process.cwd()}/dispatch.toml`; - let tomlContent: string; - try { - tomlContent = readFileSync(tomlPath, "utf-8"); - } catch (err) { - return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); - } - - // Match the [[keys]] block containing this id and remove it. - // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF. - const blockPattern = new RegExp( - `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`, - "s", - ); - const match = blockPattern.exec(tomlContent); - if (!match) { - return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404); - } - - const newContent = - tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length); - - try { - writeFileSync(tomlPath, newContent, "utf-8"); - } catch (err) { - return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); - } - - return c.json({ success: true }); -}); - -// ─── Shared wake function ───────────────────────────────────── - -/** Max chars of upstream error body to keep in the surfaced message. */ -const MAX_ERROR_BODY_CHARS = 200; - -/** - * Turn a non-OK probe response into a short, human-readable reason. Anthropic - * returns a JSON error envelope (`{ error: { message } }`); fall back to a - * truncated raw body, then to the bare status. Never throws. - */ -async function describeFailedResponse(res: Response): Promise<string> { - let detail = ""; - try { - const text = await res.text(); - try { - const parsed = JSON.parse(text) as { error?: { message?: unknown } }; - const message = parsed?.error?.message; - detail = typeof message === "string" ? message : text; - } catch { - detail = text; - } - } catch { - detail = ""; - } - detail = detail.trim().slice(0, MAX_ERROR_BODY_CHARS); - return detail ? `HTTP ${res.status}: ${detail}` : `HTTP ${res.status}`; -} - -async function wakeAllClaudeAccounts(): Promise< - Array<{ label: string; ok: boolean; error?: string }> -> { - // Only wake accounts referenced by configured anthropic keys - const allAccounts = resolveClaudeAccounts(); - const registry = getRegistry(); - const configuredKeyIds = new Set<string>(); - if (registry) { - for (const ks of registry.getKeys()) { - if (ks.definition.provider === "anthropic") { - configuredKeyIds.add(ks.definition.id); - } - } - } - 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" }]; - } - - 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; - } - - // Resolve the probe model dynamically. A fixed model id (the old - // `claude-3-5-haiku-20241022`) eventually stops being served and - // the probe 404s, so pull the live list from `/v1/models` and pick - // the current Haiku. Fall back to the well-known list if the live - // fetch comes back empty (network blip, transient upstream error). - let availableModels = await fetchAnthropicModels(creds.accessToken); - if (availableModels.length === 0) { - availableModels = ANTHROPIC_MODELS_FALLBACK; - } - const probeModel = selectHaikuModel(availableModels); - if (!probeModel) { - results.push({ - label: acct.label, - ok: false, - error: "no 'haiku' model available from /v1/models", - }); - continue; - } - - // Mirror a genuine Claude Code CLI request. These are OAuth - // (Pro/Max) subscription accounts: Anthropic validates the - // `system[]` array and rejects (401/403) any request whose system - // block lacks the verbatim Claude Code identity string. A bare - // `{ model, messages }` body — what this probe used to send — - // always failed, which is why scheduled wakes silently died with a - // blank "failed" status. `buildWakeProbeBody` produces the correct - // shape (billing header + identity); the session/request-id headers - // match what the real CLI stamps so the probe isn't flagged. - const res = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - ...getAnthropicHeaders(creds.accessToken), - "content-type": "application/json", - "X-Claude-Code-Session-Id": randomUUID(), - "x-client-request-id": randomUUID(), - }, - body: JSON.stringify(buildWakeProbeBody(probeModel)), - }); - - if (res.ok) { - results.push({ label: acct.label, ok: true }); - } else { - // Surface WHY it failed so the panel never shows a bare - // "failed" again and breakage stays debuggable. - results.push({ - label: acct.label, - ok: false, - error: await describeFailedResponse(res), - }); - } - } catch (err) { - results.push({ - label: acct.label, - ok: false, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - return results; -} - -modelsRoutes.post("/wake", async (c) => { - const results = await wakeAllClaudeAccounts(); - return c.json({ results }); -}); - -// ─── Wake scheduler (runs on backend, survives frontend close) ─ -// -// A "marked hour" expands to 4 probe slots inside that hour: :00, :15, :30, -// :45. Each slot is its own (hour, slot_minute) row in `wake_schedule` with -// its own `next_wake_at`. When multiple slots come due in the same tick we -// coalesce into a single upstream wake — no point hitting Anthropic 4× in -// the same 30-second window. - -/** Schedule: hour (0-23) → slot minute (0/15/30/45) → next fire ms. */ -type WakeSchedule = Record<number, Partial<Record<ProbeSlotMinute, number>>>; - -interface PendingRetry { - /** Remaining attempts. Starts at MAX_RETRIES (e.g. 6 → 30 min of retries). */ - retriesLeft: number; - /** Absolute timestamp (ms) of the next retry attempt. */ - nextRetryAt: number; - /** Why we entered retry mode — surfaced on /wake-schedule. */ - reason: string; -} - -interface LastWake { - firedAt: number; - ok: boolean; - results: Array<{ label: string; ok: boolean; error?: string }>; -} - -const MAX_RETRIES = 6; -const RETRY_INTERVAL_MS = 5 * 60 * 1000; -const TICK_INTERVAL_MS = 30_000; - -function setSlot(schedule: WakeSchedule, hour: number, minute: ProbeSlotMinute, ts: number): void { - const hourEntry = schedule[hour] ?? {}; - hourEntry[minute] = ts; - schedule[hour] = hourEntry; -} - -function deleteHour(schedule: WakeSchedule, hour: number): void { - delete schedule[hour]; -} - -function countSlots(schedule: WakeSchedule): number { - let n = 0; - for (const slots of Object.values(schedule)) { - n += Object.keys(slots).length; - } - return n; -} - -function loadScheduleFromDB(): WakeSchedule { - try { - const db = getDatabase(); - const rows = db - .query("SELECT hour, slot_minute, next_wake_at FROM wake_schedule") - .all() as Array<{ hour: number; slot_minute: number; next_wake_at: number }>; - const schedule: WakeSchedule = {}; - const now = Date.now(); - let needsPersist = false; - let anyShouldFire = false; - for (const row of rows) { - if (!isProbeSlotMinute(row.slot_minute)) continue; // defensive — schema CHECKs it - const recovered = recoverScheduleEntry(row.next_wake_at, now); - setSlot(schedule, row.hour, row.slot_minute, recovered.nextWakeAt); - if (recovered.nextWakeAt !== row.next_wake_at) needsPersist = true; - if (recovered.shouldFireNow) anyShouldFire = true; - } - if (needsPersist) persistSchedule(schedule); - if (anyShouldFire) needsBootFire = true; - return schedule; - } catch { - return {}; - } -} - -function persistSchedule(scheduleToSave?: WakeSchedule): void { - try { - const db = getDatabase(); - const data = scheduleToSave ?? wakeSchedule; - const insert = db.query( - "INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)", - ); - // One atomic transaction: DELETE + every INSERT either all commit or all - // roll back. Without this, an INSERT failure (disk full, bad row, etc.) - // would leave the table empty — silently wiping the user's schedule on - // next boot since the DELETE has already committed. - const writeAll = db.transaction(() => { - db.run("DELETE FROM wake_schedule"); - for (const [hour, slots] of Object.entries(data)) { - for (const [slotMinute, nextWakeAt] of Object.entries(slots)) { - if (nextWakeAt === undefined) continue; - insert.run({ - $hour: Number(hour), - $slot: Number(slotMinute), - $nextWakeAt: nextWakeAt, - }); - } - } - }); - writeAll(); - } catch { - // Ignore DB errors — schedule still lives in-memory for this process, - // and the previously persisted snapshot stays intact thanks to the - // transaction rollback above. - } -} - -/** Set to true by loadScheduleFromDB when one or more slots need a boot fire. */ -let needsBootFire = false; -const wakeSchedule: WakeSchedule = loadScheduleFromDB(); - -/** - * A single shared retry slot. We deliberately do NOT queue one retry per - * failed wake — multiple back-to-back failures (e.g. the network is down for - * five minutes) used to spawn retries that all converged on the same instant - * and hammered the upstream. One in-flight retry covers all accounts. - */ -let pendingRetry: PendingRetry | null = null; -let lastWake: LastWake | null = null; - -// HMR-safe: track the scheduler timer on globalThis so re-imports during dev -// don't leave orphaned timers running. -const timerKey = "_dispatchWakeTimer"; -(globalThis as Record<string, unknown>)[timerKey] ??= undefined; -let isTickRunning = false; - -function recordWake(results: Array<{ label: string; ok: boolean; error?: string }>): boolean { - const ok = results.length > 0 && results.every((r) => r.ok); - lastWake = { firedAt: Date.now(), ok, results }; - return ok; -} - -function scheduleRetry(reason: string): void { - if (pendingRetry) { - // Already retrying — reset the budget so the next failure window covers - // the new incident too, but don't compound timers. - pendingRetry.retriesLeft = MAX_RETRIES; - pendingRetry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS; - pendingRetry.reason = reason; - return; - } - pendingRetry = { - retriesLeft: MAX_RETRIES, - nextRetryAt: Date.now() + RETRY_INTERVAL_MS, - reason, - }; -} - -async function fireWake(reason: string): Promise<void> { - try { - const results = await wakeAllClaudeAccounts(); - const ok = recordWake(results); - if (!ok) scheduleRetry(reason); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - lastWake = { - firedAt: Date.now(), - ok: false, - results: [{ label: "(scheduler)", ok: false, error: message }], - }; - scheduleRetry(reason); - } -} - -async function processPendingRetry(now: number): Promise<void> { - // Capture into a local so TS narrowing survives across awaits, and so a - // racing toggle that clears `pendingRetry` mid-flight can't NPE us. - const retry = pendingRetry; - if (!retry || retry.nextRetryAt > now) return; - try { - const results = await wakeAllClaudeAccounts(); - const ok = recordWake(results); - if (ok || retry.retriesLeft <= 1) { - pendingRetry = null; - } else { - retry.retriesLeft -= 1; - retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - lastWake = { - firedAt: Date.now(), - ok: false, - results: [{ label: "(retry)", ok: false, error: message }], - }; - if (retry.retriesLeft <= 1) { - pendingRetry = null; - } else { - retry.retriesLeft -= 1; - retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS; - } - } -} - -interface DueSlot { - hour: number; - minute: ProbeSlotMinute; - ts: number; -} - -/** Collect every slot whose next_wake_at is at or before `now`. */ -function collectDueSlots(now: number): DueSlot[] { - const due: DueSlot[] = []; - for (const [hourStr, slots] of Object.entries(wakeSchedule)) { - const hour = Number(hourStr); - for (const [slotStr, ts] of Object.entries(slots)) { - if (ts === undefined) continue; - const slotMinute = Number(slotStr); - if (!isProbeSlotMinute(slotMinute)) continue; - if (ts <= now) due.push({ hour, minute: slotMinute, ts }); - } - } - return due; -} - -async function schedulerTick(): Promise<void> { - // Prevent concurrent tick execution (e.g. toggle called mid-tick). - if (isTickRunning) return; - isTickRunning = true; - - try { - const now = Date.now(); - const due = collectDueSlots(now); - - let firedThisTick = false; - const bootFireRequested = needsBootFire; - if (due.length > 0 || bootFireRequested) { - needsBootFire = false; - // Advance every due slot before firing — so a slow upstream call - // can't cause us to re-fire the same slot on the next tick. - for (const slot of due) { - const next = nextDailyAfter(slot.ts, now); - setSlot(wakeSchedule, slot.hour, slot.minute, next); - } - persistSchedule(); - - const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`); - const fromBoot = bootFireRequested ? " (boot recovery)" : ""; - const reason = - reasonParts.length > 0 - ? `scheduled probe(s) ${reasonParts.join(", ")}${fromBoot}` - : "boot recovery"; - firedThisTick = true; - // COALESCED: one upstream call covers all slots due this tick. - await fireWake(reason); - } - - // Only attempt a retry on ticks that didn't *just* fire — otherwise we'd - // race the retry against a fresh attempt within the same loop iteration. - if (!firedThisTick) { - await processPendingRetry(Date.now()); - } - - // Keep ticking while there's anything to monitor. - if (countSlots(wakeSchedule) > 0 || pendingRetry !== null) { - (globalThis as Record<string, unknown>)[timerKey] = setTimeout( - schedulerTick, - TICK_INTERVAL_MS, - ); - } - } finally { - isTickRunning = false; - } -} - -export function startWakeScheduler(): void { - // Clear any previous timer (HMR-safe — works with Bun's Timer objects). - const prev = (globalThis as Record<string, unknown>)[timerKey]; - if (prev != null) clearTimeout(prev as ReturnType<typeof setTimeout>); - // Fire-and-forget; the tick re-arms itself. - void schedulerTick(); -} - -function scheduleSnapshot(): { - schedule: WakeSchedule; - resetOffsetHours: number; - probeSlotMinutes: readonly number[]; - lastWake: LastWake | null; - pendingRetry: PendingRetry | null; -} { - return { - schedule: wakeSchedule, - resetOffsetHours: CLAUDE_RESET_OFFSET_HOURS, - probeSlotMinutes: PROBE_SLOT_MINUTES, - lastWake, - pendingRetry, - }; -} - -modelsRoutes.post("/wake-schedule/toggle", async (c) => { - const body = await c.req.json<{ - hour?: unknown; - action?: unknown; - timestamps?: unknown; - }>(); - const hour = body.hour; - if (typeof hour !== "number" || !Number.isFinite(hour) || hour < 0 || hour > 23) { - return c.json({ error: "hour must be a number 0-23" }, 400); - } - if (!Number.isInteger(hour)) { - return c.json({ error: "hour must be an integer 0-23" }, 400); - } - - // The action is the CLIENT'S DECLARED INTENT. Previously the server - // derived add-vs-remove from its own in-memory state, which meant a UI - // that had become stale (e.g. due to a snapshot race) would have its - // clicks silently inverted: user clicks to turn ON an hour the UI shows - // as OFF, server sees it as already-ON, deletes it. Requiring an explicit - // action makes the request idempotent and self-describing — a stale UI's - // click is now either a redundant no-op (action matches server state) or - // a recoverable replace (action="on" against an already-on hour just - // refreshes its timestamps to the new values). - const action = body.action; - if (action !== "on" && action !== "off") { - return c.json({ error: "action must be 'on' or 'off'" }, 400); - } - - if (action === "off") { - // Idempotent: removing an already-removed hour is a no-op success. - if (wakeSchedule[hour] !== undefined) { - deleteHour(wakeSchedule, hour); - } - } else { - // action === "on" — require a `timestamps` object with one absolute - // Unix ms per probe slot (0, 15, 30, 45). The client is the source - // of truth for the *local* wall-clock intent of each probe. - // Idempotent: turning ON an already-on hour replaces its timestamps - // (so a UI recovering from a desync can re-assert the correct wall- - // clock intent without first deleting). - const timestamps = body.timestamps; - if (timestamps === null || typeof timestamps !== "object") { - return c.json( - { error: "timestamps must be an object { '0': ms, '15': ms, '30': ms, '45': ms }" }, - 400, - ); - } - const parsed: Partial<Record<ProbeSlotMinute, number>> = {}; - for (const slot of PROBE_SLOT_MINUTES) { - const raw = (timestamps as Record<string, unknown>)[String(slot)]; - // Accept any finite Unix-ms number. We deliberately do NOT reject - // past timestamps: client-server clock skew + request latency mean - // a freshly-computed `nextOccurrenceAt(HH:MM)` for an imminent slot - // can land "in the past" by the time the server validates it. The - // scheduler tick handles past entries correctly via - // `recoverScheduleEntry` — fires within MISSED_WAKE_GRACE_MS, then - // advances by 24h * N to the next future occurrence. - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return c.json({ error: `timestamps['${slot}'] must be a finite Unix ms value` }, 400); - } - parsed[slot] = raw; - } - wakeSchedule[hour] = parsed; - } - - persistSchedule(); - startWakeScheduler(); - - return c.json(scheduleSnapshot()); -}); - -modelsRoutes.get("/wake-schedule", (c) => { - return c.json(scheduleSnapshot()); -}); diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts deleted file mode 100644 index 473e837..0000000 --- a/packages/api/src/routes/notifications.ts +++ /dev/null @@ -1,88 +0,0 @@ -// `/notifications` — ntfy.sh config + test-send route. - -import { - defaultNtfyConfig, - loadNtfyConfig, - type NotificationEventType, - NTFY_EVENT_TYPES, - type NtfyConfig, - normalizeNtfyConfig, - redactNtfyConfig, - saveNtfyConfig, - sendNtfy, -} from "@dispatch/core"; -import { Hono } from "hono"; - -export const notificationsRoutes = new Hono(); - -notificationsRoutes.get("/", (c) => { - const config = loadNtfyConfig(); - return c.json({ - config: redactNtfyConfig(config), - eventTypes: NTFY_EVENT_TYPES, - defaults: defaultNtfyConfig(), - }); -}); - -notificationsRoutes.put("/", async (c) => { - const body = await c.req.json<Partial<NtfyConfig> & { authToken?: string }>(); - const existing = loadNtfyConfig(); - - // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep - // the existing token (the GET response redacts it, so the frontend doesn't - // have it to send back). Any other string ⇒ replace. - let nextAuthToken = existing.authToken; - if (typeof body.authToken === "string") nextAuthToken = body.authToken; - - const merged = normalizeNtfyConfig({ - enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled, - topic: typeof body.topic === "string" ? body.topic : existing.topic, - authToken: nextAuthToken, - events: { ...existing.events, ...(body.events ?? {}) }, - notifySubagents: - typeof body.notifySubagents === "boolean" ? body.notifySubagents : existing.notifySubagents, - }); - - // Only validation: if notifications are turned on, the topic must be - // non-empty. Any other "is this a valid ntfy topic name?" check is - // punted to the ntfy server itself — its rules vary and have changed - // over time, and a syntactically-valid name still might be rejected - // (e.g. reserved words), so a clear server error is more useful than - // a client-side guess. - if (merged.enabled && !merged.topic.trim()) { - return c.json({ error: "Topic is required" }, 400); - } - - saveNtfyConfig(merged); - return c.json({ config: redactNtfyConfig(merged) }); -}); - -notificationsRoutes.post("/test", async (c) => { - const config = loadNtfyConfig(); - if (!config.enabled) { - return c.json({ ok: false, error: "Notifications are disabled" }, 400); - } - if (!config.topic.trim()) { - return c.json({ ok: false, error: "Topic is required" }, 400); - } - - // Use a real event type so the per-event toggle is honored when wiring - // is tested end-to-end; pick `turn-completed` since it's the most - // common enabled-by-default event. - const eventType: NotificationEventType = "turn-completed"; - if (!config.events[eventType]) { - return c.json( - { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` }, - 400, - ); - } - - const result = await sendNtfy(config, { - type: eventType, - title: "Dispatch test notification", - message: "If you can see this, ntfy.sh notifications are wired up correctly.", - tags: ["bell"], - }); - if (!result.ok) return c.json(result, 502); - return c.json(result); -}); diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts deleted file mode 100644 index 7696b47..0000000 --- a/packages/api/src/routes/skills.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { AgentSkillMapping, SkillDefinition, SkillScope } from "@dispatch/core"; -import { Hono } from "hono"; - -let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({ - skills: [], - mappings: [], -}); - -export function setSkillsGetter( - getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }, -): void { - getSkills = getter; -} - -export const skillsRoutes = new Hono(); - -skillsRoutes.get("/", (c) => { - const { skills, mappings } = getSkills(); - const skillSummaries = skills.map(({ name, description, tags, scope, directory }) => ({ - name, - description, - tags, - scope, - directory, - })); - return c.json({ skills: skillSummaries, mappings }); -}); - -skillsRoutes.get("/:name", (c) => { - const { name } = c.req.param(); - const scopeParam = c.req.query("scope") as SkillScope | undefined; - const { skills } = getSkills(); - - const matches = skills.filter((s) => s.name === name); - if (matches.length === 0) { - return c.json({ error: "Skill not found" }, 404); - } - - if (scopeParam) { - const scoped = matches.find((s) => s.scope === scopeParam); - if (!scoped) { - return c.json({ error: "Skill not found" }, 404); - } - return c.json(scoped); - } - - return c.json(matches[0]); -}); diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts deleted file mode 100644 index 2ae60ed..0000000 --- a/packages/api/src/routes/tabs.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - archiveTab, - createTab, - deleteSetting, - getChunksForTab, - getSetting, - getTab, - getTotalChunkCount, - getUsageStatsForTab, - groupRowsToMessages, - listOpenTabs, - setSetting, - updateTabModel, - updateTabPositions, - updateTabStatus, - updateTabTitle, -} from "@dispatch/core"; -import { Hono } from "hono"; - -export const tabsRoutes = new Hono(); - -let getAgentManager: () => { - stopTab(id: string): void; - deleteTab(id: string): void; - compactTab(tempTabId: string, sourceTabId: string): Promise<void>; -} | null = () => null; - -export function setTabsAgentManager( - getter: () => { - stopTab(id: string): void; - deleteTab(id: string): void; - compactTab(tempTabId: string, sourceTabId: string): Promise<void>; - } | null, -): void { - getAgentManager = getter; -} - -tabsRoutes.get("/", (c) => { - // Enrich each tab with its persisted usage aggregate so the frontend can - // seed `cacheStats` on reload without an extra round-trip. N small indexed - // queries — fine for tab counts. - const tabs = listOpenTabs().map((t) => ({ ...t, usageStats: getUsageStatsForTab(t.id) })); - return c.json({ tabs }); -}); - -tabsRoutes.post("/", async (c) => { - const body = await c.req.json<{ id?: string; title?: string }>(); - const id = body.id ?? crypto.randomUUID(); - const title = body.title ?? "New Tab"; - const tab = createTab(id, title); - return c.json(tab); -}); - -// Settings routes (must be before /:id to avoid conflict) -tabsRoutes.get("/settings/title-model", (c) => { - const keyId = getSetting("title_model_key_id"); - const modelId = getSetting("title_model_id"); - return c.json({ keyId, modelId }); -}); - -tabsRoutes.put("/settings/title-model", async (c) => { - const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>(); - if (body.keyId !== undefined) { - if (body.keyId) setSetting("title_model_key_id", body.keyId); - else deleteSetting("title_model_key_id"); - } - if (body.modelId !== undefined) { - if (body.modelId) setSetting("title_model_id", body.modelId); - else deleteSetting("title_model_id"); - } - return c.json({ success: true }); -}); - -// Conversation-compaction model (key+model used to generate the summary). -// Mirrors the title-model setting. When unset, compaction falls back to the -// source tab's own key+model. -tabsRoutes.get("/settings/compaction-model", (c) => { - const keyId = getSetting("compaction_model_key_id"); - const modelId = getSetting("compaction_model_id"); - return c.json({ keyId, modelId }); -}); - -tabsRoutes.put("/settings/compaction-model", async (c) => { - const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>(); - if (body.keyId !== undefined) { - if (body.keyId) setSetting("compaction_model_key_id", body.keyId); - else deleteSetting("compaction_model_key_id"); - } - if (body.modelId !== undefined) { - if (body.modelId) setSetting("compaction_model_id", body.modelId); - else deleteSetting("compaction_model_id"); - } - return c.json({ success: true }); -}); - -// Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids; -// each tab's `position` is rewritten to its index. Must be declared before the -// `/:id` routes so "reorder" isn't captured as an id param. -tabsRoutes.patch("/reorder", async (c) => { - const body = await c.req.json<{ ids?: string[] }>(); - if (!Array.isArray(body.ids) || body.ids.some((id) => typeof id !== "string")) { - return c.json({ error: "ids must be an array of strings" }, 400); - } - updateTabPositions(body.ids); - return c.json({ success: true }); -}); - -tabsRoutes.get("/:id", (c) => { - const id = c.req.param("id"); - const tab = getTab(id); - if (!tab) return c.json({ error: "tab not found" }, 404); - return c.json(tab); -}); - -// Conversation history for a tab, paginated at CHUNK granularity. The flat -// chunk log is windowed by `limit`/`before` (both chunk-`seq` cursors) so a -// single huge turn never dumps in full, then grouped into render messages. -// `before` is the oldest chunk seq the client already holds. This is what -// powers per-chunk frontend pagination / memory control. -tabsRoutes.get("/:id/messages", (c) => { - const id = c.req.param("id"); - const limitRaw = c.req.query("limit"); - const beforeRaw = c.req.query("before"); - const limit = limitRaw !== undefined ? Number(limitRaw) : undefined; - const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined; - const options = - limit !== undefined || before !== undefined - ? { - ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}), - ...(before !== undefined && Number.isFinite(before) ? { before } : {}), - } - : undefined; - const chunks = getChunksForTab(id, options); - const messages = groupRowsToMessages(chunks); - // `oldestSeq` is the chunk-seq cursor the client pages backward from; null - // when the window is empty. - const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null; - const total = getTotalChunkCount(id); - return c.json({ messages, total, oldestSeq }); -}); - -// Raw chunk window for a tab — the chunk-native frontend's load/paginate -// source. Same `limit`/`before` chunk-`seq` windowing as `/messages`, but -// returns the flat `ChunkRow[]` WITHOUT server-side grouping (the frontend -// groups for render and evicts/paginates on the flat list). Dedupe on the -// client by `seq` when overlap-fetching. -tabsRoutes.get("/:id/chunks", (c) => { - const id = c.req.param("id"); - const limitRaw = c.req.query("limit"); - const beforeRaw = c.req.query("before"); - const limit = limitRaw !== undefined ? Number(limitRaw) : undefined; - const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined; - const options = - limit !== undefined || before !== undefined - ? { - ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}), - ...(before !== undefined && Number.isFinite(before) ? { before } : {}), - } - : undefined; - const chunks = getChunksForTab(id, options); - const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null; - const total = getTotalChunkCount(id); - return c.json({ chunks, total, oldestSeq }); -}); - -// Trigger conversation compaction. The `:id` is the TRANSIENT placeholder tab -// hosting the "compacting…" UI; `sourceTabId` (body) is the conversation being -// compacted. Fire-and-forget on the server: progress/outcome is delivered via -// the `compaction-*` WS events. Returns 202 once the run is kicked off. -tabsRoutes.post("/:id/compact", async (c) => { - const tempTabId = c.req.param("id"); - const body = await c.req - .json<{ sourceTabId?: string }>() - .catch(() => ({}) as { sourceTabId?: string }); - const sourceTabId = body.sourceTabId; - if (!sourceTabId || typeof sourceTabId !== "string") { - return c.json({ error: "sourceTabId is required" }, 400); - } - const mgr = getAgentManager(); - if (!mgr) return c.json({ error: "agent manager unavailable" }, 503); - // Run in the background; outcome is emitted over WS. - void mgr.compactTab(tempTabId, sourceTabId).catch((err) => { - console.error(`[dispatch] compactTab error for ${sourceTabId}:`, err); - }); - return c.json({ success: true }, 202); -}); - -tabsRoutes.patch("/:id", async (c) => { - const id = c.req.param("id"); - const body = await c.req.json<{ - title?: string; - keyId?: string; - modelId?: string; - status?: string; - }>(); - if (body.title !== undefined) updateTabTitle(id, body.title); - if (body.keyId !== undefined || body.modelId !== undefined) { - updateTabModel(id, body.keyId ?? null, body.modelId ?? null); - } - if (body.status !== undefined) updateTabStatus(id, body.status); - const tab = getTab(id); - return c.json(tab); -}); - -// ─── Settings ───────────────────────────────────────────────── - -tabsRoutes.get("/settings/:key", (c) => { - const key = c.req.param("key"); - const value = getSetting(key); - return c.json({ value }); -}); - -tabsRoutes.put("/settings/:key", async (c) => { - const key = c.req.param("key"); - const body = await c.req.json<{ value?: string }>(); - if (typeof body.value !== "string") { - return c.json({ error: "value is required" }, 400); - } - setSetting(key, body.value); - return c.json({ success: true }); -}); - -tabsRoutes.delete("/:id", (c) => { - const id = c.req.param("id"); - const mgr = getAgentManager(); - if (mgr) mgr.deleteTab(id); - archiveTab(id); - return c.json({ success: true }); -}); |
