diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 21:10:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 21:10:09 +0900 |
| commit | d9f53727845dface3e6d8a84ba2270b1de55482b (patch) | |
| tree | 6d42f0a0fbda15057296992e78c4b4e12046f9ed /packages/api/src | |
| parent | 80212bfb009eaf71a4743310dee6ed08b8f7e1da (diff) | |
| parent | 9d8cf7005ba4c0bb8ade0775f54c2557aa1c5683 (diff) | |
| download | dispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.tar.gz dispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.zip | |
Merge branch 'dev' into feat/cs-code-search-tool
# Conflicts:
# packages/api/src/agent-manager.ts
# packages/api/tests/agent-manager.test.ts
# packages/frontend/src/lib/components/ToolPermissions.svelte
# packages/frontend/src/lib/settings.svelte.ts
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 201 | ||||
| -rw-r--r-- | packages/api/src/index.ts | 61 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 29 |
3 files changed, 260 insertions, 31 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 48b869e..2532efa 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -14,6 +14,7 @@ import { configToRuleset, createConfigWatcher, createListFilesTool, + createLspTool, createReadFileSliceTool, createReadFileTool, createReadTabTool, @@ -38,6 +39,7 @@ import { getSetting, getTab, getUsageStatsForTab, + LspManager, listOpenTabs, loadAgent, loadAgents, @@ -46,9 +48,12 @@ import { ModelRegistry, type QueuedMessage, type ReasoningEffort, + type ResolvedLspServer, refreshAccountCredentials, refreshAccountCredentialsAsync, + reportDiagnostics, resolveApiKey, + resolveServersFromConfig, resolveTabPrefix, type SkillDefinition, type SystemChunkKind, @@ -86,6 +91,11 @@ const TOOL_DESCRIPTIONS: Record<string, string> = { web_search: "Search the web and optionally scrape full page content from results.", youtube_transcribe: "Fetch the transcript/subtitles for a YouTube video. Set background=true to start in the background and get a job_id for later retrieval.", + send_to_tab: + "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — if the target replies it will wake you with a new message in a later turn; if you are only waiting, end your turn.", + read_tab: + "Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.", + lsp: "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file: diagnostics, hover, definition, references, or documentSymbol. Line/character are 1-based.", }; /** @@ -98,6 +108,14 @@ const TOOL_DESCRIPTIONS: Record<string, string> = { */ const MAX_AGENT_AUTO_WAKES = 6; +/** + * Cap on how many OTHER files' LSP error blocks are appended to a write_file + * result, after the written file's own errors. Bounds context spend when a + * single edit surfaces project-wide diagnostics. Mirrors opencode's + * MAX_PROJECT_DIAGNOSTICS_FILES. + */ +const MAX_LSP_OTHER_FILE_DIAGNOSTICS = 5; + const DEFAULT_SYSTEM_PROMPT = "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise."; @@ -250,6 +268,21 @@ export class AgentManager { private claudeAccounts: ClaudeAccount[] = []; + /** + * Process-wide owner of LSP client lifecycles. Servers are declared in the + * `dispatch.toml` of a tab's effective working directory; clients are + * spawned lazily per (root + server) and reused across tabs/turns. Shut + * down in `destroy()`. + */ + private lspManager: LspManager = new LspManager(); + /** + * Cache of resolved LSP servers per working directory, so we parse each + * directory's `dispatch.toml` `[lsp]` block once. Cleared wholesale on any + * config hot-reload (the watcher fires for the root config; directory-level + * configs are re-read on demand after a clear). + */ + private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map(); + constructor(permissionManager?: PermissionManager) { this.permissionManager = permissionManager; @@ -291,6 +324,10 @@ export class AgentManager { } // Update model registry with new config this._initModelRegistry(newConfig); + // LSP server config may have changed — drop the per-directory cache + // so the next tool build re-reads each working directory's + // `dispatch.toml` `[lsp]` block. + this.lspServersByDir.clear(); // Re-discover Claude accounts: a config reload may accompany freshly // imported credentials, and (critically) lets a process that failed // account discovery at boot recover without a full restart. @@ -333,6 +370,77 @@ export class AgentManager { } } + /** + * Resolve (and cache) the LSP servers configured for a working directory. + * + * LSP config is project-scoped: it lives in the `dispatch.toml` of the + * tab's effective working directory, NOT the global config. We read that + * directory's config once and cache the resolved servers; the cache is + * cleared on config hot-reload. Returns `[]` when the directory has no + * `[lsp]` block (the common case). + */ + private getLspServersForDir(dir: string): ResolvedLspServer[] { + const cached = this.lspServersByDir.get(dir); + if (cached) return cached; + let servers: ResolvedLspServer[] = []; + try { + const dirConfig = loadConfig(dir); + servers = resolveServersFromConfig(dirConfig.lsp); + } catch (err) { + console.warn( + `dispatch: failed to load LSP config for ${dir}: ${err instanceof Error ? err.message : String(err)}`, + ); + servers = []; + } + this.lspServersByDir.set(dir, servers); + return servers; + } + + /** + * Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's + * working directory has LSP servers configured. The hook touches the + * just-written file through the LSP and returns a formatted diagnostics + * block (the written file's errors first, then a small cap of other-file + * errors) — opencode's diagnostics-on-write pattern. Returns `undefined` + * when no server matches, so writes stay zero-overhead for non-LSP files. + */ + private buildAfterWriteHook( + workingDirectory: string, + servers: ResolvedLspServer[], + ): ((absolutePath: string) => Promise<string>) | undefined { + if (servers.length === 0) return undefined; + const manager = this.lspManager; + return async (absolutePath: string): Promise<string> => { + if (!manager.hasServerForFile(absolutePath, servers)) return ""; + await manager.touchFile({ + file: absolutePath, + root: workingDirectory, + servers, + mode: "document", + }); + const diagnostics = manager.getDiagnostics({ + root: workingDirectory, + servers, + file: absolutePath, + }); + let output = ""; + let otherFileCount = 0; + for (const [file, issues] of Object.entries(diagnostics)) { + const current = file === absolutePath; + if (!current && otherFileCount >= MAX_LSP_OTHER_FILE_DIAGNOSTICS) continue; + const block = reportDiagnostics(file, issues); + if (!block) continue; + if (current) { + output += `${output ? "\n\n" : ""}LSP errors detected in this file, please fix:\n${block}`; + } else { + otherFileCount++; + output += `${output ? "\n\n" : ""}LSP errors detected in other files:\n${block}`; + } + } + return output; + }; + } + private _initModelRegistry(config: DispatchConfig): void { if (config.keys) { if (this.modelRegistry) { @@ -408,8 +516,9 @@ export class AgentManager { const permWebSearch = getSetting("perm_web_search") === "allow"; const permSearchCode = getSetting("perm_search_code") === "allow"; const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow"; + const permLsp = getSetting("perm_lsp") === "allow"; const sysPrompt = getSetting("system_prompt") ?? ""; - const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${sysPrompt}`; + const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permLsp}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( @@ -451,6 +560,12 @@ export class AgentManager { // Ignore — tool execution will surface the error naturally } + // Resolve LSP servers for this working directory once (cached). + // Drives both diagnostics-on-write (the write_file hook) and the + // optional `lsp` tool. Empty for directories with no `[lsp]` block. + const lspServers = this.getLspServersForDir(workingDirectory); + const afterWriteHook = this.buildAfterWriteHook(workingDirectory, lspServers); + // Build tools list — child agents use their toolsOverride whitelist, // parent agents use permission settings from DB const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = []; @@ -475,7 +590,10 @@ export class AgentManager { toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); } if (allowed.has("write_file")) { - toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); + toolEntries.push({ + name: "write_file", + tool: createWriteFileTool(workingDirectory, afterWriteHook), + }); } if (allowed.has("run_shell")) { toolEntries.push({ @@ -492,6 +610,16 @@ export class AgentManager { if (allowed.has("web_search")) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } + if (allowed.has("lsp") && lspServers.length > 0) { + toolEntries.push({ + name: "lsp", + tool: createLspTool(() => ({ + manager: this.lspManager, + workingDirectory, + servers: lspServers, + })), + }); + } if (allowed.has("youtube_transcribe")) { toolEntries.push({ name: "youtube_transcribe", @@ -552,7 +680,7 @@ export class AgentManager { } // Tab-to-tab communication — gated on the child whitelist. if (allowed.has("send_to_tab") || allowed.has("read_tab")) { - for (const entry of this.buildTabCommToolEntries(tabId)) { + for (const entry of this.buildTabCommToolEntries(tabId, allowed.has("read_tab"))) { if (allowed.has(entry.name)) toolEntries.push(entry); } } @@ -567,7 +695,10 @@ export class AgentManager { toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) }); } if (permEdit) { - toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) }); + toolEntries.push({ + name: "write_file", + tool: createWriteFileTool(workingDirectory, afterWriteHook), + }); } if (permBash) { toolEntries.push({ @@ -584,6 +715,19 @@ export class AgentManager { if (permWebSearch) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } + // The `lsp` tool exposes diagnostics + navigation on demand. It is + // gated by `perm_lsp` AND requires at least one server configured + // in the working directory's `dispatch.toml`. + if (permLsp && lspServers.length > 0) { + toolEntries.push({ + name: "lsp", + tool: createLspTool(() => ({ + manager: this.lspManager, + workingDirectory, + servers: lspServers, + })), + }); + } if (permYoutubeTranscribe) { toolEntries.push({ name: "youtube_transcribe", @@ -591,7 +735,13 @@ export class AgentManager { }); } toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) }); - if (permSummon) { + // The `summon` tool is registered when EITHER the subagent + // permission (`perm_summon`) OR the user-agent permission + // (`perm_user_agent`) is granted — the two are independent. + // `perm_summon` enables ordinary subagent spawning; granting + // only `perm_user_agent` exposes summon in user-agent-only mode + // (spawns top-level user agents exclusively). + if (permSummon || permUserAgent) { // Capture parent's allowed tool names for child permission enforcement const parentAllowedTools = new Set(toolEntries.map((e) => e.name)); const allAgentDefs = loadAgents(workingDirectory); @@ -625,25 +775,31 @@ export class AgentManager { availableUserAgents, agentDirPaths, permUserAgent, + permSummon, ), }); - toolEntries.push({ - name: "retrieve", - tool: createRetrieveTool({ - getResult: (id) => - tabAgent.shellStore.has(id) - ? tabAgent.shellStore.getResult(id) - : tabAgent.transcriptStore.has(id) - ? tabAgent.transcriptStore.getResult(id) - : this.getChildResult(id), - }), - }); + // `retrieve` collects subagent results. User agents are + // fire-and-forget, so it is bundled with the subagent + // permission only — a user-agent-only grant doesn't get it. + if (permSummon) { + toolEntries.push({ + name: "retrieve", + tool: createRetrieveTool({ + getResult: (id) => + tabAgent.shellStore.has(id) + ? tabAgent.shellStore.getResult(id) + : tabAgent.transcriptStore.has(id) + ? tabAgent.transcriptStore.getResult(id) + : this.getChildResult(id), + }), + }); + } } if (permSendToTab || permReadTab) { const tabCommAllowed = new Set<string>(); if (permSendToTab) tabCommAllowed.add("send_to_tab"); if (permReadTab) tabCommAllowed.add("read_tab"); - for (const entry of this.buildTabCommToolEntries(tabId)) { + for (const entry of this.buildTabCommToolEntries(tabId, permReadTab)) { if (tabCommAllowed.has(entry.name)) toolEntries.push(entry); } } @@ -1253,9 +1409,15 @@ export class AgentManager { * both tool-construction paths (child whitelist + permission-gated parent). * `selfHandle` is computed once so the calling tab can stamp provenance and * reject self-sends. + * + * `canReadTab` reflects whether THIS tab will also be granted `read_tab` + * (the permissions are split). It is forwarded into `send_to_tab` so the + * tool only points the agent at `read_tab` when it actually has it — never + * advertising a tool the agent wasn't granted. */ private buildTabCommToolEntries( tabId: string, + canReadTab: boolean, ): Array<{ name: string; tool: ReturnType<typeof createSendToTabTool> }> { const selfHandle = shortestUniquePrefix(tabId); return [ @@ -1269,6 +1431,7 @@ export class AgentManager { this.deliverMessage(targetId, message, { origin: "agent" }), listOpenHandles: () => this.listOpenHandles(tabId), self: { id: tabId, handle: selfHandle }, + canReadTab, }), }, { @@ -1851,5 +2014,9 @@ export class AgentManager { destroy(): void { this.configWatcher?.close(); this.skillsWatcher?.close(); + // Shut down all long-lived LSP server processes. Fire-and-forget: the + // promise is detached so `destroy()` stays synchronous (matching its + // existing contract), but every client gets `shutdown()` called. + void this.lspManager.shutdownAll(); } } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a0ad025..478abe0 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -70,9 +70,58 @@ app.get( export { app }; -export default { - port: Number(process.env.PORT) || 3000, - idleTimeout: 60, - fetch: app.fetch, - websocket, -}; +// Starting port (overridable via PORT) and the inclusive ceiling we will bump +// up to when a port is already in use. If 3000 is taken we try 3001, 3002, … +// up to MAX_PORT, so multiple dispatch instances (e.g. testing several +// features at once) can coexist without manually juggling ports. The frontend +// defaults to :3000 — point it at the chosen port via the in-app API-URL +// field / VITE_API_URL when a bump happens. +const START_PORT = Number(process.env.PORT) || 3000; +const MAX_PORT = 3010; + +/** + * Bind the server to `START_PORT`, incrementing by one on EADDRINUSE until a + * free port is found or MAX_PORT is exceeded. Bun's `Bun.serve` throws + * synchronously when the port is taken, so we can catch and retry. Returns the + * live server (whose `.port` reflects the port actually bound). + */ +function serveWithPortFallback() { + let lastError: unknown; + for (let port = START_PORT; port <= MAX_PORT; port++) { + try { + const server = Bun.serve({ + port, + idleTimeout: 60, + fetch: app.fetch, + websocket, + }); + if (port !== START_PORT) { + console.warn( + `dispatch: port ${START_PORT} in use — bound to ${port} instead. ` + + `Set the frontend's API URL to http://localhost:${port}.`, + ); + } + console.log(`dispatch: API listening on http://localhost:${server.port}`); + return server; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === "EADDRINUSE") { + lastError = err; + continue; + } + throw err; + } + } + console.error( + `dispatch: no free port in range ${START_PORT}-${MAX_PORT}. ` + + `Free one up or set PORT to an open port.`, + ); + throw lastError ?? new Error(`No free port in range ${START_PORT}-${MAX_PORT}`); +} + +// Only start the server when run as the entry point — importing this module +// (e.g. for `app`) must not bind a port. This preserves the prior +// default-export behavior where Bun served only the entry file. +if (import.meta.main) { + serveWithPortFallback(); +} diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 8f64bbb..eeb6029 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -20,6 +20,7 @@ import { refreshAccountCredentialsAsync, resolveApiKey, resolveContextLimit, + selectHaikuModel, setApiKey, validateAccountCredentials, } from "@dispatch/core"; @@ -568,13 +569,6 @@ modelsRoutes.post("/remove-key", async (c) => { // ─── Shared wake function ───────────────────────────────────── -/** - * Model used for the wake probe. A small/cheap model is enough — the only - * purpose is to register activity against the subscription so its rate-limit - * window keeps resetting on schedule. - */ -const WAKE_PROBE_MODEL = "claude-3-5-haiku-20241022"; - /** Max chars of upstream error body to keep in the surfaced message. */ const MAX_ERROR_BODY_CHARS = 200; @@ -631,6 +625,25 @@ async function wakeAllClaudeAccounts(): Promise< 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 @@ -648,7 +661,7 @@ async function wakeAllClaudeAccounts(): Promise< "X-Claude-Code-Session-Id": randomUUID(), "x-client-request-id": randomUUID(), }, - body: JSON.stringify(buildWakeProbeBody(WAKE_PROBE_MODEL)), + body: JSON.stringify(buildWakeProbeBody(probeModel)), }); if (res.ok) { |
