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/core/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/core/src')
| -rw-r--r-- | packages/core/src/config/schema.ts | 107 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 17 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 15 | ||||
| -rw-r--r-- | packages/core/src/lsp/client.ts | 658 | ||||
| -rw-r--r-- | packages/core/src/lsp/diagnostic.ts | 41 | ||||
| -rw-r--r-- | packages/core/src/lsp/index.ts | 18 | ||||
| -rw-r--r-- | packages/core/src/lsp/language.ts | 72 | ||||
| -rw-r--r-- | packages/core/src/lsp/manager.ts | 220 | ||||
| -rw-r--r-- | packages/core/src/lsp/server.ts | 68 | ||||
| -rw-r--r-- | packages/core/src/tools/lsp.ts | 135 | ||||
| -rw-r--r-- | packages/core/src/tools/send-to-tab.ts | 61 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 163 | ||||
| -rw-r--r-- | packages/core/src/tools/write-file.ts | 30 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 49 |
15 files changed, 1598 insertions, 57 deletions
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index a459a4d..304ee10 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,4 +1,9 @@ -import type { ConfigError, DispatchConfig, KeyDefinition } from "../types/index.js"; +import type { + ConfigError, + DispatchConfig, + KeyDefinition, + LspServerConfig, +} from "../types/index.js"; function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -100,6 +105,99 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi }; } +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +function validateLspServer( + raw: unknown, + path: string, + errors: ConfigError[], +): LspServerConfig | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + + const disabled = raw.disabled === true; + + // `command` is required and must be a non-empty string array unless the + // entry is explicitly disabled (a disabled entry is skipped wholesale). + if (!disabled) { + if (!isStringArray(raw.command) || raw.command.length === 0) { + errors.push({ + path: `${path}.command`, + message: "must be a non-empty array of strings", + }); + return null; + } + // `extensions` is required for custom servers — without it the client + // cannot know which files should activate the server. + if (!isStringArray(raw.extensions) || raw.extensions.length === 0) { + errors.push({ + path: `${path}.extensions`, + message: 'must be a non-empty array of strings (e.g. [".luau"])', + }); + return null; + } + } else { + // Disabled entries still must not carry a malformed command/extensions + // if present, but we do not require them. + if (raw.command !== undefined && !isStringArray(raw.command)) { + errors.push({ path: `${path}.command`, message: "must be an array of strings" }); + return null; + } + if (raw.extensions !== undefined && !isStringArray(raw.extensions)) { + errors.push({ path: `${path}.extensions`, message: "must be an array of strings" }); + return null; + } + } + + if (raw.env !== undefined && !isStringRecord(raw.env)) { + errors.push({ + path: `${path}.env`, + message: "must be a flat string-keyed object", + }); + return null; + } + + if (raw.initialization !== undefined && !isRecord(raw.initialization)) { + errors.push({ + path: `${path}.initialization`, + message: "must be an object", + }); + return null; + } + + const server: LspServerConfig = { + command: (raw.command as string[] | undefined) ?? [], + extensions: (raw.extensions as string[] | undefined) ?? [], + ...(isStringRecord(raw.env) ? { env: raw.env } : {}), + ...(isRecord(raw.initialization) + ? { initialization: raw.initialization as Record<string, unknown> } + : {}), + ...(disabled ? { disabled: true } : {}), + }; + return server; +} + +function validateLsp( + raw: unknown, + path: string, + errors: ConfigError[], +): Record<string, LspServerConfig> | undefined { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return undefined; + } + const result: Record<string, LspServerConfig> = {}; + for (const [id, value] of Object.entries(raw)) { + const server = validateLspServer(value, `${path}.${id}`, errors); + if (server) result[id] = server; + } + return Object.keys(result).length > 0 ? result : undefined; +} + export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } { const errors: ConfigError[] = []; @@ -125,9 +223,16 @@ export function validateConfig(raw: unknown): { config: DispatchConfig; errors: } } + // lsp (optional) + let lsp: Record<string, LspServerConfig> | undefined; + if (raw.lsp !== undefined) { + lsp = validateLsp(raw.lsp, "lsp", errors); + } + const config: DispatchConfig = { permissions, ...(keys !== undefined && { keys }), + ...(lsp !== undefined && { lsp }), }; return { config, errors }; diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 432e403..7818222 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -483,6 +483,23 @@ export const ANTHROPIC_MODELS_FALLBACK = [ "claude-3-opus-20240229", ]; +/** + * Pick the model to use for a Claude "wake" probe from a list of model ids. + * + * The probe only needs a small/cheap model to register activity against the + * subscription, so we target Haiku. Model ids change over time (the old + * hardcoded `claude-3-5-haiku-20241022` started returning HTTP 404), so the + * caller fetches the live list from `/v1/models` and we resolve by substring. + * + * Selection: the FIRST id whose name contains "haiku" (case-insensitive). + * Anthropic's `/v1/models` returns models newest-first, so first-match + * naturally prefers the newest Haiku. Returns `null` when nothing matches so + * the caller can surface a clear error instead of probing an invalid model. + */ +export function selectHaikuModel(models: string[]): string | null { + return models.find((id) => id.toLowerCase().includes("haiku")) ?? null; +} + // ─── Credential Validation ──────────────────────────────────── export interface ClaudeProfile { diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index 46fa5b6..5221dc6 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -24,6 +24,7 @@ export { refreshAccountCredentials, refreshAccountCredentialsAsync, SYSTEM_IDENTITY, + selectHaikuModel, validateAccountCredentials, } from "./claude.js"; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5e6f9a..08b426f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,18 @@ export { logStreamEvent, } from "./llm/debug-logger.js"; export { createProvider } from "./llm/provider.js"; +// LSP (Language Server Protocol) +export { + createLspClient, + type Diagnostic as LspDiagnostic, + type LspClient, + LspManager, + type LspServerHandle, + pretty as prettyDiagnostic, + type ResolvedLspServer, + report as reportDiagnostics, + resolveServersFromConfig, +} from "./lsp/index.js"; // Models export { getModelsCatalog, @@ -88,6 +100,7 @@ export { export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; // Tools export { createListFilesTool } from "./tools/list-files.js"; +export { createLspTool, type LspToolContext } from "./tools/lsp.js"; export { createReadFileTool } from "./tools/read-file.js"; export { createReadFileSliceTool } from "./tools/read-file-slice.js"; export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js"; @@ -112,7 +125,7 @@ export { export { createTaskListTool, TaskList, TODO_DESCRIPTION } from "./tools/task-list.js"; export { clearSpillForTab } from "./tools/truncate.js"; export { createWebSearchTool } from "./tools/web-search.js"; -export { createWriteFileTool } from "./tools/write-file.js"; +export { type AfterWriteHook, createWriteFileTool } from "./tools/write-file.js"; export { BackgroundTranscriptStore, createYoutubeTranscribeTool, diff --git a/packages/core/src/lsp/client.ts b/packages/core/src/lsp/client.ts new file mode 100644 index 0000000..da0c916 --- /dev/null +++ b/packages/core/src/lsp/client.ts @@ -0,0 +1,658 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { extname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + createMessageConnection, + type MessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { languageIdForExtension } from "./language.js"; + +export type { Diagnostic } from "vscode-languageserver-types"; + +// ─── Timing constants (mirrors opencode) ───────────────────────── +const DIAGNOSTICS_DEBOUNCE_MS = 150; +const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000; +const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000; +const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000; +const INITIALIZE_TIMEOUT_MS = 45_000; + +// ─── LSP spec constants ────────────────────────────────────────── +const FILE_CHANGE_CREATED = 1; +const FILE_CHANGE_CHANGED = 2; +const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2; + +/** + * A live spawned language-server process plus the `initializationOptions` to + * hand it. Produced by the server-spawning layer (`server.ts`) and consumed by + * `createLspClient`. + */ +export interface LspServerHandle { + process: ChildProcessWithoutNullStreams; + initialization?: Record<string, unknown>; +} + +interface ServerCapabilities { + textDocumentSync?: number | { change?: number }; + diagnosticProvider?: unknown; + [key: string]: unknown; +} + +interface DiagnosticRequestResult { + handled: boolean; + matched: boolean; + byFile: Map<string, Diagnostic[]>; +} + +interface CapabilityRegistration { + id: string; + method: string; + registerOptions?: { + identifier?: string; + workspaceDiagnostics?: boolean; + }; +} + +type DocumentDiagnosticReport = { + items?: Diagnostic[]; + relatedDocuments?: Record<string, DocumentDiagnosticReport>; +}; + +type WorkspaceDiagnosticReport = { + items?: { uri?: string; items?: Diagnostic[] }[]; +}; + +/** Public shape of a connected LSP client. */ +export interface LspClient { + readonly serverID: string; + readonly root: string; + readonly connection: MessageConnection; + /** + * Open (or re-sync) a file with the server. Returns the document version + * sent — pass it to `waitForDiagnostics` to wait for diagnostics matching + * this exact sync. + */ + notifyOpen(path: string): Promise<number>; + /** Snapshot of all known diagnostics keyed by absolute file path. */ + readonly diagnostics: Map<string, Diagnostic[]>; + /** Wait until diagnostics for `path` settle (push and/or pull). */ + waitForDiagnostics(request: { + path: string; + version: number; + mode?: "document" | "full"; + after?: number; + }): Promise<void>; + /** Generic LSP request passthrough (hover, definition, references, …). */ + request<T = unknown>(method: string, params: unknown): Promise<T | null>; + /** Shut the connection and child process down. */ + shutdown(): Promise<void>; +} + +function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { + return new Promise<T>((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error(`LSP request timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolvePromise(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +function getFilePath(uri: string): string | undefined { + if (!uri.startsWith("file://")) return undefined; + return fileURLToPath(uri); +} + +function getSyncKind(capabilities?: ServerCapabilities): number | undefined { + if (!capabilities) return undefined; + const sync = capabilities.textDocumentSync; + if (typeof sync === "number") return sync; + return sync?.change; +} + +function endPosition(text: string) { + const lines = text.split(/\r\n|\r|\n/); + return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 }; +} + +function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] { + const seen = new Set<string>(); + return items.filter((item) => { + const key = JSON.stringify({ + code: item.code, + severity: item.severity, + message: item.message, + source: item.source, + range: item.range, + }); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function configurationValue(settings: unknown, section?: string): unknown { + if (!section) return settings ?? null; + const result = section.split(".").reduce<unknown>((acc, key) => { + if (!acc || typeof acc !== "object" || !(key in acc)) return undefined; + return (acc as Record<string, unknown>)[key]; + }, settings); + return result ?? null; +} + +/** + * Create and initialize an LSP client over a spawned server's stdio. + * + * Performs the full `initialize`/`initialized` handshake (with a 45s timeout), + * wires push (`textDocument/publishDiagnostics`) and pull + * (`textDocument/diagnostic`, `workspace/diagnostic`) diagnostics, answers the + * `workspace/configuration`, `workspaceFolders`, and capability-registration + * requests servers commonly make, and returns a small client surface used by + * the manager and tools. Plain-TypeScript port of opencode's `lsp/client.ts`. + */ +export async function createLspClient(input: { + serverID: string; + server: LspServerHandle; + root: string; + directory: string; +}): Promise<LspClient> { + const { serverID, server, root, directory } = input; + + const connection = createMessageConnection( + new StreamMessageReader(server.process.stdout), + new StreamMessageWriter(server.process.stdin), + ); + + // Server stderr is routine for many tools (luau-lsp logs sourcemap status + // there). Keep it quiet unless debugging. + server.process.stderr?.on("data", () => { + /* swallowed — see opencode: stderr is mostly informational */ + }); + + // ─── Connection state ─── + const pushDiagnostics = new Map<string, Diagnostic[]>(); + const pullDiagnostics = new Map<string, Diagnostic[]>(); + const published = new Map<string, { at: number; version?: number }>(); + const diagnosticRegistrations = new Map<string, CapabilityRegistration>(); + const registrationListeners = new Set<() => void>(); + const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>(); + const files: Record<string, { version: number; text: string }> = {}; + + const mergedDiagnostics = (filePath: string) => + dedupeDiagnostics([ + ...(pushDiagnostics.get(filePath) ?? []), + ...(pullDiagnostics.get(filePath) ?? []), + ]); + const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { + pushDiagnostics.set(filePath, next); + for (const listener of diagnosticListeners) listener({ path: filePath, serverID }); + }; + const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { + pullDiagnostics.set(filePath, next); + }; + const emitRegistrationChange = () => { + for (const listener of [...registrationListeners]) listener(); + }; + + // ─── Notification / request handlers ─── + connection.onNotification( + "textDocument/publishDiagnostics", + (params: { uri: string; diagnostics: Diagnostic[]; version?: number }) => { + const filePath = getFilePath(params.uri); + if (!filePath) return; + published.set(filePath, { + at: Date.now(), + version: typeof params.version === "number" ? params.version : undefined, + }); + updatePushDiagnostics(filePath, params.diagnostics); + }, + ); + connection.onRequest("window/workDoneProgress/create", () => null); + connection.onRequest("workspace/configuration", (params: { items?: { section?: string }[] }) => { + const items = params.items ?? []; + return items.map((item) => configurationValue(server.initialization, item.section)); + }); + connection.onRequest( + "client/registerCapability", + (params: { registrations?: CapabilityRegistration[] }) => { + const registrations = params.registrations ?? []; + let changed = false; + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue; + diagnosticRegistrations.set(registration.id, registration); + changed = true; + } + if (changed) emitRegistrationChange(); + return null; + }, + ); + connection.onRequest( + "client/unregisterCapability", + (params: { unregisterations?: { id: string; method: string }[] }) => { + const registrations = params.unregisterations ?? []; + let changed = false; + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue; + diagnosticRegistrations.delete(registration.id); + changed = true; + } + if (changed) emitRegistrationChange(); + return null; + }, + ); + connection.onRequest("workspace/workspaceFolders", () => [ + { name: "workspace", uri: pathToFileURL(root).href }, + ]); + connection.onRequest("workspace/diagnostic/refresh", () => null); + connection.listen(); + + // ─── Initialize handshake ─── + const initialized = await withTimeout( + connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { + rootUri: pathToFileURL(root).href, + processId: server.process.pid ?? null, + workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }], + initializationOptions: { ...server.initialization }, + capabilities: { + window: { workDoneProgress: true }, + workspace: { + configuration: true, + didChangeWatchedFiles: { dynamicRegistration: true }, + diagnostics: { refreshSupport: false }, + }, + textDocument: { + synchronization: { didOpen: true, didChange: true }, + diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, + publishDiagnostics: { versionSupport: false }, + }, + }, + }), + INITIALIZE_TIMEOUT_MS, + ); + + const syncKind = getSyncKind(initialized.capabilities); + const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider); + + await connection.sendNotification("initialized", {}); + if (server.initialization) { + await connection.sendNotification("workspace/didChangeConfiguration", { + settings: server.initialization, + }); + } + + // ─── Pull-diagnostics helpers ─── + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { + const handled = results.some((r) => r.handled); + const matched = results.some((r) => r.matched); + if (!handled) return { handled: false, matched: false }; + + const merged = new Map<string, Diagnostic[]>(); + for (const result of results) { + for (const [target, items] of result.byFile.entries()) { + merged.set(target, (merged.get(target) ?? []).concat(items)); + } + } + if (matched && !merged.has(filePath)) merged.set(filePath, []); + for (const [target, items] of merged.entries()) { + updatePullDiagnostics(target, dedupeDiagnostics(items)); + } + return { handled, matched }; + }; + + async function requestDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise<DiagnosticRequestResult> { + const report = await withTimeout( + connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", { + ...(identifier ? { identifier } : {}), + textDocument: { uri: pathToFileURL(filePath).href }, + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null); + const empty: DiagnosticRequestResult = { + handled: false, + matched: false, + byFile: new Map(), + }; + if (!report) return empty; + + const byFile = new Map<string, Diagnostic[]>(); + const push = (target: string, items: Diagnostic[]) => { + byFile.set(target, (byFile.get(target) ?? []).concat(items)); + }; + let handled = false; + let matched = false; + if (Array.isArray(report.items)) { + push(filePath, report.items); + handled = true; + matched = true; + } + for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { + const relatedPath = getFilePath(uri); + if (!relatedPath || !Array.isArray(related.items)) continue; + push(relatedPath, related.items); + handled = true; + matched = matched || relatedPath === filePath; + } + return { handled, matched, byFile }; + } + + async function requestWorkspaceDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise<DiagnosticRequestResult> { + const report = await withTimeout( + connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", { + ...(identifier ? { identifier } : {}), + previousResultIds: [], + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null); + if (!report) return { handled: false, matched: false, byFile: new Map() }; + + const byFile = new Map<string, Diagnostic[]>(); + let matched = false; + for (const item of report.items ?? []) { + const relatedPath = item.uri ? getFilePath(item.uri) : undefined; + if (!relatedPath || !Array.isArray(item.items)) continue; + byFile.set(relatedPath, (byFile.get(relatedPath) ?? []).concat(item.items)); + matched = matched || relatedPath === filePath; + } + return { handled: true, matched, byFile }; + } + + function documentPullState() { + const documentRegistrations = [...diagnosticRegistrations.values()].filter( + (r) => r.registerOptions?.workspaceDiagnostics !== true, + ); + return { + documentIdentifiers: [ + ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), + ], + supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, + }; + } + + function workspacePullState() { + const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( + (r) => r.registerOptions?.workspaceDiagnostics === true, + ); + return { + workspaceIdentifiers: [ + ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), + ], + supported: workspaceRegistrations.length > 0, + }; + } + + const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => + results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0); + + async function requestDiagnostics( + filePath: string, + requests: Promise<DiagnosticRequestResult>[], + done: (results: DiagnosticRequestResult[]) => boolean, + ) { + if (!requests.length) return { handled: false, matched: false }; + const results: DiagnosticRequestResult[] = []; + return new Promise<{ handled: boolean; matched: boolean }>((resolvePromise) => { + let pending = requests.length; + let resolved = false; + const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { + if (resolved) return; + if (!force && !done(results)) return; + resolved = true; + resolvePromise(merged); + }; + for (const request of requests) { + request.then((result) => { + results.push(result); + pending -= 1; + const merged = mergeResults(filePath, results); + finish(merged); + if (pending === 0) finish(merged, true); + }); + } + }); + } + + async function requestDocumentDiagnostics(filePath: string) { + const state = documentPullState(); + if (!state.supported) return { handled: false, matched: false }; + return requestDiagnostics( + filePath, + [ + requestDiagnosticReport(filePath), + ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), + ], + (results) => hasCurrentFileDiagnostics(filePath, results), + ); + } + + async function requestFullDiagnostics(filePath: string) { + const documentState = documentPullState(); + const workspaceState = workspacePullState(); + if (!documentState.supported && !workspaceState.supported) { + return { handled: false, matched: false }; + } + return mergeResults( + filePath, + await Promise.all([ + ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), + ...documentState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), + ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), + ...workspaceState.workspaceIdentifiers.map((id) => + requestWorkspaceDiagnosticReport(filePath, id), + ), + ]), + ); + } + + function waitForRegistrationChange(timeout: number) { + if (timeout <= 0) return Promise.resolve(false); + return new Promise<boolean>((resolvePromise) => { + let finished = false; + let timer: ReturnType<typeof setTimeout> | undefined; + const finish = (result: boolean) => { + if (finished) return; + finished = true; + if (timer) clearTimeout(timer); + registrationListeners.delete(listener); + resolvePromise(result); + }; + const listener = () => finish(true); + registrationListeners.add(listener); + timer = setTimeout(() => finish(false), timeout); + }); + } + + function waitForFreshPush(request: { + path: string; + version: number; + after: number; + timeout: number; + }) { + if (request.timeout <= 0) return Promise.resolve(false); + return new Promise<boolean>((resolvePromise) => { + let finished = false; + let debounceTimer: ReturnType<typeof setTimeout> | undefined; + let timeoutTimer: ReturnType<typeof setTimeout> | undefined; + let unsub: (() => void) | undefined; + const finish = (result: boolean) => { + if (finished) return; + finished = true; + if (debounceTimer) clearTimeout(debounceTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + unsub?.(); + resolvePromise(result); + }; + const schedule = () => { + const hit = published.get(request.path); + if (!hit) return; + if (typeof hit.version === "number" && hit.version !== request.version) return; + if (hit.at < request.after && hit.version !== request.version) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout( + () => finish(true), + Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)), + ); + }; + timeoutTimer = setTimeout(() => finish(false), request.timeout); + const listener = (event: { path: string; serverID: string }) => { + if (event.path !== request.path || event.serverID !== serverID) return; + schedule(); + }; + diagnosticListeners.add(listener); + unsub = () => diagnosticListeners.delete(listener); + schedule(); + }); + } + + async function waitForDocumentDiagnostics(request: { + path: string; + version: number; + after?: number; + }) { + const startedAt = request.after ?? Date.now(); + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + }); + while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + const result = await requestDocumentDiagnostics(request.path); + if (result.matched) return; + const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt); + if (remaining <= 0) return; + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout")), + waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), + ]); + if (next !== "registration") return; + } + } + + async function waitForFullDiagnostics(request: { + path: string; + version: number; + after?: number; + }) { + const startedAt = request.after ?? Date.now(); + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + }); + while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + const result = await requestFullDiagnostics(request.path); + if (result.handled || result.matched) return; + const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt); + if (remaining <= 0) return; + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout")), + waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), + ]); + if (next !== "registration") return; + } + } + + const normalize = (p: string) => (isAbsolute(p) ? p : resolve(directory, p)); + + // ─── Public surface ─── + const client: LspClient = { + serverID, + root, + connection, + async notifyOpen(path: string) { + const filePath = normalize(path); + const text = await readFile(filePath, "utf8"); + const languageId = languageIdForExtension(extname(filePath)); + const uri = pathToFileURL(filePath).href; + const document = files[filePath]; + + if (document !== undefined) { + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri, type: FILE_CHANGE_CHANGED }], + }); + const next = document.version + 1; + files[filePath] = { version: next, text }; + await connection.sendNotification("textDocument/didChange", { + textDocument: { uri, version: next }, + contentChanges: + syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: endPosition(document.text), + }, + text, + }, + ] + : [{ text }], + }); + return next; + } + + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri, type: FILE_CHANGE_CREATED }], + }); + pushDiagnostics.delete(filePath); + pullDiagnostics.delete(filePath); + await connection.sendNotification("textDocument/didOpen", { + textDocument: { uri, languageId, version: 0, text }, + }); + files[filePath] = { version: 0, text }; + return 0; + }, + get diagnostics() { + const result = new Map<string, Diagnostic[]>(); + for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { + result.set(key, mergedDiagnostics(key)); + } + return result; + }, + async waitForDiagnostics(request) { + const normalizedPath = normalize(request.path); + if (request.mode === "document") { + await waitForDocumentDiagnostics({ + path: normalizedPath, + version: request.version, + after: request.after, + }); + return; + } + await waitForFullDiagnostics({ + path: normalizedPath, + version: request.version, + after: request.after, + }); + }, + async request<T = unknown>(method: string, params: unknown): Promise<T | null> { + return connection.sendRequest<T>(method, params).catch(() => null); + }, + async shutdown() { + try { + connection.end(); + connection.dispose(); + } catch { + /* connection may already be closed */ + } + server.process.kill(); + }, + }; + + return client; +} diff --git a/packages/core/src/lsp/diagnostic.ts b/packages/core/src/lsp/diagnostic.ts new file mode 100644 index 0000000..1ad4d0f --- /dev/null +++ b/packages/core/src/lsp/diagnostic.ts @@ -0,0 +1,41 @@ +import type { Diagnostic } from "vscode-languageserver-types"; + +/** + * Diagnostic formatting helpers. Ported from opencode's `lsp/diagnostic.ts`. + * + * LSP positions are 0-based on the wire; we render them 1-based (editor-style) + * so they line up with what `read_file` shows and what editors report. + */ + +/** Max diagnostics rendered per file before truncating with a "… and N more". */ +const MAX_PER_FILE = 20; + +const SEVERITY_LABEL: Record<number, string> = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", +}; + +/** Render a single diagnostic as `SEVERITY [line:col] message` (1-based). */ +export function pretty(diagnostic: Diagnostic): string { + const severity = SEVERITY_LABEL[diagnostic.severity ?? 1] ?? "ERROR"; + const line = diagnostic.range.start.line + 1; + const col = diagnostic.range.start.character + 1; + return `${severity} [${line}:${col}] ${diagnostic.message}`; +} + +/** + * Build a `<diagnostics file="…">` block for a file's ERROR-severity + * diagnostics, or `""` when there are none. Errors only — warnings/info/hints + * are intentionally omitted so the model is nudged toward the things that + * actually break the build (matching opencode's behavior). + */ +export function report(file: string, issues: Diagnostic[]): string { + const errors = issues.filter((item) => item.severity === 1); + if (errors.length === 0) return ""; + const limited = errors.slice(0, MAX_PER_FILE); + const more = errors.length - MAX_PER_FILE; + const suffix = more > 0 ? `\n... and ${more} more` : ""; + return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`; +} diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts new file mode 100644 index 0000000..fd43c2f --- /dev/null +++ b/packages/core/src/lsp/index.ts @@ -0,0 +1,18 @@ +// LSP (Language Server Protocol) integration. +// +// Config-driven only: servers are declared in `dispatch.toml`'s `[lsp.<id>]` +// block (see `LspServerConfig` in `../types`). There is no builtin server +// registry and no auto-download. The primary model-facing surface is +// diagnostics-on-write (the host passes a write hook that calls `touchFile` + +// `report`); an on-demand `lsp` tool exposes hover/definition/references too. + +export { + createLspClient, + type Diagnostic, + type LspClient, + type LspServerHandle, +} from "./client.js"; +export { pretty, report } from "./diagnostic.js"; +export { LANGUAGE_EXTENSIONS, languageIdForExtension } from "./language.js"; +export { LspManager } from "./manager.js"; +export { type ResolvedLspServer, resolveServersFromConfig } from "./server.js"; diff --git a/packages/core/src/lsp/language.ts b/packages/core/src/lsp/language.ts new file mode 100644 index 0000000..3e9fe68 --- /dev/null +++ b/packages/core/src/lsp/language.ts @@ -0,0 +1,72 @@ +/** + * File-extension → LSP `languageId` map. + * + * The LSP `textDocument/didOpen` notification carries a `languageId` string + * that tells the server how to parse the document. This table is a trimmed + * port of opencode's `lsp/language.ts`, with one critical addition for this + * project: `.luau` → `"luau"`. Roblox Luau sources use the `.luau` extension, + * which standard Lua tooling does not recognise — luau-lsp expects the + * `"luau"` languageId. + * + * Extensions are looked up with their leading dot (e.g. `".luau"`). Unknown + * extensions fall back to `"plaintext"` at the call site. + */ +export const LANGUAGE_EXTENSIONS: Record<string, string> = { + // Luau (Roblox) — the reason this module exists. Keep first for visibility. + ".luau": "luau", + ".lua": "lua", + // A pragmatic subset of common languages, mirroring opencode's table so a + // user can point an arbitrary LSP server at this codebase and have the + // right languageId reported. + ".c": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".h": "c", + ".hpp": "cpp", + ".cs": "csharp", + ".css": "css", + ".dart": "dart", + ".go": "go", + ".html": "html", + ".htm": "html", + ".java": "java", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".jsonc": "jsonc", + ".kt": "kotlin", + ".kts": "kotlin", + ".md": "markdown", + ".markdown": "markdown", + ".php": "php", + ".py": "python", + ".rb": "ruby", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".sql": "sql", + ".svelte": "svelte", + ".swift": "swift", + ".toml": "toml", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".vue": "vue", + ".xml": "xml", + ".yaml": "yaml", + ".yml": "yaml", + ".zig": "zig", +}; + +/** + * Resolve the LSP `languageId` for a file path's extension, falling back to + * `"plaintext"` when the extension is unknown. + */ +export function languageIdForExtension(extension: string): string { + return LANGUAGE_EXTENSIONS[extension] ?? "plaintext"; +} diff --git a/packages/core/src/lsp/manager.ts b/packages/core/src/lsp/manager.ts new file mode 100644 index 0000000..db8b68e --- /dev/null +++ b/packages/core/src/lsp/manager.ts @@ -0,0 +1,220 @@ +import { extname } from "node:path"; +import { createLspClient, type Diagnostic, type LspClient } from "./client.js"; +import type { ResolvedLspServer } from "./server.js"; + +/** + * Process-wide owner of LSP client lifecycles. + * + * Clients are keyed by `root + serverID` and spawned lazily on the first file + * that matches a server's extensions, then reused. Concurrent spawns for the + * same key are de-duplicated via an in-flight map, and servers that fail to + * start are remembered in `broken` so we don't spawn-spam. Modeled on + * opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery. + * + * The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a + * tab's working-directory config (`resolveServersFromConfig`) and pass them in + * alongside the `root`. This keeps per-working-directory config out of the + * manager while letting it own all the long-lived processes for the process. + */ +export class LspManager { + private clients = new Map<string, LspClient>(); + private spawning = new Map<string, Promise<LspClient | undefined>>(); + private broken = new Set<string>(); + + private key(root: string, serverID: string): string { + return `${root}\u0000${serverID}`; + } + + private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] { + const extension = extname(file) || file; + return servers.filter( + (server) => server.extensions.length === 0 || server.extensions.includes(extension), + ); + } + + /** + * True if any provided server is configured to attach to this file's + * extension (regardless of whether it has spawned yet). Used to decide + * whether an LSP operation is even applicable to a file. + */ + hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean { + return this.serversForFile(file, servers).length > 0; + } + + /** + * Get (spawning if needed) all clients that should attach to `file` at + * `root`. Spawn failures are swallowed (logged via `broken`) and simply + * yield fewer clients — callers degrade gracefully to "no diagnostics". + */ + async getClients(input: { + file: string; + root: string; + servers: ResolvedLspServer[]; + }): Promise<LspClient[]> { + const { file, root, servers } = input; + const matching = this.serversForFile(file, servers); + const result: LspClient[] = []; + + for (const server of matching) { + const key = this.key(root, server.id); + if (this.broken.has(key)) continue; + + const existing = this.clients.get(key); + if (existing) { + result.push(existing); + continue; + } + + const inflight = this.spawning.get(key); + if (inflight) { + const client = await inflight; + if (client) result.push(client); + continue; + } + + const task = this.spawn(server, root, key); + this.spawning.set(key, task); + task.finally(() => { + if (this.spawning.get(key) === task) this.spawning.delete(key); + }); + const client = await task; + if (client) result.push(client); + } + + return result; + } + + private async spawn( + server: ResolvedLspServer, + root: string, + key: string, + ): Promise<LspClient | undefined> { + let handle: ReturnType<ResolvedLspServer["spawn"]>; + try { + handle = server.spawn(root); + } catch (err) { + this.broken.add(key); + console.warn( + `dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`, + ); + return undefined; + } + + // A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH) + // emits `error` on the child process; mark broken so we don't retry it. + handle.process.on("error", (err) => { + this.broken.add(key); + console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`); + }); + + try { + const client = await createLspClient({ + serverID: server.id, + server: handle, + root, + directory: root, + }); + // A racing caller may have created the same client; prefer the + // existing one and discard ours. + const existing = this.clients.get(key); + if (existing) { + await client.shutdown(); + return existing; + } + this.clients.set(key, client); + return client; + } catch (err) { + this.broken.add(key); + try { + handle.process.kill(); + } catch { + /* already dead */ + } + console.warn( + `dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`, + ); + return undefined; + } + } + + /** + * Open/sync a file with its clients and (optionally) wait for diagnostics + * to settle. `mode: "document"` waits for the file's own diagnostics; + * `"full"` also waits on workspace diagnostics; omitted just syncs. + */ + async touchFile(input: { + file: string; + root: string; + servers: ResolvedLspServer[]; + mode?: "document" | "full"; + }): Promise<void> { + const clients = await this.getClients(input); + await Promise.all( + clients.map(async (client) => { + const after = Date.now(); + const version = await client.notifyOpen(input.file); + if (!input.mode) return; + await client.waitForDiagnostics({ + path: input.file, + version, + mode: input.mode, + after, + }); + }), + ).catch((err) => { + console.warn( + `dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } + + /** + * Merged diagnostics for a single file across all of its clients, keyed by + * absolute file path. Includes related-file diagnostics a client surfaced + * (e.g. workspace pulls), so the result map may contain more than `file`. + */ + getDiagnostics(input: { + root: string; + servers: ResolvedLspServer[]; + file: string; + }): Record<string, Diagnostic[]> { + const results: Record<string, Diagnostic[]> = {}; + const matching = this.serversForFile(input.file, input.servers); + for (const server of matching) { + const client = this.clients.get(this.key(input.root, server.id)); + if (!client) continue; + for (const [path, diags] of client.diagnostics.entries()) { + results[path] = (results[path] ?? []).concat(diags); + } + } + return results; + } + + /** + * Run a positional LSP request (hover/definition/references/etc.) against + * every client for the file and flatten the (non-null) results. `line`/ + * `character` are 0-based here — the caller converts from editor 1-based. + */ + async request(input: { + file: string; + root: string; + servers: ResolvedLspServer[]; + method: string; + params: Record<string, unknown>; + }): Promise<unknown[]> { + const clients = await this.getClients(input); + const results = await Promise.all( + clients.map((client) => client.request(input.method, input.params)), + ); + return results.filter((r) => r !== null && r !== undefined); + } + + /** Shut down every live client and clear all state. */ + async shutdownAll(): Promise<void> { + const clients = [...this.clients.values()]; + this.clients.clear(); + this.spawning.clear(); + this.broken.clear(); + await Promise.all(clients.map((client) => client.shutdown().catch(() => {}))); + } +} diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts new file mode 100644 index 0000000..1fb002e --- /dev/null +++ b/packages/core/src/lsp/server.ts @@ -0,0 +1,68 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import type { LspServerConfig } from "../types/index.js"; +import type { LspServerHandle } from "./client.js"; + +/** + * A resolved, ready-to-spawn LSP server derived from a `dispatch.toml` + * `[lsp.<id>]` entry. Config-driven only — dispatch ships no builtin server + * registry and performs no auto-download (unlike opencode). The declared + * executable (`command[0]`) must already be on PATH. + */ +export interface ResolvedLspServer { + id: string; + /** Extensions (with leading dot) this server attaches to, e.g. `".luau"`. */ + extensions: string[]; + /** Launch the server over stdio rooted at `root`. */ + spawn(root: string): LspServerHandle; +} + +/** + * Spawn a child process for an LSP server over stdio. Inherits `process.env` + * (so a PATH-resident `rojo` is visible to luau-lsp's sourcemap autogenerate) + * and merges any `env` from the server config on top. + */ +function spawnServer( + command: string[], + cwd: string, + env: Record<string, string> | undefined, + initialization: Record<string, unknown> | undefined, +): LspServerHandle { + const [cmd, ...args] = command; + if (!cmd) throw new Error("LSP server command is empty"); + const proc = spawn(cmd, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + return { + process: proc, + ...(initialization ? { initialization } : {}), + }; +} + +/** + * Turn the parsed `dispatch.toml` `lsp` block into a list of spawnable + * servers. Disabled entries are dropped. Entries with no `command`/`extensions` + * are skipped defensively (the config validator already enforces these, but we + * guard here too so a hand-built config object can't crash the manager). + */ +export function resolveServersFromConfig( + lsp: Record<string, LspServerConfig> | undefined, +): ResolvedLspServer[] { + if (!lsp) return []; + const servers: ResolvedLspServer[] = []; + for (const [id, entry] of Object.entries(lsp)) { + if (entry.disabled) continue; + if (!entry.command || entry.command.length === 0) continue; + if (!entry.extensions || entry.extensions.length === 0) continue; + const command = entry.command; + const env = entry.env; + const initialization = entry.initialization; + servers.push({ + id, + extensions: entry.extensions, + spawn: (root: string) => spawnServer(command, root, env, initialization), + }); + } + return servers; +} diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts new file mode 100644 index 0000000..3842cd4 --- /dev/null +++ b/packages/core/src/tools/lsp.ts @@ -0,0 +1,135 @@ +import { isAbsolute, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { z } from "zod"; +import { report as reportDiagnostics } from "../lsp/diagnostic.js"; +import type { LspManager } from "../lsp/manager.js"; +import type { ResolvedLspServer } from "../lsp/server.js"; +import type { ToolDefinition } from "../types/index.js"; + +const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const; +type Operation = (typeof OPERATIONS)[number]; + +/** + * Context the LSP tool needs from the host: the live manager, the tab's + * effective working directory (used as the LSP `root`), and the servers + * resolved from that directory's `dispatch.toml`. + */ +export interface LspToolContext { + manager: LspManager; + workingDirectory: string; + servers: ResolvedLspServer[]; +} + +/** + * On-demand LSP query tool. Exposes diagnostics plus the navigation + * capabilities (hover/definition/references/documentSymbol) for a file at a + * position. Gated behind `perm_lsp` by the host. + * + * Coordinates are **1-based** in this tool's API (editor-style, matching what + * `read_file` shows); they are converted to the LSP wire's 0-based positions + * before the request. + */ +export function createLspTool(getContext: () => LspToolContext): ToolDefinition { + return { + name: "lsp", + description: + "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " + + "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " + + "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " + + "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " + + "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.", + parameters: z.object({ + operation: z.enum(OPERATIONS).describe("The LSP operation to perform"), + path: z.string().describe("Path to the file, relative to the working directory"), + line: z + .number() + .int() + .min(1) + .optional() + .describe( + "Line number, 1-based (as shown in editors). Required for hover/definition/references.", + ), + character: z + .number() + .int() + .min(1) + .optional() + .describe( + "Character/column, 1-based (as shown in editors). Required for hover/definition/references.", + ), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const { manager, workingDirectory, servers } = getContext(); + const operation = args.operation as Operation; + const pathArg = typeof args.path === "string" ? args.path : ""; + if (!pathArg) return "Error: 'path' is required."; + + const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg); + + if (servers.length === 0) { + return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml."; + } + if (!manager.hasServerForFile(file, servers)) { + return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`; + } + + // Sync the file so the server has current content, then act. + await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" }); + + if (operation === "diagnostics") { + const all = manager.getDiagnostics({ root: workingDirectory, servers, file }); + const block = reportDiagnostics(file, all[file] ?? []); + return block || `No errors reported for ${pathArg}.`; + } + + if (operation === "documentSymbol") { + const uri = pathToFileURL(file).href; + const results = await manager.request({ + file, + root: workingDirectory, + servers, + method: "textDocument/documentSymbol", + params: { textDocument: { uri } }, + }); + const flat = results.flat().filter(Boolean); + return flat.length === 0 + ? `No symbols found in ${pathArg}.` + : JSON.stringify(flat, null, 2); + } + + // Positional operations need line + character. + const line = typeof args.line === "number" ? Math.floor(args.line) : undefined; + const character = typeof args.character === "number" ? Math.floor(args.character) : undefined; + if (line === undefined || character === undefined) { + return `Error: '${operation}' requires both 'line' and 'character' (1-based).`; + } + + const uri = pathToFileURL(file).href; + // Convert editor 1-based → LSP wire 0-based. + const position = { line: line - 1, character: character - 1 }; + const method = + operation === "hover" + ? "textDocument/hover" + : operation === "definition" + ? "textDocument/definition" + : "textDocument/references"; + const params: Record<string, unknown> = { + textDocument: { uri }, + position, + ...(operation === "references" ? { context: { includeDeclaration: true } } : {}), + }; + + const results = await manager.request({ + file, + root: workingDirectory, + servers, + method, + params, + }); + const flat = results.flat().filter(Boolean); + return flat.length === 0 + ? `No results found for ${operation} at ${pathArg}:${line}:${character}.` + : JSON.stringify(flat, null, 2); + }, + }; +} diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts index eb86b7e..eae6bfa 100644 --- a/packages/core/src/tools/send-to-tab.ts +++ b/packages/core/src/tools/send-to-tab.ts @@ -44,6 +44,13 @@ export interface SendToTabCallbacks { /** The calling tab's own id + handle — used to block self-sends and to * stamp provenance onto the delivered message. */ self: { id: string; handle: string }; + /** + * Whether THIS calling tab also has the `read_tab` tool granted. The + * tab-messaging permissions are split, so a tab can hold `send_to_tab` + * without `read_tab`. When false, the tool must NOT tell the agent to use + * `read_tab` (it doesn't have it) — replies only arrive on their own. + */ + canReadTab: boolean; } /** Render the "available tabs" hint shared by the none/ambiguous branches. */ @@ -54,6 +61,19 @@ function renderOpenHandles(handles: Array<{ handle: string; title: string }>): s } export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition { + // The `read_tab` follow-up hint is only truthful when this tab actually + // holds the `read_tab` tool (the permissions are split). When it doesn't, + // the only honest guidance is that a reply will wake it as a new message — never tell + // the agent to call a tool it wasn't granted. + const waitLine = callbacks.canReadTab + ? "money. If the target replies it will WAKE you with a new message in a later turn; you" + : "money. If the target replies it will WAKE you with a new message in a later turn."; + const readTabLine = callbacks.canReadTab + ? ["can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other"] + : []; + const keepGoingLine = callbacks.canReadTab + ? "work to do, keep going; if you are ONLY waiting for the reply, end your turn now." + : "If you have other work to do, keep going; if you are ONLY waiting for the reply, end your turn now."; return { name: "send_to_tab", description: [ @@ -64,9 +84,14 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti " - If the target tab is idle, your message WAKES it and starts a new turn.", "", "This is fire-and-forget: it returns immediately and does NOT wait for a reply.", - "Use the 'read_tab' tool with the same ID later to read the target's latest response.", + "Do NOT sleep, poll, or run shell commands to wait for a reply — that wastes turns and", + waitLine, + ...readTabLine, + keepGoingLine, "", - "Your tab ID is auto-added to the top of the message so the recipient can reply to you.", + "Your tab ID is auto-added to the top of the message so the recipient knows who to reply", + "to. The recipient must use this same 'send_to_tab' tool (addressed to your ID) to answer;", + "a plain text response reaches only their own user, not you.", "IDs are git-style prefixes: pass any length that uniquely identifies the target (min 4 chars).", "If the ID is ambiguous you'll be asked to add a character.", ].join("\n"), @@ -117,8 +142,18 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti } // Stamp provenance so the recipient (and the watching user) can see - // which tab the message came from and reply back via its handle. - const delivered = `[message from tab ${callbacks.self.handle}]\n\n${message}`; + // which tab the message came from and how to reply. The header makes + // clear this is a PEER AGENT, not the recipient's own user, and the + // footer states the reply contract: a reply (only if warranted) must + // go back through `send_to_tab`, since a plain text answer reaches + // only the recipient's own user — not this sender. + const delivered = [ + `[message from tab ${callbacks.self.handle} — this is another agent, NOT your user]`, + "", + message, + "", + `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". ONLY reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`, + ].join("\n"); try { const result = await callbacks.deliver(target.id, delivered); @@ -138,7 +173,23 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti result.status === "queued" ? "queued (target is busy; it will be picked up next turn)" : "delivered (target was idle; a new turn has started)"; - return `Message ${verb}. Target tab: ${target.handle} (${target.title}). Use read_tab with "${target.handle}" to read its reply later.`; + const tail = callbacks.canReadTab + ? [ + "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it", + `will WAKE you with a new message later; you can also call read_tab with "${target.handle}"`, + "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY", + "waiting for this reply, end your turn now.", + ] + : [ + "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it", + "will WAKE you with a new message later. Keep working if you have other tasks; if", + "you are ONLY waiting for this reply, end your turn now.", + ]; + return [ + `Message ${verb}. Target tab: ${target.handle} (${target.title}).`, + "", + ...tail, + ].join("\n"); } catch (err) { return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`; } diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts index 250af25..b941152 100644 --- a/packages/core/src/tools/summon.ts +++ b/packages/core/src/tools/summon.ts @@ -60,10 +60,13 @@ function renderAgentGroup(label: string, agents: AvailableAgent[]): string[] { * the disk locations where they live, injected into the summon tool's * description. * - * When `userAgentEnabled` is false only subagents are shown (under the - * generic "Available agents" heading). When it is true, subagents and - * user agents are listed as two labelled groups so the LLM understands - * which slugs require `top_level=true`. + * `subagentEnabled` and `userAgentEnabled` independently control which + * groups are shown — they mirror the `perm_summon` and `perm_user_agent` + * permissions respectively: + * - subagents only → generic "Available agents" heading; + * - user agents only → a single user-agent group (top_level is implied); + * - both → two labelled groups so the LLM understands which slugs + * require `top_level=true`. * * Returns a compact "no agents defined" notice when nothing is visible. */ @@ -72,6 +75,7 @@ function buildAgentsCatalog( userAgents: AvailableAgent[], agentDirs: string[], userAgentEnabled: boolean, + subagentEnabled: boolean, ): string { const lines: string[] = []; lines.push(""); @@ -80,8 +84,9 @@ function buildAgentsCatalog( lines.push(` - ${d}`); } + const visibleSubagents = subagentEnabled ? subagents : []; const visibleUserAgents = userAgentEnabled ? userAgents : []; - if (subagents.length === 0 && visibleUserAgents.length === 0) { + if (visibleSubagents.length === 0 && visibleUserAgents.length === 0) { lines.push(""); lines.push("No agent definitions are currently defined."); return lines.join("\n"); @@ -93,12 +98,26 @@ function buildAgentsCatalog( lines.push("and working directory; the 'tools' parameter is ignored."); lines.push(""); + // User-agent-only mode: list just the user agents. top_level is implied + // (it is the only thing this grant can spawn), so the heading omits it. + if (!subagentEnabled && userAgentEnabled) { + lines.push( + ...renderAgentGroup( + "User agents (spawned as independent top-level tabs):", + visibleUserAgents, + ), + ); + return lines.join("\n"); + } + + // Subagent-only mode: single generic heading. if (!userAgentEnabled) { - lines.push(...renderAgentGroup("Available agents:", subagents)); + lines.push(...renderAgentGroup("Available agents:", visibleSubagents)); return lines.join("\n"); } - const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", subagents); + // Both enabled: two labelled groups. + const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", visibleSubagents); const userAgentLines = renderAgentGroup( "User agents (spawned as independent top-level tabs, requires top_level=true):", visibleUserAgents, @@ -122,9 +141,14 @@ function buildAgentsCatalog( * its description; this is information-only — the runtime resolves * slugs through `loadAgent` independently. * - * `userAgentEnabled` controls whether the `top_level` parameter and the - * user-agent catalog are surfaced to the LLM. It mirrors the - * `perm_user_agent` permission. + * `userAgentEnabled` mirrors the `perm_user_agent` permission and + * `subagentEnabled` mirrors the `perm_summon` permission. They are + * independent: the tool is registered whenever at least one is granted. + * - subagentEnabled only → spawn ordinary subagents (no `top_level`); + * - userAgentEnabled only → spawn ONLY top-level user agents + * (`top_level` is forced on, the `background` knob is dropped, and + * the catalog lists user agents only); + * - both → full behavior (subagents plus `top_level` user agents). */ export function createSummonTool( _defaultWorkingDirectory: string, @@ -133,39 +157,29 @@ export function createSummonTool( availableUserAgents: AvailableAgent[] = [], agentDirs: string[] = [], userAgentEnabled = false, + subagentEnabled = true, ): ToolDefinition { + // When only the user-agent permission is granted the tool spawns user + // agents exclusively: `top_level` is implied (and forced), subagent + // mechanics (background, retrieve, parallel work) are irrelevant. + const userAgentOnly = userAgentEnabled && !subagentEnabled; + const catalog = buildAgentsCatalog( availableSubagents, availableUserAgents, agentDirs, userAgentEnabled, + subagentEnabled, ); const subagentSlugs = availableSubagents.map((a) => a.slug); const userAgentSlugs = availableUserAgents.map((a) => a.slug); - const allSlugs = userAgentEnabled ? [...subagentSlugs, ...userAgentSlugs] : subagentSlugs; + const allSlugs = userAgentOnly + ? userAgentSlugs + : userAgentEnabled + ? [...subagentSlugs, ...userAgentSlugs] + : subagentSlugs; - const description = [ - "Spawn a new child agent to work on a task independently.", - "", - "By default, blocks until the child agent finishes and returns the result directly.", - "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.", - "", - "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.", - "", - "Pattern for parallel work:", - " 1. Call summon multiple times with background=true to start several agents", - " 2. Do your own work or wait", - " 3. Call retrieve for each agent_id to collect results", - ...(userAgentEnabled - ? [ - "", - "Set top_level=true to spawn an independent user agent — a first-class", - "top-level tab with no parent. User agents are fire-and-forget: you get", - "an agent_id back but cannot retrieve their result. top_level requires an", - "'agent' definition listed under 'User agents' below.", - ] - : []), - "", + const toolNamesList = [ "The 'tools' parameter controls what the child can do. Available tool names:", " - read_file: Read file contents", " - read_file_slice: Read a character-range slice of a single line", @@ -180,11 +194,50 @@ export function createSummonTool( " - youtube_transcribe: Fetch YouTube video transcripts", " - send_to_tab: Send a message to another tab/agent by its ID", " - read_tab: Read another tab/agent's latest response by its ID", - "", - "The 'agent' parameter is required — every spawned agent must use a definition.", - "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).", - catalog, - ].join("\n"); + ]; + + const description = userAgentOnly + ? [ + "Spawn an independent top-level user agent to work on a task.", + "", + "User agents are first-class top-level tabs with no parent. They are", + "fire-and-forget: you get an agent_id back but cannot retrieve their result.", + "The user agent runs in its own tab visible to the user.", + "", + ...toolNamesList, + "", + "The 'agent' parameter is required — every spawned agent must use a definition.", + "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).", + catalog, + ].join("\n") + : [ + "Spawn a new child agent to work on a task independently.", + "", + "By default, blocks until the child agent finishes and returns the result directly.", + "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.", + "", + "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.", + "", + "Pattern for parallel work:", + " 1. Call summon multiple times with background=true to start several agents", + " 2. Do your own work or wait", + " 3. Call retrieve for each agent_id to collect results", + ...(userAgentEnabled + ? [ + "", + "Set top_level=true to spawn an independent user agent — a first-class", + "top-level tab with no parent. User agents are fire-and-forget: you get", + "an agent_id back but cannot retrieve their result. top_level requires an", + "'agent' definition listed under 'User agents' below.", + ] + : []), + "", + ...toolNamesList, + "", + "The 'agent' parameter is required — every spawned agent must use a definition.", + "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).", + catalog, + ].join("\n"); const parametersShape = { task: z @@ -206,7 +259,10 @@ export function createSummonTool( .filter(Boolean) .join(" "), ), - ...(userAgentEnabled + // `top_level` is only an explicit choice when BOTH subagents and user + // agents are available. In user-agent-only mode it is implied (forced + // on), so the knob is omitted entirely. + ...(userAgentEnabled && !userAgentOnly ? { top_level: z .boolean() @@ -250,12 +306,18 @@ export function createSummonTool( .describe( "Absolute path for the child to work in. Defaults to the agent definition's cwd (or the spawning agent's directory).", ), - background: z - .boolean() - .optional() - .describe( - "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.", - ), + // `background` is meaningless for fire-and-forget user agents, so the + // knob is omitted in user-agent-only mode. + ...(userAgentOnly + ? {} + : { + background: z + .boolean() + .optional() + .describe( + "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.", + ), + }), }; return { @@ -268,9 +330,14 @@ export function createSummonTool( const tools = args.tools as string[] | undefined; const workingDirectory = args.working_directory as string | undefined; const background = (args.background as boolean | undefined) ?? false; - const topLevel = userAgentEnabled - ? ((args.top_level as boolean | undefined) ?? false) - : false; + // User-agent-only mode always spawns top-level user agents. When both + // capabilities are present the caller chooses via `top_level`. When + // only subagents are available, top-level spawning is unavailable. + const topLevel = userAgentOnly + ? true + : userAgentEnabled + ? ((args.top_level as boolean | undefined) ?? false) + : false; try { const agentId = await callbacks.spawn({ diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index aa69c86..8a73352 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -4,7 +4,21 @@ import { z } from "zod"; import type { ToolDefinition } from "../types/index.js"; import { canonicalize } from "./path-utils.js"; -export function createWriteFileTool(workingDirectory: string): ToolDefinition { +/** + * Optional hook invoked AFTER a successful write, with the canonicalized + * absolute path of the file just written. Its returned string (when non-empty) + * is appended to the tool result. This is how LSP diagnostics are surfaced + * back to the model on write without coupling `@dispatch/core`'s tools to the + * API layer or the LSP manager — the host wires an implementation that touches + * the file through the LSP and formats any diagnostics. Errors thrown here are + * swallowed so a flaky LSP never fails the write itself. + */ +export type AfterWriteHook = (absolutePath: string) => Promise<string>; + +export function createWriteFileTool( + workingDirectory: string, + onAfterWrite?: AfterWriteHook, +): ToolDefinition { return { name: "write_file", description: "Write content to a file relative to the working directory.", @@ -31,10 +45,22 @@ export function createWriteFileTool(workingDirectory: string): ToolDefinition { try { await mkdir(dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, content, "utf8"); - return `Successfully wrote to "${filePath}".`; } catch (err) { return `Error writing file: ${err instanceof Error ? err.message : String(err)}`; } + + let result = `Successfully wrote to "${filePath}".`; + // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a + // hook failure turn a successful write into an error. + if (onAfterWrite) { + try { + const extra = await onAfterWrite(absolutePath); + if (extra) result += `\n\n${extra}`; + } catch { + /* ignore — diagnostics are advisory */ + } + } + return result; }, }; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a22b2b7..607b27d 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -435,6 +435,55 @@ export interface AgentConfig { export interface DispatchConfig { keys?: KeyDefinition[]; permissions: Record<string, string | Record<string, string>>; + /** + * Language Server Protocol servers, keyed by an arbitrary server id (e.g. + * `"luau-lsp"`). Project-scoped: read from the `dispatch.toml` in a tab's + * effective working directory and re-consulted when that directory (or the + * config) changes. Config-driven only — there is no builtin server registry + * and no auto-download; the declared `command[0]` must be on PATH. + */ + lsp?: Record<string, LspServerConfig>; +} + +/** + * A single LSP server entry as expressed in `dispatch.toml`'s `[lsp.<id>]` + * block. Mirrors opencode's custom-server schema so the Roblox Luau config + * (and any other server) is portable between the two. + * + * Example (`dispatch.toml`): + * ```toml + * [lsp.luau-lsp] + * command = ["luau-lsp", "lsp", "--definitions=globalTypes.d.luau", "--docs=api-docs.json"] + * extensions = [".luau"] + * + * [lsp.luau-lsp.initialization.luau-lsp.platform] + * type = "roblox" + * ``` + */ +export interface LspServerConfig { + /** + * Argv to launch the server over stdio. `command[0]` is the executable + * (resolved via PATH); the rest are arguments. Required for every non- + * disabled entry. + */ + command: string[]; + /** + * File extensions (with leading dot, e.g. `".luau"`) this server attaches + * to. Required for custom servers — without it the client never knows which + * files should activate the server. + */ + extensions: string[]; + /** Extra environment variables merged onto `process.env` for the child. */ + env?: Record<string, string>; + /** + * `initializationOptions` forwarded verbatim in the LSP `initialize` + * request (and echoed back for `workspace/configuration` / + * `didChangeConfiguration`). For luau-lsp this carries the + * `{ "luau-lsp": { platform, sourcemap, types, diagnostics, ... } }` block. + */ + initialization?: Record<string, unknown>; + /** When true, the entry is parsed but skipped (no server launched). */ + disabled?: boolean; } export interface KeyDefinition { |
