@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.7

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 (137) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
  3. package/dist/cli.js +3156 -3163
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/stats-cli.d.ts +1 -6
  6. package/dist/types/commands/stats.d.ts +4 -0
  7. package/dist/types/config/model-discovery.d.ts +2 -0
  8. package/dist/types/config/model-registry.d.ts +6 -1
  9. package/dist/types/config/settings-schema.d.ts +10 -0
  10. package/dist/types/debug/report-bundle.d.ts +7 -2
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +24 -6
  13. package/dist/types/extensibility/extensions/types.d.ts +60 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/launch/presence.d.ts +11 -0
  16. package/dist/types/lsp/client.d.ts +22 -0
  17. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  18. package/dist/types/mcp/config.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  21. package/dist/types/registry/agent-registry.d.ts +4 -0
  22. package/dist/types/session/agent-session-types.d.ts +0 -2
  23. package/dist/types/session/agent-session.d.ts +20 -0
  24. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  25. package/dist/types/session/irc-bridge.d.ts +2 -0
  26. package/dist/types/session/messages.d.ts +4 -0
  27. package/dist/types/session/session-loader.d.ts +18 -11
  28. package/dist/types/session/session-maintenance.d.ts +6 -2
  29. package/dist/types/session/session-manager.d.ts +19 -3
  30. package/dist/types/session/session-tools.d.ts +0 -1
  31. package/dist/types/session/turn-recovery.d.ts +7 -7
  32. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  33. package/dist/types/tools/browser/attach.d.ts +16 -0
  34. package/dist/types/tools/browser/launch.d.ts +2 -2
  35. package/dist/types/tools/browser.d.ts +1 -1
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  38. package/dist/types/tools/hub/jobs.d.ts +6 -0
  39. package/dist/types/tools/hub/types.d.ts +6 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/vibe/runtime.d.ts +3 -3
  43. package/package.json +13 -13
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/stats-cli.ts +6 -72
  47. package/src/commands/stats.ts +7 -4
  48. package/src/commit/analysis/conventional.ts +15 -9
  49. package/src/commit/analysis/summary.ts +15 -9
  50. package/src/commit/changelog/generate.ts +15 -9
  51. package/src/commit/map-reduce/map-phase.ts +3 -19
  52. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  53. package/src/config/model-discovery.ts +3 -3
  54. package/src/config/model-registry.ts +29 -16
  55. package/src/config/settings-schema.ts +13 -0
  56. package/src/debug/report-bundle.ts +25 -59
  57. package/src/discovery/claude-plugins.ts +2 -1
  58. package/src/discovery/claude.ts +4 -2
  59. package/src/discovery/helpers.ts +5 -0
  60. package/src/edit/hashline/filesystem.ts +9 -3
  61. package/src/edit/modes/patch.ts +31 -5
  62. package/src/edit/renderer.ts +48 -19
  63. package/src/extensibility/extensions/loader.ts +20 -5
  64. package/src/extensibility/extensions/runner.ts +239 -24
  65. package/src/extensibility/extensions/types.ts +62 -0
  66. package/src/extensibility/extensions/wrapper.ts +26 -11
  67. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  68. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  69. package/src/irc/bus.ts +1 -1
  70. package/src/launch/broker.ts +8 -1
  71. package/src/launch/presence.ts +77 -1
  72. package/src/lsp/client.ts +35 -3
  73. package/src/lsp/clients/biome-client.ts +47 -93
  74. package/src/lsp/servers.ts +17 -11
  75. package/src/lsp/tool.ts +2 -4
  76. package/src/lsp/workspace-diagnostics.ts +24 -2
  77. package/src/lsp/writethrough.ts +20 -10
  78. package/src/mcp/config.ts +50 -6
  79. package/src/memories/index.ts +29 -25
  80. package/src/mnemopi/backend.ts +13 -11
  81. package/src/modes/components/ask-dialog.ts +25 -5
  82. package/src/modes/components/tool-execution.ts +10 -6
  83. package/src/modes/components/welcome.ts +4 -1
  84. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  85. package/src/modes/controllers/selector-controller.ts +6 -0
  86. package/src/modes/controllers/tan-command-controller.ts +1 -0
  87. package/src/modes/interactive-mode.ts +49 -10
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  90. package/src/prompts/system/date-cwd-reminder.md +3 -0
  91. package/src/prompts/system/project-prompt.md +0 -1
  92. package/src/prompts/system/rewind-report.md +1 -3
  93. package/src/prompts/tools/browser.md +1 -0
  94. package/src/prompts/tools/rewind.md +1 -13
  95. package/src/registry/agent-lifecycle.ts +34 -0
  96. package/src/registry/agent-registry.ts +27 -2
  97. package/src/registry/persisted-agents.ts +40 -20
  98. package/src/sdk.ts +32 -2
  99. package/src/session/agent-session-types.ts +0 -2
  100. package/src/session/agent-session.ts +178 -46
  101. package/src/session/date-cwd-reminder.ts +77 -0
  102. package/src/session/irc-bridge.ts +5 -0
  103. package/src/session/messages.ts +5 -1
  104. package/src/session/session-loader.ts +106 -64
  105. package/src/session/session-maintenance.ts +189 -115
  106. package/src/session/session-manager.ts +142 -35
  107. package/src/session/session-persistence.ts +4 -4
  108. package/src/session/session-storage.ts +18 -5
  109. package/src/session/session-tools.ts +5 -10
  110. package/src/session/turn-recovery.ts +102 -20
  111. package/src/session/unexpected-stop-classifier.ts +33 -21
  112. package/src/slash-commands/builtin-session.ts +1 -1
  113. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  114. package/src/system-prompt.ts +0 -5
  115. package/src/task/executor.ts +4 -15
  116. package/src/task/persisted-revive.ts +3 -6
  117. package/src/tools/ask.ts +10 -3
  118. package/src/tools/browser/attach.ts +80 -25
  119. package/src/tools/browser/launch.ts +44 -15
  120. package/src/tools/browser/relay/daemon.ts +3 -7
  121. package/src/tools/browser/render.ts +1 -1
  122. package/src/tools/browser/shared-daemon.ts +3 -7
  123. package/src/tools/browser.ts +5 -5
  124. package/src/tools/builtin-names.ts +7 -3
  125. package/src/tools/checkpoint.ts +1 -7
  126. package/src/tools/file-write-fallback.ts +467 -0
  127. package/src/tools/hub/index.ts +1 -1
  128. package/src/tools/hub/jobs.ts +20 -3
  129. package/src/tools/hub/types.ts +6 -0
  130. package/src/tools/index.ts +1 -0
  131. package/src/tools/path-utils.ts +79 -0
  132. package/src/tools/render-utils.ts +53 -21
  133. package/src/tools/think.ts +15 -3
  134. package/src/tts/speech-enhancer.ts +19 -14
  135. package/src/utils/commit-message-generator.ts +14 -12
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
@@ -14,6 +14,7 @@ import type { Settings } from "../../config/settings";
14
14
  import type { Theme } from "../../modes/theme/theme";
15
15
  import { type ApprovalMode, formatApprovalPrompt, resolveApproval, truncateForPrompt } from "../../tools/approval";
16
16
  import { defaultLoadModeForToolName } from "../../tools/essential-tools";
17
+ import { withFileMutationSession } from "../../tools/file-write-fallback";
17
18
  import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
18
19
  import { applyToolProxy } from "../tool-proxy";
19
20
  import type { ExtensionRunner } from "./runner";
@@ -205,15 +206,18 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
205
206
  let effectiveParams = params;
206
207
  if (!loopEmittedToolCall && this.runner.hasHandlers("tool_call")) {
207
208
  try {
208
- const callResult = (await this.runner.emitToolCall({
209
- type: "tool_call",
210
- toolName: this.tool.name,
211
- toolCallId,
212
- input: normalizeToolEventInput(
213
- this.tool.name,
214
- resolveToolEventInput(this.tool, toolEventArgs(params, context)),
215
- ),
216
- })) as ToolCallEventResult | undefined;
209
+ const callResult = (await this.runner.emitToolCall(
210
+ {
211
+ type: "tool_call",
212
+ toolName: this.tool.name,
213
+ toolCallId,
214
+ input: normalizeToolEventInput(
215
+ this.tool.name,
216
+ resolveToolEventInput(this.tool, toolEventArgs(params, context)),
217
+ ),
218
+ },
219
+ signal,
220
+ )) as ToolCallEventResult | undefined;
217
221
 
218
222
  if (callResult?.block) {
219
223
  const reason = callResult.reason || "Tool execution was blocked by an extension";
@@ -265,7 +269,10 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
265
269
 
266
270
  if (approvalCheck.required) {
267
271
  const scheduledCall = context?.toolCall?.toolCalls[context.toolCall.index];
268
- if (scheduledCall?.id === toolCallId && scheduledCall.name === this.tool.name) {
272
+ if (
273
+ scheduledCall?.id === toolCallId &&
274
+ (scheduledCall.name === this.tool.name || scheduledCall.name === this.tool.customWireName)
275
+ ) {
269
276
  await untilAborted(signal, () => this.runner.waitForToolApprovalPreview(toolCallId));
270
277
  }
271
278
 
@@ -343,7 +350,15 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
343
350
  let executionError: Error | undefined;
344
351
 
345
352
  try {
346
- result = await this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context);
353
+ // A denied file write or delete inside this tool can be brokered to an
354
+ // extension handler, and that registry is PROCESS-WIDE — so the session is
355
+ // named here, the one place where every tool's execution and the runner
356
+ // that owns the handlers are both in scope (`sdk.ts` wraps the whole tool
357
+ // registry with this class whenever a runner exists). Inert with no
358
+ // fallback registered: no scope is entered.
359
+ result = await withFileMutationSession(this.runner.sessionId, () =>
360
+ this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context),
361
+ );
347
362
  } catch (err) {
348
363
  executionError = err instanceof Error ? err : new Error(String(err));
349
364
  result = {
@@ -134,6 +134,10 @@ export const getModels = getBundledModels;
134
134
  *
135
135
  * Legacy `/compat` callers pass {@link SimpleStreamOptions}; routing through
136
136
  * `streamSimple` preserves option normalization before provider dispatch.
137
+ *
138
+ * Transient-failure retry (overload, rate-limit, 5xx) is the **caller's
139
+ * responsibility**. Oneshot callers that collect the full result before acting
140
+ * should wrap with `retryTransientCompletion` from `@oh-my-pi/pi-ai`.
137
141
  */
138
142
  export function streamSimpleOpenAIResponses(
139
143
  model: Model<"openai-responses">,
@@ -11,6 +11,7 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
13
  import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
14
+ import { expandTilde } from "../../../tools/path-utils";
14
15
  import { normalizePluginRuntimeConfig } from "../runtime-config";
15
16
  import type { PluginRuntimeConfig } from "../types";
16
17
 
@@ -107,12 +108,11 @@ export class MarketplaceManager {
107
108
  }
108
109
 
109
110
  const sourceType = classifySource(source);
110
- const normalizedSource =
111
- sourceType === "local"
112
- ? path.resolve(source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source)
113
- : source;
111
+ const normalizedSource = sourceType === "local" ? path.resolve(expandTilde(source)) : source;
114
112
 
115
- const catalogPath = path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json");
113
+ const catalogPath = path.resolve(
114
+ expandTilde(path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json")),
115
+ );
116
116
 
117
117
  // Persist the fetched catalog so subsequent reads don't require re-fetching.
118
118
  await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
@@ -174,11 +174,13 @@ export class MarketplaceManager {
174
174
  await promoteCloneToCache(clonePath, this.#opts.marketplacesCacheDir, catalog.name);
175
175
  }
176
176
 
177
- // Overwrite cached catalog
178
- await Bun.write(existing.catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
177
+ // Overwrite the cached catalog and migrate legacy home-relative registry entries.
178
+ const catalogPath = path.resolve(expandTilde(existing.catalogPath));
179
+ await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
179
180
 
180
181
  const updatedEntry: MarketplaceRegistryEntry = {
181
182
  ...existing,
183
+ catalogPath,
182
184
  updatedAt: new Date().toISOString(),
183
185
  };
184
186
 
@@ -893,14 +895,13 @@ export class MarketplaceManager {
893
895
  }
894
896
 
895
897
  async #readCatalog(entry: MarketplaceRegistryEntry): Promise<MarketplaceCatalog> {
898
+ const catalogPath = path.resolve(expandTilde(entry.catalogPath));
896
899
  try {
897
- const content = await Bun.file(entry.catalogPath).text();
898
- return parseMarketplaceCatalog(content, entry.catalogPath);
900
+ const content = await Bun.file(catalogPath).text();
901
+ return parseMarketplaceCatalog(content, catalogPath);
899
902
  } catch (err) {
900
903
  if (isEnoent(err)) {
901
- throw new Error(
902
- `Marketplace catalog not found at ${entry.catalogPath}. Try: /marketplace update ${entry.name}`,
903
- );
904
+ throw new Error(`Marketplace catalog not found at ${catalogPath}. Try: /marketplace update ${entry.name}`);
904
905
  }
905
906
  throw err;
906
907
  }
@@ -918,14 +919,10 @@ export class MarketplaceManager {
918
919
  */
919
920
  #resolveMarketplaceRoot(entry: MarketplaceRegistryEntry): string {
920
921
  if (entry.sourceType === "local") {
921
- // expandHome already happened in fetcher; resolve to ensure absolute.
922
- const expanded = entry.sourceUri.startsWith("~/")
923
- ? path.join(os.homedir(), entry.sourceUri.slice(2))
924
- : entry.sourceUri;
925
- return path.resolve(expanded);
922
+ return path.resolve(expandTilde(entry.sourceUri));
926
923
  }
927
924
  // For git/github/url sources, the catalog lives at <cloneDir>/marketplace.json
928
925
  // under marketplacesCacheDir/<name>/; parent = <marketplacesCacheDir>/<name>/
929
- return path.dirname(entry.catalogPath);
926
+ return path.dirname(path.resolve(expandTilde(entry.catalogPath)));
930
927
  }
931
928
  }
package/src/irc/bus.ts CHANGED
@@ -276,7 +276,7 @@ export class IrcBus {
276
276
  if (liveness) {
277
277
  const { registry, senderId } = liveness;
278
278
  const hasRunningSender = (from?: string): boolean =>
279
- registry.listVisibleTo(senderId).some(ref => ref.status === "running" && (!from || ref.id === from));
279
+ registry.listVisibleTo(senderId).some(ref => registry.isRunning(ref) && (!from || ref.id === from));
280
280
  const check = filter.from ? () => hasRunningSender(filter.from) : () => hasRunningSender();
281
281
  unsubscribeLiveness = registry.onChange(() => {
282
282
  if (!check()) {
@@ -8,7 +8,7 @@ import { hostHasInheritableConsole } from "../eval/py/spawn-options";
8
8
  import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
9
9
  import { workerEnvFromParent } from "../subprocess/worker-client";
10
10
  import { daemonBrokerEndpoint } from "./paths";
11
- import { hasLiveDaemonProjectPresence } from "./presence";
11
+ import { hasLiveDaemonProjectPresence, pruneDeadDaemonRuntimeDirs } from "./presence";
12
12
  import {
13
13
  DAEMON_IDLE_GRACE_ENV,
14
14
  DAEMON_PROJECT_DIR_ENV,
@@ -1384,6 +1384,13 @@ export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStar
1384
1384
  const lease = await acquireBrokerLease(runtimeDir);
1385
1385
  if (!lease) return;
1386
1386
  setProcessName("omp daemon broker");
1387
+ // Reclaim sibling daemon scopes left behind by dead brokers (issue #8674).
1388
+ // Detached and non-throwing so it never delays clients connecting to us.
1389
+ void pruneDeadDaemonRuntimeDirs(runtimeDir).catch(error => {
1390
+ logger.warn("Daemon runtime prune failed", {
1391
+ error: error instanceof Error ? error.message : String(error),
1392
+ });
1393
+ });
1387
1394
  const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
1388
1395
  if (!token) throw new Error("Daemon broker token is empty");
1389
1396
  const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs);
@@ -1,9 +1,19 @@
1
+ import type { Dirent } from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
- import { isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
4
+ import { isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
4
5
  import { daemonRuntimeDir } from "./paths";
5
6
 
6
7
  const CLIENTS_DIR = "clients";
8
+ const BROKER_PID_FILE = "broker.pid";
9
+ const GLOBAL_DAEMON_DIR = "global";
10
+ /**
11
+ * Grace before a dead daemon runtime dir becomes prune-eligible. Guards against
12
+ * deleting a scope whose owning omp process is mid-startup (token written, broker
13
+ * not yet spawned, presence not yet registered). The leak this reclaims is a
14
+ * weeks-scale accumulation, so a few minutes of slack costs nothing.
15
+ */
16
+ const DAEMON_RUNTIME_STALE_GRACE_MS = 5 * 60_000;
7
17
 
8
18
  /** Handle keeping one omp process registered in a project daemon scope. */
9
19
  export interface DaemonProjectPresence {
@@ -80,3 +90,69 @@ export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise<
80
90
  }
81
91
  return live;
82
92
  }
93
+
94
+ /** Whether a runtime dir's recorded broker PID is still alive. */
95
+ async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
96
+ let raw: unknown;
97
+ try {
98
+ raw = await Bun.file(path.join(runtimeDir, BROKER_PID_FILE)).json();
99
+ } catch {
100
+ return false; // Missing or malformed broker.pid => no owning broker.
101
+ }
102
+ if (typeof raw !== "object" || raw === null || !("pid" in raw) || typeof raw.pid !== "number") {
103
+ return false;
104
+ }
105
+ try {
106
+ process.kill(raw.pid, 0);
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Remove sibling project daemon runtime directories whose broker is dead and
115
+ * whose client-presence set is empty, reclaiming the disk that short-lived
116
+ * project directories leave behind (issue #8674).
117
+ *
118
+ * Best-effort and non-throwing: a scope is deleted only when its `broker.pid`
119
+ * is absent/dead, no live client presence remains, and it has been untouched
120
+ * for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir`
121
+ * and the machine-global daemon container are always skipped.
122
+ */
123
+ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise<void> {
124
+ const root = path.dirname(currentRuntimeDir);
125
+ if (path.basename(root) === GLOBAL_DAEMON_DIR) return;
126
+ const current = path.resolve(currentRuntimeDir);
127
+ let entries: Dirent[];
128
+ try {
129
+ entries = await fs.readdir(root, { withFileTypes: true });
130
+ } catch (error) {
131
+ if (!isEnoent(error)) {
132
+ logger.warn("Failed to scan daemon runtime root for pruning", {
133
+ root,
134
+ error: error instanceof Error ? error.message : String(error),
135
+ });
136
+ }
137
+ return;
138
+ }
139
+ const now = Date.now();
140
+ for (const entry of entries) {
141
+ if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue;
142
+ const dir = path.join(root, entry.name);
143
+ if (path.resolve(dir) === current) continue;
144
+ try {
145
+ const stat = await fs.stat(dir);
146
+ if (now - stat.mtimeMs < DAEMON_RUNTIME_STALE_GRACE_MS) continue;
147
+ if (await hasLiveDaemonBroker(dir)) continue;
148
+ if (await hasLiveDaemonProjectPresence(dir)) continue;
149
+ await fs.rm(dir, { recursive: true, force: true });
150
+ } catch (error) {
151
+ if (isEnoent(error)) continue;
152
+ logger.warn("Failed to prune dead daemon runtime dir", {
153
+ dir,
154
+ error: error instanceof Error ? error.message : String(error),
155
+ });
156
+ }
157
+ }
158
+ }
package/src/lsp/client.ts CHANGED
@@ -62,13 +62,32 @@ export function setIdleTimeout(ms: number | null | undefined): void {
62
62
  }
63
63
  }
64
64
 
65
+ /**
66
+ * Whether a client may be reaped by the idle checker.
67
+ *
68
+ * A client with in-flight requests is *busy*, never idle. `lastActivity` is
69
+ * stamped when a request is written, not while it is outstanding, so a single
70
+ * request that runs longer than the idle timeout used to look like silence:
71
+ * the checker tore the client down mid-flight and `shutdownClientInstance`
72
+ * rejected the caller's still-pending promise with "LSP client shutdown"
73
+ * (issue #8390). Requests that settle refresh `lastActivity`, so a client
74
+ * becomes eligible again only after the final one lands and the full idle
75
+ * window then elapses.
76
+ *
77
+ * Exported for tests; the idle checker is the only production caller.
78
+ */
79
+ export function isIdleClient(client: LspClient, now: number, timeoutMs: number): boolean {
80
+ if (client.pendingRequests.size > 0) return false;
81
+ return now - client.lastActivity > timeoutMs;
82
+ }
83
+
65
84
  function startIdleChecker(): void {
66
85
  if (idleCheckInterval) return;
67
86
  idleCheckInterval = setInterval(() => {
68
87
  if (!idleTimeoutMs) return;
69
88
  const now = Date.now();
70
89
  for (const [key, client] of Array.from(clients.entries())) {
71
- if (now - client.lastActivity > idleTimeoutMs) {
90
+ if (isIdleClient(client, now, idleTimeoutMs)) {
72
91
  void shutdownClient(key);
73
92
  }
74
93
  }
@@ -746,7 +765,13 @@ function commandBasename(command: string): string {
746
765
  return separator === -1 ? command : command.slice(separator + 1);
747
766
  }
748
767
 
749
- function isRustAnalyzerClient(client: LspClient): boolean {
768
+ /**
769
+ * True when this client speaks the rust-analyzer protocol, detected by the
770
+ * command basename (`rust-analyzer[.exe]`) of the configured or resolved
771
+ * binary. Callers use it to gate rust-analyzer-only requests such as
772
+ * `rust-analyzer/reloadWorkspace` (see {@link reloadServer}).
773
+ */
774
+ export function isRustAnalyzerClient(client: LspClient): boolean {
750
775
  return (
751
776
  commandBasename(client.config.command) === "rust-analyzer" ||
752
777
  (client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
@@ -1456,15 +1481,22 @@ export async function sendRequest(
1456
1481
  }
1457
1482
  }
1458
1483
 
1459
- // Register pending request with timeout wrapper
1484
+ // Register pending request with timeout wrapper.
1485
+ // Settling stamps `lastActivity`: the idle window must be measured from when
1486
+ // the exchange finished, not from when it started. Without this a request
1487
+ // that outlives the timeout would leave the client instantly reapable the
1488
+ // moment it lands, so the next idle sweep would kill a server that had just
1489
+ // answered (issue #8390).
1460
1490
  client.pendingRequests.set(id, {
1461
1491
  resolve: result => {
1462
1492
  if (timeout) clearTimeout(timeout);
1493
+ client.lastActivity = Date.now();
1463
1494
  cleanup();
1464
1495
  resolve(result);
1465
1496
  },
1466
1497
  reject: err => {
1467
1498
  if (timeout) clearTimeout(timeout);
1499
+ client.lastActivity = Date.now();
1468
1500
  cleanup();
1469
1501
  reject(err);
1470
1502
  },
@@ -14,14 +14,21 @@ interface BiomeJsonOutput {
14
14
  diagnostics: BiomeDiagnostic[];
15
15
  }
16
16
 
17
+ /**
18
+ * A single diagnostic from Biome's `--reporter=json` output (Biome 2.x).
19
+ *
20
+ * Positions are 1-indexed `{ line, column }` pairs; the path is a plain string
21
+ * relative to the CLI's cwd. Older releases used byte-offset `span`s, which
22
+ * this client no longer parses.
23
+ */
17
24
  interface BiomeDiagnostic {
18
25
  category: string; // e.g., "lint/correctness/noUnusedVariables"
19
- severity: "error" | "warning" | "info" | "hint";
20
- description: string;
26
+ severity: string; // "error" | "warning" | "info" | "hint"
27
+ message: string;
21
28
  location?: {
22
- path?: { file: string };
23
- span?: [number, number]; // [startOffset, endOffset] in bytes
24
- sourceCode?: string;
29
+ path?: string;
30
+ start?: { line: number; column: number };
31
+ end?: { line: number; column: number };
25
32
  };
26
33
  }
27
34
 
@@ -29,43 +36,6 @@ interface BiomeDiagnostic {
29
36
  // Helpers
30
37
  // =============================================================================
31
38
 
32
- /**
33
- * Convert byte offsets to line:column positions in a single pass over the source.
34
- */
35
- function offsetsToPositions(source: string, offsets: number[]): Map<number, { line: number; column: number }> {
36
- const sorted = [...new Set(offsets)].sort((a, b) => a - b);
37
- const result = new Map<number, { line: number; column: number }>();
38
- let line = 1;
39
- let column = 1;
40
- let byteIndex = 0;
41
- let next = 0;
42
-
43
- for (const ch of source) {
44
- if (next >= sorted.length) break;
45
- const cp = ch.codePointAt(0) as number;
46
- const byteLen = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
47
- while (next < sorted.length && byteIndex + byteLen > sorted[next]) {
48
- result.set(sorted[next], { line, column });
49
- next++;
50
- }
51
- if (ch === "\n") {
52
- line++;
53
- column = 1;
54
- } else {
55
- column++;
56
- }
57
- byteIndex += byteLen;
58
- }
59
-
60
- // Offsets at or past end-of-file map to the final position.
61
- while (next < sorted.length) {
62
- result.set(sorted[next], { line, column });
63
- next++;
64
- }
65
-
66
- return result;
67
- }
68
-
69
39
  /**
70
40
  * Parse Biome severity to LSP DiagnosticSeverity.
71
41
  */
@@ -176,8 +146,6 @@ export class BiomeClient implements LinterClient {
176
146
  * Parse Biome's JSON output into LSP Diagnostics.
177
147
  */
178
148
  #parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] {
179
- const diagnostics: Diagnostic[] = [];
180
-
181
149
  let parsed: BiomeJsonOutput;
182
150
  try {
183
151
  parsed = JSON.parse(jsonOutput);
@@ -186,61 +154,36 @@ export class BiomeClient implements LinterClient {
186
154
  cwd: this.cwd,
187
155
  file: targetFile,
188
156
  });
189
- return diagnostics;
157
+ return [];
190
158
  }
191
159
 
160
+ const emitted = parsed.diagnostics ?? [];
192
161
  const target = path.resolve(targetFile);
193
- const relevant: BiomeDiagnostic[] = [];
194
- // Batch all span offsets per source text so each source is scanned once
195
- // instead of twice per diagnostic.
196
- const offsetsBySource = new Map<string, number[]>();
197
- for (const diag of parsed.diagnostics ?? []) {
198
- const location = diag.location;
199
- if (!location?.path?.file) continue;
200
-
201
- // Resolve file path
202
- const diagFile = path.isAbsolute(location.path.file)
203
- ? location.path.file
204
- : path.join(this.cwd, location.path.file);
205
-
206
- // Only include diagnostics for the target file
207
- if (path.resolve(diagFile) !== target) {
208
- continue;
209
- }
162
+ const diagnostics: Diagnostic[] = [];
163
+ // Biome's JSON reporter is experimental and may reshape its output in
164
+ // patch releases. Track whether any diagnostic carried a usable location
165
+ // so a schema drift surfaces as a warning instead of a silent empty list.
166
+ let sawUsableLocation = false;
210
167
 
211
- relevant.push(diag);
212
- if (location.span && location.sourceCode) {
213
- const offsets = offsetsBySource.get(location.sourceCode);
214
- if (offsets) offsets.push(location.span[0], location.span[1]);
215
- else offsetsBySource.set(location.sourceCode, [location.span[0], location.span[1]]);
216
- }
217
- }
168
+ for (const diag of emitted) {
169
+ const location = diag.location;
170
+ const filePath = location?.path;
171
+ if (!filePath) continue;
172
+ sawUsableLocation = true;
218
173
 
219
- const positionsBySource = new Map<string, Map<number, { line: number; column: number }>>();
220
- for (const [source, offsets] of offsetsBySource) {
221
- positionsBySource.set(source, offsetsToPositions(source, offsets));
222
- }
174
+ // Biome reports paths relative to its cwd.
175
+ const diagFile = path.isAbsolute(filePath) ? filePath : path.join(this.cwd, filePath);
223
176
 
224
- for (const diag of relevant) {
225
- const location = diag.location;
226
- let startLine = 1;
227
- let startColumn = 1;
228
- let endLine = 1;
229
- let endColumn = 1;
177
+ // Only include diagnostics for the target file.
178
+ if (path.resolve(diagFile) !== target) continue;
230
179
 
231
- if (location?.span && location.sourceCode) {
232
- const positions = positionsBySource.get(location.sourceCode);
233
- const startPos = positions?.get(location.span[0]);
234
- const endPos = positions?.get(location.span[1]);
235
- if (startPos) {
236
- startLine = startPos.line;
237
- startColumn = startPos.column;
238
- }
239
- if (endPos) {
240
- endLine = endPos.line;
241
- endColumn = endPos.column;
242
- }
243
- }
180
+ // Biome positions are 1-indexed; LSP ranges are 0-indexed.
181
+ const start = location.start;
182
+ const end = location.end ?? start;
183
+ const startLine = start?.line ?? 1;
184
+ const startColumn = start?.column ?? 1;
185
+ const endLine = end?.line ?? startLine;
186
+ const endColumn = end?.column ?? startColumn;
244
187
 
245
188
  diagnostics.push({
246
189
  range: {
@@ -248,12 +191,23 @@ export class BiomeClient implements LinterClient {
248
191
  end: { line: endLine - 1, character: endColumn - 1 },
249
192
  },
250
193
  severity: parseSeverity(diag.severity),
251
- message: diag.description,
194
+ message: diag.message,
252
195
  source: "biome",
253
196
  code: diag.category,
254
197
  });
255
198
  }
256
199
 
200
+ // Non-empty output whose diagnostics all lacked a recognizable location
201
+ // means the reporter schema changed out from under us — warn loudly
202
+ // instead of masking the regression as "no lint issues".
203
+ if (emitted.length > 0 && !sawUsableLocation) {
204
+ warnBiomeOnce(
205
+ `schema:${this.cwd}`,
206
+ "Biome diagnostics had no recognizable location; reporter schema may have changed",
207
+ { cwd: this.cwd, file: targetFile, count: emitted.length },
208
+ );
209
+ }
210
+
257
211
  return diagnostics;
258
212
  }
259
213
 
@@ -4,6 +4,7 @@ import {
4
4
  getActiveClients,
5
5
  getActiveOrPendingClient,
6
6
  getOrCreateClient,
7
+ isRustAnalyzerClient,
7
8
  type LspServerStatus,
8
9
  notifySaved,
9
10
  sendNotification,
@@ -256,17 +257,22 @@ export function isMethodNotFoundError(err: unknown): boolean {
256
257
 
257
258
  export async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
258
259
  throwIfAborted(signal);
259
- // rust-analyzer exposes a real reload request. Every other server rejects it
260
- // with method-not-found that alone justifies the generic fallback. A caller
261
- // cancel or tool timeout must propagate, never be mistaken for an unsupported
262
- // method and swallowed into a bogus "Restarted" (issue #6369).
263
- try {
264
- await sendRequest(client, "rust-analyzer/reloadWorkspace", null, signal);
265
- return `Reloaded ${serverName}`;
266
- } catch (err) {
267
- throwIfAborted(signal);
268
- if (!isMethodNotFoundError(err)) throw err;
269
- // Method not supported — fall through to the generic reload.
260
+ // rust-analyzer exposes a real reload request. Only rust-analyzer implements
261
+ // it, so gate the request on the binary (or registered name) rather than
262
+ // probing every server: some servers (Roslyn) crash the whole process on an
263
+ // unknown method instead of replying with method-not-found killing the
264
+ // server `lsp reload` was meant to refresh (issue #8571, dotnet/roslyn#84890).
265
+ // A caller cancel or tool timeout must propagate, never be mistaken for an
266
+ // unsupported method and swallowed into a bogus "Restarted" (issue #6369).
267
+ if (isRustAnalyzerClient(client) || serverName === "rust-analyzer") {
268
+ try {
269
+ await sendRequest(client, "rust-analyzer/reloadWorkspace", undefined, signal);
270
+ return `Reloaded ${serverName}`;
271
+ } catch (err) {
272
+ throwIfAborted(signal);
273
+ if (!isMethodNotFoundError(err)) throw err;
274
+ // Method not supported — fall through to the generic reload.
275
+ }
270
276
  }
271
277
  // workspace/didChangeConfiguration is a notification per spec; sending it
272
278
  // as a request hangs until the tool deadline on servers that route it to
package/src/lsp/tool.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  ensureFileOpen,
22
22
  getActiveClients,
23
23
  getOrCreateClient,
24
+ isRustAnalyzerClient,
24
25
  type LspServerStatus,
25
26
  refreshFile,
26
27
  sendNotification,
@@ -1075,10 +1076,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1075
1076
  try {
1076
1077
  const client = await getOrCreateClient(serverConfig, this.session.cwd, undefined, signal);
1077
1078
  const targetFile = resolvedFile;
1078
- const isRustAnalyzerServer =
1079
- serverName === "rust-analyzer" ||
1080
- path.basename(serverConfig.command) === "rust-analyzer" ||
1081
- (serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
1079
+ const isRustAnalyzerServer = isRustAnalyzerClient(client) || serverName === "rust-analyzer";
1082
1080
  const needsProjectIndex =
1083
1081
  targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
1084
1082
  const rustWorkspaceWait =
@@ -114,6 +114,17 @@ async function detectProjectType(cwd: string, signal?: AbortSignal): Promise<Pro
114
114
  return { type: "unknown", description: "Unknown project type" };
115
115
  }
116
116
 
117
+ /** Interpret an empty checker result without mistaking a crash for a clean workspace. */
118
+ export function interpretEmptyDiagnosticsResult(
119
+ exitCode: number,
120
+ signalCode: string | null,
121
+ command: readonly string[],
122
+ ): string {
123
+ if (exitCode === 0) return "No issues found";
124
+ const detail = signalCode ? `was killed by ${signalCode}` : `exited with code ${exitCode}`;
125
+ return `Failed to run ${command.join(" ")}: the checker ${detail} without reporting anything, so the workspace was not verified`;
126
+ }
127
+
117
128
  /** Run workspace diagnostics command and parse output */
118
129
  export async function runWorkspaceDiagnostics(
119
130
  cwd: string,
@@ -146,11 +157,22 @@ export async function runWorkspaceDiagnostics(
146
157
  new Response(proc.stdout).text(),
147
158
  new Response(proc.stderr).text(),
148
159
  ]);
149
- await proc.exited;
160
+ const exitCode = await proc.exited;
150
161
  throwIfAborted(signal);
151
162
  const combined = (stdout + stderr).trim();
152
163
  if (!combined) {
153
- return { output: "No issues found", projectType };
164
+ // A checker that exits non-zero without writing a single byte never
165
+ // inspected the workspace: it failed to start (missing toolchain),
166
+ // crashed, or was killed (OOM). Reporting "No issues found" there
167
+ // tells the agent the workspace is clean when nothing actually
168
+ // checked it. A non-zero exit *with* output is the normal way
169
+ // tsc/cargo/pyright report diagnostics and still falls through to
170
+ // the branch below. Mirrors the exit-status gate
171
+ // `resolveGoWorkspaceDiagnosticsCommand` already applies above.
172
+ return {
173
+ output: interpretEmptyDiagnosticsResult(exitCode, proc.signalCode, projectType.command),
174
+ projectType,
175
+ };
154
176
  }
155
177
  // Limit output length
156
178
  const lines = combined.split("\n");