@oh-my-pi/pi-coding-agent 17.1.7 → 17.2.0

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 (246) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
  3. package/dist/cli.js +9554 -12379
  4. package/dist/template-8vdv6xb4.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +8 -11
  10. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  11. package/dist/types/async/job-manager.d.ts +11 -0
  12. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  13. package/dist/types/cleanse/agent.d.ts +19 -0
  14. package/dist/types/cleanse/balance.d.ts +7 -0
  15. package/dist/types/cleanse/checkers.d.ts +13 -0
  16. package/dist/types/cleanse/index.d.ts +16 -0
  17. package/dist/types/cleanse/loop.d.ts +16 -0
  18. package/dist/types/cleanse/parsers.d.ts +13 -0
  19. package/dist/types/cleanse/progress.d.ts +14 -0
  20. package/dist/types/cleanse/types.d.ts +64 -0
  21. package/dist/types/cli/args.d.ts +2 -0
  22. package/dist/types/cli/session-picker.d.ts +14 -7
  23. package/dist/types/collab/guest.d.ts +21 -2
  24. package/dist/types/commands/cleanse.d.ts +23 -0
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/config/settings-schema.d.ts +63 -8
  27. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  28. package/dist/types/cursor.d.ts +193 -8
  29. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  30. package/dist/types/edit/hashline/diff.d.ts +9 -1
  31. package/dist/types/edit/index.d.ts +8 -1
  32. package/dist/types/edit/streaming.d.ts +8 -1
  33. package/dist/types/export/html/index.d.ts +2 -0
  34. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  35. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  38. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  39. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  40. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  41. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  42. package/dist/types/lsp/client.d.ts +2 -0
  43. package/dist/types/main.d.ts +2 -0
  44. package/dist/types/mcp/manager.d.ts +33 -4
  45. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  47. package/dist/types/modes/components/session-selector.d.ts +6 -0
  48. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  50. package/dist/types/modes/components/usage-row.d.ts +2 -0
  51. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  52. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  53. package/dist/types/modes/interactive-mode.d.ts +8 -5
  54. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  55. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  56. package/dist/types/modes/theme/theme.d.ts +12 -1
  57. package/dist/types/modes/types.d.ts +2 -1
  58. package/dist/types/sdk.d.ts +1 -0
  59. package/dist/types/session/agent-session-events.d.ts +2 -0
  60. package/dist/types/session/agent-session-types.d.ts +36 -1
  61. package/dist/types/session/agent-session.d.ts +4 -2
  62. package/dist/types/session/async-job-delivery.d.ts +8 -0
  63. package/dist/types/session/claude-session-store.d.ts +13 -0
  64. package/dist/types/session/codex-session-store.d.ts +14 -0
  65. package/dist/types/session/credential-pin.d.ts +58 -0
  66. package/dist/types/session/foreign-session-import.d.ts +15 -0
  67. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  68. package/dist/types/session/foreign-session-store.d.ts +23 -0
  69. package/dist/types/session/model-controls.d.ts +3 -3
  70. package/dist/types/session/session-advisors.d.ts +47 -2
  71. package/dist/types/session/session-entries.d.ts +19 -1
  72. package/dist/types/session/session-handoff.d.ts +4 -1
  73. package/dist/types/session/session-loader.d.ts +2 -0
  74. package/dist/types/session/session-maintenance.d.ts +1 -1
  75. package/dist/types/session/session-manager.d.ts +21 -0
  76. package/dist/types/session/session-tools.d.ts +25 -1
  77. package/dist/types/session/turn-recovery.d.ts +10 -4
  78. package/dist/types/system-prompt.d.ts +2 -1
  79. package/dist/types/task/executor.d.ts +11 -2
  80. package/dist/types/task/isolation-ownership.d.ts +34 -0
  81. package/dist/types/task/render.d.ts +7 -0
  82. package/dist/types/thinking.d.ts +18 -6
  83. package/dist/types/tools/acp-bridge.d.ts +28 -5
  84. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  85. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  86. package/dist/types/tools/grep.d.ts +17 -1
  87. package/dist/types/tools/hub/launch.d.ts +5 -1
  88. package/dist/types/tools/index.d.ts +4 -1
  89. package/dist/types/tools/path-utils.d.ts +19 -0
  90. package/dist/types/tools/read.d.ts +2 -0
  91. package/dist/types/tools/xdev.d.ts +2 -1
  92. package/dist/types/tui/output-block.d.ts +5 -5
  93. package/dist/types/utils/changelog.d.ts +19 -0
  94. package/dist/types/utils/git.d.ts +10 -0
  95. package/dist/types/utils/jj.d.ts +11 -4
  96. package/dist/types/web/search/types.d.ts +2 -2
  97. package/package.json +16 -12
  98. package/scripts/bundle-dist.ts +3 -1
  99. package/src/advisor/advise-tool.ts +0 -11
  100. package/src/advisor/runtime.ts +138 -24
  101. package/src/advisor/transcript-recorder.ts +56 -0
  102. package/src/async/job-manager.ts +30 -7
  103. package/src/auto-thinking/classifier.ts +48 -10
  104. package/src/cleanse/agent.ts +226 -0
  105. package/src/cleanse/balance.ts +79 -0
  106. package/src/cleanse/checkers.ts +996 -0
  107. package/src/cleanse/index.ts +190 -0
  108. package/src/cleanse/loop.ts +51 -0
  109. package/src/cleanse/parsers.ts +726 -0
  110. package/src/cleanse/progress.ts +50 -0
  111. package/src/cleanse/prompts/assignment.md +47 -0
  112. package/src/cleanse/types.ts +72 -0
  113. package/src/cli/args.ts +6 -0
  114. package/src/cli/flag-tables.ts +2 -0
  115. package/src/cli/gallery-fixtures/fs.ts +25 -0
  116. package/src/cli/session-picker.ts +34 -17
  117. package/src/cli/ttsr-cli.ts +19 -1
  118. package/src/cli/update-cli.ts +3 -0
  119. package/src/cli/usage-cli.ts +29 -4
  120. package/src/cli/worktree-cli.ts +28 -11
  121. package/src/cli-commands.ts +1 -0
  122. package/src/cli.ts +11 -1
  123. package/src/collab/guest.ts +37 -4
  124. package/src/collab/host.ts +1 -0
  125. package/src/commands/cleanse.ts +45 -0
  126. package/src/commands/launch.ts +6 -0
  127. package/src/config/model-registry.ts +103 -25
  128. package/src/config/settings-schema.ts +65 -6
  129. package/src/config/settings.ts +25 -0
  130. package/src/cursor-bridge-tools.ts +81 -0
  131. package/src/cursor.ts +463 -12
  132. package/src/discovery/claude-plugins.ts +144 -34
  133. package/src/edit/edit-clipboard.ts +23 -0
  134. package/src/edit/hashline/diff.ts +49 -10
  135. package/src/edit/hashline/execute.ts +38 -6
  136. package/src/edit/hashline/filesystem.ts +27 -3
  137. package/src/edit/index.ts +12 -2
  138. package/src/edit/renderer.ts +4 -4
  139. package/src/edit/streaming.ts +17 -2
  140. package/src/export/html/index.ts +17 -10
  141. package/src/export/html/template.js +1 -1
  142. package/src/extensibility/extensions/runner.ts +70 -0
  143. package/src/extensibility/extensions/types.ts +30 -0
  144. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  145. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  146. package/src/internal-urls/local-protocol.ts +3 -2
  147. package/src/internal-urls/mcp-protocol.ts +4 -1
  148. package/src/launch/broker.ts +14 -4
  149. package/src/launch/terminal-output-worker-client.ts +53 -0
  150. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  151. package/src/launch/terminal-output-worker.ts +23 -0
  152. package/src/lsp/client.ts +18 -3
  153. package/src/lsp/index.ts +4 -0
  154. package/src/lsp/render.ts +1 -1
  155. package/src/main.ts +127 -56
  156. package/src/mcp/manager.ts +120 -18
  157. package/src/modes/acp/acp-agent.ts +27 -18
  158. package/src/modes/acp/acp-event-mapper.ts +38 -4
  159. package/src/modes/components/chat-transcript-builder.ts +30 -11
  160. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  161. package/src/modes/components/read-tool-group.ts +159 -17
  162. package/src/modes/components/session-selector.ts +19 -3
  163. package/src/modes/components/settings-selector.ts +9 -1
  164. package/src/modes/components/status-line/component.ts +381 -54
  165. package/src/modes/components/tool-execution.ts +78 -31
  166. package/src/modes/components/usage-row.ts +10 -5
  167. package/src/modes/controllers/event-controller.ts +26 -4
  168. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  169. package/src/modes/controllers/selector-controller.ts +94 -36
  170. package/src/modes/controllers/tan-command-controller.ts +14 -0
  171. package/src/modes/interactive-mode.ts +67 -64
  172. package/src/modes/prompt-action-autocomplete.ts +5 -3
  173. package/src/modes/rpc/rpc-client.ts +18 -0
  174. package/src/modes/rpc/rpc-mode.ts +16 -1
  175. package/src/modes/rpc/rpc-types.ts +11 -0
  176. package/src/modes/theme/theme.ts +151 -6
  177. package/src/modes/types.ts +2 -1
  178. package/src/modes/utils/ui-helpers.ts +38 -21
  179. package/src/prompts/goals/guided-goal-interview.md +41 -6
  180. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  181. package/src/prompts/system/plan-mode-active.md +2 -2
  182. package/src/prompts/system/system-prompt.md +3 -0
  183. package/src/prompts/system/vibe-mode-active.md +4 -1
  184. package/src/prompts/system/xdev-mount-notice.md +1 -1
  185. package/src/prompts/tools/bash.md +15 -17
  186. package/src/prompts/tools/browser.md +1 -0
  187. package/src/prompts/tools/checkpoint.md +1 -1
  188. package/src/prompts/tools/glob.md +7 -6
  189. package/src/prompts/tools/goal.md +1 -1
  190. package/src/prompts/tools/grep.md +7 -6
  191. package/src/sdk.ts +178 -15
  192. package/src/session/agent-session-events.ts +1 -0
  193. package/src/session/agent-session-types.ts +40 -1
  194. package/src/session/agent-session.ts +330 -179
  195. package/src/session/async-job-delivery.ts +8 -0
  196. package/src/session/claude-session-store.ts +426 -0
  197. package/src/session/codex-session-store.ts +673 -0
  198. package/src/session/credential-pin.ts +93 -0
  199. package/src/session/foreign-session-import.ts +52 -0
  200. package/src/session/foreign-session-jsonl.ts +29 -0
  201. package/src/session/foreign-session-store.ts +26 -0
  202. package/src/session/model-controls.ts +15 -7
  203. package/src/session/session-advisors.ts +142 -23
  204. package/src/session/session-entries.ts +21 -1
  205. package/src/session/session-handoff.ts +20 -3
  206. package/src/session/session-history-format.ts +31 -6
  207. package/src/session/session-loader.ts +25 -9
  208. package/src/session/session-maintenance.ts +59 -11
  209. package/src/session/session-manager.ts +61 -0
  210. package/src/session/session-tools.ts +107 -5
  211. package/src/session/settings-stream-fn.ts +1 -0
  212. package/src/session/turn-recovery.ts +97 -51
  213. package/src/slash-commands/builtin-registry.ts +136 -5
  214. package/src/system-prompt.ts +3 -2
  215. package/src/task/executor.ts +17 -4
  216. package/src/task/isolation-ownership.ts +106 -0
  217. package/src/task/render.ts +20 -4
  218. package/src/task/worktree.ts +8 -0
  219. package/src/thinking.ts +29 -10
  220. package/src/tools/acp-bridge.ts +52 -8
  221. package/src/tools/ask.ts +3 -3
  222. package/src/tools/ast-edit.ts +9 -2
  223. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  224. package/src/tools/browser/tab-worker.ts +12 -35
  225. package/src/tools/browser.ts +7 -2
  226. package/src/tools/checkpoint.ts +0 -13
  227. package/src/tools/gh-renderer.ts +3 -3
  228. package/src/tools/grep.ts +64 -8
  229. package/src/tools/hub/launch.ts +10 -15
  230. package/src/tools/index.ts +38 -5
  231. package/src/tools/path-utils.ts +88 -0
  232. package/src/tools/read.ts +3 -0
  233. package/src/tools/write.ts +44 -3
  234. package/src/tools/xdev.ts +39 -14
  235. package/src/tui/code-cell.ts +4 -4
  236. package/src/tui/output-block.ts +25 -8
  237. package/src/utils/changelog.ts +144 -4
  238. package/src/utils/git.ts +61 -14
  239. package/src/utils/jj.ts +19 -13
  240. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  241. package/src/web/search/providers/codex.ts +34 -0
  242. package/src/web/search/render.ts +2 -2
  243. package/src/web/search/types.ts +1 -1
  244. package/dist/types/goals/guided-setup.d.ts +0 -30
  245. package/src/goals/guided-setup.ts +0 -171
  246. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -1,4 +1,4 @@
1
- import * as fs from "node:fs/promises";
1
+ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentState } from "@oh-my-pi/pi-agent-core";
4
4
  import { APP_NAME, isEnoent } from "@oh-my-pi/pi-utils";
@@ -7,31 +7,38 @@ import type { SessionEntry, SessionHeader } from "../../session/session-entries"
7
7
  import { loadEntriesFromFile } from "../../session/session-loader";
8
8
  import { SessionManager } from "../../session/session-manager";
9
9
  import type { ExportThemeNames } from "./args";
10
- import templateCss from "./template.css" with { type: "text" };
11
- import templateHtml from "./template.html" with { type: "text" };
12
- import templateJs from "./template.js" with { type: "text" };
10
+ import templateCssPath from "./template.css" with { type: "file" };
11
+ import templateHtmlPath from "./template.html" with { type: "file" };
12
+ import templateJsPath from "./template.js" with { type: "file" };
13
13
  // Pre-built React tool renderers: built by `gen:tool-views` (`bun run gen:tool-views`),
14
14
  // run automatically by root `prepare` on install and by `prepack` at publish.
15
- import toolViewsJs from "./tool-views.generated.js" with { type: "text" };
15
+ import toolViewsJsPath from "./tool-views.generated.js" with { type: "file" };
16
16
  import { webExportThemeVars } from "./web-palette";
17
17
 
18
18
  export { type ExportThemeNames, parseExportArgs } from "./args";
19
19
 
20
20
  let cachedTemplate: string | undefined;
21
+ /** Resolve a Bun file-loader value without parsing Windows drive letters as URL schemes. */
22
+ export function resolveBundledHtmlAssetPath(assetPath: string, moduleDir: string = import.meta.dir): string {
23
+ if (path.isAbsolute(assetPath) || path.win32.isAbsolute(assetPath)) return assetPath;
24
+ return path.resolve(moduleDir, assetPath);
25
+ }
21
26
 
22
27
  /** Compose the standalone export template: minified CSS, tool renderers, and viewer JS inlined. */
23
28
  export function getTemplate(): string {
24
29
  if (cachedTemplate) return cachedTemplate;
30
+ const templateCss = fs.readFileSync(resolveBundledHtmlAssetPath(templateCssPath), "utf8");
31
+ const templateHtml = fs.readFileSync(resolveBundledHtmlAssetPath(templateHtmlPath as unknown as string), "utf8");
32
+ const templateJs = fs.readFileSync(resolveBundledHtmlAssetPath(templateJsPath), "utf8");
33
+ const toolViewsJs = fs.readFileSync(resolveBundledHtmlAssetPath(toolViewsJsPath), "utf8");
25
34
  const minifiedCss = templateCss
26
35
  .replace(/\/\*[\s\S]*?\*\//g, "")
27
36
  .replace(/\s+/g, " ")
28
37
  .replace(/\s*([{}:;,])\s*/g, "$1")
29
38
  .trim();
30
39
  // Function replacements so `$'`, `$&`, `$$`, etc. inside the embedded
31
- // CSS/JS are not interpreted as substitution patterns. The cast is safe:
32
- // `with { type: "text" }` yields a string at runtime; bun-types just types
33
- // every *.html import as HTMLBundle (TS can't vary types by import attribute).
34
- cachedTemplate = (templateHtml as unknown as string)
40
+ // CSS/JS are not interpreted as substitution patterns.
41
+ cachedTemplate = templateHtml
35
42
  .replace("<template-css/>", () => `<style>${minifiedCss}</style>`)
36
43
  .replace("<template-tool-views/>", () => `<script>${toolViewsJs}</script>`)
37
44
  .replace("<template-js/>", () => `<script>${templateJs}</script>`);
@@ -207,7 +214,7 @@ async function collectSubSessionsFromDir(
207
214
  ): Promise<void> {
208
215
  let names: string[];
209
216
  try {
210
- names = await fs.readdir(dir);
217
+ names = await fs.promises.readdir(dir);
211
218
  } catch (err) {
212
219
  if (isEnoent(err)) return;
213
220
  throw err;
@@ -395,7 +395,7 @@
395
395
  }
396
396
 
397
397
  // Apply filter mode
398
- const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init'].includes(entry.type);
398
+ const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init', 'credential_pin'].includes(entry.type);
399
399
  let passesFilter = true;
400
400
 
401
401
  switch (filterMode) {
@@ -10,6 +10,7 @@ import type { Settings } from "../../config/settings";
10
10
  import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
11
11
  import type { MemoryRuntimeContext } from "../../memory-backend";
12
12
  import { type Theme, theme } from "../../modes/theme/theme";
13
+ import type { AsyncJobSnapshot } from "../../session/agent-session";
13
14
  import type { SessionManager } from "../../session/session-manager";
14
15
  import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
15
16
  import { ManagedTimers } from "./managed-timers";
@@ -40,6 +41,7 @@ import type {
40
41
  ExtensionUIDialogOptions,
41
42
  InputEvent,
42
43
  InputEventResult,
44
+ McpNotificationEvent,
43
45
  MessageRenderer,
44
46
  RegisteredCommand,
45
47
  RegisteredTool,
@@ -218,6 +220,14 @@ async function raceHandlerWithTimeout<T>(
218
220
 
219
221
  const MAX_PENDING_CREDENTIAL_DISABLED = 32;
220
222
 
223
+ /**
224
+ * Buffer cap for `mcp_notification` events received before {@link ExtensionRunner.initialize}
225
+ * has run. Sized to match the manager-side buffer in `MCPManager.NOTIFICATION_BUFFER_CAP` so
226
+ * the two layers can't drop different amounts of the same burst — the pipe drains, or it
227
+ * spills, but it does so consistently at both ends. Drop-oldest under pressure.
228
+ */
229
+ const MAX_PENDING_MCP_NOTIFICATIONS = 100;
230
+
221
231
  /**
222
232
  * Events handled by the generic emit() method.
223
233
  * Events with dedicated emitXxx() methods are excluded for stronger type safety.
@@ -327,6 +337,7 @@ export class ExtensionRunner {
327
337
  #getContextUsageFn: () => ContextUsage | undefined = () => undefined;
328
338
  #compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
329
339
  #getSystemPromptFn: () => string[] = () => [];
340
+ #getAsyncJobSnapshotFn: () => AsyncJobSnapshot | null = () => null;
330
341
  #newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
331
342
  #branchHandler: BranchHandler = async () => ({ cancelled: false });
332
343
  #navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
@@ -345,6 +356,19 @@ export class ExtensionRunner {
345
356
  */
346
357
  #pendingCredentialDisabled: CredentialDisabledEvent[] = [];
347
358
 
359
+ /**
360
+ * Buffer for `mcp_notification` events received via {@link emitMcpNotification} before
361
+ * {@link initialize} has run. Two-layer race: `MCPManager` also buffers frames until
362
+ * its first `addNotificationListener` subscriber attaches, but the sdk.ts bridge is
363
+ * registered inside `createAgentSession` — BEFORE the mode controller calls
364
+ * `ExtensionRunner.initialize()`. Without this second buffer, the manager's drain
365
+ * arrives at the bridge → the bridge calls `emitMcpNotification` → the runner drops
366
+ * the frame because `#initialized === false`, and the frame evaporates a second time.
367
+ * Bounded at {@link MAX_PENDING_MCP_NOTIFICATIONS}; oldest entries are dropped under
368
+ * pressure. Drained in {@link initialize} once the runtime/UI context is wired.
369
+ */
370
+ #pendingMcpNotifications: Array<Omit<McpNotificationEvent, "type">> = [];
371
+
348
372
  /**
349
373
  * Timers scheduled by extensions through the sanctioned `ctx.setInterval` /
350
374
  * `ctx.setTimeout` helpers. Callbacks run with the same isolation as handler
@@ -392,9 +416,11 @@ export class ExtensionRunner {
392
416
  getMemory?: () => MemoryRuntimeContext | undefined,
393
417
  private readonly settings?: Settings,
394
418
  private readonly localProtocolOptions?: LocalProtocolOptions,
419
+ getAsyncJobSnapshot?: () => AsyncJobSnapshot | null,
395
420
  ) {
396
421
  this.#uiContext = noOpUIContext;
397
422
  this.#getMemoryFn = getMemory;
423
+ this.#getAsyncJobSnapshotFn = getAsyncJobSnapshot ?? (() => null);
398
424
  }
399
425
 
400
426
  initialize(
@@ -457,6 +483,23 @@ export class ExtensionRunner {
457
483
  });
458
484
  }
459
485
  });
486
+
487
+ // Drain events buffered by emitMcpNotification() before initialize ran, using the
488
+ // same deferred-microtask ordering as the credential-disabled drain above so any
489
+ // onError listener registered synchronously after initialize() still catches
490
+ // handler errors during flush.
491
+ const pendingMcp = this.#pendingMcpNotifications.splice(0);
492
+ queueMicrotask(() => {
493
+ for (const event of pendingMcp) {
494
+ this.emit({ type: "mcp_notification", ...event }).catch((error: unknown) => {
495
+ logger.warn("mcp_notification handler threw during initialize flush", {
496
+ server: event.server,
497
+ method: event.method,
498
+ error: error instanceof Error ? error.message : String(error),
499
+ });
500
+ });
501
+ }
502
+ });
460
503
  }
461
504
 
462
505
  /**
@@ -484,6 +527,32 @@ export class ExtensionRunner {
484
527
  await this.emit({ type: "credential_disabled", ...event });
485
528
  }
486
529
 
530
+ /**
531
+ * Forward an MCP server notification to extension handlers.
532
+ *
533
+ * If {@link initialize} has not yet run, the notification is buffered and replayed
534
+ * once initialize wires the runtime/UI context. Matches the credential-disabled
535
+ * deferral above: the sdk.ts bridge registers `MCPManager.addNotificationListener`
536
+ * inside `createAgentSession` — BEFORE the mode controller calls `initialize()` on
537
+ * this runner — so notification frames drained by the manager (either fresh
538
+ * arrivals or replay from its own startup buffer) can reach us pre-init. Without
539
+ * this buffer they would evaporate for a second time here.
540
+ *
541
+ * Bounded at {@link MAX_PENDING_MCP_NOTIFICATIONS}; oldest entries drop under
542
+ * pressure. Never throws; per-handler errors are routed through {@link onError}
543
+ * via {@link emit}'s normal isolation.
544
+ */
545
+ async emitMcpNotification(event: Omit<McpNotificationEvent, "type">): Promise<void> {
546
+ if (!this.#initialized) {
547
+ if (this.#pendingMcpNotifications.length >= MAX_PENDING_MCP_NOTIFICATIONS) {
548
+ this.#pendingMcpNotifications.shift();
549
+ }
550
+ this.#pendingMcpNotifications.push(event);
551
+ return;
552
+ }
553
+ await this.emit({ type: "mcp_notification", ...event });
554
+ }
555
+
487
556
  /** Emits a session stop pass that can be cancelled with the active settle signal. */
488
557
  async emitSessionStop(event: Omit<SessionStopEvent, "type">): Promise<SessionStopEventResult | undefined> {
489
558
  if (event.signal.aborted) return undefined;
@@ -666,6 +735,7 @@ export class ExtensionRunner {
666
735
  ui: this.#uiContext,
667
736
  getContextUsage: () => this.#getContextUsageFn(),
668
737
  compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions),
738
+ getAsyncJobSnapshot: () => this.#getAsyncJobSnapshotFn(),
669
739
  hasUI: this.hasUI(),
670
740
  cwd: this.cwd,
671
741
  sessionManager: this.sessionManager,
@@ -49,6 +49,7 @@ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
49
49
  import type { MemoryRuntimeContext } from "../../memory-backend";
50
50
  import type { CustomEditor } from "../../modes/components/custom-editor";
51
51
  import type { Theme } from "../../modes/theme/theme";
52
+ import type { AsyncJobSnapshot } from "../../session/agent-session";
52
53
  import type { CompactMode } from "../../session/compact-modes";
53
54
  import type { CustomMessage, CustomMessagePayload } from "../../session/messages";
54
55
  import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager";
@@ -415,6 +416,8 @@ export interface ExtensionContext {
415
416
  ui: ExtensionUIContext;
416
417
  /** Get current context usage for the active model. */
417
418
  getContextUsage(): ContextUsage | undefined;
419
+ /** Get a read-only snapshot of async jobs owned by this session. */
420
+ getAsyncJobSnapshot(): AsyncJobSnapshot | null;
418
421
  /** Compact the session context (interactive mode shows UI). */
419
422
  compact(instructionsOrOptions?: string | CompactOptions): Promise<void>;
420
423
  /** Whether UI is available (false in print/RPC mode) */
@@ -713,6 +716,31 @@ export interface CredentialDisabledEvent {
713
716
  disabledCause: string;
714
717
  }
715
718
 
719
+ // ============================================================================
720
+ // MCP Events
721
+ // ============================================================================
722
+
723
+ /**
724
+ * Fired for every JSON-RPC notification received from a connected MCP server,
725
+ * AFTER the runtime's own handling of known list/update methods. Unknown or
726
+ * server-custom methods are delivered too — extensions can bridge them into
727
+ * session behavior by inspecting `method`/`params` and injecting a follow-up
728
+ * via `pi.sendMessage(..., { deliverAs })` or `pi.sendUserMessage(...)`.
729
+ */
730
+ export interface McpNotificationEvent {
731
+ type: "mcp_notification";
732
+ /**
733
+ * Server name as declared in the MCP config (raw, unsanitized). Note this
734
+ * differs from the sanitized prefix used in `mcp__<sanitized_server>_<tool>`
735
+ * tool names — filter by this raw name, not by tool-name prefix matching.
736
+ */
737
+ server: string;
738
+ /** JSON-RPC method (e.g. `notifications/tools/list_changed`, or server-custom). */
739
+ method: string;
740
+ /** JSON-RPC params, opaque to the runtime. */
741
+ params: unknown;
742
+ }
743
+
716
744
  // ============================================================================
717
745
  // User Bash Events
718
746
  // ============================================================================
@@ -941,6 +969,7 @@ export type ExtensionEvent =
941
969
  | TodoReminderEvent
942
970
  | GoalUpdatedEvent
943
971
  | CredentialDisabledEvent
972
+ | McpNotificationEvent
944
973
  | UserBashEvent
945
974
  | UserPythonEvent
946
975
  | InputEvent
@@ -1133,6 +1162,7 @@ export interface ExtensionAPI {
1133
1162
  on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
1134
1163
  on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
1135
1164
  on(event: "user_python", handler: ExtensionHandler<UserPythonEvent, UserPythonEventResult>): void;
1165
+ on(event: "mcp_notification", handler: ExtensionHandler<McpNotificationEvent>): void;
1136
1166
 
1137
1167
  // =========================================================================
1138
1168
  // Tool Registration
@@ -19,7 +19,7 @@
19
19
  * `types.ts` via the `export *` below — pi-ai still exports both as types,
20
20
  * only the runtime `Type` builder and `StringEnum()` helper were removed.
21
21
  */
22
- import type { Api, Model } from "@oh-my-pi/pi-ai";
22
+ import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
23
23
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
24
24
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
25
25
  import {
@@ -80,6 +80,33 @@ export function clampThinkingLevel<TApi extends Api>(model: Model<TApi>, level:
80
80
  return clampThinkingLevelForModel(model, level) ?? "off";
81
81
  }
82
82
 
83
+ /**
84
+ * Provider-error classification patterns ported verbatim from historical pi-ai
85
+ * (`@earendil-works/pi-ai` `utils/retry.ts`). Legacy extensions call
86
+ * {@link isRetryableAssistantError} to decide whether to restart a failed
87
+ * assistant turn, so the wording tables must match the upstream semantics they
88
+ * were authored against rather than OMP's own `Error`-based classifiers.
89
+ */
90
+ const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN =
91
+ /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i;
92
+ const RETRYABLE_PROVIDER_ERROR_PATTERN =
93
+ /overloaded|rate.?limit|too many requests|429|500|502|503|504|524|service.?unavailable|server.?error|internal.?error|provider.?returned.?error|network.?error|connection.?error|connection.?refused|connection.?lost|other side closed|fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|upstream.?connect|reset before headers|socket hang up|socket connection was closed|timed? out|timeout|terminated|websocket.?closed|websocket.?error|ended without|stream ended before message_stop|stream ended before a terminal response event|http2 request did not get a response|retry delay|you can retry your request|try your request again|please retry your request|ResourceExhausted/i;
94
+
95
+ /**
96
+ * Compatibility implementation of historical pi-ai's `isRetryableAssistantError`.
97
+ *
98
+ * Classifies whether a failed assistant message looks like a transient provider
99
+ * or transport error so legacy extensions can decide if the last assistant turn
100
+ * should be restarted. Account/quota limits are treated as non-retryable. This
101
+ * does not implement any retry policy; callers own budget, backoff, and reporting.
102
+ */
103
+ export function isRetryableAssistantError(message: AssistantMessage): boolean {
104
+ if (message.stopReason !== "error" || !message.errorMessage) return false;
105
+ const errorMessage = message.errorMessage;
106
+ if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false;
107
+ return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage);
108
+ }
109
+
83
110
  export * from "@oh-my-pi/pi-ai";
84
111
  /**
85
112
  * Compatibility re-exports for catalog symbols that pi-ai historically exposed
@@ -93,3 +120,21 @@ export * from "@oh-my-pi/pi-ai";
93
120
  export { calculateCost, getBundledModel, getBundledModels, getBundledProviders, modelsAreEqual, Type };
94
121
  export const getModel = getBundledModel;
95
122
  export const getModels = getBundledModels;
123
+
124
+ /**
125
+ * Compatibility re-exports for runtime helpers that upstream
126
+ * `@earendil-works/pi-ai` exposed from its package root but omp's
127
+ * `@oh-my-pi/pi-ai` barrel no longer forwards. Each symbol still exists in the
128
+ * host graph — only its root re-export was dropped — so bridging it here keeps
129
+ * legacy extensions importing it from the pi-ai root resolving through Bun's
130
+ * static named-export check (e.g. `omp plugin install pi-blackhole`).
131
+ *
132
+ * This is the full set derived from an audit of the upstream root surface: the
133
+ * error-classification predicate `isContextOverflow` (now under
134
+ * `@oh-my-pi/pi-ai/error`) and the JSON-repair helpers that omp relocated to
135
+ * `@oh-my-pi/pi-utils`. Upstream root symbols with no omp equivalent are
136
+ * intentionally not shimmed — the package has diverged and there is nothing to
137
+ * forward.
138
+ */
139
+ export { isContextOverflow } from "@oh-my-pi/pi-ai/error";
140
+ export { parseJsonWithRepair, parseStreamingJson, repairJson } from "@oh-my-pi/pi-utils";
@@ -17,6 +17,7 @@ import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import { type AuthCredential, SqliteAuthCredentialStore, type TSchema } from "@oh-my-pi/pi-ai";
20
+ import { piEscapeRegexLiteral, piJoinPath } from "@oh-my-pi/pi-ai/providers/cursor-pi-args";
20
21
  import { getKeybindings, type Keybinding, Text } from "@oh-my-pi/pi-tui";
21
22
  import {
22
23
  getAgentDbPath,
@@ -298,16 +299,6 @@ function lineRangePath(readPath: string, offset: number | undefined, limit: numb
298
299
  return `${readPath}:${start}-${end}`;
299
300
  }
300
301
 
301
- function escapeRegexLiteral(value: string): string {
302
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
303
- }
304
-
305
- function joinLegacyGlob(searchPath: string, pattern: string): string {
306
- if (path.isAbsolute(pattern)) return pattern;
307
- if (!searchPath || searchPath === ".") return pattern;
308
- return path.join(searchPath, pattern);
309
- }
310
-
311
302
  function normalizeLegacyLimit(limit: number | undefined, fallback: number): number {
312
303
  if (limit === undefined || !Number.isFinite(limit)) return fallback;
313
304
  return Math.max(1, Math.floor(limit));
@@ -512,7 +503,7 @@ export function createGrepToolDefinition(cwd: string, options?: GrepToolOptions)
512
503
  renderResult: legacyRenderResult,
513
504
  execute: (toolCallId, params, signal, onUpdate) => {
514
505
  const rawPattern = stringField(params, "pattern") ?? "";
515
- const pattern = booleanField(params, "literal") ? escapeRegexLiteral(rawPattern) : rawPattern;
506
+ const pattern = booleanField(params, "literal") ? piEscapeRegexLiteral(rawPattern) : rawPattern;
516
507
  const searchPath = stringField(params, "path") ?? ".";
517
508
  const glob = stringField(params, "glob");
518
509
  const context = numberField(params, "context");
@@ -529,7 +520,7 @@ export function createGrepToolDefinition(cwd: string, options?: GrepToolOptions)
529
520
  toolCallId,
530
521
  {
531
522
  pattern,
532
- path: glob ? joinLegacyGlob(searchPath, glob) : searchPath,
523
+ path: glob ? piJoinPath(searchPath, glob) : searchPath,
533
524
  case: booleanField(params, "ignoreCase") ? false : undefined,
534
525
  },
535
526
  signal,
@@ -587,7 +578,7 @@ export function createFindToolDefinition(cwd: string, options?: FindToolOptions)
587
578
  }
588
579
  return tool.execute(
589
580
  toolCallId,
590
- { path: joinLegacyGlob(searchPath, pattern), hidden: true, gitignore: true, limit },
581
+ { path: piJoinPath(searchPath, pattern), hidden: true, gitignore: true, limit },
591
582
  signal,
592
583
  onUpdate,
593
584
  );
@@ -1378,6 +1369,14 @@ export function getPackageDir(): string {
1378
1369
  // check during validation (issue #6583).
1379
1370
  export { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
1380
1371
 
1372
+ // Same barrel gap for two more legacy package-root exports: pi re-exported the
1373
+ // `CONFIG_DIR_NAME` constant and the CLI parser `parseArgs`. In omp
1374
+ // `CONFIG_DIR_NAME` lives in `@oh-my-pi/pi-utils` and `parseArgs` in
1375
+ // `../cli/args`, neither of which the barrel below forwards, so legacy
1376
+ // extensions importing either fail Bun's static export check during validation.
1377
+ export { CONFIG_DIR_NAME } from "@oh-my-pi/pi-utils";
1378
+ export { parseArgs } from "../cli/args";
1379
+
1381
1380
  export * from "../index";
1382
1381
  export { formatBytes as formatSize } from "../tools/render-utils";
1383
1382
  export { copyToClipboard } from "../utils/clipboard";
@@ -390,8 +390,9 @@ export class LocalProtocolHandler implements ProtocolHandler {
390
390
 
391
391
  /**
392
392
  * Install a process-global override that wins over the AgentRegistry-based
393
- * derivation. Used by SDK consumers that wire `localProtocolOptions` on
394
- * `createAgentSession` and by subagents that share their parent's root.
393
+ * derivation. Used by top-level SDK consumers that wire
394
+ * `localProtocolOptions` on `createAgentSession`; subagents keep their
395
+ * inherited mapping session-bound.
395
396
  */
396
397
  static setOverride(value: LocalProtocolOptions | undefined): void {
397
398
  LocalProtocolHandler.#override = value;
@@ -97,7 +97,10 @@ function formatAvailableResources(mcpManager: MCPManager): string {
97
97
  .getConnectedServers()
98
98
  .flatMap(name => {
99
99
  const serverResources = mcpManager.getServerResources(name);
100
- return (serverResources?.resources ?? []).map(r => ` ${r.uri} (${name})`);
100
+ if (!serverResources) return [];
101
+ const concrete = serverResources.resources.map(r => ` ${r.uri} (${name})`);
102
+ const templates = serverResources.templates.map(t => ` ${t.uriTemplate} (${name}, template)`);
103
+ return [...concrete, ...templates];
101
104
  })
102
105
  .join("\n");
103
106
  return available || " (none)";
@@ -3,7 +3,7 @@ import * as net from "node:net";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { Process, type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives";
6
- import { isEexist, isEnoent, logger, postmortem, procmgr, sanitizeText } from "@oh-my-pi/pi-utils";
6
+ import { isEexist, isEnoent, logger, postmortem, procmgr, sanitizeText, setProcessName } from "@oh-my-pi/pi-utils";
7
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";
@@ -103,6 +103,10 @@ function terminalState(state: DaemonSnapshot["state"]): boolean {
103
103
  return state === "exited" || state === "failed";
104
104
  }
105
105
 
106
+ function settledState(state: DaemonSnapshot["state"]): boolean {
107
+ return terminalState(state) || state === "restarting";
108
+ }
109
+
106
110
  /**
107
111
  * Order daemons for the `list` response: non-terminal (active) daemons first,
108
112
  * oldest to newest, so the process the user is acting on is immediately visible
@@ -754,7 +758,7 @@ class DaemonBroker {
754
758
  }
755
759
 
756
760
  async #refreshDetached(record: ManagedDaemon): Promise<void> {
757
- if (!record.spec.detached || terminalState(record.snapshot.state)) return;
761
+ if (!record.spec.detached || settledState(record.snapshot.state)) return;
758
762
  const generation = record.generation;
759
763
  await this.#readDetachedOutput(record, generation);
760
764
  if (generation !== record.generation || record.process) return;
@@ -791,8 +795,14 @@ class DaemonBroker {
791
795
  }
792
796
 
793
797
  async #settle(record: ManagedDaemon, generation: number, exitCode?: number, error?: string): Promise<void> {
794
- if (generation !== record.generation || terminalState(record.snapshot.state)) return;
798
+ // `restarting` is a settled state (child exited, relaunch timer armed). Any op that
799
+ // runs #refreshDetached on such a record must not re-settle it: re-entry double-counts
800
+ // restartCount and overwrites record.restartTimer, orphaning the armed timer so it fires
801
+ // after stop() and resurrects the daemon (issue #6852).
802
+ if (generation !== record.generation || settledState(record.snapshot.state)) return;
795
803
  await this.#readDetachedOutput(record, generation);
804
+ // The output read yields, so a concurrent refresh may settle this generation first.
805
+ if (generation !== record.generation || settledState(record.snapshot.state)) return;
796
806
  record.process = undefined;
797
807
  record.input = undefined;
798
808
  record.pty = undefined;
@@ -1110,7 +1120,7 @@ export async function startDaemonBrokerFromEnvironment(): Promise<void> {
1110
1120
  await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
1111
1121
  const lease = await acquireBrokerLease(runtimeDir);
1112
1122
  if (!lease) return;
1113
- process.title = "omp daemon broker";
1123
+ setProcessName("omp daemon broker");
1114
1124
  const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
1115
1125
  if (!token) throw new Error("Daemon broker token is empty");
1116
1126
  const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs);
@@ -0,0 +1,53 @@
1
+ import { workerHostEntry } from "@oh-my-pi/pi-utils/worker-host";
2
+ import type { TerminalOutputOptions } from "./terminal-output";
3
+ import {
4
+ TERMINAL_OUTPUT_WORKER_ARG,
5
+ type TerminalOutputWorkerRequest,
6
+ type TerminalOutputWorkerResult,
7
+ } from "./terminal-output-worker-protocol";
8
+
9
+ /** Replay legacy broker PTY bytes without evaluating xterm in the client process. */
10
+ export async function renderTerminalOutputIsolated(
11
+ output: string,
12
+ options: TerminalOutputOptions,
13
+ ): Promise<string[] | undefined> {
14
+ const hostEntry = workerHostEntry();
15
+ const worker = hostEntry
16
+ ? new Worker(hostEntry, { type: "module", argv: [TERMINAL_OUTPUT_WORKER_ARG] })
17
+ : new Worker(new URL("./terminal-output-worker.ts", import.meta.url).href, { type: "module" });
18
+ const pending = Promise.withResolvers<string[] | undefined>();
19
+ const onMessage = (event: MessageEvent<TerminalOutputWorkerResult>): void => {
20
+ if (event.data.ok) pending.resolve(event.data.rows);
21
+ else pending.reject(new Error(event.data.error));
22
+ };
23
+ const onError = (event: ErrorEvent): void => {
24
+ pending.reject(event.error instanceof Error ? event.error : new Error(event.message));
25
+ };
26
+ const onClose = (): void => {
27
+ pending.reject(new Error("Terminal output worker exited before responding"));
28
+ };
29
+ worker.addEventListener("message", onMessage);
30
+ worker.addEventListener("error", onError);
31
+ worker.addEventListener("close", onClose);
32
+ try {
33
+ const request: TerminalOutputWorkerRequest = { output, options };
34
+ worker.postMessage(request);
35
+ return await pending.promise;
36
+ } finally {
37
+ worker.removeEventListener("message", onMessage);
38
+ worker.removeEventListener("error", onError);
39
+ worker.removeEventListener("close", onClose);
40
+ worker.terminate();
41
+ }
42
+ }
43
+
44
+ /** Distribution smoke for source, npm-bundle, and compiled worker routing. */
45
+ export async function smokeTestTerminalOutputWorker(): Promise<void> {
46
+ const rows = await renderTerminalOutputIsolated("old\r\x1b[2K\x1b[1;32mready\x1b[0m", {
47
+ head: false,
48
+ maxRows: 10,
49
+ });
50
+ if (rows?.length !== 1 || rows[0] !== "\x1b[0m\x1b[1;38;5;2mready") {
51
+ throw new Error("terminal output worker smoke mismatch");
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ import type { TerminalOutputOptions } from "./terminal-output";
2
+
3
+ /** Hidden CLI selector for legacy PTY replay outside the client process. */
4
+ export const TERMINAL_OUTPUT_WORKER_ARG = "__omp_worker_terminal_output";
5
+
6
+ export interface TerminalOutputWorkerRequest {
7
+ output: string;
8
+ options: TerminalOutputOptions;
9
+ }
10
+
11
+ export type TerminalOutputWorkerResult = { ok: true; rows: string[] | undefined } | { ok: false; error: string };
@@ -0,0 +1,23 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
3
+ import { renderTerminalOutput } from "./terminal-output";
4
+ import type { TerminalOutputWorkerRequest, TerminalOutputWorkerResult } from "./terminal-output-worker-protocol";
5
+
6
+ if (!parentPort) throw new Error("terminal-output-worker: missing parentPort");
7
+
8
+ const port = parentPort;
9
+ const inbox = consumeWorkerInbox();
10
+ const handle = async (message: unknown): Promise<void> => {
11
+ const request = message as TerminalOutputWorkerRequest;
12
+ let result: TerminalOutputWorkerResult;
13
+ try {
14
+ result = { ok: true, rows: await renderTerminalOutput(request.output, request.options) };
15
+ } catch (error) {
16
+ result = { ok: false, error: error instanceof Error ? error.message : String(error) };
17
+ }
18
+ port.postMessage(result);
19
+ port.close();
20
+ };
21
+
22
+ if (inbox) inbox.bind(message => void handle(message));
23
+ else port.on("message", message => void handle(message));
package/src/lsp/client.ts CHANGED
@@ -27,6 +27,7 @@ const fileOperationLocks = new Map<string, Promise<void>>();
27
27
  /** Negative cache of recent init failures so a broken server fails fast instead of re-spawning per call. */
28
28
  const INIT_FAILURE_BACKOFF_MS = 3 * 60 * 1000;
29
29
  const initFailures = new Map<string, { at: number; message: string }>();
30
+ const READER_EXIT_GRACE_MS = 100;
30
31
 
31
32
  // Idle timeout configuration (disabled by default)
32
33
  let idleTimeoutMs: number | null = null;
@@ -303,6 +304,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
303
304
 
304
305
  const framer = new MessageFramer(Buffer.from(client.messageBuffer));
305
306
 
307
+ let readerFailed = false;
306
308
  try {
307
309
  while (true) {
308
310
  const { done, value } = await reader.read();
@@ -391,6 +393,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
391
393
  }
392
394
  }
393
395
  } catch (err) {
396
+ readerFailed = true;
394
397
  // Connection closed or error - reject all pending requests
395
398
  for (const pending of Array.from(client.pendingRequests.values())) {
396
399
  pending.reject(new Error(`LSP connection closed: ${err}`));
@@ -401,6 +404,9 @@ async function startMessageReader(client: LspClient): Promise<void> {
401
404
  client.messageBuffer = framer.remainder();
402
405
  reader.releaseLock();
403
406
  client.isReading = false;
407
+ if (!readerFailed && client.proc.exitCode === null) {
408
+ await waitForExit(client, READER_EXIT_GRACE_MS);
409
+ }
404
410
  // Reader exited while the server process is still alive (unrecoverable
405
411
  // read error or bad stream state): nothing will route responses anymore,
406
412
  // so tear the client down — the next call respawns instead of timing out.
@@ -676,6 +682,15 @@ const PROJECT_LOAD_TIMEOUT_MS = 15_000;
676
682
  const SHUTDOWN_TIMEOUT_MS = 5_000;
677
683
  const EXIT_TIMEOUT_MS = 1_000;
678
684
 
685
+ function clientKey(config: ServerConfig, cwd: string): string {
686
+ return `${config.command}:${cwd}`;
687
+ }
688
+
689
+ /** Allow an explicit user reload to retry a matching initialization failure immediately. */
690
+ export function clearInitializationFailure(config: ServerConfig, cwd: string): void {
691
+ initFailures.delete(clientKey(config, cwd));
692
+ }
693
+
679
694
  /**
680
695
  * Get or create an LSP client for the given server configuration and working directory.
681
696
  * @param config - Server configuration
@@ -692,7 +707,7 @@ export async function getOrCreateClient(
692
707
  initTimeoutMs?: number,
693
708
  signal?: AbortSignal,
694
709
  ): Promise<LspClient> {
695
- const key = `${config.command}:${cwd}`;
710
+ const key = clientKey(config, cwd);
696
711
 
697
712
  // Check if client already exists
698
713
  const existingClient = clients.get(key);
@@ -865,13 +880,13 @@ export async function getActiveOrPendingClient(
865
880
  signal?: AbortSignal,
866
881
  ): Promise<LspClient | undefined> {
867
882
  throwIfAborted(signal);
868
- const client = clients.get(`${config.command}:${cwd}`);
883
+ const client = clients.get(clientKey(config, cwd));
869
884
  if (client) {
870
885
  client.lastActivity = Date.now();
871
886
  return client;
872
887
  }
873
888
 
874
- const pending = clientLocks.get(`${config.command}:${cwd}`);
889
+ const pending = clientLocks.get(clientKey(config, cwd));
875
890
  if (!pending) return undefined;
876
891
  try {
877
892
  return await untilAborted(signal, pending);