summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-23 04:18:54 +0900
committerAdam Malczewski <[email protected]>2026-05-23 04:18:54 +0900
commit80ce5960c479fe35ab72c822e3b67799d7e1491e (patch)
tree2b58bb1cfbc458a70ad021adc1ce0ae6ed810d84
parentc47346cc6237044ecb60ff22c4011d89744af581 (diff)
downloaddispatch-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.toml6
-rw-r--r--docker-compose.yml6
-rw-r--r--packages/api/src/agent-manager.ts34
-rw-r--r--packages/api/src/index.ts1
-rw-r--r--packages/core/src/agent/agent.ts7
-rw-r--r--packages/core/src/index.ts4
-rw-r--r--packages/core/src/tools/retrieve.ts10
-rw-r--r--packages/core/src/tools/run-shell.ts96
-rw-r--r--packages/core/src/tools/summon.ts4
-rw-r--r--packages/core/src/tools/web-search.ts104
-rw-r--r--packages/core/src/tools/youtube-transcribe.ts105
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte11
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 &middot; {entry.data.limits.weekly}/wk &middot; {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}