diff options
| author | Adam Malczewski <[email protected]> | 2026-05-23 04:18:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-23 04:18:54 +0900 |
| commit | 80ce5960c479fe35ab72c822e3b67799d7e1491e (patch) | |
| tree | 2b58bb1cfbc458a70ad021adc1ce0ae6ed810d84 | |
| parent | c47346cc6237044ecb60ff22c4011d89744af581 (diff) | |
| download | dispatch-80ce5960c479fe35ab72c822e3b67799d7e1491e.tar.gz dispatch-80ce5960c479fe35ab72c822e3b67799d7e1491e.zip | |
feat: web_search + youtube_transcribe tools, shell interrupt backgrounding, fixes
- Add web_search tool (Firecrawl POST to /v1/search with query, limit, lang, country, scrapeOptions)
- Add youtube_transcribe tool (GET to transcriber service, handles completed/queued/failed statuses)
- Both tools registered for parent agents (always) and child agents (permission-gated)
- Added to summon enum, TOOL_DESCRIPTIONS, and core exports
- Shell interrupt: run_shell now races against user queue interrupt
- When interrupted, command continues in background with run_shell_<uuid> job ID
- BackgroundShellStore holds running processes, auto-cleans 10min after completion
- retrieve tool extended to handle both agent IDs and shell job IDs
- Tool error detection: results starting with 'Error:' now marked isError in UI
- Fix TS error: cast unavailMatch[1] regex capture group to string
- Docker: network_mode host for Tailscale/LAN access to external services
- Bun.serve idleTimeout set to 60s (was default 10s)
- KeyUsage: clearer message when OpenCode usage data unavailable
- Firecrawl: only send scrapeOptions when scrape=true (avoid 400 on instances without scrape support)
| -rw-r--r-- | dispatch.toml | 6 | ||||
| -rw-r--r-- | docker-compose.yml | 6 | ||||
| -rw-r--r-- | packages/api/src/agent-manager.ts | 34 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/agent/agent.ts | 7 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 4 | ||||
| -rw-r--r-- | packages/core/src/tools/retrieve.ts | 10 | ||||
| -rw-r--r-- | packages/core/src/tools/run-shell.ts | 96 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 4 | ||||
| -rw-r--r-- | packages/core/src/tools/web-search.ts | 104 | ||||
| -rw-r--r-- | packages/core/src/tools/youtube-transcribe.ts | 105 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 11 |
12 files changed, 356 insertions, 32 deletions
diff --git a/dispatch.toml b/dispatch.toml index 72ff9bb..b35f27a 100644 --- a/dispatch.toml +++ b/dispatch.toml @@ -25,12 +25,6 @@ base_url = "https://opencode.ai/zen/go/v1" id = "opencode-2" provider = "opencode-go" base_url = "https://opencode.ai/zen/go/v1" - -[[keys]] -id = "copilot" -provider = "github-copilot" -base_url = "https://api.githubcopilot.com" - # ─── Permissions ───────────────────────────────────────────────── [permissions] diff --git a/docker-compose.yml b/docker-compose.yml index d8514da..c1b009f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,7 @@ services: context: . dockerfile: Dockerfile.dev command: ["bun", "--watch", "packages/api/src/index.ts"] - ports: - - "3000:3000" + network_mode: host volumes: - .:/app - ${HOME}/.claude:/home/${HOST_USER:-dispatch}/.claude @@ -25,8 +24,7 @@ services: context: . dockerfile: Dockerfile.dev command: ["bun", "run", "--cwd", "packages/frontend", "dev", "--", "--host"] - ports: - - "5173:5173" + network_mode: host volumes: - .:/app depends_on: diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 70cada4..b0a2f56 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -11,10 +11,13 @@ import { createReadFileTool, createRetrieveTool, createRunShellTool, + BackgroundShellStore, createSkillsWatcher, createSummonTool, createTaskListTool, + createWebSearchTool, createWriteFileTool, + createYoutubeTranscribeTool, type DispatchConfig, getClaudeAccountsFromDB, getSetting, @@ -46,6 +49,8 @@ const TOOL_DESCRIPTIONS: Record<string, string> = { "Spawn a child agent to work on a task independently. Returns an agent_id immediately (non-blocking). Use retrieve to collect the result later.", retrieve: "Wait for a child agent to finish and get its result (blocking). Pass the agent_id from summon.", + web_search: "Search the web and optionally scrape full page content from results.", + youtube_transcribe: "Fetch the transcript/subtitles for a YouTube video.", }; const DEFAULT_SYSTEM_PROMPT = @@ -134,6 +139,8 @@ interface TabAgent { messageQueue: QueuedMessage[]; /** Callbacks to wake up blocking tools waiting for queued messages. */ queueListeners: Array<() => void>; + /** Store for shell commands backgrounded due to user interrupt. */ + shellStore: BackgroundShellStore; } export class AgentManager { @@ -269,6 +276,7 @@ export class AgentManager { taskList, messageQueue: [], queueListeners: [], + shellStore: new BackgroundShellStore(), }; this.tabAgents.set(tabId, tabAgent); } @@ -346,7 +354,13 @@ export class AgentManager { toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); } if (allowed.has("run_shell")) { - toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) }); + toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory, tabAgent.shellStore) }); + } + if (allowed.has("web_search")) { + toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); + } + if (allowed.has("youtube_transcribe")) { + toolEntries.push({ name: "youtube_transcribe", tool: createYoutubeTranscribeTool() }); } if (allowed.has("todo")) { toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); @@ -370,7 +384,12 @@ export class AgentManager { if (allowed.has("retrieve")) { toolEntries.push({ name: "retrieve", - tool: createRetrieveTool({ getResult: (id) => this.getChildResult(id) }), + tool: createRetrieveTool({ + getResult: (id) => + tabAgent.shellStore.has(id) + ? tabAgent.shellStore.getResult(id) + : this.getChildResult(id), + }), }); } } else { @@ -383,8 +402,10 @@ export class AgentManager { toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); } if (permBash) { - toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) }); + toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory, tabAgent.shellStore) }); } + toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); + toolEntries.push({ name: "youtube_transcribe", tool: createYoutubeTranscribeTool() }); toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); if (permSummon) { // Capture parent's allowed tool names for child permission enforcement @@ -404,7 +425,12 @@ export class AgentManager { }); toolEntries.push({ name: "retrieve", - tool: createRetrieveTool({ getResult: (id) => this.getChildResult(id) }), + tool: createRetrieveTool({ + getResult: (id) => + tabAgent.shellStore.has(id) + ? tabAgent.shellStore.getResult(id) + : this.getChildResult(id), + }), }); } } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b188e39..a0ad025 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -72,6 +72,7 @@ export { app }; export default { port: Number(process.env.PORT) || 3000, + idleTimeout: 60, fetch: app.fetch, websocket, }; diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index b4c0eec..64a602b 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -196,11 +196,12 @@ export class Agent { }); const rawResult = await execPromise; + const resultStr = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult); return { toolCallId: tc.id, toolName: tc.name, - result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult), - isError: false, + result: resultStr, + isError: resultStr.startsWith("Error:"), }; } catch (err) { return { @@ -326,7 +327,7 @@ export class Agent { // Model tried to call an unavailable tool. // Add a synthetic tool call + error result for the bad tool. - const badToolName = unavailMatch[1]; + const badToolName = unavailMatch[1] as string; const fakeId = `unavail_${crypto.randomUUID().slice(0, 8)}`; const availableTools = Object.keys(aiTools).join(", "); const errorResult = `Tool "${badToolName}" is not available. Available tools: ${availableTools}. Please use only available tools.`; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 283916a..b092fe2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -51,10 +51,12 @@ export { createListFilesTool } from "./tools/list-files.js"; export { createReadFileTool } from "./tools/read-file.js"; export { createToolRegistry } from "./tools/registry.js"; export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js"; -export { createRunShellTool } from "./tools/run-shell.js"; +export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js"; export { analyzeCommand } from "./tools/shell-analyze.js"; export { createSummonTool, type SummonCallbacks } from "./tools/summon.js"; export { createTaskListTool, TaskList } from "./tools/task-list.js"; +export { createWebSearchTool } from "./tools/web-search.js"; export { createWriteFileTool } from "./tools/write-file.js"; +export { createYoutubeTranscribeTool } from "./tools/youtube-transcribe.js"; // Types & Permissions export * from "./types/index.js"; diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts index 8e14a96..021d1b7 100644 --- a/packages/core/src/tools/retrieve.ts +++ b/packages/core/src/tools/retrieve.ts @@ -11,15 +11,19 @@ export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition return { name: "retrieve", description: [ - "Wait for a child agent to finish and retrieve its result. This tool BLOCKS until the child completes.", + "Wait for a child agent or backgrounded shell command to finish and retrieve its result. This tool BLOCKS until completion.", "", - "Pass the agent_id returned by the summon tool. Once the child finishes, its final output is returned.", - "If the child encountered an error, the error message is returned instead.", + "Pass the ID returned by summon (agent_id) or by an interrupted run_shell (job_id). Once it finishes, the output is returned.", + "If an error occurred, the error message is returned instead.", "", "Typical usage:", ' 1. summon({ task: "...", tools: [...] }) -> get agent_id', " 2. ... do other work or summon more agents ...", ' 3. retrieve({ agent_id: "..." }) -> blocks until done, returns result', + "", + "Also used for backgrounded shell commands:", + " If run_shell is interrupted by a user message, it returns a job_id (run_shell_...).", + ' Use retrieve({ agent_id: "run_shell_..." }) to get the final output when ready.', ].join("\n"), parameters: z.object({ agent_id: z.string().describe("The agent_id returned by a previous summon call."), diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts index 608c91d..6671c31 100644 --- a/packages/core/src/tools/run-shell.ts +++ b/packages/core/src/tools/run-shell.ts @@ -1,14 +1,56 @@ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { z } from "zod"; import type { ToolDefinition, ToolExecuteContext } from "../types/index.js"; const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes -export function createRunShellTool(workingDirectory: string): ToolDefinition { +export interface BackgroundShellJob { + command: string; + stdout: string; + stderr: string; + /** Resolves when the process exits */ + completion: Promise<{ stdout: string; stderr: string; exitCode: number; error?: string }>; +} + +/** Shared store for shell commands that were backgrounded due to user interrupt */ +export class BackgroundShellStore { + private jobs = new Map<string, BackgroundShellJob>(); + + register(job: BackgroundShellJob): string { + const id = `run_shell_${randomUUID()}`; + this.jobs.set(id, job); + // Auto-cleanup after completion + 10 minutes + job.completion.finally(() => { + setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000); + }); + return id; + } + + async getResult( + id: string, + ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> { + const job = this.jobs.get(id); + if (!job) { + return { status: "error", error: `No background shell job found with id '${id}'` }; + } + const result = await job.completion; + return { status: "done", result: JSON.stringify(result) }; + } + + has(id: string): boolean { + return this.jobs.has(id); + } +} + +export function createRunShellTool( + workingDirectory: string, + shellStore?: BackgroundShellStore, +): ToolDefinition { return { name: "run_shell", description: - "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks.", + "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. If the user interrupts while a command is running, the command continues in the background and you receive a job ID. Use the retrieve tool with that ID to get the result later.", parameters: z.object({ command: z.string().describe("The shell command to execute"), timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"), @@ -21,11 +63,6 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT; const [shell, shellArgs] = getShell(); - // NOTE (MVP limitation): `spawn` timeout sends SIGTERM only to the shell - // process itself, not to any child processes it may have spawned. If the - // command forks sub-processes they will continue running after timeout. - // A full fix would require spawning with `detached: true` and killing the - // entire process group (process.kill(-child.pid, "SIGTERM")). const child = spawn(shell, [...shellArgs, command], { cwd: workingDirectory, env: process.env, @@ -36,7 +73,7 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { let stdout = ""; let stderr = ""; - const result = await new Promise<{ + const completionPromise = new Promise<{ stdout: string; stderr: string; exitCode: number; @@ -62,6 +99,49 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { }); }); + const queueCallbacks = context?.queueCallbacks; + + if (queueCallbacks && shellStore) { + const { promise: queuePromise, cancel: cancelQueueWait } = + queueCallbacks.waitForQueuedMessage(); + const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); + + const raceResult = await Promise.race([completionPromise, queueSignal]); + + if (raceResult === "QUEUE_INTERRUPT") { + // Background the still-running process + const jobId = shellStore.register({ + command, + stdout, + stderr, + completion: completionPromise, + }); + + const queuedMsgs = queueCallbacks.dequeueMessages(); + const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); + + return [ + `Command backgrounded — still running.`, + `job_id: ${jobId}`, + `command: ${command}`, + `stdout so far: ${stdout.slice(-500) || "(none)"}`, + `stderr so far: ${stderr.slice(-500) || "(none)"}`, + ``, + `Use the retrieve tool with this job_id to get the final result when ready.`, + ``, + `[USER INTERRUPT]`, + `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`, + ``, + userMessages, + ].join("\n"); + } + + // Command finished before interrupt — clean up queue listener + cancelQueueWait(); + return JSON.stringify(raceResult); + } + + const result = await completionPromise; return JSON.stringify(result); }, }; diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts index 582b871..29f27d1 100644 --- a/packages/core/src/tools/summon.ts +++ b/packages/core/src/tools/summon.ts @@ -29,6 +29,8 @@ export function createSummonTool( " - todo: Track work items", " - summon: Spawn its own child agents (enables nesting)", " - retrieve: Collect results from its children (required if summon is given)", + " - web_search: Search the web", + " - youtube_transcribe: Fetch YouTube video transcripts", "", "If tools is omitted, the child gets read_file, list_files, and todo only (read-only by default).", ].join("\n"), @@ -48,6 +50,8 @@ export function createSummonTool( "todo", "summon", "retrieve", + "web_search", + "youtube_transcribe", ]), ) .optional() diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts new file mode 100644 index 0000000..4265aa7 --- /dev/null +++ b/packages/core/src/tools/web-search.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; + +const FIRECRAWL_URL = "http://100.102.55.49:31329/v1/search"; +const MAX_OUTPUT_CHARS = 60000; +const TIMEOUT_MS = 30000; + +export function createWebSearchTool(): ToolDefinition { + return { + name: "web_search", + description: + "Search the web via a self-hosted Firecrawl instance. Returns a list of results with titles, URLs, and descriptions. Optionally scrapes the full markdown content of each result page.", + parameters: z.object({ + query: z.string().describe("The search query"), + limit: z + .number() + .optional() + .default(7) + .describe("Maximum number of results to return (default 7)"), + scrape: z + .boolean() + .optional() + .default(false) + .describe("Whether to also scrape the full markdown content of each result page"), + lang: z.string().optional().describe('Language code to filter results (e.g. "en")'), + country: z.string().optional().describe('Country code to filter results (e.g. "us")'), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const query = args.query as string; + const limit = (args.limit as number | undefined) ?? 7; + const scrape = (args.scrape as boolean | undefined) ?? false; + const lang = args.lang as string | undefined; + const country = args.country as string | undefined; + + const body: Record<string, unknown> = { query, limit }; + if (lang !== undefined) body.lang = lang; + if (country !== undefined) body.country = country; + if (scrape) { + body.scrapeOptions = { formats: ["markdown"], onlyMainContent: true }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(FIRECRAWL_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return "Error: Request to Firecrawl timed out after 30 seconds."; + } + if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") { + return `Error: Could not connect to Firecrawl at http://100.102.55.49:31329. Is it running?`; + } + return `Error: ${err instanceof Error ? err.message : String(err)}`; + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + return `Error: Firecrawl returned HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`; + } + + let json: { data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }> }; + try { + json = await response.json(); + } catch { + return "Error: Failed to parse Firecrawl response as JSON"; + } + + const results = json.data ?? []; + if (results.length === 0) { + return "No results found."; + } + + const parts: string[] = []; + for (const result of results) { + const title = result.title ?? "(no title)"; + const url = result.url ?? ""; + const description = result.description ?? ""; + let section = `### ${title}\n${url}\n\n${description}`; + if (result.markdown) { + section += `\n\n${result.markdown}`; + } + parts.push(section); + } + + let output = parts.join("\n\n---\n\n"); + if (output.length > MAX_OUTPUT_CHARS) { + output = output.slice(0, MAX_OUTPUT_CHARS) + "\n\n[Output truncated]"; + } + return output; + }, + }; +} diff --git a/packages/core/src/tools/youtube-transcribe.ts b/packages/core/src/tools/youtube-transcribe.ts new file mode 100644 index 0000000..58ca465 --- /dev/null +++ b/packages/core/src/tools/youtube-transcribe.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; + +export function createYoutubeTranscribeTool(): ToolDefinition { + return { + name: "youtube_transcribe", + description: [ + "Fetch the transcript/subtitles for a YouTube video from a local transcriber service.", + "", + "If the transcript has not been downloaded before, the video will be queued for processing.", + "When status is 'queued' or 'processing', call this tool again later to check if the transcript is ready.", + "", + "Accepted URL formats:", + " - youtube.com/watch?v=", + " - youtu.be/", + " - youtube.com/embed/", + " - youtube.com/shorts/", + ].join("\n"), + parameters: z.object({ + url: z.string().describe("The YouTube video URL to fetch the transcript for."), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const url = args.url as string; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + + try { + const apiUrl = `http://100.102.55.49:41090/api/transcript?url=${encodeURIComponent(url)}`; + const response = await fetch(apiUrl, { signal: controller.signal }); + + if (!response.ok) { + return `Error: Transcriber service returned HTTP ${response.status} ${response.statusText}`; + } + + const data = (await response.json()) as Record<string, unknown>; + const status = data.status as string; + + if (status === "completed") { + const videoId = data.video_id as string; + const fullText = data.full_text as string; + const segments = data.segments as Array<{ text: string; start: number; duration: number }>; + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + }; + + const segmentsText = segments + .map((seg) => `[${formatTime(seg.start)}] ${seg.text}`) + .join("\n"); + + const output = [ + `Video ID: ${videoId}`, + "", + "## Transcript", + "", + fullText, + "", + "## Timestamped Segments", + "", + segmentsText, + ].join("\n"); + + return output.length > 60000 ? output.slice(0, 60000) + "\n\n[Transcript truncated]" : output; + } + + if (status === "queued" || status === "processing") { + const videoId = data.video_id as string; + const position = data.position as number; + const estimatedSeconds = data.estimated_seconds as number; + + return [ + `Transcript for video ${videoId} is being processed.`, + `Status: ${status}`, + `Queue position: ${position}`, + `Estimated wait time: ${estimatedSeconds} seconds`, + "", + "You can try calling this tool again later to check if the transcript is ready.", + ].join("\n"); + } + + if (status === "failed") { + const videoId = data.video_id as string; + const error = data.error as string; + const errorType = data.error_type as string; + + return `Error transcribing video ${videoId}: [${errorType}] ${error}`; + } + + return `Unexpected response status: ${status}`; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return "Error: Request to YouTube transcriber timed out after 30 seconds."; + } + if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") { + return "Error: Could not connect to YouTube transcriber at http://100.102.55.49:41090. Is it running?"; + } + return `Error: ${err instanceof Error ? err.message : String(err)}`; + } finally { + clearTimeout(timeout); + } + }, + }; +} diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index fc85739..49c3cf6 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -307,10 +307,15 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { {:else if entry.data.provider === "opencode-go"} {#if entry.data.unavailable} - <p class="text-xs text-base-content/70">Check console for usage.</p> + <p class="text-xs text-base-content/70">Usage data not available. Set OPENCODE_COOKIE env var to enable.</p> + {#if entry.data.limits} + <div class="text-xs text-base-content/50 mt-1"> + Limits: {entry.data.limits.fiveHour}/5h · {entry.data.limits.weekly}/wk · {entry.data.limits.monthly}/mo + </div> + {/if} {#if entry.data.consoleUrl} - <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs"> - Open console + <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs mt-1"> + View usage on opencode.ai </a> {/if} {:else} |
