@oh-my-pi/pi-coding-agent 3.13.1337 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/docs/theme.md +38 -5
  3. package/examples/sdk/11-sessions.ts +2 -2
  4. package/package.json +7 -4
  5. package/src/cli/file-processor.ts +51 -2
  6. package/src/cli/plugin-cli.ts +25 -19
  7. package/src/cli/update-cli.ts +4 -3
  8. package/src/core/agent-session.ts +31 -4
  9. package/src/core/compaction/branch-summarization.ts +4 -32
  10. package/src/core/compaction/compaction.ts +6 -84
  11. package/src/core/compaction/utils.ts +2 -3
  12. package/src/core/custom-tools/types.ts +2 -0
  13. package/src/core/export-html/index.ts +1 -1
  14. package/src/core/hooks/index.ts +1 -1
  15. package/src/core/hooks/tool-wrapper.ts +0 -1
  16. package/src/core/hooks/types.ts +2 -2
  17. package/src/core/plugins/doctor.ts +9 -1
  18. package/src/core/sdk.ts +2 -1
  19. package/src/core/session-manager.ts +552 -41
  20. package/src/core/settings-manager.ts +174 -0
  21. package/src/core/system-prompt.ts +9 -14
  22. package/src/core/title-generator.ts +2 -8
  23. package/src/core/tools/ask.ts +19 -37
  24. package/src/core/tools/bash.ts +2 -37
  25. package/src/core/tools/edit.ts +2 -9
  26. package/src/core/tools/exa/render.ts +52 -48
  27. package/src/core/tools/find.ts +10 -8
  28. package/src/core/tools/grep.ts +45 -17
  29. package/src/core/tools/ls.ts +22 -2
  30. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  31. package/src/core/tools/lsp/clients/index.ts +49 -0
  32. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  33. package/src/core/tools/lsp/config.ts +3 -0
  34. package/src/core/tools/lsp/index.ts +107 -55
  35. package/src/core/tools/lsp/render.ts +192 -79
  36. package/src/core/tools/lsp/types.ts +27 -0
  37. package/src/core/tools/lsp/utils.ts +62 -22
  38. package/src/core/tools/notebook.ts +9 -1
  39. package/src/core/tools/output.ts +37 -14
  40. package/src/core/tools/read.ts +349 -34
  41. package/src/core/tools/renderers.ts +290 -89
  42. package/src/core/tools/review.ts +12 -5
  43. package/src/core/tools/task/agents.ts +5 -5
  44. package/src/core/tools/task/commands.ts +3 -3
  45. package/src/core/tools/task/executor.ts +33 -1
  46. package/src/core/tools/task/index.ts +93 -6
  47. package/src/core/tools/task/render.ts +147 -66
  48. package/src/core/tools/task/types.ts +14 -9
  49. package/src/core/tools/web-fetch.ts +242 -103
  50. package/src/core/tools/web-search/index.ts +64 -20
  51. package/src/core/tools/web-search/providers/exa.ts +68 -172
  52. package/src/core/tools/web-search/render.ts +264 -74
  53. package/src/core/tools/write.ts +2 -8
  54. package/src/main.ts +10 -6
  55. package/src/modes/cleanup.ts +23 -0
  56. package/src/modes/index.ts +9 -4
  57. package/src/modes/interactive/components/bash-execution.ts +6 -3
  58. package/src/modes/interactive/components/branch-summary-message.ts +1 -1
  59. package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
  60. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  61. package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
  62. package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
  63. package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
  64. package/src/modes/interactive/components/hook-message.ts +2 -2
  65. package/src/modes/interactive/components/hook-selector.ts +1 -1
  66. package/src/modes/interactive/components/model-selector.ts +22 -9
  67. package/src/modes/interactive/components/oauth-selector.ts +20 -4
  68. package/src/modes/interactive/components/plugin-settings.ts +4 -2
  69. package/src/modes/interactive/components/session-selector.ts +9 -6
  70. package/src/modes/interactive/components/settings-defs.ts +285 -1
  71. package/src/modes/interactive/components/settings-selector.ts +176 -3
  72. package/src/modes/interactive/components/status-line/index.ts +4 -0
  73. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  74. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  75. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  76. package/src/modes/interactive/components/status-line/types.ts +81 -0
  77. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  78. package/src/modes/interactive/components/status-line.ts +169 -233
  79. package/src/modes/interactive/components/tool-execution.ts +446 -211
  80. package/src/modes/interactive/components/tree-selector.ts +17 -6
  81. package/src/modes/interactive/components/ttsr-notification.ts +4 -4
  82. package/src/modes/interactive/components/welcome.ts +27 -19
  83. package/src/modes/interactive/interactive-mode.ts +98 -13
  84. package/src/modes/interactive/theme/dark.json +3 -2
  85. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  86. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  87. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  88. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  89. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  90. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  91. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  92. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  93. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  94. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  95. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  96. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  97. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  98. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  99. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  100. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  101. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  102. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  103. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  104. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  105. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  106. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  107. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  108. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  109. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  110. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  111. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  112. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  113. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  114. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  115. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  116. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  117. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  118. package/src/modes/interactive/theme/light.json +3 -2
  119. package/src/modes/interactive/theme/theme-schema.json +120 -4
  120. package/src/modes/interactive/theme/theme.ts +1228 -14
  121. package/src/prompts/branch-summary-preamble.md +3 -0
  122. package/src/prompts/branch-summary.md +28 -0
  123. package/src/prompts/compaction-summary.md +34 -0
  124. package/src/prompts/compaction-turn-prefix.md +16 -0
  125. package/src/prompts/compaction-update-summary.md +41 -0
  126. package/src/prompts/init.md +30 -0
  127. package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
  128. package/src/prompts/summarization-system.md +3 -0
  129. package/src/prompts/system-prompt.md +27 -0
  130. package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
  131. package/src/prompts/title-system.md +8 -0
  132. package/src/prompts/tools/ask.md +24 -0
  133. package/src/prompts/tools/bash.md +23 -0
  134. package/src/prompts/tools/edit.md +9 -0
  135. package/src/prompts/tools/find.md +6 -0
  136. package/src/prompts/tools/grep.md +12 -0
  137. package/src/prompts/tools/lsp.md +14 -0
  138. package/src/prompts/tools/output.md +23 -0
  139. package/src/prompts/tools/read.md +25 -0
  140. package/src/prompts/tools/web-fetch.md +8 -0
  141. package/src/prompts/tools/web-search.md +10 -0
  142. package/src/prompts/tools/write.md +10 -0
  143. package/src/commands/init.md +0 -20
  144. /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
  145. /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
  146. /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
  147. /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
  148. /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
  149. /package/src/{core/tools/task/bundled-agents → prompts}/plan.md +0 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Biome CLI-based linter client.
3
+ * Uses Biome's CLI with JSON output instead of LSP (which has stale diagnostics issues).
4
+ */
5
+
6
+ import path from "node:path";
7
+ import type { Diagnostic, DiagnosticSeverity, LinterClient, ServerConfig } from "../types";
8
+
9
+ // =============================================================================
10
+ // Biome JSON Output Types
11
+ // =============================================================================
12
+
13
+ interface BiomeJsonOutput {
14
+ diagnostics: BiomeDiagnostic[];
15
+ }
16
+
17
+ interface BiomeDiagnostic {
18
+ category: string; // e.g., "lint/correctness/noUnusedVariables"
19
+ severity: "error" | "warning" | "info" | "hint";
20
+ description: string;
21
+ location?: {
22
+ path?: { file: string };
23
+ span?: [number, number]; // [startOffset, endOffset] in bytes
24
+ sourceCode?: string;
25
+ };
26
+ }
27
+
28
+ // =============================================================================
29
+ // Helpers
30
+ // =============================================================================
31
+
32
+ /**
33
+ * Convert byte offset to line:column using source code.
34
+ */
35
+ function offsetToPosition(source: string, offset: number): { line: number; column: number } {
36
+ let line = 1;
37
+ let column = 1;
38
+ let byteIndex = 0;
39
+
40
+ for (const ch of source) {
41
+ const byteLen = Buffer.byteLength(ch);
42
+ if (byteIndex + byteLen > offset) {
43
+ break;
44
+ }
45
+ if (ch === "\n") {
46
+ line++;
47
+ column = 1;
48
+ } else {
49
+ column++;
50
+ }
51
+ byteIndex += byteLen;
52
+ }
53
+
54
+ return { line, column };
55
+ }
56
+
57
+ /**
58
+ * Parse Biome severity to LSP DiagnosticSeverity.
59
+ */
60
+ function parseSeverity(severity: string): DiagnosticSeverity {
61
+ switch (severity) {
62
+ case "error":
63
+ return 1;
64
+ case "warning":
65
+ return 2;
66
+ case "info":
67
+ return 3;
68
+ case "hint":
69
+ return 4;
70
+ default:
71
+ return 2;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Run a Biome CLI command.
77
+ */
78
+ async function runBiome(
79
+ args: string[],
80
+ cwd: string,
81
+ resolvedCommand?: string,
82
+ ): Promise<{ stdout: string; stderr: string; success: boolean }> {
83
+ const command = resolvedCommand ?? "biome";
84
+
85
+ try {
86
+ const proc = Bun.spawn([command, ...args], {
87
+ cwd,
88
+ stdout: "pipe",
89
+ stderr: "pipe",
90
+ });
91
+
92
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
93
+ const exitCode = await proc.exited;
94
+
95
+ return { stdout, stderr, success: exitCode === 0 };
96
+ } catch (err) {
97
+ return { stdout: "", stderr: String(err), success: false };
98
+ }
99
+ }
100
+
101
+ // =============================================================================
102
+ // Biome Client
103
+ // =============================================================================
104
+
105
+ /**
106
+ * Biome CLI-based linter client.
107
+ * Parses Biome's --reporter=json output into LSP Diagnostic format.
108
+ */
109
+ export class BiomeClient implements LinterClient {
110
+ private config: ServerConfig;
111
+ private cwd: string;
112
+
113
+ constructor(config: ServerConfig, cwd: string) {
114
+ this.config = config;
115
+ this.cwd = cwd;
116
+ }
117
+
118
+ async format(filePath: string, content: string): Promise<string> {
119
+ // Write content to file first
120
+ await Bun.write(filePath, content);
121
+
122
+ // Run biome format --write
123
+ const result = await runBiome(["format", "--write", filePath], this.cwd, this.config.resolvedCommand);
124
+
125
+ if (result.success) {
126
+ // Read back formatted content
127
+ return await Bun.file(filePath).text();
128
+ }
129
+
130
+ // Format failed, return original
131
+ return content;
132
+ }
133
+
134
+ async lint(filePath: string): Promise<Diagnostic[]> {
135
+ // Run biome lint with JSON reporter
136
+ const result = await runBiome(["lint", "--reporter=json", filePath], this.cwd, this.config.resolvedCommand);
137
+
138
+ return this.parseJsonOutput(result.stdout, filePath);
139
+ }
140
+
141
+ /**
142
+ * Parse Biome's JSON output into LSP Diagnostics.
143
+ */
144
+ private parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] {
145
+ const diagnostics: Diagnostic[] = [];
146
+
147
+ try {
148
+ const parsed: BiomeJsonOutput = JSON.parse(jsonOutput);
149
+
150
+ for (const diag of parsed.diagnostics) {
151
+ const location = diag.location;
152
+ if (!location?.path?.file) continue;
153
+
154
+ // Resolve file path
155
+ const diagFile = path.isAbsolute(location.path.file)
156
+ ? location.path.file
157
+ : path.join(this.cwd, location.path.file);
158
+
159
+ // Only include diagnostics for the target file
160
+ if (path.resolve(diagFile) !== path.resolve(targetFile)) {
161
+ continue;
162
+ }
163
+
164
+ // Convert byte offset to line:column
165
+ let startLine = 1;
166
+ let startColumn = 1;
167
+ let endLine = 1;
168
+ let endColumn = 1;
169
+
170
+ if (location.span && location.sourceCode) {
171
+ const startPos = offsetToPosition(location.sourceCode, location.span[0]);
172
+ const endPos = offsetToPosition(location.sourceCode, location.span[1]);
173
+ startLine = startPos.line;
174
+ startColumn = startPos.column;
175
+ endLine = endPos.line;
176
+ endColumn = endPos.column;
177
+ }
178
+
179
+ diagnostics.push({
180
+ range: {
181
+ start: { line: startLine - 1, character: startColumn - 1 },
182
+ end: { line: endLine - 1, character: endColumn - 1 },
183
+ },
184
+ severity: parseSeverity(diag.severity),
185
+ message: diag.description,
186
+ source: "biome",
187
+ code: diag.category,
188
+ });
189
+ }
190
+ } catch {
191
+ // JSON parse failed, return empty
192
+ }
193
+
194
+ return diagnostics;
195
+ }
196
+
197
+ dispose(): void {
198
+ // Nothing to dispose for CLI client
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Factory function to create a Biome client.
204
+ */
205
+ export function createBiomeClient(config: ServerConfig, cwd: string): LinterClient {
206
+ return new BiomeClient(config, cwd);
207
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Linter client implementations.
3
+ *
4
+ * The LinterClient interface provides a common API for formatters and linters.
5
+ * Different implementations can use LSP protocol, CLI tools, or other mechanisms.
6
+ */
7
+
8
+ export { BiomeClient, createBiomeClient } from "./biome-client";
9
+ export { createLspLinterClient, LspLinterClient } from "./lsp-linter-client";
10
+
11
+ import type { LinterClient, ServerConfig } from "../types";
12
+ import { createLspLinterClient } from "./lsp-linter-client";
13
+
14
+ // Cache of linter clients by server name + cwd
15
+ const clientCache = new Map<string, LinterClient>();
16
+
17
+ /**
18
+ * Get or create a linter client for a server configuration.
19
+ * Uses the server's custom factory if provided, otherwise falls back to LSP.
20
+ */
21
+ export function getLinterClient(serverName: string, config: ServerConfig, cwd: string): LinterClient {
22
+ const key = `${serverName}:${cwd}`;
23
+
24
+ let client = clientCache.get(key);
25
+ if (client) {
26
+ return client;
27
+ }
28
+
29
+ // Use custom factory if provided
30
+ if (config.createClient) {
31
+ client = config.createClient(config, cwd);
32
+ } else {
33
+ // Default to LSP
34
+ client = createLspLinterClient(config, cwd);
35
+ }
36
+
37
+ clientCache.set(key, client);
38
+ return client;
39
+ }
40
+
41
+ /**
42
+ * Clear all cached linter clients.
43
+ */
44
+ export function clearLinterClientCache(): void {
45
+ for (const client of clientCache.values()) {
46
+ client.dispose?.();
47
+ }
48
+ clientCache.clear();
49
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * LSP-based linter client.
3
+ * Uses the Language Server Protocol for formatting and diagnostics.
4
+ */
5
+
6
+ import { getOrCreateClient, notifySaved, sendRequest, syncContent } from "../client";
7
+ import { applyTextEditsToString } from "../edits";
8
+ import type { Diagnostic, LinterClient, LspClient, ServerConfig, TextEdit } from "../types";
9
+ import { fileToUri } from "../utils";
10
+
11
+ /** Default formatting options for LSP */
12
+ const DEFAULT_FORMAT_OPTIONS = {
13
+ tabSize: 3,
14
+ insertSpaces: true,
15
+ trimTrailingWhitespace: true,
16
+ insertFinalNewline: true,
17
+ trimFinalNewlines: true,
18
+ };
19
+
20
+ /**
21
+ * LSP-based linter client implementation.
22
+ * Wraps the existing LSP client infrastructure.
23
+ */
24
+ export class LspLinterClient implements LinterClient {
25
+ private config: ServerConfig;
26
+ private cwd: string;
27
+ private client: LspClient | null = null;
28
+
29
+ constructor(config: ServerConfig, cwd: string) {
30
+ this.config = config;
31
+ this.cwd = cwd;
32
+ }
33
+
34
+ private async getClient(): Promise<LspClient> {
35
+ if (!this.client) {
36
+ this.client = await getOrCreateClient(this.config, this.cwd);
37
+ }
38
+ return this.client;
39
+ }
40
+
41
+ async format(filePath: string, content: string): Promise<string> {
42
+ const client = await this.getClient();
43
+ const uri = fileToUri(filePath);
44
+
45
+ // Sync content to LSP
46
+ await syncContent(client, filePath, content);
47
+
48
+ // Check if server supports formatting
49
+ const caps = client.serverCapabilities;
50
+ if (!caps?.documentFormattingProvider) {
51
+ return content;
52
+ }
53
+
54
+ // Request formatting
55
+ const edits = (await sendRequest(client, "textDocument/formatting", {
56
+ textDocument: { uri },
57
+ options: DEFAULT_FORMAT_OPTIONS,
58
+ })) as TextEdit[] | null;
59
+
60
+ if (!edits || edits.length === 0) {
61
+ return content;
62
+ }
63
+
64
+ return applyTextEditsToString(content, edits);
65
+ }
66
+
67
+ async lint(filePath: string): Promise<Diagnostic[]> {
68
+ const client = await this.getClient();
69
+ const uri = fileToUri(filePath);
70
+
71
+ // Notify that file was saved to trigger diagnostics
72
+ await notifySaved(client, filePath);
73
+
74
+ // Wait for diagnostics with timeout
75
+ const timeoutMs = 3000;
76
+ const start = Date.now();
77
+ while (Date.now() - start < timeoutMs) {
78
+ const diagnostics = client.diagnostics.get(uri);
79
+ if (diagnostics !== undefined) {
80
+ return diagnostics;
81
+ }
82
+ await new Promise((resolve) => setTimeout(resolve, 100));
83
+ }
84
+
85
+ return client.diagnostics.get(uri) ?? [];
86
+ }
87
+
88
+ dispose(): void {
89
+ // Client lifecycle is managed globally, nothing to dispose here
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Factory function to create an LSP linter client.
95
+ */
96
+ export function createLspLinterClient(config: ServerConfig, cwd: string): LinterClient {
97
+ return new LspLinterClient(config, cwd);
98
+ }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { extname, join } from "node:path";
4
4
  import { getConfigDirPaths } from "../../../config.js";
5
+ import { createBiomeClient } from "./clients/biome-client";
5
6
  import type { ServerConfig } from "./types";
6
7
 
7
8
  export interface LspConfig {
@@ -110,6 +111,8 @@ export const SERVERS: Record<string, ServerConfig> = {
110
111
  fileTypes: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc"],
111
112
  rootMarkers: ["biome.json", "biome.jsonc"],
112
113
  isLinter: true,
114
+ // Use CLI instead of LSP - Biome's LSP has known stale diagnostics issues
115
+ createClient: createBiomeClient,
113
116
  },
114
117
 
115
118
  eslint: {
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
4
  import type { BunFile } from "bun";
5
- import type { Theme } from "../../../modes/interactive/theme/theme";
5
+ import { type Theme, theme } from "../../../modes/interactive/theme/theme";
6
+ import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
6
7
  import { logger } from "../../logger";
7
8
  import { once, untilAborted } from "../../utils";
8
9
  import { resolveToCwd } from "../path-utils";
@@ -17,7 +18,8 @@ import {
17
18
  setIdleTimeout,
18
19
  syncContent,
19
20
  } from "./client";
20
- import { getServerForFile, getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
21
+ import { getLinterClient } from "./clients";
22
+ import { getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
21
23
  import { applyTextEditsToString, applyWorkspaceEdit } from "./edits";
22
24
  import { renderCall, renderResult } from "./render";
23
25
  import * as rustAnalyzer from "./rust-analyzer";
@@ -79,10 +81,11 @@ export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
79
81
  const config = loadConfig(cwd);
80
82
  setIdleTimeout(config.idleTimeoutMs);
81
83
  const servers: LspWarmupResult["servers"] = [];
84
+ const lspServers = getLspServers(config);
82
85
 
83
86
  // Start all detected servers in parallel
84
87
  const results = await Promise.allSettled(
85
- Object.entries(config.servers).map(async ([name, serverConfig]) => {
88
+ lspServers.map(async ([name, serverConfig]) => {
86
89
  const client = await getOrCreateClient(serverConfig, cwd);
87
90
  return { name, client, fileTypes: serverConfig.fileTypes };
88
91
  }),
@@ -134,6 +137,9 @@ async function syncFileContent(
134
137
  ): Promise<void> {
135
138
  await Promise.allSettled(
136
139
  servers.map(async ([_serverName, serverConfig]) => {
140
+ if (serverConfig.createClient) {
141
+ return;
142
+ }
137
143
  const client = await getOrCreateClient(serverConfig, cwd);
138
144
  await syncContent(client, absolutePath, content);
139
145
  }),
@@ -155,6 +161,9 @@ async function notifyFileSaved(
155
161
  ): Promise<void> {
156
162
  await Promise.allSettled(
157
163
  servers.map(async ([_serverName, serverConfig]) => {
164
+ if (serverConfig.createClient) {
165
+ return;
166
+ }
158
167
  const client = await getOrCreateClient(serverConfig, cwd);
159
168
  await notifySaved(client, absolutePath);
160
169
  }),
@@ -174,6 +183,41 @@ function getConfig(cwd: string): LspConfig {
174
183
  return config;
175
184
  }
176
185
 
186
+ function isCustomLinter(serverConfig: ServerConfig): boolean {
187
+ return Boolean(serverConfig.createClient);
188
+ }
189
+
190
+ function splitServers(servers: Array<[string, ServerConfig]>): {
191
+ lspServers: Array<[string, ServerConfig]>;
192
+ customLinterServers: Array<[string, ServerConfig]>;
193
+ } {
194
+ const lspServers: Array<[string, ServerConfig]> = [];
195
+ const customLinterServers: Array<[string, ServerConfig]> = [];
196
+ for (const entry of servers) {
197
+ if (isCustomLinter(entry[1])) {
198
+ customLinterServers.push(entry);
199
+ } else {
200
+ lspServers.push(entry);
201
+ }
202
+ }
203
+ return { lspServers, customLinterServers };
204
+ }
205
+
206
+ function getLspServers(config: LspConfig): Array<[string, ServerConfig]> {
207
+ return (Object.entries(config.servers) as Array<[string, ServerConfig]>).filter(
208
+ ([, serverConfig]) => !isCustomLinter(serverConfig),
209
+ );
210
+ }
211
+
212
+ function getLspServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
213
+ return getServersForFile(config, filePath).filter(([, serverConfig]) => !isCustomLinter(serverConfig));
214
+ }
215
+
216
+ function getLspServerForFile(config: LspConfig, filePath: string): [string, ServerConfig] | null {
217
+ const servers = getLspServersForFile(config, filePath);
218
+ return servers.length > 0 ? servers[0] : null;
219
+ }
220
+
177
221
  const FILE_SEARCH_MAX_DEPTH = 5;
178
222
  const IGNORED_DIRS = new Set(["node_modules", "target", "dist", "build", ".git"]);
179
223
 
@@ -214,7 +258,7 @@ function findFileForServer(cwd: string, serverConfig: ServerConfig): string | nu
214
258
  }
215
259
 
216
260
  function getRustServer(config: LspConfig): [string, ServerConfig] | null {
217
- const entries = Object.entries(config.servers) as Array<[string, ServerConfig]>;
261
+ const entries = getLspServers(config);
218
262
  const byName = entries.find(([name, server]) => name === "rust-analyzer" || server.command === "rust-analyzer");
219
263
  if (byName) return byName;
220
264
 
@@ -234,7 +278,7 @@ function getRustServer(config: LspConfig): [string, ServerConfig] | null {
234
278
  }
235
279
 
236
280
  function getServerForWorkspaceAction(config: LspConfig, action: string): [string, ServerConfig] | null {
237
- const entries = Object.entries(config.servers) as Array<[string, ServerConfig]>;
281
+ const entries = getLspServers(config);
238
282
  if (entries.length === 0) return null;
239
283
 
240
284
  if (action === "workspace_symbols") {
@@ -379,8 +423,7 @@ export interface FileDiagnosticsResult {
379
423
  }
380
424
 
381
425
  /**
382
- * Get LSP diagnostics for a file.
383
- * Assumes content was synced and didSave was sent - just waits for diagnostics.
426
+ * Get diagnostics for a file using LSP or custom linter client.
384
427
  *
385
428
  * @param absolutePath - Absolute path to the file
386
429
  * @param cwd - Working directory for LSP config resolution
@@ -404,6 +447,14 @@ async function getDiagnosticsForFile(
404
447
  // Wait for diagnostics from all servers in parallel
405
448
  const results = await Promise.allSettled(
406
449
  servers.map(async ([serverName, serverConfig]) => {
450
+ // Use custom linter client if configured
451
+ if (serverConfig.createClient) {
452
+ const linterClient = getLinterClient(serverName, serverConfig, cwd);
453
+ const diagnostics = await linterClient.lint(absolutePath);
454
+ return { serverName, diagnostics };
455
+ }
456
+
457
+ // Default: use LSP
407
458
  const client = await getOrCreateClient(serverConfig, cwd);
408
459
  // Content already synced + didSave sent, just wait for diagnostics
409
460
  const diagnostics = await waitForDiagnostics(client, uri);
@@ -469,9 +520,7 @@ const DEFAULT_FORMAT_OPTIONS = {
469
520
  };
470
521
 
471
522
  /**
472
- * Format content in-memory using LSP.
473
- * Assumes content was already synced to all servers via syncFileContent.
474
- * Requests formatting from first capable server, applies edits in-memory.
523
+ * Format content using LSP or custom linter client.
475
524
  *
476
525
  * @param absolutePath - Absolute path (for URI)
477
526
  * @param content - Content to format
@@ -491,8 +540,15 @@ async function formatContent(
491
540
 
492
541
  const uri = fileToUri(absolutePath);
493
542
 
494
- for (const [_serverName, serverConfig] of servers) {
543
+ for (const [serverName, serverConfig] of servers) {
495
544
  try {
545
+ // Use custom linter client if configured
546
+ if (serverConfig.createClient) {
547
+ const linterClient = getLinterClient(serverName, serverConfig, cwd);
548
+ return await linterClient.format(absolutePath, content);
549
+ }
550
+
551
+ // Default: use LSP
496
552
  const client = await getOrCreateClient(serverConfig, cwd);
497
553
 
498
554
  const caps = client.serverCapabilities;
@@ -561,36 +617,48 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
561
617
  if (servers.length === 0) {
562
618
  return writethroughNoop(dst, content, signal, file);
563
619
  }
620
+ const { lspServers, customLinterServers } = splitServers(servers);
564
621
 
565
622
  let finalContent = content;
566
- const getWritePromise = once(() => (file ? file.write(finalContent) : Bun.write(dst, finalContent)));
623
+ const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
624
+ const getWritePromise = once(() => writeContent(finalContent));
625
+ const useCustomFormatter = enableFormat && customLinterServers.length > 0;
567
626
 
568
627
  let formatter: FileFormatResult | undefined;
569
628
  let diagnostics: FileDiagnosticsResult | undefined;
570
629
  try {
571
630
  signal ??= AbortSignal.timeout(10_000);
572
631
  await untilAborted(signal, async () => {
573
- // 1. Sync original content to ALL servers
574
- await syncFileContent(dst, content, cwd, servers);
575
-
576
- // 2. Format in-memory (servers already have content)
577
- if (enableFormat) {
578
- finalContent = await formatContent(dst, content, cwd, servers);
632
+ if (useCustomFormatter) {
633
+ // Custom linters (e.g. Biome CLI) require on-disk input.
634
+ await writeContent(content);
635
+ finalContent = await formatContent(dst, content, cwd, customLinterServers);
579
636
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
580
- }
637
+ await writeContent(finalContent);
638
+ await syncFileContent(dst, finalContent, cwd, lspServers);
639
+ } else {
640
+ // 1. Sync original content to LSP servers
641
+ await syncFileContent(dst, content, cwd, lspServers);
642
+
643
+ // 2. Format in-memory via LSP
644
+ if (enableFormat) {
645
+ finalContent = await formatContent(dst, content, cwd, lspServers);
646
+ formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
647
+ }
581
648
 
582
- // 3. If formatted, sync formatted content to ALL servers
583
- if (finalContent !== content) {
584
- await syncFileContent(dst, finalContent, cwd, servers);
585
- }
649
+ // 3. If formatted, sync formatted content to LSP servers
650
+ if (finalContent !== content) {
651
+ await syncFileContent(dst, finalContent, cwd, lspServers);
652
+ }
586
653
 
587
- // 4. Write to disk
588
- await getWritePromise();
654
+ // 4. Write to disk
655
+ await getWritePromise();
656
+ }
589
657
 
590
- // 5. Notify saved to ALL servers
591
- await notifyFileSaved(dst, cwd, servers);
658
+ // 5. Notify saved to LSP servers
659
+ await notifyFileSaved(dst, cwd, lspServers);
592
660
 
593
- // 6. Get diagnostics from ALL servers
661
+ // 6. Get diagnostics from all servers
594
662
  if (enableDiagnostics) {
595
663
  diagnostics = await getDiagnosticsForFile(dst, cwd, servers);
596
664
  }
@@ -618,29 +686,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
618
686
  return {
619
687
  name: "lsp",
620
688
  label: "LSP",
621
- description: `Interact with Language Server Protocol (LSP) servers to get code intelligence features.
622
-
623
- Standard operations:
624
- - diagnostics: Get errors/warnings for a file
625
- - workspace_diagnostics: Check entire project for errors (uses tsc, cargo check, go build, etc.)
626
- - definition: Go to symbol definition
627
- - references: Find all references to a symbol
628
- - hover: Get type info and documentation
629
- - symbols: List symbols in a file (functions, classes, etc.)
630
- - workspace_symbols: Search for symbols across the project
631
- - rename: Rename a symbol across the codebase
632
- - actions: List and apply code actions (quick fixes, refactors)
633
- - incoming_calls: Find all callers of a function
634
- - outgoing_calls: Find all functions called by a function
635
- - status: Show active language servers
636
-
637
- Rust-analyzer specific (require rust-analyzer):
638
- - flycheck: Run clippy/cargo check
639
- - expand_macro: Show macro expansion at cursor
640
- - ssr: Structural search-replace
641
- - runnables: Find runnable tests/binaries
642
- - related_tests: Find tests for a function
643
- - reload_workspace: Reload Cargo.toml changes`,
689
+ description: lspDescription,
644
690
  parameters: lspSchema,
645
691
  renderCall,
646
692
  renderResult,
@@ -709,7 +755,7 @@ Rust-analyzer specific (require rust-analyzer):
709
755
  const resolved = resolveToCwd(target, cwd);
710
756
  const servers = getServersForFile(config, resolved);
711
757
  if (servers.length === 0) {
712
- results.push(`✗ ${target}: No language server found`);
758
+ results.push(`${theme.status.error} ${target}: No language server found`);
713
759
  continue;
714
760
  }
715
761
 
@@ -721,6 +767,12 @@ Rust-analyzer specific (require rust-analyzer):
721
767
  for (const [serverName, serverConfig] of servers) {
722
768
  allServerNames.add(serverName);
723
769
  try {
770
+ if (serverConfig.createClient) {
771
+ const linterClient = getLinterClient(serverName, serverConfig, cwd);
772
+ const diagnostics = await linterClient.lint(resolved);
773
+ allDiagnostics.push(...diagnostics);
774
+ continue;
775
+ }
724
776
  const client = await getOrCreateClient(serverConfig, cwd);
725
777
  await refreshFile(client, resolved);
726
778
  const diagnostics = await waitForDiagnostics(client, uri);
@@ -759,10 +811,10 @@ Rust-analyzer specific (require rust-analyzer):
759
811
  }
760
812
 
761
813
  if (uniqueDiagnostics.length === 0) {
762
- results.push(`✓ ${relPath}: no issues`);
814
+ results.push(`${theme.status.success} ${relPath}: no issues`);
763
815
  } else {
764
816
  const summary = formatDiagnosticsSummary(uniqueDiagnostics);
765
- results.push(`✗ ${relPath}: ${summary}`);
817
+ results.push(`${theme.status.error} ${relPath}: ${summary}`);
766
818
  for (const diag of uniqueDiagnostics) {
767
819
  results.push(` ${formatDiagnostic(diag, relPath)}`);
768
820
  }
@@ -792,7 +844,7 @@ Rust-analyzer specific (require rust-analyzer):
792
844
 
793
845
  const resolvedFile = file ? resolveToCwd(file, cwd) : null;
794
846
  const serverInfo = resolvedFile
795
- ? getServerForFile(config, resolvedFile)
847
+ ? getLspServerForFile(config, resolvedFile)
796
848
  : getServerForWorkspaceAction(config, action);
797
849
 
798
850
  if (!serverInfo) {