summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 21:10:09 +0900
committerAdam Malczewski <[email protected]>2026-06-02 21:10:09 +0900
commitd9f53727845dface3e6d8a84ba2270b1de55482b (patch)
tree6d42f0a0fbda15057296992e78c4b4e12046f9ed /packages/api/src
parent80212bfb009eaf71a4743310dee6ed08b8f7e1da (diff)
parent9d8cf7005ba4c0bb8ade0775f54c2557aa1c5683 (diff)
downloaddispatch-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.ts201
-rw-r--r--packages/api/src/index.ts61
-rw-r--r--packages/api/src/routes/models.ts29
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) {