oh-my-opencode 5.0.0-beta.30 → 5.0.0-beta.31

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 (134) hide show
  1. package/dist/cli/index.js +75 -26
  2. package/dist/cli-node/index.js +75 -26
  3. package/dist/config/schema/background-task.d.ts +1 -0
  4. package/dist/config/schema/oh-my-opencode-config.d.ts +1 -0
  5. package/dist/features/background-agent/constants.d.ts +0 -7
  6. package/dist/features/background-agent/manager.d.ts +2 -1
  7. package/dist/features/background-agent/parent-wake-dispatched-tracker.d.ts +0 -1
  8. package/dist/features/background-agent/subagent-spawn-limits.d.ts +8 -0
  9. package/dist/features/task-toast-manager/manager.d.ts +0 -24
  10. package/dist/hooks/anthropic-context-window-limit-recovery/message-storage-directory.d.ts +0 -3
  11. package/dist/hooks/anthropic-context-window-limit-recovery/state.d.ts +1 -0
  12. package/dist/hooks/anthropic-context-window-limit-recovery/storage/empty-messages.d.ts +0 -1
  13. package/dist/hooks/claude-code-hooks/session-hook-state.d.ts +1 -0
  14. package/dist/hooks/claude-code-hooks/stop.d.ts +2 -0
  15. package/dist/hooks/claude-code-hooks/todo.d.ts +0 -1
  16. package/dist/hooks/claude-code-hooks/transcript.d.ts +2 -0
  17. package/dist/hooks/fsync-skip-warning/index.d.ts +4 -0
  18. package/dist/hooks/hashline-edit-diff-enhancer/hook.d.ts +1 -0
  19. package/dist/hooks/hashline-edit-diff-enhancer/pending-captures.d.ts +13 -0
  20. package/dist/hooks/keyword-detector/hook.d.ts +7 -0
  21. package/dist/hooks/keyword-detector/ultrawork/source-detector.d.ts +0 -1
  22. package/dist/hooks/model-fallback/hook.d.ts +1 -1
  23. package/dist/index.js +439 -218
  24. package/dist/oh-my-opencode.schema.json +5 -0
  25. package/dist/plugin-dispose.d.ts +3 -0
  26. package/dist/shared/agent-tool-restrictions.d.ts +0 -1
  27. package/dist/tools/delegate-task/sync-session-cleanup.d.ts +3 -0
  28. package/dist/tools/delegate-task/sync-session-poller.d.ts +1 -0
  29. package/dist/tools/delegate-task/types.d.ts +1 -0
  30. package/dist/tui.js +56 -3
  31. package/package.json +15 -15
  32. package/packages/git-bash-mcp/dist/cli.js +1 -0
  33. package/packages/lsp-core/src/lsp/client-wrapper.test.ts +97 -0
  34. package/packages/lsp-core/src/lsp/client-wrapper.ts +21 -6
  35. package/packages/lsp-core/src/lsp/constants.ts +5 -0
  36. package/packages/lsp-core/src/lsp/errors.ts +15 -0
  37. package/packages/lsp-core/src/lsp/manager-lifecycle.test.ts +262 -0
  38. package/packages/lsp-core/src/lsp/manager.ts +106 -13
  39. package/packages/lsp-core/src/lsp/workspace-markers.ts +18 -1
  40. package/packages/lsp-core/src/mcp.ts +3 -0
  41. package/packages/lsp-daemon/dist/cli.js +114 -18
  42. package/packages/lsp-daemon/dist/client.js +114 -18
  43. package/packages/lsp-daemon/dist/index.js +114 -18
  44. package/packages/lsp-daemon/dist/proxy.js +3 -0
  45. package/packages/lsp-tools-mcp/dist/cli.js +114 -18
  46. package/packages/lsp-tools-mcp/dist/lsp/manager.js +93 -12
  47. package/packages/lsp-tools-mcp/dist/mcp.js +114 -18
  48. package/packages/lsp-tools-mcp/dist/tools.js +113 -18
  49. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  50. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  51. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  52. package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +31 -2
  53. package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +31 -2
  54. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  55. package/packages/omo-codex/plugin/components/codegraph/src/mcp-unavailable.ts +12 -0
  56. package/packages/omo-codex/plugin/components/codegraph/test/serve-unavailable-idle.test.ts +47 -0
  57. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  58. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  59. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  60. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  61. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  62. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  63. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +3 -3
  64. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +113 -18
  65. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  66. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  67. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  68. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  69. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  70. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  71. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  72. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  73. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  74. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  75. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +2 -2
  76. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  77. package/packages/omo-codex/plugin/components/ulw-loop/AGENTS.md +1 -0
  78. package/packages/omo-codex/plugin/components/ulw-loop/dist/checkpoint.js +2 -0
  79. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +100 -54
  80. package/packages/omo-codex/plugin/components/ulw-loop/dist/codex-goal-instruction.d.ts +2 -0
  81. package/packages/omo-codex/plugin/components/ulw-loop/dist/codex-goal-instruction.js +9 -6
  82. package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate.d.ts +4 -2
  83. package/packages/omo-codex/plugin/components/ulw-loop/dist/quality-gate.js +7 -10
  84. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +3 -2
  85. package/packages/omo-codex/plugin/components/ulw-loop/dist/surface.d.ts +15 -0
  86. package/packages/omo-codex/plugin/components/ulw-loop/dist/surface.js +50 -0
  87. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  88. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  89. package/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts +2 -0
  90. package/packages/omo-codex/plugin/components/ulw-loop/src/codex-goal-instruction.ts +16 -5
  91. package/packages/omo-codex/plugin/components/ulw-loop/src/quality-gate.ts +11 -14
  92. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +3 -2
  93. package/packages/omo-codex/plugin/components/ulw-loop/src/surface.ts +73 -0
  94. package/packages/omo-codex/plugin/components/ulw-loop/test/checkpoint-final.test.ts +44 -1
  95. package/packages/omo-codex/plugin/components/ulw-loop/test/codex-goal-instruction.test.ts +25 -0
  96. package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/quality-gate-builder.ts +7 -3
  97. package/packages/omo-codex/plugin/components/ulw-loop/test/quality-gate-roles.test.ts +45 -2
  98. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +20 -0
  99. package/packages/omo-codex/plugin/components/ulw-loop/test/surface.test.ts +74 -0
  100. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  101. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  102. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  103. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  104. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  105. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  106. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  107. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  108. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  109. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  110. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  118. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-ulw-execute-continuation.json +1 -1
  119. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  120. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  121. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  122. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  123. package/packages/omo-codex/plugin/package-lock.json +13 -13
  124. package/packages/omo-codex/plugin/package.json +1 -1
  125. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  126. package/dist/hooks/ralph-loop/completion-promise-detector-test-input.d.ts +0 -11
  127. package/dist/hooks/ralph-loop/loop-session-recovery.d.ts +0 -7
  128. package/dist/tools/call-omo-agent/message-storage-directory.d.ts +0 -1
  129. package/dist/tools/delegate-task/model-string-parser.d.ts +0 -1
  130. package/packages/lsp-tools-mcp/dist/lsp/cleanup-errors.d.ts +0 -1
  131. package/packages/lsp-tools-mcp/dist/lsp/constants.d.ts +0 -1
  132. package/packages/lsp-tools-mcp/dist/lsp/language-mappings.d.ts +0 -1
  133. package/packages/lsp-tools-mcp/dist/lsp/process-signal-cleanup.d.ts +0 -1
  134. package/packages/lsp-tools-mcp/dist/missing-dependency-result.d.ts +0 -1
@@ -0,0 +1,262 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import type { LspClient } from "./client.js";
4
+ import { LspManager } from "./manager.js";
5
+ import type { ResolvedServer } from "./types.js";
6
+
7
+ const SERVER: ResolvedServer = {
8
+ id: "typescript",
9
+ command: ["typescript-language-server", "--stdio"],
10
+ extensions: [".ts"],
11
+ priority: 1,
12
+ };
13
+
14
+ /** Simulates a stop that lingers (production stops reach ~21s), which is the behavior under test. */
15
+ const SLOW_STOP_MS = 50;
16
+
17
+ interface RecordingClientOptions {
18
+ readonly stopDelayMs: number;
19
+ readonly initializeError?: Error;
20
+ readonly alive: boolean;
21
+ }
22
+
23
+ class RecordingFakeClient {
24
+ alive: boolean;
25
+ stopCallCount = 0;
26
+
27
+ constructor(
28
+ readonly root: string,
29
+ private readonly log: EventLog,
30
+ private readonly options: RecordingClientOptions,
31
+ ) {
32
+ this.alive = options.alive;
33
+ }
34
+
35
+ async start(): Promise<void> {
36
+ this.log.push(`spawn:${this.root}`);
37
+ }
38
+
39
+ async initialize(): Promise<void> {
40
+ if (this.options.initializeError !== undefined) {
41
+ throw this.options.initializeError;
42
+ }
43
+ }
44
+
45
+ isAlive(): boolean {
46
+ return this.alive;
47
+ }
48
+
49
+ command(): string[] {
50
+ return [...SERVER.command];
51
+ }
52
+
53
+ async stop(): Promise<void> {
54
+ this.stopCallCount += 1;
55
+ this.log.push(`stop-start:${this.root}`);
56
+ await delay(this.options.stopDelayMs);
57
+ this.alive = false;
58
+ this.log.push(`stop-end:${this.root}`);
59
+ }
60
+ }
61
+
62
+ class EventLog {
63
+ readonly entries: string[] = [];
64
+ private waiters: Array<{ readonly event: string; readonly resolve: () => void }> = [];
65
+
66
+ push(event: string): void {
67
+ this.entries.push(event);
68
+ const due = this.waiters.filter((waiter) => waiter.event === event);
69
+ if (due.length === 0) return;
70
+ this.waiters = this.waiters.filter((waiter) => waiter.event !== event);
71
+ for (const waiter of due) waiter.resolve();
72
+ }
73
+
74
+ waitFor(event: string): Promise<void> {
75
+ if (this.entries.includes(event)) return Promise.resolve();
76
+ return new Promise<void>((resolve) => {
77
+ this.waiters.push({ event, resolve });
78
+ });
79
+ }
80
+ }
81
+
82
+ function delay(ms: number): Promise<void> {
83
+ return new Promise((resolve) => setTimeout(resolve, ms));
84
+ }
85
+
86
+ interface HarnessOptions {
87
+ readonly maxResidentClients?: number;
88
+ readonly stopDelayMs?: number;
89
+ /** Leading generations whose initialize() rejects; later generations start cleanly. */
90
+ readonly initializeFailures?: number;
91
+ /** Generations at or above this index report alive; earlier ones emulate a crash-looping server. */
92
+ readonly aliveFromGeneration?: number;
93
+ }
94
+
95
+ function managerHarness(options: HarnessOptions = {}): {
96
+ readonly manager: LspManager;
97
+ readonly clients: RecordingFakeClient[];
98
+ readonly log: EventLog;
99
+ readonly clock: { value: number };
100
+ } {
101
+ const clients: RecordingFakeClient[] = [];
102
+ const log = new EventLog();
103
+ const clock = { value: 1_000 };
104
+ const generations = new Map<string, number>();
105
+ const aliveFrom = options.aliveFromGeneration ?? 1;
106
+ const stopDelayMs = options.stopDelayMs ?? 0;
107
+ const initializeFailures = options.initializeFailures ?? 0;
108
+ const manager = new LspManager({
109
+ maxResidentClients: options.maxResidentClients ?? 6,
110
+ reaperIntervalMs: 60_000,
111
+ idleTimeoutMs: 60_000_000,
112
+ now: () => clock.value,
113
+ clientFactory: (root) => {
114
+ const generation = (generations.get(root) ?? 0) + 1;
115
+ generations.set(root, generation);
116
+ const client = new RecordingFakeClient(root, log, {
117
+ stopDelayMs,
118
+ alive: generation >= aliveFrom,
119
+ ...(generation <= initializeFailures ? { initializeError: new Error("initialize boom") } : {}),
120
+ });
121
+ clients.push(client);
122
+ return client as unknown as LspClient;
123
+ },
124
+ });
125
+ return { manager, clients, log, clock };
126
+ }
127
+
128
+ describe("LspManager pending-stop tombstones", () => {
129
+ it("#given a slow-stopping client evicted at the resident cap #when the evicted root is re-acquired while the stop is pending #then the replacement spawns only after the old client stopped", async () => {
130
+ // given
131
+ const { manager, log } = managerHarness({ maxResidentClients: 1, stopDelayMs: SLOW_STOP_MS });
132
+ try {
133
+ await manager.getClient("/root-a", SERVER);
134
+ manager.releaseClient("/root-a", SERVER.id);
135
+
136
+ // when: admitting root-b synchronously evicts idle root-a (void stop), and root-a is
137
+ // re-acquired in the same window while the evicted client's stop is still pending.
138
+ const rootBPromise = manager.getClient("/root-b", SERVER);
139
+ const rootAPromise = manager.getClient("/root-a", SERVER);
140
+ await Promise.all([rootBPromise, rootAPromise]);
141
+ manager.releaseClient("/root-b", SERVER.id);
142
+ manager.releaseClient("/root-a", SERVER.id);
143
+
144
+ // then
145
+ expect(log.entries.filter((event) => event.endsWith("/root-a"))).toEqual([
146
+ "spawn:/root-a",
147
+ "stop-start:/root-a",
148
+ "stop-end:/root-a",
149
+ "spawn:/root-a",
150
+ ]);
151
+ } finally {
152
+ await manager.stopAll();
153
+ }
154
+ });
155
+
156
+ it("#given an invalidated client whose stop is still pending #when the same root is re-acquired #then the replacement spawns only after the invalidated client stopped", async () => {
157
+ // given
158
+ const { manager, log } = managerHarness({ stopDelayMs: SLOW_STOP_MS });
159
+ try {
160
+ await manager.getClient("/root-a", SERVER);
161
+ manager.releaseClient("/root-a", SERVER.id);
162
+
163
+ // when
164
+ manager.invalidateClient("/root-a", SERVER.id);
165
+ await manager.getClient("/root-a", SERVER);
166
+
167
+ // then
168
+ expect(log.entries).toEqual([
169
+ "spawn:/root-a",
170
+ "stop-start:/root-a",
171
+ "stop-end:/root-a",
172
+ "spawn:/root-a",
173
+ ]);
174
+ } finally {
175
+ await manager.stopAll();
176
+ }
177
+ });
178
+
179
+ it("#given a client whose initialization failed while its stop is pending #when another getClient races the stop #then the replacement spawns only after the failed client stopped", async () => {
180
+ // given
181
+ const { manager, log } = managerHarness({
182
+ stopDelayMs: SLOW_STOP_MS,
183
+ initializeFailures: 1,
184
+ });
185
+ try {
186
+ const failing = manager.getClient("/root-fail", SERVER);
187
+
188
+ // when: the second acquisition starts once the first one's stop is provably in flight.
189
+ await log.waitFor("stop-start:/root-fail");
190
+ const racing = manager.getClient("/root-fail", SERVER);
191
+ await expect(failing).rejects.toThrow("initialize boom");
192
+ await racing;
193
+ manager.releaseClient("/root-fail", SERVER.id);
194
+
195
+ // then
196
+ expect(log.entries).toEqual([
197
+ "spawn:/root-fail",
198
+ "stop-start:/root-fail",
199
+ "stop-end:/root-fail",
200
+ "spawn:/root-fail",
201
+ ]);
202
+ } finally {
203
+ await manager.stopAll();
204
+ }
205
+ });
206
+ });
207
+
208
+ describe("LspManager dead-client respawn budget", () => {
209
+ it("#given a crash-looping client that is dead on every acquisition #when getClient is called repeatedly #then respawn attempts stay within the retry budget", async () => {
210
+ // given
211
+ const { manager, clients } = managerHarness({ aliveFromGeneration: Number.POSITIVE_INFINITY });
212
+ try {
213
+ // when
214
+ for (let attempt = 0; attempt < 8; attempt += 1) {
215
+ await manager
216
+ .getClient("/root-crash", SERVER)
217
+ .then(
218
+ () => manager.releaseClient("/root-crash", SERVER.id),
219
+ () => undefined,
220
+ );
221
+ }
222
+
223
+ // then: one initial generation plus at most two budgeted respawns.
224
+ expect(clients.length).toBeLessThanOrEqual(3);
225
+ } finally {
226
+ await manager.stopAll();
227
+ }
228
+ });
229
+
230
+ it("#given the respawn budget is exhausted #when getClient is called again #then it rejects with a budget error instead of respawning", async () => {
231
+ // given
232
+ const { manager } = managerHarness({ aliveFromGeneration: Number.POSITIVE_INFINITY });
233
+ try {
234
+ await expect(manager.getClient("/root-crash", SERVER)).rejects.toThrow(/respawn budget/);
235
+
236
+ // when / then
237
+ await expect(manager.getClient("/root-crash", SERVER)).rejects.toThrow(/respawn budget/);
238
+ } finally {
239
+ await manager.stopAll();
240
+ }
241
+ });
242
+
243
+ it("#given the budget was exhausted but the cooldown elapsed and the server recovered #when getClient is called #then it spawns again and resets the budget on a healthy generation", async () => {
244
+ // given: generations one and two crash, the third is healthy.
245
+ const { manager, clients, clock } = managerHarness({ aliveFromGeneration: 3 });
246
+ try {
247
+ await expect(manager.getClient("/root-crash", SERVER)).rejects.toThrow(/respawn budget/);
248
+
249
+ // when: cooldown elapses (60s default) and the server stays alive again.
250
+ clock.value += 60_001;
251
+ const recovered = await manager.getClient("/root-crash", SERVER);
252
+ manager.releaseClient("/root-crash", SERVER.id);
253
+
254
+ // then
255
+ expect(clients.length).toBe(3);
256
+ await expect(manager.getClient("/root-crash", SERVER)).resolves.toBe(recovered);
257
+ manager.releaseClient("/root-crash", SERVER.id);
258
+ } finally {
259
+ await manager.stopAll();
260
+ }
261
+ });
262
+ });
@@ -1,6 +1,14 @@
1
1
  import { reportBestEffortCleanupError } from "./cleanup-errors.js";
2
2
  import { LspClient } from "./client.js";
3
- import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, MAX_RESIDENT_CLIENTS, REAPER_INTERVAL_MS } from "./constants.js";
3
+ import {
4
+ CLIENT_RESPAWN_COOLDOWN_MS,
5
+ CLIENT_RESPAWN_RETRY_LIMIT,
6
+ IDLE_TIMEOUT_MS,
7
+ INIT_TIMEOUT_MS,
8
+ MAX_RESIDENT_CLIENTS,
9
+ REAPER_INTERVAL_MS,
10
+ } from "./constants.js";
11
+ import { LspClientRespawnBudgetExceededError } from "./errors.js";
4
12
  import { installProcessSignalCleanup } from "./process-signal-cleanup.js";
5
13
  import type { ResolvedServer } from "./types.js";
6
14
 
@@ -14,6 +22,11 @@ interface ManagedClient {
14
22
  initializingSince: number | null;
15
23
  }
16
24
 
25
+ interface RespawnBudget {
26
+ deadGenerations: number;
27
+ exhaustedAt: number | null;
28
+ }
29
+
17
30
  export interface ClientSnapshot {
18
31
  root: string;
19
32
  serverId: string;
@@ -75,6 +88,9 @@ function awaitWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined
75
88
 
76
89
  export class LspManager {
77
90
  private readonly clients = new Map<string, ManagedClient>();
91
+ /** In-flight stops per key; getClient awaits these instead of spawning a duplicate client. */
92
+ private readonly pendingStops = new Map<string, Promise<void>>();
93
+ private readonly respawnBudgets = new Map<string, RespawnBudget>();
78
94
  private reaperHandle: NodeJS.Timeout | null = null;
79
95
  private signalDisposer: (() => void) | null = null;
80
96
  private disposed = false;
@@ -112,6 +128,50 @@ export class LspManager {
112
128
  return `${root}::${serverId}`;
113
129
  }
114
130
 
131
+ /**
132
+ * Deletes-then-stops with a tombstone: the pending stop is recorded so a concurrent getClient
133
+ * for the same key awaits it instead of spawning a duplicate client while the old one lingers
134
+ * (production stops reach ~21s). The tombstone removes itself once the stop settles.
135
+ */
136
+ private tombstoneStop(key: string, client: LspClient): Promise<void> {
137
+ const stop = stopClientBestEffort(client);
138
+ this.pendingStops.set(key, stop);
139
+ void stop.finally(() => {
140
+ if (this.pendingStops.get(key) === stop) {
141
+ this.pendingStops.delete(key);
142
+ }
143
+ });
144
+ return stop;
145
+ }
146
+
147
+ private recordDeadGeneration(key: string): void {
148
+ let budget = this.respawnBudgets.get(key);
149
+ if (budget === undefined) {
150
+ budget = { deadGenerations: 0, exhaustedAt: null };
151
+ this.respawnBudgets.set(key, budget);
152
+ }
153
+ budget.deadGenerations += 1;
154
+ }
155
+
156
+ private markHealthyGeneration(key: string): void {
157
+ this.respawnBudgets.delete(key);
158
+ }
159
+
160
+ /** Throws while the respawn budget is spent and the cooldown has not elapsed. */
161
+ private ensureRespawnAllowed(key: string, root: string, serverId: string): void {
162
+ const budget = this.respawnBudgets.get(key);
163
+ if (budget === undefined || budget.deadGenerations < CLIENT_RESPAWN_RETRY_LIMIT) return;
164
+ if (budget.exhaustedAt === null) {
165
+ budget.exhaustedAt = this.now();
166
+ throw new LspClientRespawnBudgetExceededError(serverId, root, CLIENT_RESPAWN_RETRY_LIMIT);
167
+ }
168
+ if (this.now() - budget.exhaustedAt < CLIENT_RESPAWN_COOLDOWN_MS) {
169
+ throw new LspClientRespawnBudgetExceededError(serverId, root, CLIENT_RESPAWN_RETRY_LIMIT);
170
+ }
171
+ budget.deadGenerations = 0;
172
+ budget.exhaustedAt = null;
173
+ }
174
+
115
175
  private reapStale(): void {
116
176
  const t = this.now();
117
177
  for (const [key, managed] of this.clients) {
@@ -120,8 +180,8 @@ export class LspManager {
120
180
  managed.initializingSince !== null &&
121
181
  t - managed.initializingSince > this.initTimeoutMs
122
182
  ) {
123
- void stopClientBestEffort(managed.client);
124
183
  this.clients.delete(key);
184
+ void this.tombstoneStop(key, managed.client);
125
185
  continue;
126
186
  }
127
187
 
@@ -131,8 +191,8 @@ export class LspManager {
131
191
  managed.pendingWaiters === 0 &&
132
192
  t - managed.lastUsedAt > this.idleTimeoutMs
133
193
  ) {
134
- void stopClientBestEffort(managed.client);
135
194
  this.clients.delete(key);
195
+ void this.tombstoneStop(key, managed.client);
136
196
  }
137
197
  }
138
198
  }
@@ -148,7 +208,7 @@ export class LspManager {
148
208
  const victim = this.leastRecentlyUsedIdleClient();
149
209
  if (!victim) return;
150
210
  this.clients.delete(victim.key);
151
- void stopClientBestEffort(victim.managed.client);
211
+ void this.tombstoneStop(victim.key, victim.managed.client);
152
212
  }
153
213
  }
154
214
 
@@ -171,7 +231,7 @@ export class LspManager {
171
231
  this.clients.get(key) === managed
172
232
  ) {
173
233
  this.clients.delete(key);
174
- await stopClientBestEffort(managed.client);
234
+ await this.tombstoneStop(key, managed.client);
175
235
  }
176
236
  }
177
237
 
@@ -182,6 +242,16 @@ export class LspManager {
182
242
  signal?.throwIfAborted();
183
243
 
184
244
  const key = this.getKey(root, server.id);
245
+ for (;;) {
246
+ const pendingStop = this.pendingStops.get(key);
247
+ if (pendingStop === undefined) break;
248
+ await awaitWithSignal(pendingStop, signal);
249
+ signal?.throwIfAborted();
250
+ }
251
+ if (this.disposed) {
252
+ throw new Error("LspManager has been disposed");
253
+ }
254
+
185
255
  let managed = this.clients.get(key);
186
256
 
187
257
  if (managed) {
@@ -191,8 +261,8 @@ export class LspManager {
191
261
  managed.initializingSince !== null &&
192
262
  t - managed.initializingSince > this.initTimeoutMs
193
263
  ) {
194
- await stopClientBestEffort(managed.client);
195
264
  this.clients.delete(key);
265
+ await this.tombstoneStop(key, managed.client);
196
266
  managed = undefined;
197
267
  }
198
268
  }
@@ -216,17 +286,22 @@ export class LspManager {
216
286
  }
217
287
 
218
288
  if (!managed.client.isAlive()) {
219
- await stopClientBestEffort(managed.client);
220
- this.clients.delete(key);
289
+ this.recordDeadGeneration(key);
290
+ if (this.clients.get(key) === managed) {
291
+ this.clients.delete(key);
292
+ }
293
+ await this.tombstoneStop(key, managed.client);
221
294
  return this.getClient(root, server, signal);
222
295
  }
223
296
 
297
+ this.markHealthyGeneration(key);
224
298
  managed.refCount++;
225
299
  managed.lastUsedAt = this.now();
226
300
  return managed.client;
227
301
  }
228
302
 
229
303
  this.evictForAdmission();
304
+ this.ensureRespawnAllowed(key, root, server.id);
230
305
 
231
306
  const client = this.clientFactory(root, server);
232
307
  const initStartedAt = this.now();
@@ -253,7 +328,7 @@ export class LspManager {
253
328
  if (this.clients.get(key) === newManaged) {
254
329
  this.clients.delete(key);
255
330
  }
256
- await stopClientBestEffort(client);
331
+ await this.tombstoneStop(key, client);
257
332
  throw err;
258
333
  }
259
334
 
@@ -267,6 +342,16 @@ export class LspManager {
267
342
  signal.throwIfAborted();
268
343
  }
269
344
 
345
+ if (!client.isAlive()) {
346
+ this.recordDeadGeneration(key);
347
+ if (this.clients.get(key) === newManaged) {
348
+ this.clients.delete(key);
349
+ }
350
+ await this.tombstoneStop(key, client);
351
+ return this.getClient(root, server, signal);
352
+ }
353
+
354
+ this.markHealthyGeneration(key);
270
355
  newManaged.refCount++;
271
356
  newManaged.lastUsedAt = this.now();
272
357
  return client;
@@ -287,15 +372,20 @@ export class LspManager {
287
372
  if (!managed) return;
288
373
  if (client && managed.client !== client) return;
289
374
  this.clients.delete(key);
290
- void stopClientBestEffort(managed.client);
375
+ void this.tombstoneStop(key, managed.client);
291
376
  }
292
377
 
293
378
  warmupClient(root: string, server: ResolvedServer): void {
294
379
  if (this.disposed) return;
295
380
  const key = this.getKey(root, server.id);
296
- if (this.clients.has(key)) return;
381
+ if (this.clients.has(key) || this.pendingStops.has(key)) return;
297
382
 
298
383
  this.evictForAdmission();
384
+ try {
385
+ this.ensureRespawnAllowed(key, root, server.id);
386
+ } catch {
387
+ return;
388
+ }
299
389
 
300
390
  const client = this.clientFactory(root, server);
301
391
  const initStartedAt = this.now();
@@ -326,7 +416,7 @@ export class LspManager {
326
416
  if (this.clients.get(key) === managed) {
327
417
  this.clients.delete(key);
328
418
  }
329
- void stopClientBestEffort(client);
419
+ void this.tombstoneStop(key, client);
330
420
  },
331
421
  );
332
422
  }
@@ -379,8 +469,11 @@ export class LspManager {
379
469
  for (const managed of this.clients.values()) {
380
470
  stopPromises.push(stopClientBestEffort(managed.client));
381
471
  }
472
+ const tombstonedStops = [...this.pendingStops.values()];
382
473
  this.clients.clear();
383
- await Promise.allSettled(stopPromises);
474
+ this.respawnBudgets.clear();
475
+ await Promise.allSettled([...stopPromises, ...tombstonedStops]);
476
+ this.pendingStops.clear();
384
477
  }
385
478
  }
386
479
 
@@ -1 +1,18 @@
1
- export const WORKSPACE_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"] as const;
1
+ /** Repository marker: an ancestor containing this always wins over intermediate package markers. */
2
+ export const GIT_WORKSPACE_MARKER = ".git" as const;
3
+
4
+ /**
5
+ * Package/project markers. On their own they resolve to the NEAREST marked directory, but inside a
6
+ * request cwd they are only a fallback: a `.git` ancestor further up collapses nested package roots
7
+ * (one client per repository instead of one per monorepo package).
8
+ */
9
+ export const PROJECT_WORKSPACE_MARKERS = [
10
+ "package.json",
11
+ "pyproject.toml",
12
+ "Cargo.toml",
13
+ "go.mod",
14
+ "pom.xml",
15
+ "build.gradle",
16
+ ] as const;
17
+
18
+ export const WORKSPACE_MARKERS = [GIT_WORKSPACE_MARKER, ...PROJECT_WORKSPACE_MARKERS] as const;
@@ -71,6 +71,9 @@ export async function runMcpStdioServer(
71
71
  await runJsonRpcStdioServer({
72
72
  input,
73
73
  output,
74
+ // Idle stays disabled: hosts of this CLI (legacy opencode configs and
75
+ // standalone lsp-tools-mcp users) have no evidenced respawn for an exited
76
+ // stdio server; opencode in particular marks it failed without retry.
74
77
  idleTimeoutMs: 0,
75
78
  parentWatchdog: options.parentWatchdog ?? {},
76
79
  handler: (request) => runWithRequestContext(requestContext, () => handleLspMcpRequest(request)),