@oh-my-pi/pi-coding-agent 1.338.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 (42) hide show
  1. package/CHANGELOG.md +60 -1
  2. package/package.json +3 -3
  3. package/src/cli/args.ts +8 -0
  4. package/src/core/agent-session.ts +32 -14
  5. package/src/core/export-html/index.ts +48 -15
  6. package/src/core/export-html/template.html +3 -11
  7. package/src/core/mcp/client.ts +43 -16
  8. package/src/core/mcp/config.ts +152 -6
  9. package/src/core/mcp/index.ts +6 -2
  10. package/src/core/mcp/loader.ts +30 -3
  11. package/src/core/mcp/manager.ts +69 -10
  12. package/src/core/mcp/types.ts +9 -3
  13. package/src/core/model-resolver.ts +101 -0
  14. package/src/core/sdk.ts +65 -18
  15. package/src/core/session-manager.ts +117 -14
  16. package/src/core/settings-manager.ts +107 -19
  17. package/src/core/title-generator.ts +94 -0
  18. package/src/core/tools/bash.ts +1 -2
  19. package/src/core/tools/edit-diff.ts +2 -2
  20. package/src/core/tools/edit.ts +43 -5
  21. package/src/core/tools/grep.ts +3 -2
  22. package/src/core/tools/index.ts +73 -13
  23. package/src/core/tools/lsp/client.ts +45 -20
  24. package/src/core/tools/lsp/config.ts +708 -34
  25. package/src/core/tools/lsp/index.ts +423 -23
  26. package/src/core/tools/lsp/types.ts +5 -0
  27. package/src/core/tools/task/bundled-agents/explore.md +1 -1
  28. package/src/core/tools/task/bundled-agents/reviewer.md +1 -1
  29. package/src/core/tools/task/model-resolver.ts +52 -3
  30. package/src/core/tools/write.ts +67 -4
  31. package/src/index.ts +5 -0
  32. package/src/main.ts +23 -2
  33. package/src/modes/interactive/components/model-selector.ts +96 -18
  34. package/src/modes/interactive/components/session-selector.ts +20 -7
  35. package/src/modes/interactive/components/settings-defs.ts +59 -2
  36. package/src/modes/interactive/components/settings-selector.ts +8 -11
  37. package/src/modes/interactive/components/tool-execution.ts +18 -0
  38. package/src/modes/interactive/components/tree-selector.ts +2 -2
  39. package/src/modes/interactive/components/welcome.ts +40 -3
  40. package/src/modes/interactive/interactive-mode.ts +87 -10
  41. package/src/core/export-html/vendor/highlight.min.js +0 -1213
  42. package/src/core/export-html/vendor/marked.min.js +0 -6
@@ -3,9 +3,17 @@ import path from "node:path";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
4
  import type { Theme } from "../../../modes/interactive/theme/theme.js";
5
5
  import { resolveToCwd } from "../path-utils.js";
6
- import { ensureFileOpen, getOrCreateClient, refreshFile, sendRequest } from "./client.js";
7
- import { getServerForFile, hasCapability, type LspConfig, loadConfig } from "./config.js";
8
- import { applyWorkspaceEdit } from "./edits.js";
6
+ import {
7
+ ensureFileOpen,
8
+ getActiveClients,
9
+ getOrCreateClient,
10
+ type LspServerStatus,
11
+ refreshFile,
12
+ sendRequest,
13
+ setIdleTimeout,
14
+ } from "./client.js";
15
+ import { getServerForFile, getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config.js";
16
+ import { applyTextEdits, applyWorkspaceEdit } from "./edits.js";
9
17
  import { renderCall, renderResult } from "./render.js";
10
18
  import * as rustAnalyzer from "./rust-analyzer.js";
11
19
  import {
@@ -25,6 +33,7 @@ import {
25
33
  lspSchema,
26
34
  type ServerConfig,
27
35
  type SymbolInformation,
36
+ type TextEdit,
28
37
  type WorkspaceEdit,
29
38
  } from "./types.js";
30
39
  import {
@@ -41,8 +50,68 @@ import {
41
50
  uriToFile,
42
51
  } from "./utils.js";
43
52
 
53
+ export type { LspServerStatus } from "./client.js";
44
54
  export type { LspToolDetails } from "./types.js";
45
55
 
56
+ /** Result from warming up LSP servers */
57
+ export interface LspWarmupResult {
58
+ servers: Array<{
59
+ name: string;
60
+ status: "ready" | "error";
61
+ fileTypes: string[];
62
+ error?: string;
63
+ }>;
64
+ }
65
+
66
+ /**
67
+ * Warm up LSP servers for a directory by connecting to all detected servers.
68
+ * This should be called at startup to avoid cold-start delays.
69
+ *
70
+ * @param cwd - Working directory to detect and start servers for
71
+ * @returns Status of each server that was started
72
+ */
73
+ export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
74
+ const config = loadConfig(cwd);
75
+ setIdleTimeout(config.idleTimeoutMs);
76
+ const servers: LspWarmupResult["servers"] = [];
77
+
78
+ // Start all detected servers in parallel
79
+ const results = await Promise.allSettled(
80
+ Object.entries(config.servers).map(async ([name, serverConfig]) => {
81
+ const client = await getOrCreateClient(serverConfig, cwd);
82
+ return { name, client, fileTypes: serverConfig.fileTypes };
83
+ }),
84
+ );
85
+
86
+ for (const result of results) {
87
+ if (result.status === "fulfilled") {
88
+ servers.push({
89
+ name: result.value.name,
90
+ status: "ready",
91
+ fileTypes: result.value.fileTypes,
92
+ });
93
+ } else {
94
+ // Extract server name from error if possible
95
+ const errorMsg = result.reason?.message ?? String(result.reason);
96
+ servers.push({
97
+ name: "unknown",
98
+ status: "error",
99
+ fileTypes: [],
100
+ error: errorMsg,
101
+ });
102
+ }
103
+ }
104
+
105
+ return { servers };
106
+ }
107
+
108
+ /**
109
+ * Get status of currently active LSP servers.
110
+ */
111
+ export function getLspStatus(): LspServerStatus[] {
112
+ return getActiveClients();
113
+ }
114
+
46
115
  // Cache config per cwd to avoid repeated file I/O
47
116
  const configCache = new Map<string, LspConfig>();
48
117
 
@@ -50,6 +119,7 @@ function getConfig(cwd: string): LspConfig {
50
119
  let config = configCache.get(cwd);
51
120
  if (!config) {
52
121
  config = loadConfig(cwd);
122
+ setIdleTimeout(config.idleTimeoutMs);
53
123
  configCache.set(cwd, config);
54
124
  }
55
125
  return config;
@@ -139,6 +209,303 @@ async function waitForDiagnostics(client: LspClient, uri: string, timeoutMs = 30
139
209
  return client.diagnostics.get(uri) ?? [];
140
210
  }
141
211
 
212
+ /** Project type detection result */
213
+ interface ProjectType {
214
+ type: "rust" | "typescript" | "go" | "python" | "unknown";
215
+ command?: string[];
216
+ description: string;
217
+ }
218
+
219
+ /** Detect project type from root markers */
220
+ function detectProjectType(cwd: string): ProjectType {
221
+ // Check for Rust (Cargo.toml)
222
+ if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
223
+ return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" };
224
+ }
225
+
226
+ // Check for TypeScript (tsconfig.json)
227
+ if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
228
+ return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" };
229
+ }
230
+
231
+ // Check for Go (go.mod)
232
+ if (fs.existsSync(path.join(cwd, "go.mod"))) {
233
+ return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" };
234
+ }
235
+
236
+ // Check for Python (pyproject.toml or pyrightconfig.json)
237
+ if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "pyrightconfig.json"))) {
238
+ return { type: "python", command: ["pyright"], description: "Python (pyright)" };
239
+ }
240
+
241
+ return { type: "unknown", description: "Unknown project type" };
242
+ }
243
+
244
+ /** Run workspace diagnostics command and parse output */
245
+ async function runWorkspaceDiagnostics(
246
+ cwd: string,
247
+ config: LspConfig,
248
+ ): Promise<{ output: string; projectType: ProjectType }> {
249
+ const projectType = detectProjectType(cwd);
250
+
251
+ // For Rust, use flycheck via rust-analyzer if available
252
+ if (projectType.type === "rust") {
253
+ const rustServer = getRustServer(config);
254
+ if (rustServer && hasCapability(rustServer[1], "flycheck")) {
255
+ const [_serverName, serverConfig] = rustServer;
256
+ try {
257
+ const client = await getOrCreateClient(serverConfig, cwd);
258
+ await rustAnalyzer.flycheck(client);
259
+
260
+ const collected: Array<{ filePath: string; diagnostic: Diagnostic }> = [];
261
+ for (const [diagUri, diags] of client.diagnostics.entries()) {
262
+ const relPath = path.relative(cwd, uriToFile(diagUri));
263
+ for (const diag of diags) {
264
+ collected.push({ filePath: relPath, diagnostic: diag });
265
+ }
266
+ }
267
+
268
+ if (collected.length === 0) {
269
+ return { output: "No issues found", projectType };
270
+ }
271
+
272
+ const summary = formatDiagnosticsSummary(collected.map((d) => d.diagnostic));
273
+ const formatted = collected.slice(0, 50).map((d) => formatDiagnostic(d.diagnostic, d.filePath));
274
+ const more = collected.length > 50 ? `\n ... and ${collected.length - 50} more` : "";
275
+ return { output: `${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}${more}`, projectType };
276
+ } catch (e) {
277
+ // Fall through to shell command
278
+ }
279
+ }
280
+ }
281
+
282
+ // Fall back to shell command
283
+ if (!projectType.command) {
284
+ return {
285
+ output: `Cannot detect project type. Supported: Rust (Cargo.toml), TypeScript (tsconfig.json), Go (go.mod), Python (pyproject.toml)`,
286
+ projectType,
287
+ };
288
+ }
289
+
290
+ try {
291
+ const proc = Bun.spawn(projectType.command, {
292
+ cwd,
293
+ stdout: "pipe",
294
+ stderr: "pipe",
295
+ });
296
+
297
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
298
+ await proc.exited;
299
+
300
+ const combined = (stdout + stderr).trim();
301
+ if (!combined) {
302
+ return { output: "No issues found", projectType };
303
+ }
304
+
305
+ // Limit output length
306
+ const lines = combined.split("\n");
307
+ if (lines.length > 50) {
308
+ return { output: lines.slice(0, 50).join("\n") + `\n... and ${lines.length - 50} more lines`, projectType };
309
+ }
310
+
311
+ return { output: combined, projectType };
312
+ } catch (e) {
313
+ return { output: `Failed to run ${projectType.command.join(" ")}: ${e}`, projectType };
314
+ }
315
+ }
316
+
317
+ /** Result from getDiagnosticsForFile */
318
+ export interface FileDiagnosticsResult {
319
+ /** Whether an LSP server was available for the file type */
320
+ available: boolean;
321
+ /** Name of the LSP server used (if available) */
322
+ serverName?: string;
323
+ /** Formatted diagnostic messages */
324
+ diagnostics: string[];
325
+ /** Summary string (e.g., "2 error(s), 1 warning(s)") */
326
+ summary: string;
327
+ /** Whether there are any errors (severity 1) */
328
+ hasErrors: boolean;
329
+ /** Whether there are any warnings (severity 2) */
330
+ hasWarnings: boolean;
331
+ }
332
+
333
+ /**
334
+ * Get LSP diagnostics for a file after it has been written.
335
+ * Queries all applicable language servers (e.g., TypeScript + Biome) and merges results.
336
+ *
337
+ * @param absolutePath - Absolute path to the file
338
+ * @param cwd - Working directory for LSP config resolution
339
+ * @param timeoutMs - Timeout for waiting for diagnostics (default: 5000ms)
340
+ * @returns Diagnostic results or null if no LSP server available
341
+ */
342
+ export async function getDiagnosticsForFile(
343
+ absolutePath: string,
344
+ cwd: string,
345
+ timeoutMs = 5000,
346
+ ): Promise<FileDiagnosticsResult> {
347
+ const config = getConfig(cwd);
348
+ const servers = getServersForFile(config, absolutePath);
349
+
350
+ if (servers.length === 0) {
351
+ return {
352
+ available: false,
353
+ diagnostics: [],
354
+ summary: "",
355
+ hasErrors: false,
356
+ hasWarnings: false,
357
+ };
358
+ }
359
+
360
+ const uri = fileToUri(absolutePath);
361
+ const relPath = path.relative(cwd, absolutePath);
362
+ const allDiagnostics: Diagnostic[] = [];
363
+ const serverNames: string[] = [];
364
+
365
+ // Query all applicable servers in parallel
366
+ const results = await Promise.allSettled(
367
+ servers.map(async ([serverName, serverConfig]) => {
368
+ const client = await getOrCreateClient(serverConfig, cwd);
369
+ await refreshFile(client, absolutePath);
370
+ const diagnostics = await waitForDiagnostics(client, uri, timeoutMs);
371
+ return { serverName, diagnostics };
372
+ }),
373
+ );
374
+
375
+ for (const result of results) {
376
+ if (result.status === "fulfilled") {
377
+ serverNames.push(result.value.serverName);
378
+ allDiagnostics.push(...result.value.diagnostics);
379
+ }
380
+ }
381
+
382
+ if (serverNames.length === 0) {
383
+ // All servers failed
384
+ return {
385
+ available: false,
386
+ diagnostics: [],
387
+ summary: "",
388
+ hasErrors: false,
389
+ hasWarnings: false,
390
+ };
391
+ }
392
+
393
+ if (allDiagnostics.length === 0) {
394
+ return {
395
+ available: true,
396
+ serverName: serverNames.join(", "),
397
+ diagnostics: [],
398
+ summary: "No issues",
399
+ hasErrors: false,
400
+ hasWarnings: false,
401
+ };
402
+ }
403
+
404
+ // Deduplicate diagnostics by range + message (different servers might report similar issues)
405
+ const seen = new Set<string>();
406
+ const uniqueDiagnostics: Diagnostic[] = [];
407
+ for (const d of allDiagnostics) {
408
+ const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
409
+ if (!seen.has(key)) {
410
+ seen.add(key);
411
+ uniqueDiagnostics.push(d);
412
+ }
413
+ }
414
+
415
+ const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
416
+ const summary = formatDiagnosticsSummary(uniqueDiagnostics);
417
+ const hasErrors = uniqueDiagnostics.some((d) => d.severity === 1);
418
+ const hasWarnings = uniqueDiagnostics.some((d) => d.severity === 2);
419
+
420
+ return {
421
+ available: true,
422
+ serverName: serverNames.join(", "),
423
+ diagnostics: formatted,
424
+ summary,
425
+ hasErrors,
426
+ hasWarnings,
427
+ };
428
+ }
429
+
430
+ /** Result from formatFile */
431
+ export interface FileFormatResult {
432
+ /** Whether an LSP server with formatting support was available */
433
+ available: boolean;
434
+ /** Name of the LSP server used (if available) */
435
+ serverName?: string;
436
+ /** Whether formatting was applied */
437
+ formatted: boolean;
438
+ /** Error message if formatting failed */
439
+ error?: string;
440
+ }
441
+
442
+ /** Default formatting options for LSP */
443
+ const DEFAULT_FORMAT_OPTIONS = {
444
+ tabSize: 3,
445
+ insertSpaces: true,
446
+ trimTrailingWhitespace: true,
447
+ insertFinalNewline: true,
448
+ trimFinalNewlines: true,
449
+ };
450
+
451
+ /**
452
+ * Format a file using LSP.
453
+ * Uses the first available server that supports formatting.
454
+ *
455
+ * @param absolutePath - Absolute path to the file
456
+ * @param cwd - Working directory for LSP config resolution
457
+ * @returns Format result indicating success/failure
458
+ */
459
+ export async function formatFile(absolutePath: string, cwd: string): Promise<FileFormatResult> {
460
+ const config = getConfig(cwd);
461
+ const servers = getServersForFile(config, absolutePath);
462
+
463
+ if (servers.length === 0) {
464
+ return { available: false, formatted: false };
465
+ }
466
+
467
+ const uri = fileToUri(absolutePath);
468
+
469
+ // Try each server until one successfully formats
470
+ for (const [serverName, serverConfig] of servers) {
471
+ try {
472
+ const client = await getOrCreateClient(serverConfig, cwd);
473
+
474
+ // Check if server supports formatting
475
+ const caps = client.serverCapabilities;
476
+ if (!caps?.documentFormattingProvider) {
477
+ continue;
478
+ }
479
+
480
+ // Ensure file is open and synced
481
+ await ensureFileOpen(client, absolutePath);
482
+ await refreshFile(client, absolutePath);
483
+
484
+ // Request formatting
485
+ const edits = (await sendRequest(client, "textDocument/formatting", {
486
+ textDocument: { uri },
487
+ options: DEFAULT_FORMAT_OPTIONS,
488
+ })) as TextEdit[] | null;
489
+
490
+ if (!edits || edits.length === 0) {
491
+ // No changes needed - file already formatted
492
+ return { available: true, serverName, formatted: false };
493
+ }
494
+
495
+ // Apply the formatting edits
496
+ await applyTextEdits(absolutePath, edits);
497
+
498
+ // Notify LSP of the change so diagnostics update
499
+ await refreshFile(client, absolutePath);
500
+
501
+ return { available: true, serverName, formatted: true };
502
+ } catch {}
503
+ }
504
+
505
+ // No server could format
506
+ return { available: false, formatted: false };
507
+ }
508
+
142
509
  export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolDetails, Theme> {
143
510
  return {
144
511
  name: "lsp",
@@ -147,6 +514,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
147
514
 
148
515
  Standard operations:
149
516
  - diagnostics: Get errors/warnings for a file
517
+ - workspace_diagnostics: Check entire project for errors (uses tsc, cargo check, go build, etc.)
150
518
  - definition: Go to symbol definition
151
519
  - references: Find all references to a symbol
152
520
  - hover: Get type info and documentation
@@ -201,7 +569,21 @@ Rust-analyzer specific (require rust-analyzer):
201
569
  };
202
570
  }
203
571
 
204
- // Diagnostics can be batch or single-file
572
+ // Workspace diagnostics - check entire project
573
+ if (action === "workspace_diagnostics") {
574
+ const result = await runWorkspaceDiagnostics(cwd, config);
575
+ return {
576
+ content: [
577
+ {
578
+ type: "text",
579
+ text: `Workspace diagnostics (${result.projectType.description}):\n${result.output}`,
580
+ },
581
+ ],
582
+ details: { action, success: true },
583
+ };
584
+ }
585
+
586
+ // Diagnostics can be batch or single-file - queries all applicable servers
205
587
  if (action === "diagnostics") {
206
588
  const targets = files?.length ? files : file ? [file] : null;
207
589
  if (!targets) {
@@ -213,49 +595,67 @@ Rust-analyzer specific (require rust-analyzer):
213
595
 
214
596
  const detailed = Boolean(files?.length);
215
597
  const results: string[] = [];
216
- let lastServerName: string | undefined;
598
+ const allServerNames = new Set<string>();
217
599
 
218
600
  for (const target of targets) {
219
601
  const resolved = resolveToCwd(target, cwd);
220
- const serverInfo = getServerForFile(config, resolved);
221
- if (!serverInfo) {
602
+ const servers = getServersForFile(config, resolved);
603
+ if (servers.length === 0) {
222
604
  results.push(`✗ ${target}: No language server found`);
223
605
  continue;
224
606
  }
225
607
 
226
- const [serverName, serverConfig] = serverInfo;
227
- lastServerName = serverName;
228
-
229
- const client = await getOrCreateClient(serverConfig, cwd);
230
- await refreshFile(client, resolved);
231
-
232
608
  const uri = fileToUri(resolved);
233
- const diagnostics = await waitForDiagnostics(client, uri);
234
609
  const relPath = path.relative(cwd, resolved);
610
+ const allDiagnostics: Diagnostic[] = [];
611
+
612
+ // Query all applicable servers for this file
613
+ for (const [serverName, serverConfig] of servers) {
614
+ allServerNames.add(serverName);
615
+ try {
616
+ const client = await getOrCreateClient(serverConfig, cwd);
617
+ await refreshFile(client, resolved);
618
+ const diagnostics = await waitForDiagnostics(client, uri);
619
+ allDiagnostics.push(...diagnostics);
620
+ } catch {
621
+ // Server failed, continue with others
622
+ }
623
+ }
624
+
625
+ // Deduplicate diagnostics
626
+ const seen = new Set<string>();
627
+ const uniqueDiagnostics: Diagnostic[] = [];
628
+ for (const d of allDiagnostics) {
629
+ const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
630
+ if (!seen.has(key)) {
631
+ seen.add(key);
632
+ uniqueDiagnostics.push(d);
633
+ }
634
+ }
235
635
 
236
636
  if (!detailed && targets.length === 1) {
237
- if (diagnostics.length === 0) {
637
+ if (uniqueDiagnostics.length === 0) {
238
638
  return {
239
639
  content: [{ type: "text", text: "No diagnostics" }],
240
- details: { action, serverName, success: true },
640
+ details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
241
641
  };
242
642
  }
243
643
 
244
- const summary = formatDiagnosticsSummary(diagnostics);
245
- const formatted = diagnostics.map((d) => formatDiagnostic(d, relPath));
644
+ const summary = formatDiagnosticsSummary(uniqueDiagnostics);
645
+ const formatted = uniqueDiagnostics.map((d) => formatDiagnostic(d, relPath));
246
646
  const output = `${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}`;
247
647
  return {
248
648
  content: [{ type: "text", text: output }],
249
- details: { action, serverName, success: true },
649
+ details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
250
650
  };
251
651
  }
252
652
 
253
- if (diagnostics.length === 0) {
653
+ if (uniqueDiagnostics.length === 0) {
254
654
  results.push(`✓ ${relPath}: no issues`);
255
655
  } else {
256
- const summary = formatDiagnosticsSummary(diagnostics);
656
+ const summary = formatDiagnosticsSummary(uniqueDiagnostics);
257
657
  results.push(`✗ ${relPath}: ${summary}`);
258
- for (const diag of diagnostics) {
658
+ for (const diag of uniqueDiagnostics) {
259
659
  results.push(` ${formatDiagnostic(diag, relPath)}`);
260
660
  }
261
661
  }
@@ -263,7 +663,7 @@ Rust-analyzer specific (require rust-analyzer):
263
663
 
264
664
  return {
265
665
  content: [{ type: "text", text: results.join("\n") }],
266
- details: { action, serverName: lastServerName, success: true },
666
+ details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
267
667
  };
268
668
  }
269
669
 
@@ -10,6 +10,7 @@ export const lspSchema = Type.Object({
10
10
  [
11
11
  // Standard LSP operations
12
12
  Type.Literal("diagnostics"),
13
+ Type.Literal("workspace_diagnostics"),
13
14
  Type.Literal("references"),
14
15
  Type.Literal("definition"),
15
16
  Type.Literal("hover"),
@@ -336,6 +337,10 @@ export interface ServerConfig {
336
337
  settings?: Record<string, unknown>;
337
338
  disabled?: boolean;
338
339
  capabilities?: ServerCapabilities;
340
+ /** If true, this is a linter/formatter server (e.g., Biome) - used only for diagnostics/actions, not type intelligence */
341
+ isLinter?: boolean;
342
+ /** Resolved absolute path to the command binary (set during config loading) */
343
+ resolvedCommand?: string;
339
344
  }
340
345
 
341
346
  // =============================================================================
@@ -2,7 +2,7 @@
2
2
  name: explore
3
3
  description: Fast read-only codebase scout that returns compressed context for handoff
4
4
  tools: read, grep, glob, ls, bash
5
- model: claude-haiku-4-5, haiku, flash, mini
5
+ model: pi/smol, haiku, flash, mini
6
6
  ---
7
7
 
8
8
  You are a file search specialist and codebase scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
@@ -2,7 +2,7 @@
2
2
  name: reviewer
3
3
  description: Expert code reviewer for PRs and implementation changes
4
4
  tools: read, grep, glob, ls, bash
5
- model: gpt-5.2-codex, gpt-5.2, codex, gpt
5
+ model: pi/slow, gpt-5.2-codex, gpt-5.2, codex, gpt
6
6
  ---
7
7
 
8
8
  You are an expert code reviewer. Analyze code changes and provide thorough reviews.
@@ -6,9 +6,14 @@
6
6
  * - Fuzzy match: "opus" → "claude-opus-4-5"
7
7
  * - Comma fallback: "gpt, opus" → tries gpt first, then opus
8
8
  * - "default" → undefined (use system default)
9
+ * - "pi/default" → configured default model from settings
10
+ * - "pi/smol" → configured smol model from settings
9
11
  */
10
12
 
11
13
  import { spawnSync } from "node:child_process";
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
12
17
 
13
18
  /** pi command: 'pi.cmd' on Windows, 'pi' elsewhere */
14
19
  const PI_CMD = process.platform === "win32" ? "pi.cmd" : "pi";
@@ -74,6 +79,42 @@ export function clearModelCache(): void {
74
79
  cacheExpiry = 0;
75
80
  }
76
81
 
82
+ /**
83
+ * Load model roles from settings file.
84
+ */
85
+ function loadModelRoles(): Record<string, string> {
86
+ try {
87
+ const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
88
+ if (!existsSync(settingsPath)) return {};
89
+ const content = readFileSync(settingsPath, "utf-8");
90
+ const settings = JSON.parse(content);
91
+ return settings.modelRoles ?? {};
92
+ } catch {
93
+ return {};
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Resolve a pi/<role> alias to a model string.
99
+ * Looks up the role in settings.modelRoles and returns the configured model.
100
+ * Returns undefined if the role isn't configured.
101
+ */
102
+ function resolvePiAlias(role: string, availableModels: string[]): string | undefined {
103
+ const roles = loadModelRoles();
104
+
105
+ // Look up role in settings (case-insensitive)
106
+ const configured = roles[role] || roles[role.toLowerCase()];
107
+ if (!configured) return undefined;
108
+
109
+ // configured is in "provider/modelId" format, extract just the modelId for matching
110
+ const slashIdx = configured.indexOf("/");
111
+ if (slashIdx <= 0) return undefined;
112
+
113
+ const modelId = configured.slice(slashIdx + 1);
114
+ // Find in available models
115
+ return availableModels.find((m) => m.toLowerCase() === modelId.toLowerCase());
116
+ }
117
+
77
118
  /**
78
119
  * Resolve a fuzzy model pattern to an actual model name.
79
120
  *
@@ -97,16 +138,24 @@ export function resolveModelPattern(pattern: string | undefined, availableModels
97
138
  // Split by comma, try each pattern in order
98
139
  const patterns = pattern
99
140
  .split(",")
100
- .map((p) => p.trim().toLowerCase())
141
+ .map((p) => p.trim())
101
142
  .filter(Boolean);
102
143
 
103
144
  for (const p of patterns) {
145
+ // Handle pi/<role> aliases - looks up role in settings.modelRoles
146
+ if (p.toLowerCase().startsWith("pi/")) {
147
+ const role = p.slice(3); // Remove "pi/" prefix
148
+ const resolved = resolvePiAlias(role, models);
149
+ if (resolved) return resolved;
150
+ continue; // Role not configured, try next pattern
151
+ }
152
+
104
153
  // Try exact match first
105
- const exactMatch = models.find((m) => m.toLowerCase() === p);
154
+ const exactMatch = models.find((m) => m.toLowerCase() === p.toLowerCase());
106
155
  if (exactMatch) return exactMatch;
107
156
 
108
157
  // Try fuzzy match (substring)
109
- const fuzzyMatch = models.find((m) => m.toLowerCase().includes(p));
158
+ const fuzzyMatch = models.find((m) => m.toLowerCase().includes(p.toLowerCase()));
110
159
  if (fuzzyMatch) return fuzzyMatch;
111
160
  }
112
161