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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-tt9k4jpr.md} +68 -0
  3. package/dist/cli.js +3146 -3153
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/config/model-discovery.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/debug/report-bundle.d.ts +7 -2
  9. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  10. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +11 -0
  14. package/dist/types/lsp/client.d.ts +22 -0
  15. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  16. package/dist/types/mcp/config.d.ts +3 -1
  17. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  18. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  19. package/dist/types/registry/agent-registry.d.ts +4 -0
  20. package/dist/types/session/agent-session-types.d.ts +0 -2
  21. package/dist/types/session/agent-session.d.ts +20 -0
  22. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  23. package/dist/types/session/irc-bridge.d.ts +2 -0
  24. package/dist/types/session/messages.d.ts +4 -0
  25. package/dist/types/session/session-loader.d.ts +18 -11
  26. package/dist/types/session/session-maintenance.d.ts +6 -2
  27. package/dist/types/session/session-manager.d.ts +19 -3
  28. package/dist/types/session/session-tools.d.ts +0 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -7
  30. package/dist/types/tools/browser/attach.d.ts +16 -0
  31. package/dist/types/tools/browser/launch.d.ts +2 -2
  32. package/dist/types/tools/browser.d.ts +1 -1
  33. package/dist/types/tools/builtin-names.d.ts +1 -1
  34. package/dist/types/tools/hub/jobs.d.ts +6 -0
  35. package/dist/types/tools/hub/types.d.ts +6 -0
  36. package/dist/types/vibe/runtime.d.ts +3 -3
  37. package/package.json +13 -13
  38. package/src/auto-thinking/classifier.ts +37 -23
  39. package/src/cli/models-cli.ts +1 -1
  40. package/src/commit/analysis/conventional.ts +15 -9
  41. package/src/commit/analysis/summary.ts +15 -9
  42. package/src/commit/changelog/generate.ts +15 -9
  43. package/src/commit/map-reduce/map-phase.ts +3 -19
  44. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  45. package/src/config/model-discovery.ts +3 -3
  46. package/src/config/model-registry.ts +29 -16
  47. package/src/config/settings-schema.ts +13 -0
  48. package/src/debug/report-bundle.ts +25 -59
  49. package/src/discovery/claude-plugins.ts +2 -1
  50. package/src/discovery/claude.ts +4 -2
  51. package/src/discovery/helpers.ts +5 -0
  52. package/src/edit/renderer.ts +48 -19
  53. package/src/extensibility/extensions/loader.ts +9 -5
  54. package/src/extensibility/extensions/runner.ts +124 -21
  55. package/src/extensibility/extensions/types.ts +2 -0
  56. package/src/extensibility/extensions/wrapper.ts +16 -10
  57. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  58. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  59. package/src/irc/bus.ts +1 -1
  60. package/src/launch/broker.ts +8 -1
  61. package/src/launch/presence.ts +77 -1
  62. package/src/lsp/client.ts +35 -3
  63. package/src/lsp/clients/biome-client.ts +47 -93
  64. package/src/lsp/servers.ts +17 -11
  65. package/src/lsp/tool.ts +2 -4
  66. package/src/lsp/workspace-diagnostics.ts +24 -2
  67. package/src/mcp/config.ts +50 -6
  68. package/src/memories/index.ts +29 -25
  69. package/src/mnemopi/backend.ts +13 -11
  70. package/src/modes/components/ask-dialog.ts +25 -5
  71. package/src/modes/components/tool-execution.ts +10 -6
  72. package/src/modes/components/welcome.ts +4 -1
  73. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  74. package/src/modes/controllers/selector-controller.ts +6 -0
  75. package/src/modes/controllers/tan-command-controller.ts +1 -0
  76. package/src/modes/interactive-mode.ts +49 -10
  77. package/src/modes/theme/defaults/birch.json +2 -2
  78. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  79. package/src/prompts/system/date-cwd-reminder.md +3 -0
  80. package/src/prompts/system/project-prompt.md +0 -1
  81. package/src/prompts/system/rewind-report.md +1 -3
  82. package/src/prompts/tools/browser.md +1 -0
  83. package/src/prompts/tools/rewind.md +1 -13
  84. package/src/registry/agent-lifecycle.ts +34 -0
  85. package/src/registry/agent-registry.ts +27 -2
  86. package/src/registry/persisted-agents.ts +40 -20
  87. package/src/sdk.ts +32 -2
  88. package/src/session/agent-session-types.ts +0 -2
  89. package/src/session/agent-session.ts +178 -46
  90. package/src/session/date-cwd-reminder.ts +77 -0
  91. package/src/session/irc-bridge.ts +5 -0
  92. package/src/session/messages.ts +5 -1
  93. package/src/session/session-loader.ts +106 -64
  94. package/src/session/session-maintenance.ts +189 -115
  95. package/src/session/session-manager.ts +142 -35
  96. package/src/session/session-persistence.ts +4 -4
  97. package/src/session/session-storage.ts +18 -5
  98. package/src/session/session-tools.ts +5 -10
  99. package/src/session/turn-recovery.ts +102 -20
  100. package/src/session/unexpected-stop-classifier.ts +33 -21
  101. package/src/system-prompt.ts +0 -5
  102. package/src/task/executor.ts +4 -15
  103. package/src/task/persisted-revive.ts +3 -6
  104. package/src/tools/ask.ts +10 -3
  105. package/src/tools/browser/attach.ts +80 -25
  106. package/src/tools/browser/launch.ts +44 -15
  107. package/src/tools/browser/relay/daemon.ts +3 -7
  108. package/src/tools/browser/render.ts +1 -1
  109. package/src/tools/browser/shared-daemon.ts +3 -7
  110. package/src/tools/browser.ts +5 -5
  111. package/src/tools/builtin-names.ts +7 -3
  112. package/src/tools/checkpoint.ts +1 -7
  113. package/src/tools/hub/index.ts +1 -1
  114. package/src/tools/hub/jobs.ts +20 -3
  115. package/src/tools/hub/types.ts +6 -0
  116. package/src/tools/render-utils.ts +53 -21
  117. package/src/tools/think.ts +15 -3
  118. package/src/tts/speech-enhancer.ts +19 -14
  119. package/src/utils/commit-message-generator.ts +14 -12
  120. package/src/utils/title-generator.ts +23 -19
  121. package/src/vibe/runtime.ts +32 -17
@@ -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");
package/src/mcp/config.ts CHANGED
@@ -224,6 +224,43 @@ export function extractExaApiKey(config: MCPServerConfig): string | undefined {
224
224
  return undefined;
225
225
  }
226
226
 
227
+ /** Exa MCP tools already covered by the native Exa integration. */
228
+ const NATIVE_EXA_MCP_TOOLS: Record<string, true> = { web_search_exa: true };
229
+
230
+ /**
231
+ * Parse the comma-separated `tools` restriction from an Exa MCP config.
232
+ * Returns `null` when the config does not restrict its tool set.
233
+ */
234
+ function getRequestedExaMcpTools(config: MCPServerConfig): string[] | null {
235
+ const raw = (() => {
236
+ if (config.type === "http" || config.type === "sse") {
237
+ const httpConfig = config as { url?: string };
238
+ if (!httpConfig.url) return undefined;
239
+ try {
240
+ return new URL(httpConfig.url).searchParams.get("tools") ?? undefined;
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+ if (!config.type || config.type === "stdio") {
246
+ const stdioConfig = config as { args?: string[] };
247
+ const args = stdioConfig.args ?? [];
248
+ for (let i = 0; i < args.length; i++) {
249
+ if (/^--?tools$/i.test(args[i])) return args[i + 1];
250
+ const match = args[i].match(/(?:^|[\s?&])tools=([^&\s]+)/i) ?? args[i].match(/--?tools[=\s]([^\s]+)/i);
251
+ if (match) return match[1];
252
+ }
253
+ }
254
+ return undefined;
255
+ })();
256
+ if (!raw) return null;
257
+ const tools = raw
258
+ .split(",")
259
+ .map(tool => tool.trim())
260
+ .filter(tool => tool.length > 0);
261
+ return tools.length > 0 ? tools : null;
262
+ }
263
+
227
264
  /** Result of filtering Exa MCP servers */
228
265
  export interface ExaFilterResult {
229
266
  /** Configs with Exa servers removed */
@@ -236,7 +273,9 @@ export interface ExaFilterResult {
236
273
 
237
274
  /**
238
275
  * Filter out Exa MCP servers and extract their API keys.
239
- * Since we have native Exa integration, we don't need the MCP server.
276
+ * Since we have native Exa integration, we don't need the MCP server
277
+ * unless the config explicitly requests Exa tools the native integration
278
+ * does not provide (e.g. `web_fetch_exa`, `web_search_advanced_exa`).
240
279
  */
241
280
  export function filterExaMCPServers(
242
281
  configs: Record<string, MCPServerConfig>,
@@ -248,17 +287,22 @@ export function filterExaMCPServers(
248
287
 
249
288
  for (const [name, config] of Object.entries(configs)) {
250
289
  if (isExaMCPServer(name, config)) {
251
- // Extract API key before filtering
290
+ // Extract API key for the native Exa integration even when the MCP
291
+ // server is kept below for its extra tools.
252
292
  const apiKey = extractExaApiKey(config);
253
293
  if (apiKey) {
254
294
  exaApiKeys.push(apiKey);
255
295
  }
256
- } else {
257
- filtered[name] = config;
258
- if (sources[name]) {
259
- filteredSources[name] = sources[name];
296
+ const requested = getRequestedExaMcpTools(config);
297
+ const hasExtraTools = requested?.some(tool => !NATIVE_EXA_MCP_TOOLS[tool.toLowerCase()]) ?? false;
298
+ if (!hasExtraTools) {
299
+ continue;
260
300
  }
261
301
  }
302
+ filtered[name] = config;
303
+ if (sources[name]) {
304
+ filteredSources[name] = sources[name];
305
+ }
262
306
  }
263
307
 
264
308
  return { configs: filtered, exaApiKeys, sources: filteredSources };
@@ -3,7 +3,7 @@ import type * as fsNode from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
6
- import { type ApiKey, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
6
+ import { type ApiKey, completeSimple, Effort, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai";
7
7
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
8
8
  import { getAgentDbPath, getMemoriesDir, isEnoent, logger, parseJsonlLenient, prompt } from "@oh-my-pi/pi-utils";
9
9
 
@@ -750,18 +750,20 @@ async function runStage1Job(options: {
750
750
  response_items_json: truncatedItems,
751
751
  });
752
752
 
753
- const response = await completeSimple(
754
- model,
755
- {
756
- systemPrompt: [stageOneSystemTemplate],
757
- messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
758
- },
759
- {
760
- apiKey,
761
- metadata: options.metadata,
762
- maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
763
- reasoning: clampThinkingLevelForModel(model, Effort.Low),
764
- },
753
+ const response = await retryTransientCompletion(() =>
754
+ completeSimple(
755
+ model,
756
+ {
757
+ systemPrompt: [stageOneSystemTemplate],
758
+ messages: [{ role: "user", content: [{ type: "text", text: inputPrompt }], timestamp: Date.now() }],
759
+ },
760
+ {
761
+ apiKey,
762
+ metadata: options.metadata,
763
+ maxTokens: Math.max(1024, Math.min(4096, Math.floor(modelMaxTokens * 0.2))),
764
+ reasoning: clampThinkingLevelForModel(model, Effort.Low),
765
+ },
766
+ ),
765
767
  );
766
768
 
767
769
  if (response.stopReason === "error") {
@@ -887,18 +889,20 @@ async function runConsolidationModel(options: {
887
889
  rollout_summaries: truncateByApproxTokens(rolloutSummaries, 12_000),
888
890
  });
889
891
 
890
- const response = await completeSimple(
891
- model,
892
- {
893
- systemPrompt: [consolidationSystemTemplate],
894
- messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
895
- },
896
- {
897
- apiKey,
898
- metadata: options.metadata,
899
- maxTokens: 8192,
900
- reasoning: clampThinkingLevelForModel(model, Effort.Medium),
901
- },
892
+ const response = await retryTransientCompletion(() =>
893
+ completeSimple(
894
+ model,
895
+ {
896
+ systemPrompt: [consolidationSystemTemplate],
897
+ messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
898
+ },
899
+ {
900
+ apiKey,
901
+ metadata: options.metadata,
902
+ maxTokens: 8192,
903
+ reasoning: clampThinkingLevelForModel(model, Effort.Medium),
904
+ },
905
+ ),
902
906
  );
903
907
  if (response.stopReason === "error") {
904
908
  throw new Error(response.errorMessage || "phase2 model error");
@@ -1,6 +1,6 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { type ApiKeyResolver, completeSimple } from "@oh-my-pi/pi-ai";
3
+ import { type ApiKeyResolver, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
4
4
  import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
5
5
  import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
6
6
  import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
@@ -545,16 +545,18 @@ async function resolveMnemopiProviderOptions(
545
545
  });
546
546
  return null;
547
547
  }
548
- const message = await completeSimple(
549
- model,
550
- {
551
- messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
552
- },
553
- {
554
- apiKey: modelRegistry.resolver(model, sessionId),
555
- maxTokens: opts?.maxTokens,
556
- temperature: opts?.temperature,
557
- },
548
+ const message = await retryTransientCompletion(() =>
549
+ completeSimple(
550
+ model,
551
+ {
552
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
553
+ },
554
+ {
555
+ apiKey: modelRegistry.resolver(model, sessionId),
556
+ maxTokens: opts?.maxTokens,
557
+ temperature: opts?.temperature,
558
+ },
559
+ ),
558
560
  );
559
561
  return message.content
560
562
  .filter(