@oh-my-pi/pi-coding-agent 14.0.4 → 14.0.5

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.0.5] - 2026-04-11
6
+ ### Added
7
+
8
+ - Added `designer` model role for UI/UX design tasks with Gemini 3.1 Pro as default model
9
+ - Added support for model role fallback lists — roles can now resolve to multiple model patterns with automatic fallback to next available model
10
+ - Added `extractReadableFromHtml` utility function to extract readable content from HTML with Readability article extraction and CSS selector fallback
11
+ - Added support for GFM (GitHub Flavored Markdown) features including tables, strikethrough, and task lists in HTML-to-markdown conversion
12
+ - Added `resolveDiagnosticTargets` utility function to handle glob pattern resolution with fallback to literal file paths for bracket-style paths
13
+
14
+ ### Changed
15
+
16
+ - Clarified fenced code block editing behavior in markdown — the tool now preserves literal indentation inside fenced blocks, with content written verbatim as supplied
17
+ - Updated guidance for inserting content after markdown section headings to use `after` on the heading chunk rather than `before`/`prepend` on the section itself
18
+ - Reduced default image resize limits to 1568px (from 2000px) and 500KB (from 4.5MB) to match Anthropic's internal downscaling threshold and reduce payload sizes in tool calls
19
+ - Adjusted screenshot compression to use 1024px max dimensions and 150KB budget for more aggressive optimization of browser screenshots in LLM requests
20
+ - Updated JPEG quality defaults from 80 to 75 and refined quality ladder steps (70, 60, 50, 40) for tighter byte budgets
21
+ - Improved image resize fast-path to skip re-encoding when images are already within dimensions and at ≤25% of byte budget, avoiding unnecessary processing of small icons and diagrams
22
+ - Clarified that chunk names are truncated and must be copied from `read` or `?` output rather than constructed from source identifiers
23
+ - Enhanced guidance for editing fenced code blocks in markdown to preserve exact whitespace using `raw` reads, as the tool normalizes tabs to spaces which can damage indentation-sensitive content
24
+ - Updated designer agent to use `pi/designer` role alias instead of explicit model list
25
+ - Refactored model role resolution to support multiple fallback patterns per role, improving model availability handling
26
+ - Replaced regex-based HTML-to-markdown conversion with Turndown library and GFM plugin for more accurate formatting of complex HTML structures
27
+ - Simplified no-changes response to omit redundant response text when chunk content already matches
28
+ - Clarified region suffix behavior on leaf and compound statement chunks — `~` and `^` now fall back to whole-chunk replacement with explicit guidance to supply complete structural content
29
+ - Updated CRC refresh guidance to direct users to use CRCs from edit responses or run `read(path="file", sel="?")`
30
+ - Added clarification that region suffixes fall back to whole-chunk replacement for prose and data formats (markdown, YAML, JSON, fenced code blocks, frontmatter)
31
+ - Documented `L20` shorthand syntax for single-line reads extending to end-of-file, with `L20-L20` for one-line windows
32
+ - Refactored diagnostic target resolution to use new `resolveDiagnosticTargets` function, consolidating glob pattern detection and file matching logic
33
+ - Updated chunk selector syntax from `@region` format to `~` (body) and `^` (head) suffixes for more concise region targeting
34
+ - Simplified chunk edit documentation to use new `~` and `^` region syntax instead of `@head`, `@body`, `@tail`, `@decl` keywords
35
+ - Replaced internal `raceAbort` function with imported `raceWithAbort` utility from pi-utils
36
+ - Refactored cleanup timer to use async iterator pattern with `timers.setInterval` instead of `setInterval`
37
+ - Made `#cleanupIdleSessions` synchronous and moved async cleanup loop logic to new `#runCleanupLoop` method
38
+ - Replaced regex-based `htmlToBasicMarkdown` with a Turndown + GFM plugin pipeline (tables, strikethrough, task lists, nested lists now convert correctly). Added direct `turndown` and `turndown-plugin-gfm` dependencies
39
+
40
+ ### Fixed
41
+
42
+ - Fixed chunk edit tool to report file-not-found error distinctly when attempting to use chunk selectors on non-existent files, with guidance to use write tool or verify the path
43
+ - Fixed stale child selector reuse to correctly match chunks by checksum when multiple sibling chunks with the same name exist under the same parent
44
+ - Fixed stale diagnostics being reused after unrelated file publishes by clearing cached diagnostics before refreshing file state
45
+ - Fixed Codex search to use streamed answer text when final answer is an image placeholder or empty
46
+
5
47
  ## [14.0.4] - 2026-04-10
6
48
  ### Added
7
49
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.0.4",
4
+ "version": "14.0.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@oh-my-pi/omp-stats": "14.0.4",
50
- "@oh-my-pi/pi-agent-core": "14.0.4",
51
- "@oh-my-pi/pi-ai": "14.0.4",
52
- "@oh-my-pi/pi-natives": "14.0.4",
53
- "@oh-my-pi/pi-tui": "14.0.4",
54
- "@oh-my-pi/pi-utils": "14.0.4",
49
+ "@oh-my-pi/omp-stats": "14.0.5",
50
+ "@oh-my-pi/pi-agent-core": "14.0.5",
51
+ "@oh-my-pi/pi-ai": "14.0.5",
52
+ "@oh-my-pi/pi-natives": "14.0.5",
53
+ "@oh-my-pi/pi-tui": "14.0.5",
54
+ "@oh-my-pi/pi-utils": "14.0.5",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -63,10 +63,13 @@
63
63
  "lru-cache": "11.3.1",
64
64
  "markit-ai": "0.5.0",
65
65
  "puppeteer": "^24.37",
66
+ "turndown": "7.2.4",
67
+ "turndown-plugin-gfm": "1.0.2",
66
68
  "zod": "4.3.6"
67
69
  },
68
70
  "devDependencies": {
69
- "@types/bun": "^1.3"
71
+ "@types/bun": "^1.3",
72
+ "@types/turndown": "5.0.6"
70
73
  },
71
74
  "engines": {
72
75
  "bun": ">=1.3.7"
@@ -39,7 +39,7 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st
39
39
  return Boolean(apiKey) && apiKey !== kNoAuth;
40
40
  }
41
41
 
42
- export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "commit" | "task";
42
+ export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
43
43
 
44
44
  export interface ModelRoleInfo {
45
45
  tag?: string;
@@ -53,11 +53,12 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
53
53
  slow: { tag: "SLOW", name: "Thinking", color: "accent" },
54
54
  vision: { tag: "VISION", name: "Vision", color: "error" },
55
55
  plan: { tag: "PLAN", name: "Architect", color: "muted" },
56
+ designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
56
57
  commit: { tag: "COMMIT", name: "Commit", color: "dim" },
57
58
  task: { tag: "TASK", name: "Subtask", color: "muted" },
58
59
  };
59
60
 
60
- export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "commit", "task"];
61
+ export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"];
61
62
 
62
63
  /** Alias for ModelRoleInfo - used for both built-in and custom roles */
63
64
  export type RoleInfo = ModelRoleInfo;
@@ -387,7 +387,7 @@ function isSessionInheritedAgentPattern(value: string): boolean {
387
387
  return value === DEFAULT_MODEL_ROLE || value === `${PREFIX_MODEL_ROLE}${DEFAULT_MODEL_ROLE}` || value === "pi/task";
388
388
  }
389
389
 
390
- function resolveConfiguredRolePattern(value: string, settings?: Settings): string | undefined {
390
+ function resolveConfiguredRolePattern(value: string, settings?: Settings): string[] | undefined {
391
391
  const normalized = value.trim();
392
392
  if (!normalized) return undefined;
393
393
 
@@ -396,11 +396,16 @@ function resolveConfiguredRolePattern(value: string, settings?: Settings): strin
396
396
  lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
397
397
  const aliasCandidate = thinkingLevel ? normalized.slice(0, lastColonIndex) : normalized;
398
398
  const role = getModelRoleAlias(aliasCandidate);
399
- if (!role) return normalized;
399
+ if (!role) return [normalized];
400
400
 
401
401
  const configured = settings?.getModelRole(role)?.trim();
402
- if (!configured) return undefined;
403
- return thinkingLevel ? `${configured}:${thinkingLevel}` : configured;
402
+ const roleDefaults = normalizeModelPatternList(MODEL_PRIO[role as keyof typeof MODEL_PRIO]);
403
+ const resolved = configured ? normalizeModelPatternList(configured) : roleDefaults;
404
+ if (!resolved || resolved.length === 0) {
405
+ return undefined;
406
+ }
407
+
408
+ return thinkingLevel ? resolved.map(pattern => `${pattern}:${thinkingLevel}`) : resolved;
404
409
  }
405
410
 
406
411
  /**
@@ -412,7 +417,7 @@ export function expandRoleAlias(value: string, settings?: Settings): string {
412
417
  return settings?.getModelRole("default") ?? value;
413
418
  }
414
419
 
415
- const resolved = resolveConfiguredRolePattern(value, settings);
420
+ const resolved = resolveConfiguredRolePattern(value, settings)?.[0];
416
421
  return resolved ?? value;
417
422
  }
418
423
 
@@ -420,10 +425,9 @@ export function resolveConfiguredModelPatterns(value: string | string[] | undefi
420
425
  const patterns = normalizeModelPatternList(value);
421
426
  return patterns.flatMap(pattern => {
422
427
  const resolved = resolveConfiguredRolePattern(pattern, settings);
423
- return resolved ? [resolved] : [];
428
+ return resolved ?? [];
424
429
  });
425
430
  }
426
-
427
431
  export interface AgentModelPatternResolutionOptions {
428
432
  settingsOverride?: string | string[];
429
433
  agentModel?: string | string[];
@@ -477,28 +481,32 @@ export function resolveModelRoleValue(
477
481
  }
478
482
 
479
483
  const lastColonIndex = normalized.lastIndexOf(":");
480
- const thinkingSelector =
484
+ const _thinkingSelector =
481
485
  lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
482
- const aliasCandidate = thinkingSelector ? normalized.slice(0, lastColonIndex) : normalized;
483
- const effectivePattern = resolveConfiguredRolePattern(aliasCandidate, options?.settings);
484
- if (!effectivePattern) {
486
+ const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
487
+ if (!effectivePatterns || effectivePatterns.length === 0) {
485
488
  return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
486
489
  }
487
- const patternWithSuffix = thinkingSelector ? `${effectivePattern}:${thinkingSelector}` : effectivePattern;
488
- const { model, thinkingLevel, warning, explicitThinkingLevel } = parseModelPattern(
489
- patternWithSuffix,
490
- availableModels,
491
- options?.matchPreferences,
492
- );
493
490
 
494
- return {
495
- model,
496
- thinkingLevel: explicitThinkingLevel
497
- ? (resolveThinkingLevelForModel(model, thinkingLevel) ?? thinkingLevel)
498
- : thinkingLevel,
499
- explicitThinkingLevel,
500
- warning,
501
- };
491
+ let warning: string | undefined;
492
+ for (const effectivePattern of effectivePatterns) {
493
+ const resolved = parseModelPattern(effectivePattern, availableModels, options?.matchPreferences);
494
+ if (resolved.model) {
495
+ return {
496
+ model: resolved.model,
497
+ thinkingLevel: resolved.explicitThinkingLevel
498
+ ? (resolveThinkingLevelForModel(resolved.model, resolved.thinkingLevel) ?? resolved.thinkingLevel)
499
+ : resolved.thinkingLevel,
500
+ explicitThinkingLevel: resolved.explicitThinkingLevel,
501
+ warning: resolved.warning,
502
+ };
503
+ }
504
+ if (!warning && resolved.warning) {
505
+ warning = resolved.warning;
506
+ }
507
+ }
508
+
509
+ return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning };
502
510
  }
503
511
 
504
512
  export function extractExplicitThinkingSelector(
@@ -13,8 +13,15 @@
13
13
 
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
- import { setDefaultTabWidth } from "@oh-my-pi/pi-natives";
17
- import { getAgentDbPath, getAgentDir, getProjectDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
16
+ import {
17
+ getAgentDbPath,
18
+ getAgentDir,
19
+ getProjectDir,
20
+ isEnoent,
21
+ logger,
22
+ procmgr,
23
+ setDefaultTabWidth,
24
+ } from "@oh-my-pi/pi-utils";
18
25
  import { YAML } from "bun";
19
26
  import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
20
27
  import type { ModelRole } from "../config/model-registry";
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import { logger, ptree } from "@oh-my-pi/pi-utils";
2
+ import * as timers from "node:timers/promises";
3
+ import { logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
3
4
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
4
5
  import { DapClient } from "./client";
5
6
  import type {
@@ -154,27 +155,10 @@ function buildSummary(session: DapSession): DapSessionSummary {
154
155
  };
155
156
  }
156
157
 
157
- async function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
158
- if (!signal) return promise;
159
- if (signal.aborted) {
160
- throw signal.reason instanceof Error ? signal.reason : new Error("Operation aborted");
161
- }
162
- const { promise: abortPromise, reject } = Promise.withResolvers<never>();
163
- const onAbort = () => {
164
- reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"));
165
- };
166
- signal.addEventListener("abort", onAbort, { once: true });
167
- try {
168
- return await Promise.race([promise, abortPromise]);
169
- } finally {
170
- signal.removeEventListener("abort", onAbort);
171
- }
172
- }
173
-
174
158
  export class DapSessionManager {
175
159
  #sessions = new Map<string, DapSession>();
176
160
  #activeSessionId: string | null = null;
177
- #cleanupTimer?: NodeJS.Timeout;
161
+ #cleanupLoopPromise?: Promise<void>;
178
162
  #nextId = 0;
179
163
 
180
164
  constructor() {
@@ -235,7 +219,7 @@ export class DapSessionManager {
235
219
  // Try to capture initial stopped state (e.g. stopOnEntry).
236
220
  // Timeout is acceptable — the program may simply be running.
237
221
  try {
238
- await raceAbort(initialStopPromise, signal);
222
+ await untilAborted(signal, initialStopPromise);
239
223
  if (session.status === "stopped") {
240
224
  await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
241
225
  }
@@ -283,7 +267,7 @@ export class DapSessionManager {
283
267
  await this.#completeConfigurationHandshake(session, signal, timeoutMs);
284
268
  await attachPromise;
285
269
  try {
286
- await raceAbort(initialStopPromise, signal);
270
+ await untilAborted(signal, initialStopPromise);
287
271
  if (session.status === "stopped") {
288
272
  await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
289
273
  }
@@ -696,9 +680,9 @@ export class DapSessionManager {
696
680
  // between the request and here. Wait for it, but tolerate timeout if the
697
681
  // session already transitioned.
698
682
  try {
699
- await raceAbort(
700
- session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs),
683
+ await untilAborted(
701
684
  signal,
685
+ session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs),
702
686
  );
703
687
  } catch {
704
688
  // Timeout or abort — report current state regardless
@@ -833,16 +817,16 @@ export class DapSessionManager {
833
817
  session.lastUsedAt = Date.now();
834
818
  if (session.status !== "terminated") {
835
819
  if (session.capabilities?.supportsTerminateRequest) {
836
- await raceAbort(
837
- session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
820
+ await untilAborted(
838
821
  signal,
822
+ session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
839
823
  );
840
824
  }
841
- await raceAbort(
825
+ await untilAborted(
826
+ signal,
842
827
  session.client
843
828
  .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
844
829
  .catch(() => undefined),
845
- signal,
846
830
  );
847
831
  }
848
832
  session.status = "terminated";
@@ -852,22 +836,30 @@ export class DapSessionManager {
852
836
  }
853
837
 
854
838
  #startCleanupTimer(): void {
855
- if (this.#cleanupTimer) return;
856
- this.#cleanupTimer = setInterval(() => {
857
- void this.#cleanupIdleSessions();
858
- }, CLEANUP_INTERVAL_MS);
859
- this.#cleanupTimer.unref?.();
839
+ if (this.#cleanupLoopPromise) return;
840
+ this.#cleanupLoopPromise = this.#runCleanupLoop();
841
+ }
842
+
843
+ async #runCleanupLoop(): Promise<void> {
844
+ for await (const _ of timers.setInterval(CLEANUP_INTERVAL_MS, null, { ref: false })) {
845
+ try {
846
+ this.#cleanupIdleSessions();
847
+ } catch (error) {
848
+ logger.error("DAP idle session cleanup failed", { error: toErrorMessage(error) });
849
+ }
850
+ }
860
851
  }
861
852
 
862
- async #cleanupIdleSessions(): Promise<void> {
853
+ #cleanupIdleSessions(): void {
854
+ if (this.#sessions.size === 0) return;
863
855
  const now = Date.now();
864
- for (const session of Array.from(this.#sessions.values())) {
856
+ for (const session of this.#sessions.values()) {
865
857
  if (
866
858
  session.status === "terminated" ||
867
859
  now - session.lastUsedAt > IDLE_TIMEOUT_MS ||
868
860
  !session.client.isAlive()
869
861
  ) {
870
- await this.#disposeSession(session);
862
+ this.#disposeSession(session);
871
863
  }
872
864
  }
873
865
  }
@@ -1006,7 +998,7 @@ export class DapSessionManager {
1006
998
  // Wait for the initialized event if we haven't seen it yet.
1007
999
  if (!session.initializedSeen) {
1008
1000
  try {
1009
- await raceAbort(session.client.waitForEvent("initialized", undefined, signal, timeoutMs), signal);
1001
+ await untilAborted(signal, session.client.waitForEvent("initialized", undefined, signal, timeoutMs));
1010
1002
  } catch {
1011
1003
  // Adapter may not send initialized (e.g. it already terminated).
1012
1004
  // Proceed anyway — the launch/attach response will surface any real error.
@@ -1100,7 +1092,7 @@ export class DapSessionManager {
1100
1092
  timeoutMs: number = 30_000,
1101
1093
  ): Promise<DapContinueOutcome> {
1102
1094
  try {
1103
- await raceAbort(outcomePromise, signal);
1095
+ await untilAborted(signal, outcomePromise);
1104
1096
  if (session.status === "stopped") {
1105
1097
  await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, 5_000));
1106
1098
  }
@@ -1243,12 +1235,12 @@ export class DapSessionManager {
1243
1235
  return session;
1244
1236
  }
1245
1237
 
1246
- async #disposeSession(session: DapSession): Promise<void> {
1238
+ #disposeSession(session: DapSession) {
1247
1239
  if (this.#activeSessionId === session.id) {
1248
1240
  this.#activeSessionId = null;
1249
1241
  }
1250
1242
  this.#sessions.delete(session.id);
1251
- await session.client.dispose().catch(() => {});
1243
+ void session.client.dispose().catch(() => {});
1252
1244
  }
1253
1245
  }
1254
1246
 
@@ -1,5 +1,5 @@
1
- import { sanitizeText, wrapTextWithAnsi } from "@oh-my-pi/pi-natives";
2
- import { replaceTabs, truncateToWidth } from "../tools/render-utils";
1
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
2
+ import { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "../tools/render-utils";
3
3
 
4
4
  export function formatDebugLogLine(line: string, maxWidth: number): string {
5
5
  const sanitized = sanitizeText(line);
@@ -332,7 +332,7 @@ export const chunkToolEditSchema = Type.Object({
332
332
  op: StringEnum(CHUNK_OP_VALUES),
333
333
  sel: Type.String({
334
334
  description:
335
- "Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. Omit @region to target the full chunk. Valid regions: head, body, tail, decl.",
335
+ "Chunk selector. Use 'path~' or 'path^' for insertions, 'path#CRC~' or 'path#CRC^' for replace, or omit the suffix to target the full chunk.",
336
336
  }),
337
337
  content: Type.String({
338
338
  description:
@@ -443,6 +443,12 @@ export async function executeChunkMode(
443
443
  }
444
444
  const normalizedOperations = normalizeChunkEditOperations(edits);
445
445
 
446
+ if (!sourceExists && normalizedOperations.some(op => op.sel)) {
447
+ throw new Error(
448
+ `File does not exist: ${path}. Cannot resolve chunk selectors on a non-existent file. Use the write tool to create a new file, or check the path for typos.`,
449
+ );
450
+ }
451
+
446
452
  const chunkResult = applyChunkEdits({
447
453
  source: rawContent,
448
454
  language: chunkLanguage,
@@ -453,9 +459,8 @@ export async function executeChunkMode(
453
459
  });
454
460
 
455
461
  if (!chunkResult.changed) {
456
- const responseText = `[No changes needed — content already matches.]\n\n${chunkResult.responseText}`;
457
462
  return {
458
- content: [{ type: "text", text: responseText }],
463
+ content: [{ type: "text", text: "[No changes needed \u2014 content already matches.]" }],
459
464
  details: {
460
465
  diff: "",
461
466
  op: sourceExists ? "update" : "create",
package/src/lsp/client.ts CHANGED
@@ -638,15 +638,17 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
638
638
  const uri = fileToUri(filePath);
639
639
  const lockKey = `${client.name}:${uri}`;
640
640
 
641
- // Check if another operation is in progress
642
641
  const existingLock = fileOperationLocks.get(lockKey);
643
642
  if (existingLock) {
644
643
  await untilAborted(signal, () => existingLock);
645
644
  }
646
645
 
647
- // Lock and refresh file
648
646
  const refreshPromise = (async () => {
649
647
  throwIfAborted(signal);
648
+ // Drop cached diagnostics for this URI before asking the server to recompute.
649
+ // Otherwise an unrelated publishDiagnostics notification can advance the global
650
+ // diagnostics version and cause waiters to accept stale unversioned diagnostics.
651
+ client.diagnostics.delete(uri);
650
652
  const info = client.openFiles.get(uri);
651
653
 
652
654
  if (!info) {
package/src/lsp/index.ts CHANGED
@@ -47,7 +47,6 @@ import {
47
47
  } from "./types";
48
48
  import {
49
49
  applyCodeAction,
50
- collectGlobMatches,
51
50
  dedupeWorkspaceSymbols,
52
51
  extractHoverText,
53
52
  fileToUri,
@@ -60,8 +59,8 @@ import {
60
59
  formatLocation,
61
60
  formatSymbolInformation,
62
61
  formatWorkspaceEdit,
63
- hasGlobPattern,
64
62
  readLocationContext,
63
+ resolveDiagnosticTargets,
65
64
  resolveSymbolColumn,
66
65
  sortDiagnostics,
67
66
  symbolKindToIcon,
@@ -1172,13 +1171,9 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1172
1171
 
1173
1172
  let targets: string[];
1174
1173
  let truncatedGlobTargets = false;
1175
- if (hasGlobPattern(file)) {
1176
- const globMatches = await collectGlobMatches(file, this.session.cwd, MAX_GLOB_DIAGNOSTIC_TARGETS);
1177
- targets = globMatches.matches;
1178
- truncatedGlobTargets = globMatches.truncated;
1179
- } else {
1180
- targets = [file];
1181
- }
1174
+ const resolvedTargets = await resolveDiagnosticTargets(file, this.session.cwd, MAX_GLOB_DIAGNOSTIC_TARGETS);
1175
+ targets = resolvedTargets.matches;
1176
+ truncatedGlobTargets = resolvedTargets.truncated;
1182
1177
 
1183
1178
  if (targets.length === 0) {
1184
1179
  return {
package/src/lsp/utils.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { truncate } from "@oh-my-pi/pi-utils";
2
2
 
3
+ import * as fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
5
6
  import { type Theme, theme } from "../modes/theme/theme";
7
+ import { resolveToCwd } from "../tools/path-utils";
6
8
  import type {
7
9
  CodeAction,
8
10
  Command,
@@ -550,6 +552,30 @@ export async function collectGlobMatches(
550
552
  }
551
553
  return { matches, truncated: false };
552
554
  }
555
+
556
+ export async function resolveDiagnosticTargets(
557
+ file: string,
558
+ cwd: string,
559
+ maxMatches: number,
560
+ ): Promise<{ matches: string[]; truncated: boolean }> {
561
+ if (!hasGlobPattern(file)) {
562
+ return { matches: [file], truncated: false };
563
+ }
564
+
565
+ const resolved = resolveToCwd(file, cwd);
566
+ try {
567
+ const stat = await fs.stat(resolved);
568
+ if (stat.isFile()) {
569
+ return { matches: [file], truncated: false };
570
+ }
571
+ } catch (error) {
572
+ if (!isEnoent(error)) {
573
+ throw error;
574
+ }
575
+ }
576
+
577
+ return collectGlobMatches(file, cwd, maxMatches);
578
+ }
553
579
  // =============================================================================
554
580
  // Hover Content Extraction
555
581
  // =============================================================================
@@ -1,4 +1,4 @@
1
- import { getIndentation } from "@oh-my-pi/pi-natives";
1
+ import { getIndentation } from "@oh-my-pi/pi-utils";
2
2
  import * as Diff from "diff";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
  import { replaceTabs } from "../../tools/render-utils";