@oh-my-pi/pi-coding-agent 17.2.10 → 17.2.12

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 (247) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/{CHANGELOG-rhwzpexa.md → CHANGELOG-k9ghy5sn.md} +53 -0
  3. package/dist/cli.js +10775 -10762
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/capability/mcp.d.ts +11 -0
  6. package/dist/types/capability/skill.d.ts +6 -0
  7. package/dist/types/cli/command-help.d.ts +3 -0
  8. package/dist/types/commands/share.d.ts +25 -0
  9. package/dist/types/commit/agentic/index.d.ts +3 -1
  10. package/dist/types/commit/execute.d.ts +32 -0
  11. package/dist/types/commit/index.d.ts +2 -1
  12. package/dist/types/commit/pipeline.d.ts +7 -1
  13. package/dist/types/config/custom-models.d.ts +31 -0
  14. package/dist/types/config/model-config-values.d.ts +19 -0
  15. package/dist/types/config/model-patch.d.ts +93 -0
  16. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  17. package/dist/types/config/model-registry.d.ts +9 -52
  18. package/dist/types/config/settings-schema.d.ts +1 -34
  19. package/dist/types/config/settings.d.ts +5 -3
  20. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  21. package/dist/types/discovery/agent-plugin-format.d.ts +136 -0
  22. package/dist/types/discovery/agent-plugins.d.ts +1 -0
  23. package/dist/types/discovery/contained-path.d.ts +33 -0
  24. package/dist/types/discovery/index.d.ts +1 -0
  25. package/dist/types/eval/executor-base.d.ts +8 -2
  26. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  27. package/dist/types/export/share.d.ts +1 -1
  28. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  29. package/dist/types/extensibility/extensions/loader.d.ts +7 -0
  30. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  31. package/dist/types/extensibility/shared-events.d.ts +3 -2
  32. package/dist/types/extensibility/skills.d.ts +5 -0
  33. package/dist/types/lsp/diagnostics.d.ts +96 -0
  34. package/dist/types/lsp/index.d.ts +7 -128
  35. package/dist/types/lsp/servers.d.ts +72 -0
  36. package/dist/types/lsp/tool.d.ts +42 -0
  37. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  38. package/dist/types/lsp/writethrough.d.ts +33 -0
  39. package/dist/types/mcp/transports/header-policy.d.ts +46 -0
  40. package/dist/types/mcp/types.d.ts +15 -0
  41. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  42. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  43. package/dist/types/modes/components/agent-hub.d.ts +2 -0
  44. package/dist/types/modes/components/agent-transcript-viewer.d.ts +2 -0
  45. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  46. package/dist/types/modes/components/custom-editor.d.ts +1 -2
  47. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  49. package/dist/types/modes/theme/color.d.ts +19 -0
  50. package/dist/types/modes/theme/loader.d.ts +19 -0
  51. package/dist/types/modes/theme/schema.d.ts +175 -0
  52. package/dist/types/modes/theme/symbols.d.ts +28 -0
  53. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  54. package/dist/types/modes/theme/theme.d.ts +9 -280
  55. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  56. package/dist/types/modes/utils/transcript-render-helpers.d.ts +3 -2
  57. package/dist/types/registry/agent-registry.d.ts +1 -3
  58. package/dist/types/secrets/index.d.ts +26 -2
  59. package/dist/types/secrets/message-transform.d.ts +40 -0
  60. package/dist/types/secrets/obfuscator.d.ts +0 -108
  61. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  62. package/dist/types/secrets/placeholder.d.ts +94 -0
  63. package/dist/types/secrets/replacement.d.ts +82 -0
  64. package/dist/types/session/agent-session-events.d.ts +2 -2
  65. package/dist/types/session/agent-session-types.d.ts +6 -0
  66. package/dist/types/session/agent-session.d.ts +2 -2
  67. package/dist/types/session/messages.d.ts +1 -0
  68. package/dist/types/session/prewalk.d.ts +3 -1
  69. package/dist/types/session/session-handoff.d.ts +3 -3
  70. package/dist/types/session/session-manager.d.ts +32 -0
  71. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  72. package/dist/types/session/session-tools.d.ts +8 -0
  73. package/dist/types/session/turn-recovery.d.ts +9 -4
  74. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  75. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  76. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  77. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  78. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  79. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  80. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  81. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  82. package/dist/types/task/index.d.ts +2 -0
  83. package/dist/types/tools/gh-common.d.ts +69 -0
  84. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  85. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  86. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  87. package/dist/types/tools/gh-search.d.ts +61 -0
  88. package/dist/types/tools/gh-types.d.ts +340 -0
  89. package/dist/types/tools/gh-view.d.ts +88 -0
  90. package/dist/types/tools/gh.d.ts +4 -201
  91. package/dist/types/tools/read-archive.d.ts +16 -0
  92. package/dist/types/tools/read-format.d.ts +104 -0
  93. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  94. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  95. package/dist/types/tools/read-renderer.d.ts +24 -0
  96. package/dist/types/tools/read-selector.d.ts +27 -0
  97. package/dist/types/tools/read-sqlite.d.ts +16 -0
  98. package/dist/types/tools/read-summary.d.ts +19 -0
  99. package/dist/types/tools/read.d.ts +1 -23
  100. package/dist/types/tools/shell-tokenize.d.ts +18 -1
  101. package/dist/types/utils/changelog.d.ts +0 -5
  102. package/package.json +13 -13
  103. package/scripts/legacy-pi-virtual-module.ts +4 -1
  104. package/src/advisor/runtime.ts +12 -7
  105. package/src/capability/mcp.ts +11 -0
  106. package/src/capability/skill.ts +6 -0
  107. package/src/cli/command-help.ts +4 -0
  108. package/src/cli/gallery-cli.ts +1 -1
  109. package/src/cli-commands.ts +5 -0
  110. package/src/commands/commit.ts +16 -3
  111. package/src/commands/share.ts +71 -0
  112. package/src/commit/agentic/index.ts +39 -21
  113. package/src/commit/execute.ts +56 -0
  114. package/src/commit/index.ts +2 -1
  115. package/src/commit/pipeline.ts +20 -7
  116. package/src/config/custom-models.ts +188 -0
  117. package/src/config/model-config-values.ts +128 -0
  118. package/src/config/model-discovery.ts +1 -1
  119. package/src/config/model-patch.ts +252 -0
  120. package/src/config/model-provider-discovery.ts +132 -0
  121. package/src/config/model-registry.ts +84 -704
  122. package/src/config/settings-schema.ts +2 -33
  123. package/src/config/settings.ts +53 -3
  124. package/src/debug/raw-sse-buffer.ts +20 -0
  125. package/src/discovery/agent-plugin-format.ts +551 -0
  126. package/src/discovery/agent-plugins.ts +341 -0
  127. package/src/discovery/claude-plugins.ts +21 -5
  128. package/src/discovery/contained-path.ts +75 -0
  129. package/src/discovery/helpers.ts +23 -9
  130. package/src/discovery/index.ts +1 -0
  131. package/src/discovery/omp-plugins.ts +20 -7
  132. package/src/edit/hashline/diff.ts +5 -1
  133. package/src/eval/executor-base.ts +39 -6
  134. package/src/eval/jl/executor.ts +82 -371
  135. package/src/eval/kernel-session-registry.ts +398 -0
  136. package/src/eval/py/executor.ts +53 -261
  137. package/src/eval/rb/executor.ts +36 -279
  138. package/src/exec/non-interactive-env.ts +1 -0
  139. package/src/export/share.ts +2 -1
  140. package/src/extensibility/custom-tools/types.ts +2 -2
  141. package/src/extensibility/extensions/loader.ts +78 -14
  142. package/src/extensibility/extensions/runner.ts +6 -0
  143. package/src/extensibility/extensions/types.ts +12 -0
  144. package/src/extensibility/plugins/marketplace/manager.ts +2 -1
  145. package/src/extensibility/shared-events.ts +3 -2
  146. package/src/extensibility/skills.ts +9 -0
  147. package/src/internal-urls/skill-protocol.ts +14 -0
  148. package/src/internal-urls/vault-protocol.ts +2 -2
  149. package/src/launch/client.ts +11 -1
  150. package/src/launch/protocol.ts +7 -2
  151. package/src/lsp/diagnostics.ts +516 -0
  152. package/src/lsp/index.ts +20 -2819
  153. package/src/lsp/servers.ts +296 -0
  154. package/src/lsp/tool.ts +1352 -0
  155. package/src/lsp/workspace-diagnostics.ts +170 -0
  156. package/src/lsp/writethrough.ts +561 -0
  157. package/src/main.ts +3 -2
  158. package/src/mcp/config.ts +3 -0
  159. package/src/mcp/manager.ts +12 -9
  160. package/src/mcp/transports/header-policy.ts +95 -0
  161. package/src/mcp/transports/http.ts +35 -52
  162. package/src/mcp/transports/sse.ts +20 -27
  163. package/src/mcp/types.ts +15 -0
  164. package/src/modes/acp/acp-agent.ts +15 -4
  165. package/src/modes/components/agent-dashboard.ts +2 -0
  166. package/src/modes/components/agent-hub-projection.ts +2 -2
  167. package/src/modes/components/agent-hub-renderer.ts +3 -7
  168. package/src/modes/components/agent-hub.ts +5 -0
  169. package/src/modes/components/agent-transcript-viewer.ts +3 -0
  170. package/src/modes/components/chat-transcript-builder.ts +3 -0
  171. package/src/modes/components/custom-editor.ts +0 -9
  172. package/src/modes/components/status-line/component.ts +1 -0
  173. package/src/modes/components/status-line/segments.ts +6 -4
  174. package/src/modes/components/status-line/types.ts +2 -0
  175. package/src/modes/components/tool-execution.ts +13 -16
  176. package/src/modes/components/tree-selector.ts +62 -3
  177. package/src/modes/controllers/command-controller.ts +8 -2
  178. package/src/modes/controllers/event-controller.ts +15 -15
  179. package/src/modes/controllers/input-controller.ts +20 -2
  180. package/src/modes/controllers/selector-controller.ts +1 -0
  181. package/src/modes/theme/color.ts +133 -0
  182. package/src/modes/theme/loader.ts +178 -0
  183. package/src/modes/theme/schema.ts +263 -0
  184. package/src/modes/theme/symbols.ts +1000 -0
  185. package/src/modes/theme/theme-class.ts +611 -0
  186. package/src/modes/theme/theme.ts +27 -2453
  187. package/src/modes/theme/tui-adapters.ts +279 -0
  188. package/src/modes/utils/transcript-render-helpers.ts +4 -2
  189. package/src/modes/utils/ui-helpers.ts +1 -0
  190. package/src/registry/agent-registry.ts +2 -2
  191. package/src/sdk.ts +6 -49
  192. package/src/secrets/index.ts +51 -6
  193. package/src/secrets/message-transform.ts +287 -0
  194. package/src/secrets/obfuscator.ts +39 -1303
  195. package/src/secrets/placeholder-scan.ts +506 -0
  196. package/src/secrets/placeholder.ts +309 -0
  197. package/src/secrets/replacement.ts +216 -0
  198. package/src/session/agent-session-events.ts +2 -2
  199. package/src/session/agent-session-types.ts +6 -0
  200. package/src/session/agent-session.ts +146 -21
  201. package/src/session/indexed-session-storage.ts +7 -2
  202. package/src/session/messages.ts +1 -0
  203. package/src/session/prewalk.ts +57 -17
  204. package/src/session/session-advisors.ts +32 -34
  205. package/src/session/session-context.ts +3 -5
  206. package/src/session/session-handoff.ts +39 -16
  207. package/src/session/session-manager.ts +67 -3
  208. package/src/session/session-provider-boundary.ts +3 -6
  209. package/src/session/session-tools.ts +37 -3
  210. package/src/session/todo-tracker.ts +11 -1
  211. package/src/session/turn-recovery.ts +115 -84
  212. package/src/slash-commands/builtin-collaboration.ts +504 -0
  213. package/src/slash-commands/builtin-completions.ts +291 -0
  214. package/src/slash-commands/builtin-control.ts +78 -0
  215. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  216. package/src/slash-commands/builtin-marketplace.ts +567 -0
  217. package/src/slash-commands/builtin-modes.ts +518 -0
  218. package/src/slash-commands/builtin-registry.ts +21 -2996
  219. package/src/slash-commands/builtin-session.ts +592 -0
  220. package/src/task/executor.ts +17 -14
  221. package/src/task/index.ts +21 -6
  222. package/src/tools/bash-skill-urls.ts +15 -0
  223. package/src/tools/bash.ts +10 -11
  224. package/src/tools/debug.ts +1 -1
  225. package/src/tools/gh-common.ts +288 -0
  226. package/src/tools/gh-pr-checkout.ts +679 -0
  227. package/src/tools/gh-pr-diff.ts +473 -0
  228. package/src/tools/gh-run-watch.ts +1015 -0
  229. package/src/tools/gh-search.ts +500 -0
  230. package/src/tools/gh-types.ts +379 -0
  231. package/src/tools/gh-view.ts +612 -0
  232. package/src/tools/gh.ts +109 -3821
  233. package/src/tools/jtd-to-json-schema.ts +123 -21
  234. package/src/tools/read-archive.ts +209 -0
  235. package/src/tools/read-format.ts +593 -0
  236. package/src/tools/read-path-resolution.ts +36 -0
  237. package/src/tools/read-pdf-images.ts +250 -0
  238. package/src/tools/read-renderer.ts +289 -0
  239. package/src/tools/read-selector.ts +84 -0
  240. package/src/tools/read-sqlite.ts +215 -0
  241. package/src/tools/read-summary.ts +199 -0
  242. package/src/tools/read.ts +82 -1824
  243. package/src/tools/shell-tokenize.ts +99 -1
  244. package/src/utils/changelog.ts +1 -1
  245. package/src/utils/title-generator.ts +3 -1
  246. package/src/web/search/providers/exa.ts +1 -1
  247. package/src/web/search/providers/zai.ts +12 -3
@@ -27,6 +27,11 @@ export interface Skill {
27
27
  * prompt's `<skills>` listing.
28
28
  */
29
29
  hide?: boolean;
30
+ /**
31
+ * Filesystem-resolved plugin root for Agent Plugin skills (spec §4.1):
32
+ * every `skill://` resource access must realpath-resolve within it.
33
+ */
34
+ containRoot?: string;
30
35
  /** Source metadata for display */
31
36
  _source?: SourceMeta;
32
37
  }
@@ -104,6 +109,7 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
104
109
  filePath: capSkill.path,
105
110
  baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
106
111
  source: options.source,
112
+ ...(capSkill.containRoot !== undefined && { containRoot: capSkill.containRoot }),
107
113
  hide: capSkill.frontmatter?.hide === true || capSkill.frontmatter?.disableModelInvocation === true,
108
114
  _source: capSkill._source,
109
115
  })),
@@ -239,6 +245,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
239
245
  filePath: capSkill.path,
240
246
  baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
241
247
  source: `${capSkill._source.provider}:${capSkill.level}`,
248
+ ...(capSkill.containRoot !== undefined && { containRoot: capSkill.containRoot }),
242
249
  hide: capSkill.frontmatter?.hide === true || capSkill.frontmatter?.disableModelInvocation === true,
243
250
  _source: capSkill._source,
244
251
  });
@@ -276,6 +283,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
276
283
  filePath: capSkill.path,
277
284
  baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
278
285
  source: "custom:user",
286
+ ...(capSkill.containRoot !== undefined && { containRoot: capSkill.containRoot }),
279
287
  hide: capSkill.frontmatter?.hide === true || capSkill.frontmatter?.disableModelInvocation === true,
280
288
  _source: { ...capSkill._source, providerName: "Custom" },
281
289
  },
@@ -376,6 +384,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
376
384
  filePath: capSkill.path,
377
385
  baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
378
386
  source: `${capSkill._source.provider}:${capSkill.level}`,
387
+ ...(capSkill.containRoot !== undefined && { containRoot: capSkill.containRoot }),
379
388
  hide: capSkill.frontmatter?.hide === true || capSkill.frontmatter?.disableModelInvocation === true,
380
389
  _source: capSkill._source,
381
390
  });
@@ -11,6 +11,7 @@ import type * as fsTypes from "node:fs";
11
11
  import * as fs from "node:fs/promises";
12
12
  import * as path from "node:path";
13
13
  import { isEnoent } from "@oh-my-pi/pi-utils";
14
+ import { resolveContainedPath } from "../discovery/contained-path";
14
15
  import { getActiveSkills } from "../extensibility/skills";
15
16
  import { isMarkdownPath } from "../utils/lang-from-path";
16
17
  import { buildDirectoryResource } from "./filesystem-resource";
@@ -76,6 +77,19 @@ export class SkillProtocolHandler implements ProtocolHandler {
76
77
  if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
77
78
  throw new Error("Path traversal is not allowed");
78
79
  }
80
+ // Agent Plugin skills (§4.1): the resource must canonically resolve
81
+ // within the plugin root; a dangling or unresolvable path fails closed.
82
+ // Symlinks may target other files inside the same package.
83
+ if (skill.containRoot) {
84
+ const contained = await resolveContainedPath(skill.containRoot, resolvedPath);
85
+ if (contained.status === "outside") {
86
+ throw new Error(`skill:// path resolves outside the plugin root: ${url.href}`);
87
+ }
88
+ if (contained.status === "missing") {
89
+ throw new Error(`File not found: ${resolvedPath}`);
90
+ }
91
+ targetPath = contained.realPath;
92
+ }
79
93
  } else {
80
94
  targetPath = context?.pathOnly === true ? skill.baseDir : skill.filePath;
81
95
  }
@@ -787,7 +787,7 @@ export class VaultProtocolHandler implements ProtocolHandler {
787
787
  const cacheKey = parsed.ref.active ? "_" : (parsed.ref.vault ?? "_");
788
788
  let cliInfo = cachedVaultInfo.get(cacheKey);
789
789
  if (cliInfo === undefined) {
790
- const result = await this.#spawn(["vault", "info", ...this.#vaultCliArg(parsed.ref)], context);
790
+ const result = await this.#spawn([...this.#vaultCliArg(parsed.ref), "vault", "info"], context);
791
791
  assertCliSuccess("vault info", result);
792
792
  cliInfo = result.stdout.trim();
793
793
  cachedVaultInfo.set(cacheKey, cliInfo);
@@ -926,7 +926,7 @@ export class VaultProtocolHandler implements ProtocolHandler {
926
926
  context?: ResolveContext,
927
927
  ): Promise<InternalResource> {
928
928
  const invocation = buildObsidianCliInvocation(parsed);
929
- const args = [...invocation.args, ...this.#vaultCliArg(parsed.ref)];
929
+ const args = [...this.#vaultCliArg(parsed.ref), ...invocation.args];
930
930
  const result = await this.#spawn(args, context);
931
931
  assertCliSuccess(invocation.opLabel, result);
932
932
  return {
@@ -367,7 +367,17 @@ class SocketDaemonClient implements DaemonBrokerClient {
367
367
  try {
368
368
  message = parseDaemonWireMessage(decoded);
369
369
  } catch (error) {
370
- this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
370
+ const parseError = error instanceof Error ? error : new Error(String(error));
371
+ if (
372
+ typeof decoded === "object" &&
373
+ decoded !== null &&
374
+ "event" in decoded &&
375
+ decoded.event === "daemon-completed"
376
+ ) {
377
+ logger.warn("Ignoring malformed daemon completion", { error: parseError.message });
378
+ continue;
379
+ }
380
+ this.#rejectPending(parseError);
371
381
  continue;
372
382
  }
373
383
  if ("event" in message) {
@@ -168,6 +168,11 @@ function optionalString(value: unknown, label: string): string | undefined {
168
168
  return stringValue(value, label);
169
169
  }
170
170
 
171
+ function optionalRawString(value: unknown, label: string): string | undefined {
172
+ if (value === undefined) return undefined;
173
+ return rawString(value, label);
174
+ }
175
+
171
176
  function booleanValue(value: unknown, label: string): boolean {
172
177
  if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
173
178
  return value;
@@ -272,7 +277,7 @@ export function parseDaemonSnapshot(value: unknown): DaemonSnapshot {
272
277
  restartCount: numberValue(source.restartCount, "daemon.restartCount"),
273
278
  outputBytes: numberValue(source.outputBytes, "daemon.outputBytes"),
274
279
  owner: optionalString(source.owner, "daemon.owner"),
275
- readyMatch: optionalString(source.readyMatch, "daemon.readyMatch"),
280
+ readyMatch: optionalRawString(source.readyMatch, "daemon.readyMatch"),
276
281
  readyPending: source.readyPending === undefined ? undefined : readyPendingList(source.readyPending),
277
282
  persist: booleanValue(source.persist, "daemon.persist"),
278
283
  detached: source.detached === undefined ? false : booleanValue(source.detached, "daemon.detached"),
@@ -427,7 +432,7 @@ export function parseDaemonRpcResult(operation: DaemonOperation, value: unknown)
427
432
  return {
428
433
  op: "wait",
429
434
  daemon: parseDaemonSnapshot(source.daemon),
430
- matched: optionalString(source.matched, "result.matched"),
435
+ matched: optionalRawString(source.matched, "result.matched"),
431
436
  timedOut: booleanValue(source.timedOut, "result.timedOut"),
432
437
  };
433
438
  case "send":
@@ -0,0 +1,516 @@
1
+ import * as fs from "node:fs";
2
+ import path from "node:path";
3
+ import { logger } from "@oh-my-pi/pi-utils";
4
+ import { formatPathRelativeToCwd } from "../tools/path-utils";
5
+ import { throwIfAborted } from "../tools/tool-errors";
6
+ import { getOrCreateClient, sendRequest, supportsDocumentDiagnostics, waitForProjectLoaded } from "./client";
7
+ import { getLinterClient } from "./clients";
8
+ import { hasRootMarkerAncestor } from "./config";
9
+ import { applyTextEditsToString } from "./edits";
10
+ import { resolveFormatOptions } from "./format-options";
11
+ import { isProjectAwareLspServer } from "./servers";
12
+ import type {
13
+ Diagnostic,
14
+ Location,
15
+ LocationLink,
16
+ LspClient,
17
+ Position,
18
+ PublishedDiagnostics,
19
+ ServerConfig,
20
+ TextEdit,
21
+ } from "./types";
22
+ import {
23
+ fileToUri,
24
+ formatDiagnostic,
25
+ formatDiagnosticsSummary,
26
+ formatLocation,
27
+ readLocationContext,
28
+ sortDiagnostics,
29
+ uriToFile,
30
+ } from "./utils";
31
+
32
+ const DIAGNOSTIC_MESSAGE_LIMIT = 50;
33
+ export const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
34
+ export const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
35
+ const DIAGNOSTICS_POLL_MS = 100;
36
+ const DIAGNOSTICS_SETTLE_MS = 250;
37
+ /**
38
+ * How long the edit/write writethrough blocks inline waiting for fresh
39
+ * diagnostics before handing slow servers off to the deferred late-injection
40
+ * channel. Keeps the common fast-server case inline while letting an edit
41
+ * return promptly when a server (e.g. a large-monorepo tsserver) is slow to
42
+ * publish fresh diagnostics.
43
+ */
44
+ export const INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 500;
45
+ /**
46
+ * Inner per-server diagnostics wait budget for the background/deferred fetch.
47
+ * Longer than the inline cap (and the old 3s default) so a slow server still
48
+ * delivers late instead of giving up before it ever publishes.
49
+ */
50
+ export const DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS = 12_000;
51
+ export const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
52
+ export const WORKSPACE_SYMBOL_LIMIT = 200;
53
+ export const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
54
+ "definition",
55
+ "type_definition",
56
+ "implementation",
57
+ "references",
58
+ "rename",
59
+ "hover",
60
+ ]);
61
+
62
+ const RUST_WORKSPACE_MARKERS = ["Cargo.toml", "rust-analyzer.toml"] as const;
63
+
64
+ export function hasRustWorkspaceAncestor(filePath: string): boolean {
65
+ let dir = path.dirname(filePath);
66
+ while (true) {
67
+ for (const marker of RUST_WORKSPACE_MARKERS) {
68
+ if (fs.existsSync(path.join(dir, marker))) {
69
+ return true;
70
+ }
71
+ }
72
+ const parent = path.dirname(dir);
73
+ if (parent === dir) {
74
+ return false;
75
+ }
76
+ dir = parent;
77
+ }
78
+ }
79
+
80
+ export function limitDiagnosticMessages(messages: string[]): string[] {
81
+ if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
82
+ return messages;
83
+ }
84
+ return messages.slice(0, DIAGNOSTIC_MESSAGE_LIMIT);
85
+ }
86
+
87
+ const ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES: Record<number, true> = {
88
+ 1375: true,
89
+ 1378: true,
90
+ 2307: true,
91
+ 2580: true,
92
+ 2591: true,
93
+ 2792: true,
94
+ 2867: true,
95
+ };
96
+
97
+ function diagnosticCodeNumber(diagnostic: Diagnostic): number | null {
98
+ if (typeof diagnostic.code === "number") return diagnostic.code;
99
+ if (typeof diagnostic.code === "string" && /^\d+$/.test(diagnostic.code)) return Number(diagnostic.code);
100
+ return null;
101
+ }
102
+ function isTypeScriptProjectDiagnostic(serverName: string, diagnostic: Diagnostic): boolean {
103
+ if (diagnostic.source !== "typescript" && !serverName.toLowerCase().includes("typescript")) {
104
+ return false;
105
+ }
106
+ const code = diagnosticCodeNumber(diagnostic);
107
+ return code !== null && ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES[code] === true;
108
+ }
109
+
110
+ function filterOrphanProjectDiagnostics(
111
+ absolutePath: string,
112
+ serverName: string,
113
+ serverConfig: ServerConfig,
114
+ diagnostics: Diagnostic[],
115
+ ): Diagnostic[] {
116
+ if (!serverConfig.rootMarkers.length || hasRootMarkerAncestor(absolutePath, serverConfig.rootMarkers)) {
117
+ return diagnostics;
118
+ }
119
+ return diagnostics.filter(diagnostic => !isTypeScriptProjectDiagnostic(serverName, diagnostic));
120
+ }
121
+
122
+ const LOCATION_CONTEXT_LINES = 1;
123
+ export const REFERENCE_CONTEXT_LIMIT = 50;
124
+
125
+ export const REFERENCES_RETRY_COUNT = 2;
126
+ export const REFERENCES_RETRY_DELAY_MS = 250;
127
+
128
+ function comparePosition(a: Position, b: Position): number {
129
+ return a.line === b.line ? a.character - b.character : a.line - b.line;
130
+ }
131
+
132
+ function rangeContainsPosition(range: Location["range"], position: Position): boolean {
133
+ return comparePosition(range.start, position) <= 0 && comparePosition(position, range.end) <= 0;
134
+ }
135
+
136
+ export function isOnlyQueriedDeclaration(locations: Location[], uri: string, position: Position): boolean {
137
+ return locations.length === 1 && locations[0]?.uri === uri && rangeContainsPosition(locations[0].range, position);
138
+ }
139
+
140
+ export function normalizeLocationResult(
141
+ result: Location | Location[] | LocationLink | LocationLink[] | null,
142
+ ): Location[] {
143
+ if (!result) return [];
144
+ const raw = Array.isArray(result) ? result : [result];
145
+ return raw.flatMap(loc => {
146
+ if ("uri" in loc) {
147
+ return [loc as Location];
148
+ }
149
+ if ("targetUri" in loc) {
150
+ const link = loc as LocationLink;
151
+ return [{ uri: link.targetUri, range: link.targetSelectionRange ?? link.targetRange }];
152
+ }
153
+ return [];
154
+ });
155
+ }
156
+
157
+ export async function formatLocationWithContext(location: Location, cwd: string): Promise<string> {
158
+ const header = ` ${formatLocation(location, cwd)}`;
159
+ const context = await readLocationContext(
160
+ uriToFile(location.uri),
161
+ location.range.start.line + 1,
162
+ LOCATION_CONTEXT_LINES,
163
+ );
164
+ if (context.length === 0) {
165
+ return header;
166
+ }
167
+ return `${header}\n${context.map(lineText => ` ${lineText}`).join("\n")}`;
168
+ }
169
+
170
+ interface WaitForDiagnosticsOptions {
171
+ timeoutMs?: number;
172
+ signal?: AbortSignal;
173
+ minVersion?: number;
174
+ expectedDocumentVersion?: number;
175
+ /**
176
+ * Quiescence window (ms). typescript-language-server never echoes the document
177
+ * version (issue #983) and emits diagnostics from several sources at different
178
+ * times, so there is no single "complete, version-matched" publish to gate on.
179
+ * When the server does not exact-version-match, accept the latest publish only
180
+ * after no newer one has arrived for this long, letting an in-flight pre-edit
181
+ * publish be superseded by the fresh one.
182
+ */
183
+ settleMs?: number;
184
+ }
185
+
186
+ function requestDocumentDiagnostics(
187
+ client: LspClient,
188
+ uri: string,
189
+ signal: AbortSignal | undefined,
190
+ timeoutMs: number,
191
+ ): Promise<Diagnostic[] | undefined> {
192
+ return sendRequest(client, "textDocument/diagnostic", { textDocument: { uri } }, signal, timeoutMs)
193
+ .then(report => {
194
+ if (!report || typeof report !== "object" || !("kind" in report) || report.kind !== "full") {
195
+ return undefined;
196
+ }
197
+ if (!("items" in report) || !Array.isArray(report.items)) return undefined;
198
+ return report.items;
199
+ })
200
+ .catch(err => {
201
+ if (!signal?.aborted) {
202
+ logger.debug("LSP document diagnostic pull failed", { server: client.name, uri, error: String(err) });
203
+ }
204
+ return undefined;
205
+ });
206
+ }
207
+
208
+ export async function waitForDiagnostics(
209
+ client: LspClient,
210
+ uri: string,
211
+ options: WaitForDiagnosticsOptions = {},
212
+ ): Promise<Diagnostic[]> {
213
+ const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, settleMs = DIAGNOSTICS_SETTLE_MS } = options;
214
+ const deadline = Date.now() + timeoutMs;
215
+ let pullAttempted = false;
216
+ let pullResultPromise: Promise<{ diagnostics: Diagnostic[] | undefined }> | undefined;
217
+ let pulled: Diagnostic[] | undefined;
218
+ let settledRef: PublishedDiagnostics | undefined;
219
+ let settledAt = 0;
220
+ while (Date.now() < deadline) {
221
+ throwIfAborted(signal);
222
+ if (!pullAttempted && supportsDocumentDiagnostics(client)) {
223
+ pullAttempted = true;
224
+ pullResultPromise = requestDocumentDiagnostics(client, uri, signal, Math.max(1, deadline - Date.now())).then(
225
+ diagnostics => ({ diagnostics }),
226
+ );
227
+ }
228
+
229
+ const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
230
+ const published = client.diagnostics.get(uri);
231
+ if (published && versionOk) {
232
+ // Server honored our exact document version → authoritative, accept now.
233
+ if (expectedDocumentVersion !== undefined && published.version === expectedDocumentVersion) {
234
+ return published.diagnostics;
235
+ }
236
+ // Unversioned/mismatched publish: wait for the stream to go quiet so an
237
+ // in-flight publish for the pre-edit content is superseded by the fresh one.
238
+ if (published !== settledRef) {
239
+ settledRef = published;
240
+ settledAt = Date.now();
241
+ } else if (Date.now() - settledAt >= settleMs) {
242
+ return published.diagnostics;
243
+ }
244
+ }
245
+
246
+ const pollMs = Math.min(DIAGNOSTICS_POLL_MS, Math.max(0, deadline - Date.now()));
247
+ if (!pullResultPromise) {
248
+ await Bun.sleep(pollMs);
249
+ continue;
250
+ }
251
+ const pullResult = await Promise.race([pullResultPromise, Bun.sleep(pollMs).then(() => undefined)]);
252
+ if (pullResult) {
253
+ pullResultPromise = undefined;
254
+ pulled = pullResult.diagnostics;
255
+ if (pulled !== undefined) break;
256
+ }
257
+ }
258
+
259
+ const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
260
+ const published = client.diagnostics.get(uri);
261
+ if (published && versionOk) {
262
+ return published.diagnostics;
263
+ }
264
+ if (pullResultPromise) {
265
+ pulled = (await pullResultPromise).diagnostics;
266
+ }
267
+ throwIfAborted(signal);
268
+ if (pulled === undefined) return [];
269
+ client.diagnostics.set(uri, {
270
+ diagnostics: pulled,
271
+ version: expectedDocumentVersion ?? client.openFiles.get(uri)?.version ?? null,
272
+ });
273
+ client.diagnosticsVersion += 1;
274
+ return pulled;
275
+ }
276
+
277
+ /** Result from getDiagnosticsForFile */
278
+ export interface FileDiagnosticsResult {
279
+ /** Name of the LSP server used (if available) */
280
+ server?: string;
281
+ /** Formatted diagnostic messages */
282
+ messages: string[];
283
+ /** Summary string (e.g., "2 error(s), 1 warning(s)") */
284
+ summary: string;
285
+ /** Whether there are any errors (severity 1) */
286
+ errored: boolean;
287
+ /** Whether the file was formatted */
288
+ formatter?: FileFormatResult;
289
+ }
290
+
291
+ export type ServerVersionMap = Map<string, number>;
292
+
293
+ interface GetDiagnosticsForFileOptions {
294
+ signal?: AbortSignal;
295
+ minVersions?: ServerVersionMap;
296
+ expectedDocumentVersions?: ServerVersionMap;
297
+ /** Per-server wait budget (ms). Defaults to {@link SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS}. */
298
+ timeoutMs?: number;
299
+ }
300
+
301
+ /**
302
+ * Capture current diagnostic versions for all LSP servers.
303
+ * Call this BEFORE syncing content to detect stale diagnostics later.
304
+ */
305
+ export async function captureDiagnosticVersions(
306
+ cwd: string,
307
+ servers: Array<[string, ServerConfig]>,
308
+ initTimeoutMs?: number,
309
+ signal?: AbortSignal,
310
+ ): Promise<ServerVersionMap> {
311
+ const versions = new Map<string, number>();
312
+ await Promise.allSettled(
313
+ servers.map(async ([serverName, serverConfig]) => {
314
+ if (serverConfig.createClient) return;
315
+ const client = await getOrCreateClient(serverConfig, cwd, initTimeoutMs, signal);
316
+ versions.set(serverName, client.diagnosticsVersion);
317
+ }),
318
+ );
319
+ return versions;
320
+ }
321
+
322
+ export async function captureOpenFileVersions(
323
+ absolutePath: string,
324
+ cwd: string,
325
+ servers: Array<[string, ServerConfig]>,
326
+ signal?: AbortSignal,
327
+ ): Promise<ServerVersionMap> {
328
+ const uri = fileToUri(absolutePath);
329
+ const versions = new Map<string, number>();
330
+ await Promise.allSettled(
331
+ servers.map(async ([serverName, serverConfig]) => {
332
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
333
+ const version = client.openFiles.get(uri)?.version;
334
+ if (version !== undefined) {
335
+ versions.set(serverName, version);
336
+ }
337
+ }),
338
+ );
339
+ return versions;
340
+ }
341
+
342
+ /**
343
+ * Get diagnostics for a file using LSP or custom linter client.
344
+ *
345
+ * @param absolutePath - Absolute path to the file
346
+ * @param cwd - Working directory for LSP config resolution
347
+ * @param servers - Servers to query diagnostics for
348
+ * @param minVersions - Minimum diagnostic versions per server (to detect stale results)
349
+ * @returns Diagnostic results or undefined if no servers
350
+ */
351
+ export async function getDiagnosticsForFile(
352
+ absolutePath: string,
353
+ cwd: string,
354
+ servers: Array<[string, ServerConfig]>,
355
+ options: GetDiagnosticsForFileOptions = {},
356
+ ): Promise<FileDiagnosticsResult | undefined> {
357
+ const { signal, minVersions, expectedDocumentVersions, timeoutMs } = options;
358
+ if (servers.length === 0) {
359
+ return undefined;
360
+ }
361
+
362
+ const uri = fileToUri(absolutePath);
363
+ const relPath = formatPathRelativeToCwd(absolutePath, cwd);
364
+ const allDiagnostics: Diagnostic[] = [];
365
+ const serverNames: string[] = [];
366
+
367
+ // Wait for diagnostics from all servers in parallel
368
+ const results = await Promise.allSettled(
369
+ servers.map(async ([serverName, serverConfig]) => {
370
+ throwIfAborted(signal);
371
+ // Use custom linter client if configured
372
+ if (serverConfig.createClient) {
373
+ const linterClient = getLinterClient(serverName, serverConfig, cwd);
374
+ const diagnostics = await linterClient.lint(absolutePath);
375
+ return { serverName, serverConfig, diagnostics };
376
+ }
377
+
378
+ // Default: use LSP
379
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
380
+ throwIfAborted(signal);
381
+ if (isProjectAwareLspServer(serverConfig)) {
382
+ await waitForProjectLoaded(client, signal);
383
+ throwIfAborted(signal);
384
+ }
385
+ // Content already synced + didSave sent, wait for fresh diagnostics
386
+ const minVersion = minVersions?.get(serverName);
387
+ const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
388
+ const diagnostics = await waitForDiagnostics(client, uri, {
389
+ timeoutMs: timeoutMs ?? SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
390
+ signal,
391
+ minVersion,
392
+ expectedDocumentVersion,
393
+ });
394
+ return { serverName, serverConfig, diagnostics };
395
+ }),
396
+ );
397
+
398
+ for (const result of results) {
399
+ if (result.status === "fulfilled") {
400
+ serverNames.push(result.value.serverName);
401
+ allDiagnostics.push(
402
+ ...filterOrphanProjectDiagnostics(
403
+ absolutePath,
404
+ result.value.serverName,
405
+ result.value.serverConfig,
406
+ result.value.diagnostics,
407
+ ),
408
+ );
409
+ }
410
+ }
411
+
412
+ if (serverNames.length === 0) {
413
+ return undefined;
414
+ }
415
+
416
+ if (allDiagnostics.length === 0) {
417
+ return {
418
+ server: serverNames.join(", "),
419
+ messages: [],
420
+ summary: "OK",
421
+ errored: false,
422
+ };
423
+ }
424
+
425
+ // Deduplicate diagnostics by range + message (different servers might report similar issues)
426
+ const seen = new Set<string>();
427
+ const uniqueDiagnostics: Diagnostic[] = [];
428
+ for (const d of allDiagnostics) {
429
+ const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
430
+ if (!seen.has(key)) {
431
+ seen.add(key);
432
+ uniqueDiagnostics.push(d);
433
+ }
434
+ }
435
+
436
+ sortDiagnostics(uniqueDiagnostics);
437
+ const formatted = uniqueDiagnostics.map(d => formatDiagnostic(d, relPath));
438
+ const limited = limitDiagnosticMessages(formatted);
439
+ const summary = formatDiagnosticsSummary(uniqueDiagnostics);
440
+ const hasErrors = uniqueDiagnostics.some(d => d.severity === 1);
441
+
442
+ return {
443
+ server: serverNames.join(", "),
444
+ messages: limited,
445
+ summary,
446
+ errored: hasErrors,
447
+ };
448
+ }
449
+
450
+ export enum FileFormatResult {
451
+ UNCHANGED = "unchanged",
452
+ FORMATTED = "formatted",
453
+ }
454
+
455
+ /**
456
+ * Format content using LSP or custom linter client.
457
+ *
458
+ * @param absolutePath - Absolute path (for URI)
459
+ * @param content - Content to format
460
+ * @param cwd - Working directory for LSP config resolution
461
+ * @param servers - Servers to try formatting with
462
+ * @returns Formatted content, or original if no formatter available
463
+ */
464
+ export async function formatContent(
465
+ absolutePath: string,
466
+ content: string,
467
+ cwd: string,
468
+ servers: Array<[string, ServerConfig]>,
469
+ signal?: AbortSignal,
470
+ ): Promise<string> {
471
+ if (servers.length === 0) {
472
+ return content;
473
+ }
474
+
475
+ const uri = fileToUri(absolutePath);
476
+
477
+ for (const [serverName, serverConfig] of servers) {
478
+ try {
479
+ throwIfAborted(signal);
480
+ // Use custom linter client if configured
481
+ if (serverConfig.createClient) {
482
+ const linterClient = getLinterClient(serverName, serverConfig, cwd);
483
+ return await linterClient.format(absolutePath, content);
484
+ }
485
+
486
+ // Default: use LSP
487
+ const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
488
+ throwIfAborted(signal);
489
+
490
+ const caps = client.serverCapabilities;
491
+ if (!caps?.documentFormattingProvider) {
492
+ continue;
493
+ }
494
+
495
+ // Request formatting (content already synced)
496
+ const edits = (await sendRequest(
497
+ client,
498
+ "textDocument/formatting",
499
+ {
500
+ textDocument: { uri },
501
+ options: resolveFormatOptions(absolutePath, content),
502
+ },
503
+ signal,
504
+ )) as TextEdit[] | null;
505
+
506
+ if (!edits || edits.length === 0) {
507
+ return content;
508
+ }
509
+
510
+ // Apply edits in-memory and return
511
+ return applyTextEditsToString(content, edits);
512
+ } catch {}
513
+ }
514
+
515
+ return content;
516
+ }