@oh-my-pi/pi-coding-agent 1.340.0 → 1.341.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 (32) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/package.json +1 -1
  3. package/src/cli/args.ts +8 -0
  4. package/src/core/agent-session.ts +32 -14
  5. package/src/core/model-resolver.ts +101 -0
  6. package/src/core/sdk.ts +50 -17
  7. package/src/core/session-manager.ts +117 -14
  8. package/src/core/settings-manager.ts +90 -19
  9. package/src/core/title-generator.ts +94 -0
  10. package/src/core/tools/bash.ts +1 -2
  11. package/src/core/tools/edit-diff.ts +2 -2
  12. package/src/core/tools/edit.ts +43 -5
  13. package/src/core/tools/grep.ts +3 -2
  14. package/src/core/tools/index.ts +73 -13
  15. package/src/core/tools/lsp/client.ts +45 -20
  16. package/src/core/tools/lsp/config.ts +708 -34
  17. package/src/core/tools/lsp/index.ts +423 -23
  18. package/src/core/tools/lsp/types.ts +5 -0
  19. package/src/core/tools/task/bundled-agents/explore.md +1 -1
  20. package/src/core/tools/task/bundled-agents/reviewer.md +1 -1
  21. package/src/core/tools/task/model-resolver.ts +52 -3
  22. package/src/core/tools/write.ts +67 -4
  23. package/src/index.ts +5 -0
  24. package/src/main.ts +23 -2
  25. package/src/modes/interactive/components/model-selector.ts +96 -18
  26. package/src/modes/interactive/components/session-selector.ts +20 -7
  27. package/src/modes/interactive/components/settings-defs.ts +50 -2
  28. package/src/modes/interactive/components/settings-selector.ts +8 -11
  29. package/src/modes/interactive/components/tool-execution.ts +18 -0
  30. package/src/modes/interactive/components/tree-selector.ts +2 -2
  31. package/src/modes/interactive/components/welcome.ts +40 -3
  32. package/src/modes/interactive/interactive-mode.ts +86 -9
@@ -51,12 +51,23 @@ export interface MCPSettings {
51
51
  enableProjectConfig?: boolean; // default: true (load .mcp.json from project root)
52
52
  }
53
53
 
54
+ export interface LspSettings {
55
+ formatOnWrite?: boolean; // default: true (format files using LSP after write tool writes code files)
56
+ diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files)
57
+ diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files)
58
+ }
59
+
60
+ export interface EditSettings {
61
+ fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
62
+ }
63
+
54
64
  export interface Settings {
55
65
  lastChangelogVersion?: string;
56
- defaultProvider?: string;
57
- defaultModel?: string;
66
+ /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
67
+ modelRoles?: Record<string, string>;
58
68
  defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
59
69
  queueMode?: "all" | "one-at-a-time";
70
+ interruptMode?: "immediate" | "wait";
60
71
  theme?: string;
61
72
  compaction?: CompactionSettings;
62
73
  branchSummary?: BranchSummarySettings;
@@ -72,6 +83,8 @@ export interface Settings {
72
83
  exa?: ExaSettings;
73
84
  bashInterceptor?: BashInterceptorSettings;
74
85
  mcp?: MCPSettings;
86
+ lsp?: LspSettings;
87
+ edit?: EditSettings;
75
88
  }
76
89
 
77
90
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -200,28 +213,29 @@ export class SettingsManager {
200
213
  this.save();
201
214
  }
202
215
 
203
- getDefaultProvider(): string | undefined {
204
- return this.settings.defaultProvider;
205
- }
206
-
207
- getDefaultModel(): string | undefined {
208
- return this.settings.defaultModel;
216
+ /**
217
+ * Get model for a role. Returns "provider/modelId" string or undefined.
218
+ */
219
+ getModelRole(role: string): string | undefined {
220
+ return this.settings.modelRoles?.[role];
209
221
  }
210
222
 
211
- setDefaultProvider(provider: string): void {
212
- this.globalSettings.defaultProvider = provider;
213
- this.save();
214
- }
215
-
216
- setDefaultModel(modelId: string): void {
217
- this.globalSettings.defaultModel = modelId;
223
+ /**
224
+ * Set model for a role. Model should be "provider/modelId" format.
225
+ */
226
+ setModelRole(role: string, model: string): void {
227
+ if (!this.globalSettings.modelRoles) {
228
+ this.globalSettings.modelRoles = {};
229
+ }
230
+ this.globalSettings.modelRoles[role] = model;
218
231
  this.save();
219
232
  }
220
233
 
221
- setDefaultModelAndProvider(provider: string, modelId: string): void {
222
- this.globalSettings.defaultProvider = provider;
223
- this.globalSettings.defaultModel = modelId;
224
- this.save();
234
+ /**
235
+ * Get all model roles.
236
+ */
237
+ getModelRoles(): Record<string, string> {
238
+ return { ...this.settings.modelRoles };
225
239
  }
226
240
 
227
241
  getQueueMode(): "all" | "one-at-a-time" {
@@ -233,6 +247,15 @@ export class SettingsManager {
233
247
  this.save();
234
248
  }
235
249
 
250
+ getInterruptMode(): "immediate" | "wait" {
251
+ return this.settings.interruptMode || "immediate";
252
+ }
253
+
254
+ setInterruptMode(mode: "immediate" | "wait"): void {
255
+ this.globalSettings.interruptMode = mode;
256
+ this.save();
257
+ }
258
+
236
259
  getTheme(): string | undefined {
237
260
  return this.settings.theme;
238
261
  }
@@ -474,4 +497,52 @@ export class SettingsManager {
474
497
  this.globalSettings.mcp.enableProjectConfig = enabled;
475
498
  this.save();
476
499
  }
500
+
501
+ getLspFormatOnWrite(): boolean {
502
+ return this.settings.lsp?.formatOnWrite ?? true;
503
+ }
504
+
505
+ setLspFormatOnWrite(enabled: boolean): void {
506
+ if (!this.globalSettings.lsp) {
507
+ this.globalSettings.lsp = {};
508
+ }
509
+ this.globalSettings.lsp.formatOnWrite = enabled;
510
+ this.save();
511
+ }
512
+
513
+ getLspDiagnosticsOnWrite(): boolean {
514
+ return this.settings.lsp?.diagnosticsOnWrite ?? true;
515
+ }
516
+
517
+ setLspDiagnosticsOnWrite(enabled: boolean): void {
518
+ if (!this.globalSettings.lsp) {
519
+ this.globalSettings.lsp = {};
520
+ }
521
+ this.globalSettings.lsp.diagnosticsOnWrite = enabled;
522
+ this.save();
523
+ }
524
+
525
+ getLspDiagnosticsOnEdit(): boolean {
526
+ return this.settings.lsp?.diagnosticsOnEdit ?? false;
527
+ }
528
+
529
+ setLspDiagnosticsOnEdit(enabled: boolean): void {
530
+ if (!this.globalSettings.lsp) {
531
+ this.globalSettings.lsp = {};
532
+ }
533
+ this.globalSettings.lsp.diagnosticsOnEdit = enabled;
534
+ this.save();
535
+ }
536
+
537
+ getEditFuzzyMatch(): boolean {
538
+ return this.settings.edit?.fuzzyMatch ?? true;
539
+ }
540
+
541
+ setEditFuzzyMatch(enabled: boolean): void {
542
+ if (!this.globalSettings.edit) {
543
+ this.globalSettings.edit = {};
544
+ }
545
+ this.globalSettings.edit.fuzzyMatch = enabled;
546
+ this.save();
547
+ }
477
548
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Generate session titles using a smol, fast model.
3
+ */
4
+
5
+ import type { Model } from "@oh-my-pi/pi-ai";
6
+ import { completeSimple } from "@oh-my-pi/pi-ai";
7
+ import type { ModelRegistry } from "./model-registry.js";
8
+ import { findSmolModel } from "./model-resolver.js";
9
+
10
+ const TITLE_SYSTEM_PROMPT = `Generate a very short title (3-6 words) for a coding session based on the user's first message. The title should capture the main task or topic. Output ONLY the title, nothing else. No quotes, no punctuation at the end.
11
+
12
+ Examples:
13
+ - "Fix TypeScript compilation errors"
14
+ - "Add user authentication"
15
+ - "Refactor database queries"
16
+ - "Debug payment webhook"
17
+ - "Update React components"`;
18
+
19
+ const MAX_INPUT_CHARS = 2000;
20
+
21
+ /**
22
+ * Find the best available model for title generation.
23
+ * Uses the configured smol model if set, otherwise auto-discovers using priority chain.
24
+ *
25
+ * @param registry Model registry
26
+ * @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
27
+ */
28
+ export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<any> | null> {
29
+ const model = await findSmolModel(registry, savedSmolModel);
30
+ return model ?? null;
31
+ }
32
+
33
+ /**
34
+ * Generate a title for a session based on the first user message.
35
+ *
36
+ * @param firstMessage The first user message
37
+ * @param registry Model registry
38
+ * @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
39
+ */
40
+ export async function generateSessionTitle(
41
+ firstMessage: string,
42
+ registry: ModelRegistry,
43
+ savedSmolModel?: string,
44
+ ): Promise<string | null> {
45
+ const model = await findTitleModel(registry, savedSmolModel);
46
+ if (!model) return null;
47
+
48
+ const apiKey = await registry.getApiKey(model);
49
+ if (!apiKey) return null;
50
+
51
+ // Truncate message if too long
52
+ const truncatedMessage =
53
+ firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
54
+
55
+ try {
56
+ const response = await completeSimple(
57
+ model,
58
+ {
59
+ systemPrompt: TITLE_SYSTEM_PROMPT,
60
+ messages: [{ role: "user", content: truncatedMessage, timestamp: Date.now() }],
61
+ },
62
+ {
63
+ apiKey,
64
+ maxTokens: 30,
65
+ },
66
+ );
67
+
68
+ // Extract title from response text content
69
+ let title = "";
70
+ for (const content of response.content) {
71
+ if (content.type === "text") {
72
+ title += content.text;
73
+ }
74
+ }
75
+ title = title.trim();
76
+
77
+ if (!title || title.length > 60) {
78
+ return null;
79
+ }
80
+
81
+ // Clean up: remove quotes, trailing punctuation
82
+ return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Set the terminal title using ANSI escape sequences.
90
+ */
91
+ export function setTerminalTitle(title: string): void {
92
+ // OSC 2 sets the window title
93
+ process.stdout.write(`\x1b]2;${title}\x07`);
94
+ }
@@ -66,8 +66,7 @@ Usage notes:
66
66
  - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel.
67
67
  - If the commands depend on each other and must run sequentially, use a single bash call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
68
68
  - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
69
- - DO NOT use newlines to separate commands (newlines are ok in quoted strings)
70
- - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.`,
69
+ - DO NOT use newlines to separate commands (newlines are ok in quoted strings)`,
71
70
  parameters: bashSchema,
72
71
  execute: async (
73
72
  _toolCallId: string,
@@ -271,7 +271,7 @@ export function formatEditMatchError(
271
271
  ? options.fuzzyMatches && options.fuzzyMatches > 1
272
272
  ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
273
273
  : `Closest match was below the ${thresholdPercent}% similarity threshold.`
274
- : "Hint: Use fuzzy=true to accept high-confidence matches.";
274
+ : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches.";
275
275
 
276
276
  return [
277
277
  options.allowFuzzy
@@ -409,7 +409,7 @@ export async function computeEditDiff(
409
409
  oldText: string,
410
410
  newText: string,
411
411
  cwd: string,
412
- fuzzy = false,
412
+ fuzzy = true,
413
413
  ): Promise<EditDiffResult | EditDiffError> {
414
414
  const absolutePath = resolveToCwd(path, cwd);
415
415
 
@@ -12,6 +12,7 @@ import {
12
12
  restoreLineEndings,
13
13
  stripBom,
14
14
  } from "./edit-diff.js";
15
+ import type { FileDiagnosticsResult } from "./lsp/index.js";
15
16
  import { resolveToCwd } from "./path-utils.js";
16
17
 
17
18
  const editSchema = Type.Object({
@@ -27,9 +28,21 @@ export interface EditToolDetails {
27
28
  diff: string;
28
29
  /** Line number of the first change in the new file (for editor navigation) */
29
30
  firstChangedLine?: number;
31
+ /** Whether LSP diagnostics were retrieved */
32
+ hasDiagnostics?: boolean;
33
+ /** Diagnostic result (if available) */
34
+ diagnostics?: FileDiagnosticsResult;
30
35
  }
31
36
 
32
- export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
37
+ export interface EditToolOptions {
38
+ /** Whether to accept high-confidence fuzzy matches for whitespace/indentation (default: true) */
39
+ fuzzyMatch?: boolean;
40
+ /** Callback to get LSP diagnostics after editing a file */
41
+ getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
42
+ }
43
+
44
+ export function createEditTool(cwd: string, options: EditToolOptions = {}): AgentTool<typeof editSchema> {
45
+ const allowFuzzy = options.fuzzyMatch ?? true;
33
46
  return {
34
47
  name: "edit",
35
48
  label: "Edit",
@@ -108,7 +121,7 @@ Usage:
108
121
  const normalizedNewText = normalizeToLF(newText);
109
122
 
110
123
  const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
111
- allowFuzzy: true,
124
+ allowFuzzy,
112
125
  similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
113
126
  });
114
127
 
@@ -131,7 +144,7 @@ Usage:
131
144
  reject(
132
145
  new Error(
133
146
  formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
134
- allowFuzzy: true,
147
+ allowFuzzy,
135
148
  similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
136
149
  fuzzyMatches: matchOutcome.fuzzyMatches,
137
150
  }),
@@ -179,14 +192,39 @@ Usage:
179
192
  }
180
193
 
181
194
  const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
195
+
196
+ // Get LSP diagnostics if callback provided
197
+ let diagnosticsResult: FileDiagnosticsResult | undefined;
198
+ if (options.getDiagnostics) {
199
+ try {
200
+ diagnosticsResult = await options.getDiagnostics(absolutePath);
201
+ } catch {
202
+ // Ignore diagnostics errors - don't fail the edit
203
+ }
204
+ }
205
+
206
+ // Build result text
207
+ let resultText = `Successfully replaced text in ${path}.`;
208
+
209
+ // Append diagnostics if available and there are issues
210
+ if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
211
+ resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
212
+ resultText += diagnosticsResult.diagnostics.map((d) => ` ${d}`).join("\n");
213
+ }
214
+
182
215
  resolve({
183
216
  content: [
184
217
  {
185
218
  type: "text",
186
- text: `Successfully replaced text in ${path}.`,
219
+ text: resultText,
187
220
  },
188
221
  ],
189
- details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
222
+ details: {
223
+ diff: diffResult.diff,
224
+ firstChangedLine: diffResult.firstChangedLine,
225
+ hasDiagnostics: diagnosticsResult?.available ?? false,
226
+ diagnostics: diagnosticsResult,
227
+ },
190
228
  });
191
229
  } catch (error: any) {
192
230
  // Clean up abort handler
@@ -68,10 +68,11 @@ export function createGrepTool(cwd: string): AgentTool<typeof grepSchema> {
68
68
 
69
69
  Usage:
70
70
  - ALWAYS use grep for search tasks. NEVER invoke \`grep\` or \`rg\` as a bash command. The grep tool has been optimized for correct permissions and access.
71
+ - Searches recursively by default - no need for -r flag
71
72
  - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
72
- - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
73
+ - Filter files with glob parameter (e.g., "*.ts", "**/*.spec.ts") or type parameter (e.g., "ts", "py", "rust") - equivalent to grep's --include
73
74
  - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
74
- - Use task tool for open-ended searches requiring multiple rounds
75
+ - Pagination: Use headLimit to limit results (like \`| head -N\`), offset to skip first N results
75
76
  - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
76
77
  - Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\``,
77
78
  parameters: grepSchema,
@@ -1,13 +1,25 @@
1
1
  export { type AskToolDetails, askTool, createAskTool } from "./ask.js";
2
2
  export { type BashToolDetails, bashTool, createBashTool } from "./bash.js";
3
- export { createEditTool, editTool } from "./edit.js";
3
+ export { createEditTool, type EditToolOptions, editTool } from "./edit.js";
4
4
  // Exa MCP tools (22 tools)
5
5
  export { exaTools } from "./exa/index.js";
6
6
  export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types.js";
7
7
  export { createFindTool, type FindToolDetails, findTool } from "./find.js";
8
8
  export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js";
9
9
  export { createLsTool, type LsToolDetails, lsTool } from "./ls.js";
10
- export { createLspTool, type LspToolDetails, lspTool } from "./lsp/index.js";
10
+ export {
11
+ createLspTool,
12
+ type FileDiagnosticsResult,
13
+ type FileFormatResult,
14
+ formatFile,
15
+ getDiagnosticsForFile,
16
+ getLspStatus,
17
+ type LspServerStatus,
18
+ type LspToolDetails,
19
+ type LspWarmupResult,
20
+ lspTool,
21
+ warmupLspServers,
22
+ } from "./lsp/index.js";
11
23
  export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./notebook.js";
12
24
  export { createReadTool, type ReadToolDetails, readTool } from "./read.js";
13
25
  export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index.js";
@@ -31,7 +43,7 @@ export {
31
43
  webSearchLinkedinTool,
32
44
  webSearchTool,
33
45
  } from "./web-search/index.js";
34
- export { createWriteTool, writeTool } from "./write.js";
46
+ export { createWriteTool, type WriteToolDetails, type WriteToolOptions, writeTool } from "./write.js";
35
47
 
36
48
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
37
49
  import { askTool, createAskTool } from "./ask.js";
@@ -41,7 +53,7 @@ import { createEditTool, editTool } from "./edit.js";
41
53
  import { createFindTool, findTool } from "./find.js";
42
54
  import { createGrepTool, grepTool } from "./grep.js";
43
55
  import { createLsTool, lsTool } from "./ls.js";
44
- import { createLspTool, lspTool } from "./lsp/index.js";
56
+ import { createLspTool, formatFile, getDiagnosticsForFile, lspTool } from "./lsp/index.js";
45
57
  import { createNotebookTool, notebookTool } from "./notebook.js";
46
58
  import { createReadTool, readTool } from "./read.js";
47
59
  import { createTaskTool, taskTool } from "./task/index.js";
@@ -57,16 +69,47 @@ export interface SessionContext {
57
69
  getSessionFile: () => string | null;
58
70
  }
59
71
 
72
+ /** Options for creating coding tools */
73
+ export interface CodingToolsOptions {
74
+ /** Whether to fetch LSP diagnostics after write tool writes files (default: true) */
75
+ lspDiagnosticsOnWrite?: boolean;
76
+ /** Whether to fetch LSP diagnostics after edit tool edits files (default: false) */
77
+ lspDiagnosticsOnEdit?: boolean;
78
+ /** Whether to format files using LSP after write tool writes (default: true) */
79
+ lspFormatOnWrite?: boolean;
80
+ /** Whether to accept high-confidence fuzzy matches in edit tool (default: true) */
81
+ editFuzzyMatch?: boolean;
82
+ }
83
+
60
84
  // Factory function type
61
- type ToolFactory = (cwd: string, sessionContext?: SessionContext) => Tool;
85
+ type ToolFactory = (cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions) => Tool;
62
86
 
63
87
  // Tool definitions: static tools and their factory functions
64
88
  const toolDefs: Record<string, { tool: Tool; create: ToolFactory }> = {
65
89
  ask: { tool: askTool, create: createAskTool },
66
90
  read: { tool: readTool, create: createReadTool },
67
91
  bash: { tool: bashTool, create: createBashTool },
68
- edit: { tool: editTool, create: createEditTool },
69
- write: { tool: writeTool, create: createWriteTool },
92
+ edit: {
93
+ tool: editTool,
94
+ create: (cwd, _ctx, options) => {
95
+ const enableDiagnostics = options?.lspDiagnosticsOnEdit ?? false;
96
+ return createEditTool(cwd, {
97
+ fuzzyMatch: options?.editFuzzyMatch ?? true,
98
+ getDiagnostics: enableDiagnostics ? (absolutePath) => getDiagnosticsForFile(absolutePath, cwd) : undefined,
99
+ });
100
+ },
101
+ },
102
+ write: {
103
+ tool: writeTool,
104
+ create: (cwd, _ctx, options) => {
105
+ const enableFormat = options?.lspFormatOnWrite ?? true;
106
+ const enableDiagnostics = options?.lspDiagnosticsOnWrite ?? true;
107
+ return createWriteTool(cwd, {
108
+ formatOnWrite: enableFormat ? (absolutePath) => formatFile(absolutePath, cwd) : undefined,
109
+ getDiagnostics: enableDiagnostics ? (absolutePath) => getDiagnosticsForFile(absolutePath, cwd) : undefined,
110
+ });
111
+ },
112
+ },
70
113
  grep: { tool: grepTool, create: createGrepTool },
71
114
  find: { tool: findTool, create: createFindTool },
72
115
  ls: { tool: lsTool, create: createLsTool },
@@ -116,10 +159,16 @@ export const allTools = Object.fromEntries(Object.entries(toolDefs).map(([name,
116
159
  * @param cwd - Working directory for tools
117
160
  * @param hasUI - Whether UI is available (includes ask tool if true)
118
161
  * @param sessionContext - Optional session context for tools that need it
162
+ * @param options - Options for tool configuration
119
163
  */
120
- export function createCodingTools(cwd: string, hasUI = false, sessionContext?: SessionContext): Tool[] {
164
+ export function createCodingTools(
165
+ cwd: string,
166
+ hasUI = false,
167
+ sessionContext?: SessionContext,
168
+ options?: CodingToolsOptions,
169
+ ): Tool[] {
121
170
  const names = hasUI ? [...baseCodingToolNames, ...uiToolNames] : baseCodingToolNames;
122
- return names.map((name) => toolDefs[name].create(cwd, sessionContext));
171
+ return names.map((name) => toolDefs[name].create(cwd, sessionContext, options));
123
172
  }
124
173
 
125
174
  /**
@@ -127,20 +176,31 @@ export function createCodingTools(cwd: string, hasUI = false, sessionContext?: S
127
176
  * @param cwd - Working directory for tools
128
177
  * @param hasUI - Whether UI is available (includes ask tool if true)
129
178
  * @param sessionContext - Optional session context for tools that need it
179
+ * @param options - Options for tool configuration
130
180
  */
131
- export function createReadOnlyTools(cwd: string, hasUI = false, sessionContext?: SessionContext): Tool[] {
181
+ export function createReadOnlyTools(
182
+ cwd: string,
183
+ hasUI = false,
184
+ sessionContext?: SessionContext,
185
+ options?: CodingToolsOptions,
186
+ ): Tool[] {
132
187
  const names = hasUI ? [...baseReadOnlyToolNames, ...uiToolNames] : baseReadOnlyToolNames;
133
- return names.map((name) => toolDefs[name].create(cwd, sessionContext));
188
+ return names.map((name) => toolDefs[name].create(cwd, sessionContext, options));
134
189
  }
135
190
 
136
191
  /**
137
192
  * Create all tools configured for a specific working directory.
138
193
  * @param cwd - Working directory for tools
139
194
  * @param sessionContext - Optional session context for tools that need it
195
+ * @param options - Options for tool configuration
140
196
  */
141
- export function createAllTools(cwd: string, sessionContext?: SessionContext): Record<ToolName, Tool> {
197
+ export function createAllTools(
198
+ cwd: string,
199
+ sessionContext?: SessionContext,
200
+ options?: CodingToolsOptions,
201
+ ): Record<ToolName, Tool> {
142
202
  return Object.fromEntries(
143
- Object.entries(toolDefs).map(([name, def]) => [name, def.create(cwd, sessionContext)]),
203
+ Object.entries(toolDefs).map(([name, def]) => [name, def.create(cwd, sessionContext, options)]),
144
204
  ) as Record<ToolName, Tool>;
145
205
  }
146
206
 
@@ -17,20 +17,32 @@ import { detectLanguageId, fileToUri } from "./utils.js";
17
17
 
18
18
  const clients = new Map<string, LspClient>();
19
19
 
20
- // Idle timeout: shutdown clients after 5 minutes of inactivity
21
- const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
20
+ // Idle timeout configuration (disabled by default)
21
+ let idleTimeoutMs: number | null = null;
22
+ let idleCheckInterval: Timer | null = null;
22
23
  const IDLE_CHECK_INTERVAL_MS = 60 * 1000;
23
24
 
24
- // Background task to shutdown idle clients
25
- let idleCheckInterval: Timer | null = null;
25
+ /**
26
+ * Configure the idle timeout for LSP clients.
27
+ * @param ms - Timeout in milliseconds, or null/undefined to disable
28
+ */
29
+ export function setIdleTimeout(ms: number | null | undefined): void {
30
+ idleTimeoutMs = ms ?? null;
31
+
32
+ if (idleTimeoutMs && idleTimeoutMs > 0) {
33
+ startIdleChecker();
34
+ } else {
35
+ stopIdleChecker();
36
+ }
37
+ }
26
38
 
27
39
  function startIdleChecker(): void {
28
40
  if (idleCheckInterval) return;
29
41
  idleCheckInterval = setInterval(() => {
42
+ if (!idleTimeoutMs) return;
30
43
  const now = Date.now();
31
44
  for (const [key, client] of Array.from(clients.entries())) {
32
- if (now - client.lastActivity > IDLE_TIMEOUT_MS) {
33
- console.log(`[LSP] Shutting down idle client: ${key}`);
45
+ if (now - client.lastActivity > idleTimeoutMs) {
34
46
  shutdownClient(key);
35
47
  }
36
48
  }
@@ -98,6 +110,12 @@ const CLIENT_CAPABILITIES = {
98
110
  properties: ["edit"],
99
111
  },
100
112
  },
113
+ formatting: {
114
+ dynamicRegistration: false,
115
+ },
116
+ rangeFormatting: {
117
+ dynamicRegistration: false,
118
+ },
101
119
  publishDiagnostics: {
102
120
  relatedInformation: true,
103
121
  versionSupport: false,
@@ -357,7 +375,8 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string): Prom
357
375
  }
358
376
 
359
377
  const args = config.args ?? [];
360
- const proc = Bun.spawn([config.command, ...args], {
378
+ const command = config.resolvedCommand ?? config.command;
379
+ const proc = Bun.spawn([command, ...args], {
361
380
  cwd,
362
381
  stdin: "pipe",
363
382
  stdout: "pipe",
@@ -379,16 +398,9 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string): Prom
379
398
  };
380
399
  clients.set(key, client);
381
400
 
382
- // Start idle checker if not already running
383
- startIdleChecker();
384
-
385
401
  // Register crash recovery - remove client on process exit
386
402
  proc.exited.then(() => {
387
- console.log(`[LSP] Process exited: ${key}`);
388
403
  clients.delete(key);
389
- if (clients.size === 0) {
390
- stopIdleChecker();
391
- }
392
404
  });
393
405
 
394
406
  // Start background message reader
@@ -497,10 +509,6 @@ export function shutdownClient(key: string): void {
497
509
  // Kill process
498
510
  client.process.kill();
499
511
  clients.delete(key);
500
-
501
- if (clients.size === 0) {
502
- stopIdleChecker();
503
- }
504
512
  }
505
513
 
506
514
  // =============================================================================
@@ -570,8 +578,6 @@ export async function sendNotification(client: LspClient, method: string, params
570
578
  * Shutdown all LSP clients.
571
579
  */
572
580
  export function shutdownAll(): void {
573
- stopIdleChecker();
574
-
575
581
  for (const client of Array.from(clients.values())) {
576
582
  // Reject all pending requests
577
583
  for (const pending of Array.from(client.pendingRequests.values())) {
@@ -587,6 +593,25 @@ export function shutdownAll(): void {
587
593
  clients.clear();
588
594
  }
589
595
 
596
+ /** Status of an LSP server */
597
+ export interface LspServerStatus {
598
+ name: string;
599
+ status: "connecting" | "ready" | "error";
600
+ fileTypes: string[];
601
+ error?: string;
602
+ }
603
+
604
+ /**
605
+ * Get status of all active LSP clients.
606
+ */
607
+ export function getActiveClients(): LspServerStatus[] {
608
+ return Array.from(clients.values()).map((client) => ({
609
+ name: client.config.command,
610
+ status: "ready" as const,
611
+ fileTypes: client.config.fileTypes,
612
+ }));
613
+ }
614
+
590
615
  // =============================================================================
591
616
  // Process Cleanup
592
617
  // =============================================================================