@oh-my-pi/pi-coding-agent 17.0.2 → 17.0.3

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 (73) hide show
  1. package/CHANGELOG.md +70 -20
  2. package/dist/cli.js +4087 -4108
  3. package/dist/types/config/settings-schema.d.ts +7 -3
  4. package/dist/types/eval/agent-bridge.d.ts +7 -19
  5. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +15 -1
  6. package/dist/types/lsp/client.d.ts +2 -0
  7. package/dist/types/lsp/types.d.ts +3 -0
  8. package/dist/types/mcp/transports/stdio.d.ts +29 -0
  9. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  10. package/dist/types/registry/persisted-agents.d.ts +3 -0
  11. package/dist/types/sdk.d.ts +14 -3
  12. package/dist/types/session/session-entries.d.ts +6 -1
  13. package/dist/types/session/session-manager.d.ts +5 -0
  14. package/dist/types/task/executor.d.ts +26 -1
  15. package/dist/types/task/index.d.ts +1 -1
  16. package/dist/types/task/parallel.d.ts +14 -0
  17. package/dist/types/task/structured-subagent.d.ts +111 -0
  18. package/dist/types/task/types.d.ts +51 -0
  19. package/dist/types/tools/fetch.d.ts +4 -5
  20. package/dist/types/tools/hub/messaging.d.ts +5 -1
  21. package/dist/types/tools/index.d.ts +16 -1
  22. package/dist/types/tools/todo.d.ts +31 -0
  23. package/dist/types/tui/tree-list.d.ts +7 -0
  24. package/dist/types/utils/markit.d.ts +11 -0
  25. package/package.json +12 -12
  26. package/src/cli/file-processor.ts +1 -2
  27. package/src/config/settings-schema.ts +4 -3
  28. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  29. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  30. package/src/eval/agent-bridge.ts +104 -477
  31. package/src/eval/jl/prelude.jl +7 -6
  32. package/src/eval/js/shared/prelude.txt +5 -4
  33. package/src/eval/py/prelude.py +11 -39
  34. package/src/eval/rb/prelude.rb +5 -6
  35. package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
  36. package/src/lsp/client.ts +57 -0
  37. package/src/lsp/index.ts +62 -6
  38. package/src/lsp/types.ts +3 -0
  39. package/src/mcp/oauth-flow.ts +20 -0
  40. package/src/mcp/transports/stdio.test.ts +269 -1
  41. package/src/mcp/transports/stdio.ts +152 -1
  42. package/src/modes/components/agent-hub.ts +1 -72
  43. package/src/modes/components/bash-execution.ts +7 -3
  44. package/src/modes/components/eval-execution.ts +3 -1
  45. package/src/modes/interactive-mode.ts +30 -8
  46. package/src/prompts/system/system-prompt.md +1 -1
  47. package/src/prompts/tools/eval.md +5 -2
  48. package/src/prompts/tools/read.md +1 -1
  49. package/src/prompts/tools/task.md +12 -8
  50. package/src/prompts/tools/write.md +1 -1
  51. package/src/registry/persisted-agents.ts +74 -0
  52. package/src/sdk.ts +129 -87
  53. package/src/session/agent-session.ts +75 -21
  54. package/src/session/session-entries.ts +6 -1
  55. package/src/session/session-manager.ts +9 -0
  56. package/src/session/streaming-output.ts +41 -1
  57. package/src/system-prompt.ts +7 -2
  58. package/src/task/executor.ts +99 -21
  59. package/src/task/index.ts +258 -429
  60. package/src/task/parallel.ts +43 -0
  61. package/src/task/persisted-revive.ts +19 -7
  62. package/src/task/structured-subagent.ts +642 -0
  63. package/src/task/types.ts +58 -0
  64. package/src/tools/__tests__/eval-description.test.ts +1 -1
  65. package/src/tools/fetch.ts +28 -105
  66. package/src/tools/hub/messaging.ts +16 -3
  67. package/src/tools/index.ts +47 -14
  68. package/src/tools/path-utils.ts +1 -0
  69. package/src/tools/read.ts +14 -26
  70. package/src/tools/todo.ts +126 -13
  71. package/src/tui/tree-list.ts +39 -0
  72. package/src/utils/markit.ts +12 -0
  73. package/src/utils/zip.ts +16 -1
@@ -519,7 +519,7 @@ function completion(prompt::String; model="default", system=nothing, schema=noth
519
519
  return schema === nothing ? text : Main.json_parse(string(text))
520
520
  end
521
521
 
522
- function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
522
+ function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
523
523
  args_dict = Dict{String, Any}("prompt" => prompt)
524
524
  if agent !== nothing
525
525
  args_dict["agent"] = agent
@@ -533,8 +533,9 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
533
533
  if schema !== nothing
534
534
  args_dict["schema"] = schema
535
535
  end
536
- # Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
537
- # with `apply`/`merge` controlling the post-run patch/branch merge.
536
+ if schema_mode !== nothing
537
+ args_dict["schemaMode"] = schema_mode
538
+ end
538
539
  if isolated !== nothing
539
540
  args_dict["isolated"] = Bool(isolated)
540
541
  end
@@ -548,13 +549,13 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
548
549
  for (k, v) in kwargs
549
550
  args_dict[string(k)] = v
550
551
  end
551
- # Tell the bridge a handle is wanted so it preserves the backing artifacts.
552
552
  if handle_result
553
553
  args_dict["handle"] = true
554
554
  end
555
555
  res = __omp_call_bridge("__agent__", args_dict)
556
556
  text = res isa AbstractDict ? get(res, "text", res) : res
557
- parsed = schema === nothing ? text : Main.json_parse(string(text))
557
+ has_data = res isa AbstractDict && haskey(res, "data")
558
+ parsed = has_data ? res["data"] : (schema === nothing ? text : Main.json_parse(string(text)))
558
559
  if !handle_result
559
560
  return parsed
560
561
  end
@@ -569,7 +570,7 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
569
570
  "id" => get(details, "id", nothing),
570
571
  "agent" => get(details, "agent", nothing)
571
572
  )
572
- if schema !== nothing
573
+ if has_data || schema !== nothing
573
574
  node["data"] = parsed
574
575
  end
575
576
  for (src_key, dst_key) in (
@@ -104,20 +104,21 @@ if (!globalThis.__omp_js_prelude_loaded__) {
104
104
  "agent",
105
105
  opts,
106
106
  rest,
107
- ["agent", "model", "label", "schema", "isolated", "apply", "merge"],
108
- "{ agent, model, label, schema, isolated, apply, merge, handle }",
107
+ ["agent", "model", "label", "schema", "isolated", "apply", "merge", "schemaMode"],
108
+ "{ agent, model, label, schema, isolated, apply, merge, schemaMode, handle }",
109
109
  );
110
110
  const { handle, ...callArgs } = o;
111
111
  const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) });
112
112
  const text = res && typeof res === "object" ? res.text : res;
113
- const parsed = hasOwn(callArgs, "schema") ? JSON.parse(text) : text;
113
+ const hasData = res && typeof res === "object" && hasOwn(res, "data");
114
+ const parsed = hasData ? res.data : hasOwn(callArgs, "schema") ? JSON.parse(text) : text;
114
115
  if (!handle) return parsed;
115
116
  const details = res && typeof res === "object" ? res.details : undefined;
116
117
  if (!details || typeof details !== "object" || details.id == null) {
117
118
  return { text, output: text, handle: null, id: null, agent: null };
118
119
  }
119
120
  const node = { text, output: text, handle: `agent://${details.id}`, id: details.id, agent: details.agent ?? null };
120
- if (hasOwn(callArgs, "schema")) node.data = parsed;
121
+ if (hasData || hasOwn(callArgs, "schema")) node.data = parsed;
121
122
  for (const key of ["isolated", "patchPath", "branchName", "nestedPatches", "changesApplied", "isolationSummary"]) {
122
123
  if (details[key] !== undefined) node[key] = details[key];
123
124
  }
@@ -487,48 +487,17 @@ if "__omp_prelude_loaded__" not in globals():
487
487
  model=None,
488
488
  label=None,
489
489
  schema=None,
490
+ schema_mode=None,
490
491
  isolated=None,
491
492
  apply=None,
492
493
  merge=None,
493
494
  handle=False,
494
495
  ):
495
- """Run a subagent and return its final output.
496
-
497
- `agent` selects the subagent definition (default "task"). Pass
498
- `model` to override that agent's model, `label` for the output artifact
499
- id, and `schema` to request structured JSON output; when `schema` is
500
- supplied the parsed object is returned. Share background by writing a
501
- local:// file and referencing it in the prompt.
502
-
503
- Pass `isolated=True` to run the subagent inside an isolation worktree
504
- (copy-on-write of the parent repo) so parallel `agent()` spawns can
505
- edit overlapping files safely. Strict opt-in, mirroring the `task`
506
- tool: the default is non-isolated regardless of `task.isolation.mode`.
507
- `isolated=True` while the setting is `"none"` errors out instead of
508
- silently downgrading.
509
-
510
- When isolated, `apply=False` keeps captured changes inside the
511
- worktree and surfaces the root patch path, branch name, and nested
512
- repository patches through the DAG node dict (combine with
513
- `handle=True` to receive them — see below; the bare return type
514
- stays bytes/string/parsed object and has nowhere to expose artifacts).
515
- `merge=False` forces patch mode even when `task.isolation.merge` is
516
- `"branch"`, avoiding the per-call git lock + repo mutation that branch
517
- mode performs.
518
-
519
- Set `handle=True` to receive a DAG node dict instead of bare
520
- text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
521
- is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
522
- ``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
523
- in its prompt so a large transcript flows through the graph by
524
- reference, never re-inlined. When ``schema`` is also set the parsed
525
- object lands under ``"data"``. When the spawn ran isolated the node
526
- also carries ``"isolated"`` and, when present, ``"patch_path"``,
527
- ``"branch_name"``, ``"nested_patches"``, ``"changes_applied"``
528
- (``True``/``False``/``None`` — ``None`` means ``apply=False``), and
529
- ``"isolation_summary"``. If
530
- the bridge returns no recoverable id the node still resolves with
531
- ``handle=None`` — the helper never throws.
496
+ """Run a subagent and return its final output or structured data.
497
+
498
+ `schema` overrides agent and session schemas. `schema_mode` is
499
+ `"permissive"` or `"strict"`. `handle=True` returns the child output
500
+ reference and metadata, with parsed data under `"data"` when available.
532
501
  """
533
502
  args = {"prompt": prompt}
534
503
  if agent is not None:
@@ -539,6 +508,8 @@ if "__omp_prelude_loaded__" not in globals():
539
508
  args["label"] = label
540
509
  if schema is not None:
541
510
  args["schema"] = schema
511
+ if schema_mode is not None:
512
+ args["schemaMode"] = schema_mode
542
513
  if isolated is not None:
543
514
  args["isolated"] = bool(isolated)
544
515
  if apply is not None:
@@ -549,7 +520,8 @@ if "__omp_prelude_loaded__" not in globals():
549
520
  args["handle"] = True
550
521
  res = _bridge_call("__agent__", args)
551
522
  text = res.get("text") if isinstance(res, dict) else res
552
- parsed = json.loads(text) if schema is not None else text
523
+ has_data = isinstance(res, dict) and "data" in res
524
+ parsed = res["data"] if has_data else json.loads(text) if schema is not None else text
553
525
  if not handle:
554
526
  return parsed
555
527
  details = res.get("details") if isinstance(res, dict) else None
@@ -568,7 +540,7 @@ if "__omp_prelude_loaded__" not in globals():
568
540
  "id": details["id"],
569
541
  "agent": details.get("agent"),
570
542
  }
571
- if schema is not None:
543
+ if has_data or schema is not None:
572
544
  node["data"] = parsed
573
545
  for src_key, dst_key in (
574
546
  ("isolated", "isolated"),
@@ -392,22 +392,21 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
392
392
  schema.nil? ? text : JSON.parse(text)
393
393
  end
394
394
 
395
- def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
395
+ def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false)
396
396
  args = { "prompt" => prompt }
397
397
  args["agent"] = agent unless agent.nil?
398
398
  args["model"] = model unless model.nil?
399
399
  args["label"] = label unless label.nil?
400
400
  args["schema"] = schema unless schema.nil?
401
- # Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
402
- # with `apply`/`merge` controlling the post-run patch/branch merge.
401
+ args["schemaMode"] = schema_mode unless schema_mode.nil?
403
402
  args["isolated"] = !!isolated unless isolated.nil?
404
403
  args["apply"] = !!apply unless apply.nil?
405
404
  args["merge"] = !!merge unless merge.nil?
406
- # Tell the bridge a handle is wanted so it preserves the backing artifacts.
407
405
  args["handle"] = true if handle
408
406
  res = OmpBridge.call("__agent__", args)
409
407
  text = res.is_a?(Hash) ? res["text"] : res
410
- parsed = schema.nil? ? text : JSON.parse(text)
408
+ has_data = res.is_a?(Hash) && res.key?("data")
409
+ parsed = has_data ? res["data"] : (schema.nil? ? text : JSON.parse(text))
411
410
  return parsed unless handle
412
411
  details = res.is_a?(Hash) ? res["details"] : nil
413
412
  if !details.is_a?(Hash) || details["id"].nil?
@@ -420,7 +419,7 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
420
419
  "id" => details["id"],
421
420
  "agent" => details["agent"],
422
421
  }
423
- node["data"] = parsed unless schema.nil?
422
+ node["data"] = parsed if has_data || !schema.nil?
424
423
  {
425
424
  "isolated" => "isolated",
426
425
  "patchPath" => "patch_path",
@@ -12,12 +12,18 @@
12
12
  * the same module identity as a direct `@oh-my-pi/pi-coding-agent` import.
13
13
  */
14
14
 
15
- import * as fs from "node:fs/promises";
15
+ import { Database } from "bun:sqlite";
16
+ import * as fs from "node:fs";
16
17
  import * as path from "node:path";
17
18
  import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
18
- import type { TSchema } from "@oh-my-pi/pi-ai";
19
+ import { type AuthCredential, SqliteAuthCredentialStore, type TSchema } from "@oh-my-pi/pi-ai";
19
20
  import { Text } from "@oh-my-pi/pi-tui";
20
- import { getAgentDir, getProjectDir, parseFrontmatter as parseOmpFrontmatter } from "@oh-my-pi/pi-utils";
21
+ import {
22
+ getAgentDbPath,
23
+ getAgentDir,
24
+ getProjectDir,
25
+ parseFrontmatter as parseOmpFrontmatter,
26
+ } from "@oh-my-pi/pi-utils";
21
27
  import type { PromptTemplate } from "../config/prompt-templates";
22
28
  import { type SettingPath, Settings } from "../config/settings";
23
29
  import { EditTool } from "../edit";
@@ -606,16 +612,16 @@ export function createLsToolDefinition(cwd: string, options?: LsToolOptions): To
606
612
  const ops = options?.operations;
607
613
  const exists = ops
608
614
  ? await ops.exists(absolutePath)
609
- : await fs.stat(absolutePath).then(
615
+ : await fs.promises.stat(absolutePath).then(
610
616
  () => true,
611
617
  () => false,
612
618
  );
613
619
  if (!exists) throw new Error(`Path not found: ${absolutePath}`);
614
- const stat = ops ? await ops.stat(absolutePath) : await fs.stat(absolutePath);
620
+ const stat = ops ? await ops.stat(absolutePath) : await fs.promises.stat(absolutePath);
615
621
  if (!stat.isDirectory()) {
616
622
  return { content: [{ type: "text", text: rawPath }] };
617
623
  }
618
- const entries = ops ? await ops.readdir(absolutePath) : await fs.readdir(absolutePath);
624
+ const entries = ops ? await ops.readdir(absolutePath) : await fs.promises.readdir(absolutePath);
619
625
  const sorted = [...entries].sort((a, b) => a.localeCompare(b));
620
626
  const limited = sorted.slice(0, limit);
621
627
  const output = limited.join("\n");
@@ -1100,7 +1106,7 @@ export class DefaultResourceLoader implements ResourceLoader {
1100
1106
  : path.resolve(this.#state.cwd, resourcePath);
1101
1107
  const files: string[] = [];
1102
1108
  try {
1103
- const stat = await fs.stat(resolvedPath);
1109
+ const stat = await fs.promises.stat(resolvedPath);
1104
1110
  if (stat.isDirectory()) {
1105
1111
  const glob = new Bun.Glob("**/*.md");
1106
1112
  for await (const entry of glob.scan({ cwd: resolvedPath, absolute: false, onlyFiles: true })) {
@@ -1288,6 +1294,46 @@ export async function createAgentSession(
1288
1294
  return ompCreateAgentSession(forwarded);
1289
1295
  }
1290
1296
 
1297
+ /**
1298
+ * Synchronous auth storage surface retained for legacy extensions.
1299
+ *
1300
+ * Modern OMP auth storage is asynchronous, while older provider extensions
1301
+ * call `AuthStorage.create().get()` during module initialization.
1302
+ */
1303
+ export class AuthStorage {
1304
+ constructor() {
1305
+ fs.mkdirSync(path.dirname(getAgentDbPath()), { recursive: true, mode: 0o700 });
1306
+ }
1307
+
1308
+ static create(): AuthStorage {
1309
+ return new AuthStorage();
1310
+ }
1311
+
1312
+ get(provider: string): AuthCredential | undefined {
1313
+ const store = new SqliteAuthCredentialStore(new Database(getAgentDbPath()));
1314
+ try {
1315
+ return store.listAuthCredentials(provider)[0]?.credential;
1316
+ } finally {
1317
+ store.close();
1318
+ }
1319
+ }
1320
+
1321
+ set(provider: string, credential: AuthCredential): void {
1322
+ const store = new SqliteAuthCredentialStore(new Database(getAgentDbPath()));
1323
+ try {
1324
+ store.upsertAuthCredentialForProvider(provider, credential);
1325
+ } finally {
1326
+ store.close();
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ /** Read the first active credential for a legacy extension provider. */
1332
+ export function readStoredCredential(provider: string): AuthCredential | undefined {
1333
+ const storage = AuthStorage.create();
1334
+ return storage.get(provider);
1335
+ }
1336
+
1291
1337
  export * from "../index";
1292
1338
  export { formatBytes as formatSize } from "../tools/render-utils";
1293
1339
  export { Type } from "./typebox";
package/src/lsp/client.ts CHANGED
@@ -142,6 +142,9 @@ const CLIENT_CAPABILITIES = {
142
142
  codeDescriptionSupport: true,
143
143
  dataSupport: true,
144
144
  },
145
+ diagnostic: {
146
+ dynamicRegistration: true,
147
+ },
145
148
  },
146
149
  window: {
147
150
  workDoneProgress: true,
@@ -456,6 +459,58 @@ async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequ
456
459
  }
457
460
  }
458
461
 
462
+ interface DynamicCapabilityRegistration {
463
+ id?: unknown;
464
+ method?: unknown;
465
+ }
466
+
467
+ interface DynamicCapabilityParams {
468
+ registrations?: DynamicCapabilityRegistration[];
469
+ unregisterations?: DynamicCapabilityRegistration[];
470
+ unregistrations?: DynamicCapabilityRegistration[];
471
+ }
472
+
473
+ function updateDynamicCapabilities(client: LspClient, message: LspJsonRpcRequest): void {
474
+ const params = message.params as DynamicCapabilityParams;
475
+ if (message.method === "client/registerCapability") {
476
+ if (!Array.isArray(params.registrations)) return;
477
+ let registrations = client.dynamicCapabilityRegistrations;
478
+ if (!registrations) {
479
+ registrations = new Map();
480
+ client.dynamicCapabilityRegistrations = registrations;
481
+ }
482
+ for (const registration of params.registrations) {
483
+ if (typeof registration.id === "string" && typeof registration.method === "string") {
484
+ registrations.set(registration.id, registration.method);
485
+ }
486
+ }
487
+ return;
488
+ }
489
+
490
+ const registrations = client.dynamicCapabilityRegistrations;
491
+ if (!registrations) return;
492
+ const unregistrations = params.unregisterations ?? params.unregistrations;
493
+ if (!Array.isArray(unregistrations)) return;
494
+ for (const registration of unregistrations) {
495
+ if (typeof registration.id === "string") {
496
+ registrations.delete(registration.id);
497
+ }
498
+ }
499
+ }
500
+
501
+ /** Whether the server advertised LSP 3.17 document diagnostic pulls statically or through registration. */
502
+ export function supportsDocumentDiagnostics(client: LspClient): boolean {
503
+ const staticProvider = client.serverCapabilities?.diagnosticProvider;
504
+ if (staticProvider) return true;
505
+
506
+ const registrations = client.dynamicCapabilityRegistrations;
507
+ if (!registrations) return false;
508
+ for (const method of registrations.values()) {
509
+ if (method === "textDocument/diagnostic") return true;
510
+ }
511
+ return false;
512
+ }
513
+
459
514
  /**
460
515
  * Respond to a server-initiated request.
461
516
  */
@@ -478,6 +533,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
478
533
  return;
479
534
  }
480
535
  if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") {
536
+ updateDynamicCapabilities(client, message);
481
537
  // Some servers block semantic requests until dynamic registration succeeds.
482
538
  await sendResponse(client, message.id, null, message.method);
483
539
  return;
@@ -685,6 +741,7 @@ export async function getOrCreateClient(
685
741
  requestId: 0,
686
742
  diagnostics: new Map(),
687
743
  diagnosticsVersion: 0,
744
+ dynamicCapabilityRegistrations: new Map(),
688
745
  openFiles: new Map(),
689
746
  pendingRequests: new Map(),
690
747
  messageBuffer: new Uint8Array(0),
package/src/lsp/index.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  sendNotification,
29
29
  sendRequest,
30
30
  setIdleTimeout,
31
+ supportsDocumentDiagnostics,
31
32
  syncContent,
32
33
  WARMUP_TIMEOUT_MS,
33
34
  waitForProjectLoaded,
@@ -530,17 +531,49 @@ interface WaitForDiagnosticsOptions {
530
531
  settleMs?: number;
531
532
  }
532
533
 
534
+ function requestDocumentDiagnostics(
535
+ client: LspClient,
536
+ uri: string,
537
+ signal: AbortSignal | undefined,
538
+ timeoutMs: number,
539
+ ): Promise<Diagnostic[] | undefined> {
540
+ return sendRequest(client, "textDocument/diagnostic", { textDocument: { uri } }, signal, timeoutMs)
541
+ .then(report => {
542
+ if (!report || typeof report !== "object" || !("kind" in report) || report.kind !== "full") {
543
+ return undefined;
544
+ }
545
+ if (!("items" in report) || !Array.isArray(report.items)) return undefined;
546
+ return report.items;
547
+ })
548
+ .catch(err => {
549
+ if (!signal?.aborted) {
550
+ logger.debug("LSP document diagnostic pull failed", { server: client.name, uri, error: String(err) });
551
+ }
552
+ return undefined;
553
+ });
554
+ }
555
+
533
556
  async function waitForDiagnostics(
534
557
  client: LspClient,
535
558
  uri: string,
536
559
  options: WaitForDiagnosticsOptions = {},
537
560
  ): Promise<Diagnostic[]> {
538
561
  const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, settleMs = DIAGNOSTICS_SETTLE_MS } = options;
539
- const start = Date.now();
562
+ const deadline = Date.now() + timeoutMs;
563
+ let pullAttempted = false;
564
+ let pullResultPromise: Promise<{ diagnostics: Diagnostic[] | undefined }> | undefined;
565
+ let pulled: Diagnostic[] | undefined;
540
566
  let settledRef: PublishedDiagnostics | undefined;
541
567
  let settledAt = 0;
542
- while (Date.now() - start < timeoutMs) {
568
+ while (Date.now() < deadline) {
543
569
  throwIfAborted(signal);
570
+ if (!pullAttempted && supportsDocumentDiagnostics(client)) {
571
+ pullAttempted = true;
572
+ pullResultPromise = requestDocumentDiagnostics(client, uri, signal, Math.max(1, deadline - Date.now())).then(
573
+ diagnostics => ({ diagnostics }),
574
+ );
575
+ }
576
+
544
577
  const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
545
578
  const published = client.diagnostics.get(uri);
546
579
  if (published && versionOk) {
@@ -557,13 +590,36 @@ async function waitForDiagnostics(
557
590
  return published.diagnostics;
558
591
  }
559
592
  }
560
- await Bun.sleep(DIAGNOSTICS_POLL_MS);
593
+
594
+ const pollMs = Math.min(DIAGNOSTICS_POLL_MS, Math.max(0, deadline - Date.now()));
595
+ if (!pullResultPromise) {
596
+ await Bun.sleep(pollMs);
597
+ continue;
598
+ }
599
+ const pullResult = await Promise.race([pullResultPromise, Bun.sleep(pollMs).then(() => undefined)]);
600
+ if (pullResult) {
601
+ pullResultPromise = undefined;
602
+ pulled = pullResult.diagnostics;
603
+ if (pulled !== undefined) break;
604
+ }
561
605
  }
606
+
562
607
  const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
563
- if (!versionOk) {
564
- return [];
608
+ const published = client.diagnostics.get(uri);
609
+ if (published && versionOk) {
610
+ return published.diagnostics;
611
+ }
612
+ if (pullResultPromise) {
613
+ pulled = (await pullResultPromise).diagnostics;
565
614
  }
566
- return client.diagnostics.get(uri)?.diagnostics ?? [];
615
+ throwIfAborted(signal);
616
+ if (pulled === undefined) return [];
617
+ client.diagnostics.set(uri, {
618
+ diagnostics: pulled,
619
+ version: expectedDocumentVersion ?? client.openFiles.get(uri)?.version ?? null,
620
+ });
621
+ client.diagnosticsVersion += 1;
622
+ return pulled;
567
623
  }
568
624
 
569
625
  /** Project type detection result */
package/src/lsp/types.ts CHANGED
@@ -387,6 +387,7 @@ export interface LspServerCapabilities {
387
387
  referencesProvider?: boolean;
388
388
  documentSymbolProvider?: boolean;
389
389
  workspaceSymbolProvider?: boolean;
390
+ diagnosticProvider?: boolean | Record<string, unknown>;
390
391
  [key: string]: unknown;
391
392
  }
392
393
 
@@ -398,6 +399,8 @@ export interface LspClient {
398
399
  requestId: number;
399
400
  diagnostics: Map<string, PublishedDiagnostics>;
400
401
  diagnosticsVersion: number;
402
+ /** Dynamic capability registrations keyed by the server-provided registration ID. */
403
+ dynamicCapabilityRegistrations?: Map<string, string>;
401
404
  openFiles: Map<string, OpenFile>;
402
405
  pendingRequests: Map<number | string, PendingRequest>;
403
406
  messageBuffer: Uint8Array;
@@ -385,6 +385,13 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
385
385
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
386
386
  if (!this.#resolvedClientId) {
387
387
  await this.#tryRegisterClient(redirectUri);
388
+ // `unapproved_client` explicitly establishes that registration cannot
389
+ // produce the required client id. Other DCR failures stay on the
390
+ // clientless probe path because they may be transient or caused by
391
+ // unrelated registration metadata.
392
+ if (!this.#resolvedClientId && this.#isDefinitiveRegistrationRejection()) {
393
+ throw this.#missingClientIdError();
394
+ }
388
395
  }
389
396
 
390
397
  const authUrl = new URL(this.config.authorizationUrl);
@@ -691,6 +698,19 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
691
698
  }
692
699
  }
693
700
 
701
+ /**
702
+ * Whether the provider explicitly rejected this client as unapproved.
703
+ *
704
+ * HTTP status alone is insufficient: payload errors such as
705
+ * `invalid_client_metadata` and `invalid_redirect_uri` do not establish that
706
+ * the authorization endpoint requires a client id. Keep those on the
707
+ * clientless probe path.
708
+ */
709
+ #isDefinitiveRegistrationRejection(): boolean {
710
+ const failure = this.#registrationFailure;
711
+ return failure?.status === 403 && /\bunapproved_client\b/i.test(failure.detail ?? "");
712
+ }
713
+
694
714
  /**
695
715
  * Build the error thrown when the authorize probe confirms the provider
696
716
  * demands a `client_id`. When dynamic client registration was attempted and