@oh-my-pi/pi-coding-agent 17.2.4 → 17.2.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 (319) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/{CHANGELOG-bpmhv26t.md → CHANGELOG-q2w43aw3.md} +51 -0
  3. package/dist/cli.js +4148 -3717
  4. package/dist/{template-8vdv6xb4.js → template-c2hyaytt.js} +8 -26
  5. package/dist/types/cli/args.d.ts +4 -1
  6. package/dist/types/cli/browser-relay-cli.d.ts +15 -0
  7. package/dist/types/cli/command-help.d.ts +99 -0
  8. package/dist/types/cli/help-extra.d.ts +2 -0
  9. package/dist/types/cli/thinking-levels.d.ts +5 -0
  10. package/dist/types/cli/update-cli.d.ts +13 -0
  11. package/dist/types/commands/browser-relay.d.ts +38 -0
  12. package/dist/types/commands/commit.d.ts +3 -0
  13. package/dist/types/commands/complete.d.ts +1 -1
  14. package/dist/types/commands/completions.d.ts +6 -0
  15. package/dist/types/commands/grep.d.ts +3 -0
  16. package/dist/types/commands/join.d.ts +4 -0
  17. package/dist/types/commands/launch-help.d.ts +171 -0
  18. package/dist/types/commands/launch.d.ts +5 -1
  19. package/dist/types/commands/models.d.ts +3 -0
  20. package/dist/types/commands/say.d.ts +10 -0
  21. package/dist/types/commands/worktree.d.ts +3 -0
  22. package/dist/types/config/keybindings.d.ts +5 -0
  23. package/dist/types/config/model-discovery.d.ts +2 -2
  24. package/dist/types/config/models-config-schema-bundle.d.ts +3 -0
  25. package/dist/types/config/models-config-schema.d.ts +2 -0
  26. package/dist/types/config/models-config.d.ts +1 -0
  27. package/dist/types/config/service-tier.d.ts +2 -0
  28. package/dist/types/config/settings-schema.d.ts +69 -21
  29. package/dist/types/cursor-bridge-tools.d.ts +1 -1
  30. package/dist/types/cursor.d.ts +7 -4
  31. package/dist/types/edit/index.d.ts +3 -3
  32. package/dist/types/edit/modes/replace.d.ts +22 -15
  33. package/dist/types/edit/renderer.d.ts +1 -1
  34. package/dist/types/eval/py/kernel.d.ts +3 -1
  35. package/dist/types/eval/py/spawn-options.d.ts +12 -16
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +8 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  38. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  39. package/dist/types/launch/broker.d.ts +1 -1
  40. package/dist/types/launch/client.d.ts +6 -3
  41. package/dist/types/launch/ensure.d.ts +8 -0
  42. package/dist/types/launch/paths.d.ts +1 -1
  43. package/dist/types/launch/protocol.d.ts +2 -2
  44. package/dist/types/lsp/client.d.ts +2 -0
  45. package/dist/types/lsp/mux/daemon.d.ts +15 -0
  46. package/dist/types/lsp/mux/protocol.d.ts +57 -0
  47. package/dist/types/lsp/mux/server.d.ts +18 -0
  48. package/dist/types/lsp/types.d.ts +22 -2
  49. package/dist/types/main.d.ts +2 -0
  50. package/dist/types/mnemopi/embed-client.d.ts +1 -1
  51. package/dist/types/mnemopi/state.d.ts +0 -23
  52. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  53. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  54. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  55. package/dist/types/modes/components/stripped-tool-calls-placeholder.d.ts +12 -0
  56. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  57. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  58. package/dist/types/modes/interactive-mode.d.ts +1 -0
  59. package/dist/types/modes/types.d.ts +1 -0
  60. package/dist/types/modes/workflow.d.ts +2 -1
  61. package/dist/types/sdk.d.ts +3 -1
  62. package/dist/types/secrets/index.d.ts +1 -1
  63. package/dist/types/session/agent-session-types.d.ts +3 -1
  64. package/dist/types/session/indexed-session-storage.d.ts +1 -0
  65. package/dist/types/session/session-advisors.d.ts +1 -1
  66. package/dist/types/session/session-context.d.ts +2 -1
  67. package/dist/types/session/session-maintenance.d.ts +1 -0
  68. package/dist/types/session/session-manager.d.ts +16 -11
  69. package/dist/types/session/session-stats.d.ts +10 -0
  70. package/dist/types/session/session-storage.d.ts +15 -3
  71. package/dist/types/system-prompt.d.ts +4 -1
  72. package/dist/types/task/index.d.ts +2 -1
  73. package/dist/types/task/spawn-policy.d.ts +5 -0
  74. package/dist/types/thinking.d.ts +1 -7
  75. package/dist/types/tools/ast-grep.d.ts +1 -1
  76. package/dist/types/tools/browser/registry.d.ts +2 -1
  77. package/dist/types/tools/browser/relay/bridge.d.ts +35 -0
  78. package/dist/types/tools/browser/relay/daemon.d.ts +16 -0
  79. package/dist/types/tools/browser/relay/kind.d.ts +19 -0
  80. package/dist/types/tools/browser/relay/protocol.d.ts +97 -0
  81. package/dist/types/tools/browser/relay/server.d.ts +35 -0
  82. package/dist/types/tools/browser/render.d.ts +1 -0
  83. package/dist/types/tools/browser.d.ts +3 -0
  84. package/dist/types/tools/computer/exposure.d.ts +9 -7
  85. package/dist/types/tools/computer/protocol.d.ts +70 -17
  86. package/dist/types/tools/computer/supervisor.d.ts +23 -10
  87. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  88. package/dist/types/tools/computer/worker.d.ts +42 -7
  89. package/dist/types/tools/computer-renderer.d.ts +7 -5
  90. package/dist/types/tools/computer.d.ts +25 -44
  91. package/dist/types/tools/context.d.ts +3 -0
  92. package/dist/types/tools/glob.d.ts +1 -1
  93. package/dist/types/tools/grep.d.ts +1 -1
  94. package/dist/types/tools/render-utils.d.ts +2 -0
  95. package/dist/types/tools/{browser/run-cancellation.d.ts → run-scope.d.ts} +8 -8
  96. package/dist/types/tools/tool-timeouts.d.ts +5 -0
  97. package/dist/types/tools/xdev.d.ts +8 -0
  98. package/dist/types/utils/clipboard.d.ts +2 -2
  99. package/dist/types/web/kagi.d.ts +1 -0
  100. package/dist/types/web/search/index.d.ts +1 -1
  101. package/dist/types/web/search/providers/anthropic.d.ts +1 -0
  102. package/dist/types/web/search/providers/base.d.ts +2 -0
  103. package/dist/types/web/search/providers/brave.d.ts +1 -0
  104. package/dist/types/web/search/providers/browser-page.d.ts +1 -0
  105. package/dist/types/web/search/providers/codex.d.ts +1 -0
  106. package/dist/types/web/search/providers/exa.d.ts +1 -0
  107. package/dist/types/web/search/providers/firecrawl.d.ts +1 -0
  108. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  109. package/dist/types/web/search/providers/jina.d.ts +1 -0
  110. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  111. package/dist/types/web/search/providers/kimi.d.ts +1 -0
  112. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  113. package/dist/types/web/search/providers/perplexity.d.ts +1 -0
  114. package/dist/types/web/search/providers/searxng.d.ts +1 -0
  115. package/dist/types/web/search/providers/tavily.d.ts +1 -0
  116. package/dist/types/web/search/providers/tinyfish.d.ts +1 -0
  117. package/dist/types/web/search/providers/utils.d.ts +6 -9
  118. package/dist/types/web/search/providers/zai.d.ts +1 -0
  119. package/dist/types/web/search/types.d.ts +4 -0
  120. package/package.json +12 -12
  121. package/src/cli/args.ts +7 -87
  122. package/src/cli/browser-relay-cli.ts +119 -0
  123. package/src/cli/command-help.ts +103 -0
  124. package/src/cli/completion-gen.ts +5 -5
  125. package/src/cli/flag-tables.ts +9 -0
  126. package/src/cli/help-extra.ts +89 -0
  127. package/src/cli/setup-cli.ts +13 -21
  128. package/src/cli/thinking-levels.ts +7 -0
  129. package/src/cli/update-cli.ts +26 -7
  130. package/src/cli-commands.ts +170 -33
  131. package/src/cli.ts +17 -5
  132. package/src/commands/acp.ts +3 -1
  133. package/src/commands/agents.ts +3 -2
  134. package/src/commands/auth-broker.ts +3 -2
  135. package/src/commands/auth-gateway.ts +3 -2
  136. package/src/commands/bench.ts +2 -3
  137. package/src/commands/browser-relay.ts +53 -0
  138. package/src/commands/cleanse.ts +2 -2
  139. package/src/commands/commit.ts +3 -2
  140. package/src/commands/complete.ts +2 -1
  141. package/src/commands/completions.ts +3 -2
  142. package/src/commands/config.ts +3 -2
  143. package/src/commands/dry-balance.ts +2 -2
  144. package/src/commands/gallery.ts +3 -2
  145. package/src/commands/gc.ts +3 -2
  146. package/src/commands/grep.ts +3 -2
  147. package/src/commands/grievances.ts +3 -2
  148. package/src/commands/install.ts +2 -2
  149. package/src/commands/join.ts +3 -2
  150. package/src/commands/launch-help.ts +116 -0
  151. package/src/commands/launch.ts +7 -191
  152. package/src/commands/models.ts +3 -2
  153. package/src/commands/plugin.ts +3 -2
  154. package/src/commands/read.ts +3 -2
  155. package/src/commands/say.ts +3 -2
  156. package/src/commands/setup.ts +3 -2
  157. package/src/commands/shell.ts +3 -2
  158. package/src/commands/ssh.ts +3 -2
  159. package/src/commands/stats.ts +3 -2
  160. package/src/commands/tiny-models.ts +2 -2
  161. package/src/commands/token.ts +2 -2
  162. package/src/commands/ttsr.ts +2 -2
  163. package/src/commands/update.ts +3 -2
  164. package/src/commands/usage.ts +3 -2
  165. package/src/commands/web-search.ts +3 -2
  166. package/src/commands/worktree.ts +3 -2
  167. package/src/config/keybindings.ts +5 -0
  168. package/src/config/model-discovery.ts +30 -13
  169. package/src/config/model-registry.ts +9 -5
  170. package/src/config/models-config-schema-bundle.ts +9 -0
  171. package/src/config/resolve-config-value.ts +2 -1
  172. package/src/config/service-tier.ts +5 -0
  173. package/src/config/settings-schema.ts +72 -20
  174. package/src/cursor-bridge-tools.ts +1 -1
  175. package/src/cursor.ts +10 -14
  176. package/src/edit/diff.ts +17 -8
  177. package/src/edit/index.ts +20 -7
  178. package/src/edit/modes/replace.ts +93 -40
  179. package/src/edit/renderer.ts +1 -1
  180. package/src/edit/streaming.ts +14 -17
  181. package/src/eval/py/kernel.ts +2 -1
  182. package/src/eval/py/spawn-options.ts +24 -29
  183. package/src/export/html/template.js +8 -26
  184. package/src/extensibility/extensions/wrapper.ts +1 -0
  185. package/src/extensibility/legacy-pi-ai-shim.ts +22 -1
  186. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -6
  187. package/src/extensibility/plugins/legacy-pi-compat.ts +124 -57
  188. package/src/extensibility/skills.ts +11 -0
  189. package/src/extensibility/utils.ts +10 -2
  190. package/src/launch/broker.ts +1 -1
  191. package/src/launch/client.ts +32 -13
  192. package/src/launch/ensure.ts +73 -0
  193. package/src/launch/paths.ts +1 -1
  194. package/src/launch/protocol.ts +2 -2
  195. package/src/lsp/client.ts +23 -2
  196. package/src/lsp/index.ts +8 -0
  197. package/src/lsp/mux/daemon.ts +348 -0
  198. package/src/lsp/mux/protocol.ts +96 -0
  199. package/src/lsp/mux/server.ts +797 -0
  200. package/src/lsp/types.ts +28 -2
  201. package/src/main.ts +7 -2
  202. package/src/mcp/oauth-flow.ts +11 -1
  203. package/src/mnemopi/embed-client.ts +49 -2
  204. package/src/mnemopi/state.ts +57 -23
  205. package/src/modes/components/assistant-message.ts +11 -0
  206. package/src/modes/components/chat-transcript-builder.ts +4 -0
  207. package/src/modes/components/custom-editor.ts +9 -0
  208. package/src/modes/components/read-tool-group.ts +10 -0
  209. package/src/modes/components/status-line/component.ts +1 -1
  210. package/src/modes/components/stripped-tool-calls-placeholder.ts +35 -0
  211. package/src/modes/components/tool-execution.ts +8 -0
  212. package/src/modes/components/tree-selector.ts +46 -48
  213. package/src/modes/controllers/event-controller.ts +17 -0
  214. package/src/modes/controllers/input-controller.ts +39 -1
  215. package/src/modes/controllers/mcp-command-controller.ts +11 -5
  216. package/src/modes/controllers/selector-controller.ts +20 -0
  217. package/src/modes/interactive-mode.ts +2 -0
  218. package/src/modes/types.ts +1 -0
  219. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  220. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  221. package/src/modes/utils/ui-helpers.ts +6 -10
  222. package/src/modes/workflow.ts +8 -2
  223. package/src/prompts/agents/init.md +1 -1
  224. package/src/prompts/system/plan-mode-active.md +2 -2
  225. package/src/prompts/system/system-prompt.md +26 -52
  226. package/src/prompts/system/workflow-notice.md +3 -3
  227. package/src/prompts/tools/ast-grep.md +1 -1
  228. package/src/prompts/tools/bash.md +1 -1
  229. package/src/prompts/tools/browser.md +1 -0
  230. package/src/prompts/tools/computer.md +21 -21
  231. package/src/prompts/tools/glob.md +1 -1
  232. package/src/prompts/tools/grep.md +1 -1
  233. package/src/prompts/tools/replace.md +5 -4
  234. package/src/prompts/tools/task.md +4 -4
  235. package/src/sdk.ts +34 -6
  236. package/src/secrets/index.ts +1 -1
  237. package/src/session/agent-session-types.ts +3 -1
  238. package/src/session/agent-session.ts +13 -1
  239. package/src/session/indexed-session-storage.ts +9 -0
  240. package/src/session/prewalk.ts +24 -2
  241. package/src/session/session-advisors.ts +2 -2
  242. package/src/session/session-context.ts +16 -12
  243. package/src/session/session-dump-format.ts +1 -1
  244. package/src/session/session-maintenance.ts +38 -4
  245. package/src/session/session-manager.ts +102 -67
  246. package/src/session/session-paths.ts +118 -55
  247. package/src/session/session-stats.ts +48 -3
  248. package/src/session/session-storage.ts +30 -22
  249. package/src/session/stream-guards.ts +1 -1
  250. package/src/slash-commands/builtin-registry.ts +27 -24
  251. package/src/system-prompt.ts +8 -3
  252. package/src/task/index.ts +23 -8
  253. package/src/task/spawn-policy.ts +14 -0
  254. package/src/thinking.ts +2 -8
  255. package/src/tools/ast-grep.ts +10 -4
  256. package/src/tools/browser/cmux/cmux-tab.ts +5 -10
  257. package/src/tools/browser/cmux/rpc.ts +2 -8
  258. package/src/tools/browser/registry.ts +53 -2
  259. package/src/tools/browser/relay/bridge.ts +945 -0
  260. package/src/tools/browser/relay/daemon.ts +117 -0
  261. package/src/tools/browser/relay/extension-assets/background.js.txt +242 -0
  262. package/src/tools/browser/relay/extension-assets/manifest.json.txt +14 -0
  263. package/src/tools/browser/relay/extension-assets/options.html.txt +53 -0
  264. package/src/tools/browser/relay/extension-assets/options.js.txt +23 -0
  265. package/src/tools/browser/relay/kind.ts +41 -0
  266. package/src/tools/browser/relay/protocol.ts +54 -0
  267. package/src/tools/browser/relay/server.ts +141 -0
  268. package/src/tools/browser/render.ts +4 -1
  269. package/src/tools/browser/shared-daemon.ts +7 -58
  270. package/src/tools/browser/tab-supervisor.ts +5 -4
  271. package/src/tools/browser/tab-worker.ts +33 -12
  272. package/src/tools/browser.ts +26 -1
  273. package/src/tools/computer/exposure.ts +11 -35
  274. package/src/tools/computer/protocol.ts +51 -10
  275. package/src/tools/computer/supervisor.ts +227 -124
  276. package/src/tools/computer/worker-entry.ts +11 -6
  277. package/src/tools/computer/worker.ts +695 -81
  278. package/src/tools/computer-renderer.ts +104 -65
  279. package/src/tools/computer.ts +134 -428
  280. package/src/tools/context.ts +3 -0
  281. package/src/tools/glob.ts +9 -2
  282. package/src/tools/grep.ts +12 -6
  283. package/src/tools/inspect-image.ts +32 -6
  284. package/src/tools/render-utils.ts +2 -0
  285. package/src/tools/{browser/run-cancellation.ts → run-scope.ts} +11 -11
  286. package/src/tools/tool-timeouts.ts +1 -0
  287. package/src/tools/xdev.ts +29 -2
  288. package/src/utils/clipboard.ts +40 -14
  289. package/src/utils/command-args.ts +26 -28
  290. package/src/utils/tool-choice.ts +0 -13
  291. package/src/web/kagi.ts +2 -1
  292. package/src/web/search/index.ts +18 -2
  293. package/src/web/search/providers/anthropic.ts +4 -1
  294. package/src/web/search/providers/base.ts +2 -0
  295. package/src/web/search/providers/brave.ts +3 -1
  296. package/src/web/search/providers/browser-page.ts +6 -4
  297. package/src/web/search/providers/codex.ts +4 -1
  298. package/src/web/search/providers/duckduckgo.ts +2 -1
  299. package/src/web/search/providers/ecosia.ts +2 -1
  300. package/src/web/search/providers/exa.ts +4 -2
  301. package/src/web/search/providers/firecrawl.ts +3 -1
  302. package/src/web/search/providers/gemini.ts +8 -2
  303. package/src/web/search/providers/google.ts +2 -1
  304. package/src/web/search/providers/jina.ts +12 -2
  305. package/src/web/search/providers/kagi.ts +3 -0
  306. package/src/web/search/providers/kimi.ts +5 -1
  307. package/src/web/search/providers/mojeek.ts +2 -1
  308. package/src/web/search/providers/parallel.ts +5 -1
  309. package/src/web/search/providers/perplexity.ts +6 -3
  310. package/src/web/search/providers/public.ts +1 -1
  311. package/src/web/search/providers/searxng.ts +11 -5
  312. package/src/web/search/providers/startpage.ts +10 -4
  313. package/src/web/search/providers/synthetic.ts +11 -5
  314. package/src/web/search/providers/tavily.ts +3 -1
  315. package/src/web/search/providers/tinyfish.ts +3 -1
  316. package/src/web/search/providers/utils.ts +12 -10
  317. package/src/web/search/providers/xai.ts +2 -2
  318. package/src/web/search/providers/zai.ts +18 -3
  319. package/src/web/search/types.ts +6 -0
package/src/lsp/client.ts CHANGED
@@ -4,12 +4,15 @@ import { MessageFramer } from "../jsonrpc/message-framing";
4
4
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
5
5
  import { applyWorkspaceEdit } from "./edits";
6
6
  import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
7
+ import { connectSharedLspTransport } from "./mux/daemon";
7
8
  import type {
8
9
  LspClient,
9
10
  LspJsonRpcId,
10
11
  LspJsonRpcNotification,
11
12
  LspJsonRpcRequest,
12
13
  LspJsonRpcResponse,
14
+ LspTransport,
15
+ LspWriteSink,
13
16
  PublishDiagnosticsParams,
14
17
  ServerConfig,
15
18
  WorkspaceEdit,
@@ -34,6 +37,17 @@ let idleTimeoutMs: number | null = null;
34
37
  let idleCheckInterval: NodeJS.Timeout | null = null;
35
38
  const IDLE_CHECK_INTERVAL_MS = 60 * 1000;
36
39
 
40
+ // Broker-shared server mode (one language server per project shared by every
41
+ // omp instance through the LSP mux daemon). Off by default so embedders and
42
+ // tests that drive getOrCreateClient directly never touch the daemon broker;
43
+ // the SDK turns it on from the `lsp.shared` setting at session creation.
44
+ let sharedLspEnabled = false;
45
+
46
+ /** Enable or disable attaching to broker-shared language servers. */
47
+ export function setSharedLspEnabled(enabled: boolean): void {
48
+ sharedLspEnabled = enabled;
49
+ }
50
+
37
51
  /**
38
52
  * Configure the idle timeout for LSP clients.
39
53
  * @param ms - Timeout in milliseconds, or null/undefined to disable
@@ -209,7 +223,7 @@ class LspDrainAbortError extends Error {
209
223
  }
210
224
 
211
225
  async function writeMessage(
212
- sink: Bun.FileSink,
226
+ sink: LspWriteSink,
213
227
  message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
214
228
  signal?: AbortSignal,
215
229
  ): Promise<void> {
@@ -742,7 +756,14 @@ export async function getOrCreateClient(
742
756
  ? await getLspmuxCommand(baseCommand, baseArgs)
743
757
  : { command: baseCommand, args: baseArgs };
744
758
 
745
- const proc = ptree.spawn([command, ...args], {
759
+ // Prefer the broker-shared server unless an external lspmux wrapper is
760
+ // already multiplexing this command. Any shared-path failure falls back
761
+ // to a private spawn so LSP never regresses on broker trouble.
762
+ let proc: LspTransport | null = null;
763
+ if (sharedLspEnabled && command === baseCommand) {
764
+ proc = await connectSharedLspTransport({ command, args, cwd, env, signal });
765
+ }
766
+ proc ??= ptree.spawn([command, ...args], {
746
767
  cwd,
747
768
  stdin: "pipe",
748
769
  env: env ? { ...Bun.env, ...env } : undefined,
package/src/lsp/index.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  } from "./edits";
48
48
  import { resolveFormatOptions } from "./format-options";
49
49
  import { detectLspmux } from "./lspmux";
50
+ import { MUX_RESTART_METHOD } from "./mux/protocol";
50
51
  import {
51
52
  type CodeAction,
52
53
  type CodeActionContext,
@@ -535,6 +536,13 @@ async function reloadServer(client: LspClient, serverName: string, signal?: Abor
535
536
  // from the registry by identity and awaiting confirmed process exit) so
536
537
  // the next request cold-starts a fresh client. A kill that never confirms
537
538
  // exit is not a restart: surface the teardown failure truthfully.
539
+ //
540
+ // On a broker-shared link a per-session teardown only detaches this
541
+ // process while the wedged server keeps serving everyone else — ask the
542
+ // mux to kill the shared server first (best-effort; it also severs us).
543
+ if (client.proc.sharedMux) {
544
+ await sendNotification(client, MUX_RESTART_METHOD, undefined, AbortSignal.timeout(2_000)).catch(() => {});
545
+ }
538
546
  if (!(await shutdownClientInstance(client))) {
539
547
  throw new Error(`Failed to restart ${serverName}: server process did not exit after kill`);
540
548
  }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Client half of the broker-shared LSP mux.
3
+ *
4
+ * `connectSharedLspTransport` ensures the per-project mux daemon is running
5
+ * under the daemon broker (same lifecycle as the shared Chromium: started on
6
+ * first use, stopped when the last omp process in the project exits), dials
7
+ * its socket, performs the `omp/muxConnect` handshake, and returns an
8
+ * {@link LspTransport} the ordinary LSP client machinery drives exactly like
9
+ * a locally spawned server. Every failure degrades to `null` so callers fall
10
+ * back to a process-local spawn.
11
+ */
12
+ import * as net from "node:net";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import { logger, ptree } from "@oh-my-pi/pi-utils";
16
+ import { MessageFramer } from "../../jsonrpc/message-framing";
17
+ import { daemonClientForProject } from "../../launch/client";
18
+ import { describeQuietly, stopQuietly, waitReady } from "../../launch/ensure";
19
+ import { daemonRuntimeDir } from "../../launch/paths";
20
+ import { resolveWorkerSpawnCmd, SMOKE_TEST_TIMEOUT_MS, workerEnvFromParent } from "../../subprocess/worker-client";
21
+ import type { LspJsonRpcRequest, LspJsonRpcResponse, LspTransport, LspWriteSink } from "../types";
22
+ import {
23
+ LSP_MUX_DAEMON_NAME,
24
+ LSP_MUX_PROJECT_DIR_ENV,
25
+ LSP_MUX_READY_PATTERN,
26
+ LSP_MUX_SOCKET_ENV,
27
+ LSP_MUX_WORKER_ARG,
28
+ lspMuxEndpoint,
29
+ MUX_CONNECT_METHOD,
30
+ MUX_PING_METHOD,
31
+ MUX_PING_RESULT,
32
+ type MuxConnectParams,
33
+ type MuxConnectResult,
34
+ } from "./protocol";
35
+
36
+ const CONNECT_TIMEOUT_MS = 3_000;
37
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
38
+ const PROBE_TIMEOUT_MS = 1_500;
39
+ const READY_TIMEOUT_MS = 15_000;
40
+ /** probe→describe→start rounds; bounds cross-process start races and wedged-mux replacement. */
41
+ const ENSURE_ATTEMPTS = 3;
42
+
43
+ /** Dial a mux endpoint (Unix socket or Windows named pipe) with a bounded connect. */
44
+ function connectEndpoint(endpoint: string, timeoutMs: number): Promise<net.Socket> {
45
+ const { promise, resolve, reject } = Promise.withResolvers<net.Socket>();
46
+ const socket = net.connect(endpoint);
47
+ const timer = setTimeout(() => {
48
+ socket.destroy();
49
+ reject(new Error(`Timed out connecting to LSP mux at ${endpoint}`));
50
+ }, timeoutMs);
51
+ socket.once("connect", () => {
52
+ clearTimeout(timer);
53
+ socket.removeListener("error", onError);
54
+ resolve(socket);
55
+ });
56
+ const onError = (error: Error) => {
57
+ clearTimeout(timer);
58
+ reject(error);
59
+ };
60
+ socket.once("error", onError);
61
+ return promise;
62
+ }
63
+
64
+ /**
65
+ * Send one request on a fresh socket and await its response. Used only
66
+ * before the transport takes over the socket; leftover bytes after the
67
+ * response are returned so the transport's stream can replay them.
68
+ */
69
+ function requestOnSocket(
70
+ socket: net.Socket,
71
+ request: LspJsonRpcRequest,
72
+ timeoutMs: number,
73
+ ): Promise<{ response: LspJsonRpcResponse; leftover: Buffer }> {
74
+ const { promise, resolve, reject } = Promise.withResolvers<{ response: LspJsonRpcResponse; leftover: Buffer }>();
75
+ const framer = new MessageFramer(Buffer.alloc(0));
76
+ const timer = setTimeout(() => {
77
+ cleanup();
78
+ reject(new Error(`LSP mux ${request.method} timed out`));
79
+ }, timeoutMs);
80
+ const onData = (chunk: Buffer) => {
81
+ framer.push(chunk);
82
+ for (const text of framer.drain(() => {})) {
83
+ let message: LspJsonRpcResponse;
84
+ try {
85
+ message = JSON.parse(text);
86
+ } catch (error) {
87
+ cleanup();
88
+ reject(error instanceof Error ? error : new Error(String(error)));
89
+ return;
90
+ }
91
+ if (message.id !== request.id) continue;
92
+ cleanup();
93
+ if (message.error) reject(new Error(`LSP mux ${request.method} failed: ${message.error.message}`));
94
+ else resolve({ response: message, leftover: framer.remainder() });
95
+ return;
96
+ }
97
+ };
98
+ const onClose = () => {
99
+ cleanup();
100
+ reject(new Error("LSP mux connection closed during handshake"));
101
+ };
102
+ const cleanup = () => {
103
+ clearTimeout(timer);
104
+ socket.removeListener("data", onData);
105
+ socket.removeListener("close", onClose);
106
+ socket.removeListener("error", onClose);
107
+ };
108
+ socket.on("data", onData);
109
+ socket.once("close", onClose);
110
+ socket.once("error", onClose);
111
+ const content = JSON.stringify(request);
112
+ socket.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
113
+ return promise;
114
+ }
115
+
116
+ /**
117
+ * Wrap a connected, handshaken mux socket as an {@link LspTransport}.
118
+ * Socket close maps to a clean "process exit" (code 0) so the LSP client's
119
+ * crash-recovery path treats a dead mux exactly like a dead server.
120
+ */
121
+ function socketTransport(socket: net.Socket, leftover: Buffer, pid: number | undefined): LspTransport {
122
+ const exited = Promise.withResolvers<number>();
123
+ let exitCode: number | null = null;
124
+ let needDrain = false;
125
+ socket.on("error", () => {
126
+ // "close" always follows; swallowing here prevents an unhandled error event.
127
+ });
128
+ socket.once("close", () => {
129
+ exitCode = 0;
130
+ exited.resolve(0);
131
+ });
132
+ const stdout = new ReadableStream<Uint8Array>({
133
+ start(controller) {
134
+ if (leftover.byteLength > 0) controller.enqueue(new Uint8Array(leftover));
135
+ socket.on("data", (chunk: Buffer) =>
136
+ controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)),
137
+ );
138
+ socket.once("close", () => {
139
+ try {
140
+ controller.close();
141
+ } catch {
142
+ // already errored/closed
143
+ }
144
+ });
145
+ },
146
+ });
147
+ const stdin: LspWriteSink = {
148
+ write(data) {
149
+ needDrain = !socket.write(data);
150
+ return typeof data === "string" ? Buffer.byteLength(data, "utf-8") : data.byteLength;
151
+ },
152
+ flush() {
153
+ if (!needDrain || socket.destroyed) return;
154
+ return new Promise<void>(resolveDrain => {
155
+ const onDrain = () => {
156
+ needDrain = false;
157
+ socket.removeListener("close", onDrain);
158
+ resolveDrain();
159
+ };
160
+ socket.once("drain", onDrain);
161
+ socket.once("close", onDrain);
162
+ });
163
+ },
164
+ };
165
+ return {
166
+ stdin,
167
+ stdout,
168
+ exited: exited.promise,
169
+ get exitCode() {
170
+ return exitCode;
171
+ },
172
+ pid,
173
+ sharedMux: true,
174
+ kill() {
175
+ socket.destroy();
176
+ },
177
+ peekStderr() {
178
+ return "";
179
+ },
180
+ };
181
+ }
182
+
183
+ let nextHandshakeId = 1;
184
+
185
+ /** Dial and handshake one server link; throws on any failure. */
186
+ async function dialMuxServer(endpoint: string, params: MuxConnectParams): Promise<LspTransport> {
187
+ const socket = await connectEndpoint(endpoint, CONNECT_TIMEOUT_MS);
188
+ socket.setNoDelay(true);
189
+ try {
190
+ const { response, leftover } = await requestOnSocket(
191
+ socket,
192
+ { jsonrpc: "2.0", id: `connect:${nextHandshakeId++}`, method: MUX_CONNECT_METHOD, params },
193
+ HANDSHAKE_TIMEOUT_MS,
194
+ );
195
+ const result = response.result as MuxConnectResult;
196
+ return socketTransport(socket, leftover, result.pid);
197
+ } catch (error) {
198
+ socket.destroy();
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ /** True when a mux answers the ping handshake at `endpoint`. */
204
+ async function probeMux(endpoint: string): Promise<boolean> {
205
+ try {
206
+ const socket = await connectEndpoint(endpoint, PROBE_TIMEOUT_MS);
207
+ try {
208
+ const { response } = await requestOnSocket(
209
+ socket,
210
+ { jsonrpc: "2.0", id: "probe", method: MUX_PING_METHOD, params: null },
211
+ PROBE_TIMEOUT_MS,
212
+ );
213
+ return response.result === MUX_PING_RESULT;
214
+ } finally {
215
+ socket.destroy();
216
+ }
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Ensure the project's mux daemon is running under the broker and reachable.
224
+ * Returns its endpoint, or null when the shared path is unavailable.
225
+ */
226
+ async function ensureLspMuxDaemon(projectDir: string, signal?: AbortSignal): Promise<string | null> {
227
+ const client = await daemonClientForProject(projectDir);
228
+ const endpoint = lspMuxEndpoint(client.projectDir, daemonRuntimeDir(client.projectDir));
229
+ // The broker connection doubles as the presence lease keeping the daemon alive.
230
+ await client.request({ op: "ping" }, signal);
231
+ if (await probeMux(endpoint)) return endpoint;
232
+ const spawn = resolveWorkerSpawnCmd(LSP_MUX_WORKER_ARG);
233
+ for (let attempt = 0; attempt < ENSURE_ATTEMPTS; attempt++) {
234
+ signal?.throwIfAborted();
235
+ // A concurrent start may have won since the last round; adopt it.
236
+ if (await probeMux(endpoint)) return endpoint;
237
+ const existing = await describeQuietly(client, LSP_MUX_DAEMON_NAME, "LSP mux", signal);
238
+ if (existing && existing.state !== "exited" && existing.state !== "failed") {
239
+ if (existing.readyAt === undefined) {
240
+ await waitReady(client, LSP_MUX_DAEMON_NAME, "LSP mux", signal, READY_TIMEOUT_MS);
241
+ }
242
+ if (await probeMux(endpoint)) return endpoint;
243
+ // Live record but nothing listening: replace the wedged daemon.
244
+ await stopQuietly(client, LSP_MUX_DAEMON_NAME, "LSP mux", signal);
245
+ continue;
246
+ }
247
+ try {
248
+ const started = await client.request(
249
+ {
250
+ op: "start",
251
+ spec: {
252
+ name: LSP_MUX_DAEMON_NAME,
253
+ application: spawn.cmd[0]!,
254
+ args: spawn.cmd.slice(1),
255
+ env: {
256
+ [LSP_MUX_SOCKET_ENV]: endpoint,
257
+ [LSP_MUX_PROJECT_DIR_ENV]: client.projectDir,
258
+ },
259
+ cwd: spawn.cwd ?? client.projectDir,
260
+ pty: false,
261
+ ready: { log: LSP_MUX_READY_PATTERN, timeoutMs: READY_TIMEOUT_MS },
262
+ restart: "no",
263
+ persist: false,
264
+ detached: false,
265
+ },
266
+ },
267
+ signal,
268
+ );
269
+ if (started.op !== "start") continue;
270
+ if (await probeMux(endpoint)) return endpoint;
271
+ await stopQuietly(client, LSP_MUX_DAEMON_NAME, "LSP mux", signal);
272
+ } catch (error) {
273
+ signal?.throwIfAborted();
274
+ // Lost a cross-process start race; the next round adopts the winner.
275
+ logger.debug("LSP mux start contention", {
276
+ name: LSP_MUX_DAEMON_NAME,
277
+ error: error instanceof Error ? error.message : String(error),
278
+ });
279
+ }
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Open a broker-shared transport for one language server, ensuring the mux
286
+ * daemon first. Returns null (after a debug log) when the shared path is
287
+ * unavailable so the caller falls back to a process-local spawn.
288
+ */
289
+ export async function connectSharedLspTransport(opts: {
290
+ command: string;
291
+ args: string[];
292
+ cwd: string;
293
+ env?: Record<string, string>;
294
+ signal?: AbortSignal;
295
+ }): Promise<LspTransport | null> {
296
+ try {
297
+ const endpoint = await ensureLspMuxDaemon(opts.cwd, opts.signal);
298
+ if (!endpoint) return null;
299
+ return await dialMuxServer(endpoint, {
300
+ command: opts.command,
301
+ args: opts.args,
302
+ cwd: opts.cwd,
303
+ env: opts.env,
304
+ });
305
+ } catch (error) {
306
+ if (opts.signal?.aborted) throw error;
307
+ logger.debug("Shared LSP transport unavailable; falling back to local spawn", {
308
+ command: opts.command,
309
+ cwd: opts.cwd,
310
+ error: error instanceof Error ? error.message : String(error),
311
+ });
312
+ return null;
313
+ }
314
+ }
315
+
316
+ /** Exercise worker-host mux startup and the ping handshake for distribution smoke tests. */
317
+ export async function smokeTestLspMux(): Promise<void> {
318
+ const endpoint =
319
+ process.platform === "win32"
320
+ ? `\\\\.\\pipe\\omp-lsp-mux-smoke-${process.pid.toString(16)}`
321
+ : path.join(os.tmpdir(), `omp-lsp-mux-smoke-${process.pid.toString(36)}.sock`);
322
+ const spawn = resolveWorkerSpawnCmd(LSP_MUX_WORKER_ARG);
323
+ const proc = ptree.spawn(spawn.cmd, {
324
+ cwd: spawn.cwd,
325
+ env: workerEnvFromParent({
326
+ [LSP_MUX_SOCKET_ENV]: endpoint,
327
+ [LSP_MUX_PROJECT_DIR_ENV]: process.cwd(),
328
+ }),
329
+ });
330
+ try {
331
+ const deadline = Date.now() + SMOKE_TEST_TIMEOUT_MS;
332
+ let alive = false;
333
+ while (Date.now() < deadline) {
334
+ if (proc.exitCode !== null) break;
335
+ if (await probeMux(endpoint)) {
336
+ alive = true;
337
+ break;
338
+ }
339
+ await Bun.sleep(200);
340
+ }
341
+ if (!alive) {
342
+ throw new Error(`lsp mux smoke failed: no ping response (${proc.peekStderr().slice(-500) || "no stderr"})`);
343
+ }
344
+ } finally {
345
+ proc.kill();
346
+ await proc.exited.catch(() => {});
347
+ }
348
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Cross-process contract for the broker-owned LSP mux daemon.
3
+ *
4
+ * One mux daemon runs per project scope (launched through the same daemon
5
+ * broker that owns the shared Chromium and `hub start` processes). It spawns
6
+ * each language server once and multiplexes every omp instance in the project
7
+ * onto that single server over a local socket. The link speaks plain
8
+ * Content-Length-framed LSP JSON-RPC after a one-request handshake
9
+ * ({@link MUX_CONNECT_METHOD}); everything below is shared by the worker
10
+ * entry (`server.ts`), the client connector (`daemon.ts`), and tests.
11
+ */
12
+ import * as path from "node:path";
13
+
14
+ /** Hidden CLI selector used to re-enter the LSP mux worker. */
15
+ export const LSP_MUX_WORKER_ARG = "__omp_worker_lsp_mux";
16
+
17
+ /** Environment key carrying the socket endpoint the mux must listen on. */
18
+ export const LSP_MUX_SOCKET_ENV = "OMP_LSP_MUX_SOCKET";
19
+
20
+ /** Environment key carrying the canonical project directory the mux serves. */
21
+ export const LSP_MUX_PROJECT_DIR_ENV = "OMP_LSP_MUX_PROJECT_DIR";
22
+
23
+ /** Stable broker daemon name for the shared LSP mux. */
24
+ export const LSP_MUX_DAEMON_NAME = "omp.lsp.mux";
25
+
26
+ /** Broker readiness regex matched against the banner printed by the worker. */
27
+ export const LSP_MUX_READY_PATTERN = String.raw`omp lsp mux listening on \S+`;
28
+
29
+ /** Banner printed on stdout once the mux socket accepts connections. */
30
+ export function lspMuxReadyBanner(endpoint: string): string {
31
+ return `omp lsp mux listening on ${endpoint}`;
32
+ }
33
+
34
+ /** Resolve the Unix socket or Windows named pipe for one project scope. */
35
+ export function lspMuxEndpoint(projectDir: string, runtimeDir: string): string {
36
+ if (process.platform === "win32") {
37
+ const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
38
+ return `\\\\.\\pipe\\omp-lsp-mux-${key}`;
39
+ }
40
+ return path.join(runtimeDir, "lsp-mux.sock");
41
+ }
42
+
43
+ // =============================================================================
44
+ // Mux-private JSON-RPC methods
45
+ // =============================================================================
46
+
47
+ /**
48
+ * First (and only pre-LSP) request on a fresh connection: binds the link to
49
+ * one shared server instance, spawning it on first use. Params:
50
+ * {@link MuxConnectParams}, result: {@link MuxConnectResult}. After the
51
+ * response the link carries ordinary LSP traffic for that server.
52
+ */
53
+ export const MUX_CONNECT_METHOD = "omp/muxConnect";
54
+
55
+ /**
56
+ * Liveness probe answered with {@link MUX_PING_RESULT} without binding the
57
+ * link to a server. Used by the smoke probe and the ensure loop.
58
+ */
59
+ export const MUX_PING_METHOD = "omp/muxPing";
60
+ export const MUX_PING_RESULT = "pong";
61
+
62
+ /**
63
+ * Notification on a bound link: kill the shared server process (all sessions
64
+ * on it are disconnected). Sent by `lsp reload` when the generic reload path
65
+ * decides the server is wedged — a plain per-session `shutdown`/`exit` is
66
+ * intercepted by the mux and would leave the wedged server running for
67
+ * every other instance.
68
+ */
69
+ export const MUX_RESTART_METHOD = "omp/muxRestartServer";
70
+
71
+ /** Handshake parameters identifying (and if needed spawning) a shared server. */
72
+ export interface MuxConnectParams {
73
+ /** Executable to spawn (the client's `resolvedCommand ?? command`). */
74
+ command: string;
75
+ /** Arguments passed verbatim to the executable. */
76
+ args: string[];
77
+ /** Workspace root the server runs in; part of the server identity key. */
78
+ cwd: string;
79
+ /** Extra environment overlaid on the mux daemon's own env for the spawn. */
80
+ env?: Record<string, string>;
81
+ }
82
+
83
+ /** Handshake result. */
84
+ export interface MuxConnectResult {
85
+ /** Server identity key inside the mux (`${command}:${cwd}`). */
86
+ key: string;
87
+ /** True when this handshake spawned the server process. */
88
+ spawned: boolean;
89
+ /** Pid of the shared server process. */
90
+ pid?: number;
91
+ }
92
+
93
+ /** Server identity key used by the mux registry. */
94
+ export function muxServerKey(command: string, cwd: string): string {
95
+ return `${command}:${cwd}`;
96
+ }