oh-my-opencode 4.18.1 → 4.18.2

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 (116) hide show
  1. package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +39 -16
  2. package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +111 -28
  3. package/bin/AGENTS.md +33 -0
  4. package/dist/cli/index.js +57 -18
  5. package/dist/cli-node/index.js +57 -18
  6. package/dist/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.d.ts +6 -0
  7. package/dist/hooks/category-skill-reminder/hook.d.ts +9 -1
  8. package/dist/hooks/comment-checker/hook.d.ts +8 -1
  9. package/dist/hooks/todo-continuation-enforcer/types.d.ts +3 -0
  10. package/dist/index.js +560 -333
  11. package/dist/plugin/messages-transform.d.ts +1 -0
  12. package/dist/plugin-handlers/prometheus-agent-config-builder.d.ts +2 -0
  13. package/dist/tui.js +43 -4
  14. package/package.json +13 -13
  15. package/packages/git-bash-mcp/dist/cli.js +81 -19
  16. package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +4 -4
  17. package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +118 -1
  18. package/packages/lsp-core/src/lsp/directory-diagnostics.ts +1 -1
  19. package/packages/lsp-daemon/dist/cli.js +262 -63
  20. package/packages/lsp-daemon/dist/client.js +194 -50
  21. package/packages/lsp-daemon/dist/daemon-client.d.ts +2 -2
  22. package/packages/lsp-daemon/dist/daemon-client.js +26 -2
  23. package/packages/lsp-daemon/dist/ensure-daemon.d.ts +5 -4
  24. package/packages/lsp-daemon/dist/ensure-daemon.js +79 -18
  25. package/packages/lsp-daemon/dist/index.js +262 -63
  26. package/packages/lsp-daemon/dist/proxy.d.ts +2 -0
  27. package/packages/lsp-daemon/dist/proxy.js +79 -23
  28. package/packages/lsp-tools-mcp/dist/cli.js +82 -20
  29. package/packages/lsp-tools-mcp/dist/mcp.js +82 -20
  30. package/packages/lsp-tools-mcp/dist/tools.js +1 -1
  31. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  32. package/packages/omo-codex/plugin/.mcp.json +2 -1
  33. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  34. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  35. package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +100 -28
  36. package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +100 -28
  37. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  38. package/packages/omo-codex/plugin/components/codegraph/src/mcp-bridge.ts +21 -9
  39. package/packages/omo-codex/plugin/components/codegraph/test/mcp-bridge-fixtures.ts +35 -0
  40. package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge-lifecycle.test.ts +69 -0
  41. package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge.test.ts +57 -1
  42. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  43. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  44. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  45. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  46. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  47. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  48. package/packages/omo-codex/plugin/components/lsp/.mcp.json +2 -1
  49. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +3 -3
  50. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +113 -31
  51. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  52. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  53. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +1 -0
  54. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
  55. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -3
  56. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  57. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  58. package/packages/omo-codex/plugin/components/start-work-continuation/README.md +2 -2
  59. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +3 -3
  60. package/packages/omo-codex/plugin/components/start-work-continuation/dist/cli.js +2 -2
  61. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  62. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  63. package/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts +1 -1
  64. package/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts +1 -1
  65. package/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts +15 -1
  66. package/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts +20 -0
  67. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  68. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  69. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  70. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  71. package/packages/omo-codex/plugin/components/ultrawork/directive.md +50 -33
  72. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  73. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  74. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +50 -33
  75. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +50 -33
  76. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +3 -3
  77. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +5 -6
  78. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  79. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  80. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +1 -1
  81. package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +5 -6
  82. package/packages/omo-codex/plugin/components/ulw-loop/test/stop-resume-hook.test.ts +2 -2
  83. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  84. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  85. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  86. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  87. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  88. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  89. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  90. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  91. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  92. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  93. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  94. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  95. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  96. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  97. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  98. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  99. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  100. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  101. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  102. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  103. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  104. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  105. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  106. package/packages/omo-codex/plugin/package-lock.json +13 -13
  107. package/packages/omo-codex/plugin/package.json +1 -1
  108. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +10 -2
  109. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +7 -0
  110. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +3 -2
  111. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +50 -33
  112. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +1 -1
  113. package/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs +1 -0
  114. package/packages/omo-codex/plugin/test/sync-skills-orchestration.test.mjs +7 -0
  115. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +35 -4
  116. package/packages/omo-codex/scripts/install-dist/install-local.mjs +1 -1
@@ -13,6 +13,7 @@ type MessagesTransformHooks = {
13
13
  teamMailboxInjector?: CreatedHooks["teamMailboxInjector"];
14
14
  toolPairValidator?: CreatedHooks["toolPairValidator"];
15
15
  monitorStatusInjector?: CreatedHooks["monitorStatusInjector"];
16
+ categorySkillReminder?: CreatedHooks["categorySkillReminder"];
16
17
  };
17
18
  export declare function createMessagesTransformHandler(args: {
18
19
  hooks: MessagesTransformHooks;
@@ -1,4 +1,5 @@
1
1
  import type { CategoryConfig } from "../config/schema";
2
+ import type { FallbackModels } from "../config/schema/fallback-models";
2
3
  type PrometheusOverride = Record<string, unknown> & {
3
4
  category?: string;
4
5
  model?: string;
@@ -12,6 +13,7 @@ type PrometheusOverride = Record<string, unknown> & {
12
13
  temperature?: number;
13
14
  top_p?: number;
14
15
  maxTokens?: number;
16
+ fallback_models?: FallbackModels;
15
17
  prompt?: string;
16
18
  prompt_append?: string;
17
19
  };
package/dist/tui.js CHANGED
@@ -20695,7 +20695,7 @@ var init_agent_model_requirements = __esm(() => {
20695
20695
  hephaestus: {
20696
20696
  fallbackChain: [
20697
20697
  {
20698
- providers: ["openai", "vercel"],
20698
+ providers: ["openai", "github-copilot", "vercel"],
20699
20699
  model: "gpt-5.6-sol",
20700
20700
  variant: "high"
20701
20701
  },
@@ -20806,6 +20806,11 @@ var init_agent_model_requirements = __esm(() => {
20806
20806
  model: "gpt-5.6-sol",
20807
20807
  variant: "xhigh"
20808
20808
  },
20809
+ {
20810
+ providers: ["github-copilot"],
20811
+ model: "gpt-5.6-sol",
20812
+ variant: "high"
20813
+ },
20809
20814
  {
20810
20815
  providers: ["openai", "github-copilot", "opencode", "vercel"],
20811
20816
  model: "gpt-5.5",
@@ -20884,6 +20889,11 @@ var init_category_model_requirements = __esm(() => {
20884
20889
  model: "gpt-5.6-sol",
20885
20890
  variant: "xhigh"
20886
20891
  },
20892
+ {
20893
+ providers: ["github-copilot"],
20894
+ model: "gpt-5.6-sol",
20895
+ variant: "high"
20896
+ },
20887
20897
  {
20888
20898
  providers: ["openai", "opencode", "vercel"],
20889
20899
  model: "gpt-5.5",
@@ -20910,7 +20920,12 @@ var init_category_model_requirements = __esm(() => {
20910
20920
  variant: "xhigh"
20911
20921
  },
20912
20922
  {
20913
- providers: ["openai", "vercel"],
20923
+ providers: ["github-copilot"],
20924
+ model: "gpt-5.6-terra",
20925
+ variant: "high"
20926
+ },
20927
+ {
20928
+ providers: ["openai", "github-copilot", "vercel"],
20914
20929
  model: "gpt-5.6-sol",
20915
20930
  variant: "high"
20916
20931
  },
@@ -20977,6 +20992,11 @@ var init_category_model_requirements = __esm(() => {
20977
20992
  model: "gpt-5.6-luna",
20978
20993
  variant: "xhigh"
20979
20994
  },
20995
+ {
20996
+ providers: ["github-copilot"],
20997
+ model: "gpt-5.6-luna",
20998
+ variant: "high"
20999
+ },
20980
21000
  {
20981
21001
  providers: ["anthropic", "github-copilot", "opencode", "vercel"],
20982
21002
  model: "claude-sonnet-4-6"
@@ -21066,9 +21086,12 @@ function stripProviderPrefixForAliasLookup(normalizedModelID) {
21066
21086
  }
21067
21087
  return normalizedModelID.slice(slashIndex + 1);
21068
21088
  }
21069
- function resolveModelIDAlias(modelID) {
21089
+ function resolveModelIDAlias(modelID, providerID) {
21070
21090
  const requestedModelID = normalizeLookupModelID(modelID);
21071
21091
  const aliasLookupModelID = stripProviderPrefixForAliasLookup(requestedModelID);
21092
+ const normalizedProviderID = providerID ? normalizeLookupModelID(providerID) : undefined;
21093
+ const providerPrefixEnd = requestedModelID.indexOf("/");
21094
+ const embeddedProviderID = providerPrefixEnd > 0 ? requestedModelID.slice(0, providerPrefixEnd) : undefined;
21072
21095
  const exactRule = EXACT_ALIAS_RULES_BY_MODEL.get(aliasLookupModelID);
21073
21096
  if (exactRule) {
21074
21097
  return {
@@ -21079,6 +21102,13 @@ function resolveModelIDAlias(modelID) {
21079
21102
  };
21080
21103
  }
21081
21104
  for (const rule of PATTERN_ALIAS_RULES) {
21105
+ if (rule.providerIDs) {
21106
+ const matchesProviderID = normalizedProviderID !== undefined && rule.providerIDs.includes(normalizedProviderID);
21107
+ const matchesEmbeddedProviderID = normalizedProviderID !== undefined && rule.allowedSubproviderHosts?.includes(normalizedProviderID) === true && embeddedProviderID !== undefined && rule.providerIDs.includes(embeddedProviderID);
21108
+ if (!matchesProviderID && !matchesEmbeddedProviderID) {
21109
+ continue;
21110
+ }
21111
+ }
21082
21112
  if (!rule.match(aliasLookupModelID)) {
21083
21113
  continue;
21084
21114
  }
@@ -21119,6 +21149,14 @@ var init_model_capability_aliases = __esm(() => {
21119
21149
  ];
21120
21150
  EXACT_ALIAS_RULES_BY_MODEL = new Map(EXACT_ALIAS_RULES.map((rule) => [rule.aliasModelID, rule]));
21121
21151
  PATTERN_ALIAS_RULES = [
21152
+ {
21153
+ ruleID: "openai-gpt-5.6-fast-service-tier-alias",
21154
+ description: "Normalizes OpenCode's OpenAI GPT-5.6 fast service-tier IDs to canonical snapshot IDs.",
21155
+ providerIDs: ["openai"],
21156
+ allowedSubproviderHosts: ["vercel"],
21157
+ match: (normalizedModelID) => /^gpt-5\.6-(?:sol|terra|luna)-fast$/.test(normalizedModelID),
21158
+ canonicalize: (normalizedModelID) => normalizedModelID.slice(0, -"-fast".length)
21159
+ },
21122
21160
  {
21123
21161
  ruleID: "claude-thinking-legacy-alias",
21124
21162
  description: "Normalizes the legacy claude-opus-4-7-thinking id to the canonical snapshot ID.",
@@ -21596,7 +21634,7 @@ function getProviderOverride(providerID, modelID) {
21596
21634
  return GITHUB_COPILOT_GPT5_MODEL.test(normalizeLookupModelID2(modelID)) ? GITHUB_COPILOT_GPT5_OVERRIDE : undefined;
21597
21635
  }
21598
21636
  function getModelCapabilities(input) {
21599
- const canonicalization = resolveModelIDAlias(input.modelID);
21637
+ const canonicalization = resolveModelIDAlias(input.modelID, input.providerID);
21600
21638
  const override = getOverride(input.modelID);
21601
21639
  const providerOverride = getProviderOverride(input.providerID, canonicalization.canonicalModelID);
21602
21640
  const runtimeModel = readRuntimeModel(input.runtimeModel ?? input.providerCache?.findProviderModelMetadata(input.providerID, input.modelID));
@@ -22460,6 +22498,7 @@ var init_opencode_storage_detection = __esm(() => {
22460
22498
  var opencodePluginsCache;
22461
22499
  var init_load_opencode_plugins = __esm(() => {
22462
22500
  init_jsonc_parser2();
22501
+ init_opencode_config_dir();
22463
22502
  opencodePluginsCache = new Map;
22464
22503
  });
22465
22504
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-opencode",
3
- "version": "4.18.1",
3
+ "version": "4.18.2",
4
4
  "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
5
5
  "main": "./dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -212,18 +212,18 @@
212
212
  "typescript": "^6.0.3"
213
213
  },
214
214
  "optionalDependencies": {
215
- "oh-my-opencode-darwin-arm64": "4.18.1",
216
- "oh-my-opencode-darwin-x64": "4.18.1",
217
- "oh-my-opencode-darwin-x64-baseline": "4.18.1",
218
- "oh-my-opencode-linux-arm64": "4.18.1",
219
- "oh-my-opencode-linux-arm64-musl": "4.18.1",
220
- "oh-my-opencode-linux-x64": "4.18.1",
221
- "oh-my-opencode-linux-x64-baseline": "4.18.1",
222
- "oh-my-opencode-linux-x64-musl": "4.18.1",
223
- "oh-my-opencode-linux-x64-musl-baseline": "4.18.1",
224
- "oh-my-opencode-windows-arm64": "4.18.1",
225
- "oh-my-opencode-windows-x64": "4.18.1",
226
- "oh-my-opencode-windows-x64-baseline": "4.18.1"
215
+ "oh-my-opencode-darwin-arm64": "4.18.2",
216
+ "oh-my-opencode-darwin-x64": "4.18.2",
217
+ "oh-my-opencode-darwin-x64-baseline": "4.18.2",
218
+ "oh-my-opencode-linux-arm64": "4.18.2",
219
+ "oh-my-opencode-linux-arm64-musl": "4.18.2",
220
+ "oh-my-opencode-linux-x64": "4.18.2",
221
+ "oh-my-opencode-linux-x64-baseline": "4.18.2",
222
+ "oh-my-opencode-linux-x64-musl": "4.18.2",
223
+ "oh-my-opencode-linux-x64-musl-baseline": "4.18.2",
224
+ "oh-my-opencode-windows-arm64": "4.18.2",
225
+ "oh-my-opencode-windows-x64": "4.18.2",
226
+ "oh-my-opencode-windows-x64-baseline": "4.18.2"
227
227
  },
228
228
  "overrides": {
229
229
  "@earendil-works/pi-agent-core": "0.80.3",
@@ -17,6 +17,9 @@ function errorResponse(id, code, message, data) {
17
17
  function jsonRpcId(value) {
18
18
  return typeof value === "string" || typeof value === "number" || value === null ? value : null;
19
19
  }
20
+ function messageFromError(error) {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
20
23
  // ../mcp-stdio-core/src/transport.ts
21
24
  var HEADER_SEPARATOR = Buffer.from(`\r
22
25
  \r
@@ -39,16 +42,45 @@ async function* readStdioJsonRpcMessages(input) {
39
42
  yield parseJsonPayload(trailing, "line");
40
43
  }
41
44
  }
42
- function writeStdioJsonRpcResponse(output, response, responseMode) {
45
+ async function writeStdioJsonRpcResponse(output, response, responseMode) {
43
46
  const body = JSON.stringify(response);
44
- if (responseMode === "framed") {
45
- output.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r
47
+ const payload = responseMode === "framed" ? `Content-Length: ${Buffer.byteLength(body, "utf8")}\r
46
48
  \r
47
- ${body}`);
48
- return;
49
- }
50
- output.write(`${body}
51
- `);
49
+ ${body}` : `${body}
50
+ `;
51
+ await writeChunk(output, payload);
52
+ }
53
+ function writeChunk(output, chunk) {
54
+ return new Promise((resolve, reject) => {
55
+ let settled = false;
56
+ const onError = (error) => {
57
+ if (settled)
58
+ return;
59
+ settled = true;
60
+ reject(error);
61
+ };
62
+ output.once("error", onError);
63
+ try {
64
+ output.write(chunk, (error) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ if (error) {
69
+ queueMicrotask(() => output.removeListener("error", onError));
70
+ reject(error);
71
+ return;
72
+ }
73
+ output.removeListener("error", onError);
74
+ resolve();
75
+ });
76
+ } catch (error) {
77
+ output.removeListener("error", onError);
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ reject(error);
82
+ }
83
+ });
52
84
  }
53
85
  function readNextMessage(buffer) {
54
86
  if (buffer.length === 0)
@@ -144,40 +176,70 @@ async function runJsonRpcStdioServer(config) {
144
176
  break;
145
177
  idleTimer.arm();
146
178
  if (message.kind === "parse_error") {
147
- handleParseError(message, config, log);
179
+ if (!await handleParseError(message, config, log))
180
+ break;
148
181
  continue;
149
182
  }
150
- await handleRequest(message, config, log);
183
+ if (!await handleRequest(message, config, log))
184
+ break;
151
185
  }
152
186
  } finally {
153
187
  idleTimer.clear();
154
188
  log("stdio_stopped");
155
189
  }
156
190
  }
157
- function handleParseError(message, config, log) {
191
+ async function handleParseError(message, config, log) {
158
192
  log("parse_error", { message: message.message });
159
193
  const response = config.parseErrorResponse?.(message.message) ?? errorResponse(null, -32700, "Parse error", message.message);
160
- if (response !== undefined) {
161
- writeStdioJsonRpcResponse(config.output, response, message.responseMode);
162
- }
194
+ if (response === undefined)
195
+ return true;
196
+ return writeResponse(response, {
197
+ output: config.output,
198
+ responseMode: message.responseMode,
199
+ log
200
+ });
163
201
  }
164
202
  async function handleRequest(message, config, log) {
165
203
  const parsed = message.payload;
166
204
  const id = isPlainRecord(parsed) ? jsonRpcId(parsed["id"]) : null;
167
205
  const method = isPlainRecord(parsed) && typeof parsed["method"] === "string" ? parsed["method"] : null;
168
206
  log("request", { id: id === null ? null : String(id), method });
207
+ let response;
169
208
  try {
170
- const response = await config.handler(parsed, config.handlerOptions);
171
- if (response === undefined)
172
- return;
173
- writeStdioJsonRpcResponse(config.output, response, message.responseMode);
174
- log("response", { id: String(response.id), method, is_error: response.error !== undefined });
209
+ response = await config.handler(parsed, config.handlerOptions);
175
210
  } catch (error) {
176
211
  if (config.onHandlerError === undefined)
177
212
  throw error;
178
213
  config.onHandlerError(error);
214
+ return true;
215
+ }
216
+ if (response === undefined)
217
+ return true;
218
+ if (!await writeResponse(response, {
219
+ output: config.output,
220
+ responseMode: message.responseMode,
221
+ log
222
+ }))
223
+ return false;
224
+ log("response", { id: String(response.id), method, is_error: response.error !== undefined });
225
+ return true;
226
+ }
227
+ async function writeResponse(response, context) {
228
+ try {
229
+ await writeStdioJsonRpcResponse(context.output, response, context.responseMode);
230
+ return true;
231
+ } catch (error) {
232
+ if (!isTerminalOutputError(error))
233
+ throw error;
234
+ context.log("output_error", { message: messageFromError(error) });
235
+ return false;
179
236
  }
180
237
  }
238
+ function isTerminalOutputError(error) {
239
+ if (!(error instanceof Error) || !("code" in error))
240
+ return false;
241
+ return error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED" || error.code === "ERR_STREAM_WRITE_AFTER_END";
242
+ }
181
243
  function createIdleTimer(idleTimeoutMs, log, onIdleTimeout) {
182
244
  let timer = null;
183
245
  let isClosed = false;
@@ -148,7 +148,7 @@ describe("LspClient diagnostics freshness", () => {
148
148
  capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
149
149
  diagnosticResponses: [
150
150
  {
151
- delayMs: 20,
151
+ delayMs: 200,
152
152
  report: { kind: "full", resultId: "v1", items: [diagnostic("pull-stale")] },
153
153
  },
154
154
  {
@@ -156,7 +156,7 @@ describe("LspClient diagnostics freshness", () => {
156
156
  },
157
157
  ],
158
158
  },
159
- { diagnosticsFreshnessTimeoutMs: 80, versionlessPublishQuiescenceMs: 5 },
159
+ { diagnosticsFreshnessTimeoutMs: 800, versionlessPublishQuiescenceMs: 5 },
160
160
  );
161
161
  await context.client.openFile(context.source);
162
162
 
@@ -209,7 +209,7 @@ describe("LspClient diagnostics freshness", () => {
209
209
  capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
210
210
  diagnosticResponses: [{ action: "close" }],
211
211
  },
212
- { diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
212
+ { diagnosticsFreshnessTimeoutMs: 500, versionlessPublishQuiescenceMs: 5 },
213
213
  );
214
214
  await context.client.openFile(context.source);
215
215
 
@@ -229,7 +229,7 @@ describe("LspClient diagnostics freshness", () => {
229
229
  ],
230
230
  diagnosticResponses: [{ error: { code: -32601, message: "Method not found" } }],
231
231
  },
232
- { diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
232
+ { diagnosticsFreshnessTimeoutMs: 500, versionlessPublishQuiescenceMs: 5 },
233
233
  );
234
234
 
235
235
  const result = await context.client.diagnostics(context.source);
@@ -5,7 +5,8 @@ import { join } from "node:path";
5
5
  import { describe, expect, it } from "bun:test";
6
6
 
7
7
  import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics.js";
8
- import type { LspClient } from "./client.js";
8
+ import { LspClient } from "./client.js";
9
+ import { LspManager } from "./manager.js";
9
10
  import type { Diagnostic, ResolvedServer } from "./types.js";
10
11
 
11
12
  describe("directory diagnostics", () => {
@@ -94,8 +95,124 @@ describe("directory diagnostics", () => {
94
95
  rmSync(workspace, { recursive: true, force: true });
95
96
  }
96
97
  });
98
+
99
+ it("#given cold client acquisition is pending #when directory diagnostics are aborted #then acquisition settles and cleans up before startup releases", async () => {
100
+ const workspace = mkdtempSync(join(tmpdir(), "lsp-directory-diag-cold-cancel-"));
101
+ const controller = new AbortController();
102
+ const startupStarted = deferred();
103
+ const startupRelease = deferred();
104
+ const server: ResolvedServer = {
105
+ id: "typescript",
106
+ command: ["typescript-language-server"],
107
+ extensions: [".ts"],
108
+ priority: 1,
109
+ };
110
+ const client = new PendingStartLspClient({
111
+ root: workspace,
112
+ server,
113
+ startupStarted,
114
+ startupRelease,
115
+ });
116
+ const manager = new LspManager({
117
+ clientFactory: () => client,
118
+ reaperIntervalMs: 60_000,
119
+ });
120
+ const aggregation = aggregateDiagnosticsForDirectory(workspace, ".ts", "all", 1, {
121
+ manager,
122
+ listFiles: () => [join(workspace, "a.ts")],
123
+ workspaceRoot: workspace,
124
+ server,
125
+ signal: controller.signal,
126
+ });
127
+
128
+ try {
129
+ await startupStarted.promise;
130
+ controller.abort();
131
+
132
+ const settledBeforeStartupRelease = await settlesWithin(aggregation, 100);
133
+
134
+ expect(settledBeforeStartupRelease).toBe(true);
135
+ await expect(aggregation).rejects.toHaveProperty("name", "AbortError");
136
+ expect(manager.clientCount()).toBe(0);
137
+ expect(client.stopCallCount).toBe(1);
138
+ } finally {
139
+ startupRelease.resolve();
140
+ await aggregation.catch(() => undefined);
141
+ await manager.stopAll();
142
+ rmSync(workspace, { recursive: true, force: true });
143
+ }
144
+ });
97
145
  });
98
146
 
147
+ class PendingStartLspClient extends LspClient {
148
+ stopCallCount = 0;
149
+ private readonly startupStarted: Deferred;
150
+ private readonly startupRelease: Deferred;
151
+
152
+ constructor(options: PendingStartLspClientOptions) {
153
+ super(options.root, options.server);
154
+ this.startupStarted = options.startupStarted;
155
+ this.startupRelease = options.startupRelease;
156
+ }
157
+
158
+ override async start(): Promise<void> {
159
+ this.startupStarted.resolve();
160
+ await this.startupRelease.promise;
161
+ }
162
+
163
+ override async initialize(): Promise<void> {}
164
+
165
+ override async stop(): Promise<void> {
166
+ this.stopCallCount += 1;
167
+ }
168
+
169
+ override isAlive(): boolean {
170
+ return true;
171
+ }
172
+
173
+ override async diagnostics(): Promise<{ readonly items: Diagnostic[] }> {
174
+ return { items: [] };
175
+ }
176
+ }
177
+
178
+ interface PendingStartLspClientOptions {
179
+ readonly root: string;
180
+ readonly server: ResolvedServer;
181
+ readonly startupStarted: Deferred;
182
+ readonly startupRelease: Deferred;
183
+ }
184
+
185
+ interface Deferred {
186
+ readonly promise: Promise<void>;
187
+ resolve(): void;
188
+ }
189
+
190
+ function deferred(): Deferred {
191
+ let resolvePromise: (() => void) | undefined;
192
+ const promise = new Promise<void>((resolve) => {
193
+ resolvePromise = resolve;
194
+ });
195
+ return { promise, resolve: () => resolvePromise?.() };
196
+ }
197
+
198
+ async function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
199
+ let timer: ReturnType<typeof setTimeout> | undefined;
200
+ try {
201
+ return await Promise.race([
202
+ promise.then(
203
+ () => true,
204
+ () => true,
205
+ ),
206
+ new Promise<boolean>((resolve) => {
207
+ timer = setTimeout(() => resolve(false), timeoutMs);
208
+ timer.unref();
209
+ }),
210
+ ]);
211
+ } finally {
212
+ if (timer !== undefined) clearTimeout(timer);
213
+ }
214
+ }
215
+
99
216
  function diagnostic(message: string): Diagnostic {
100
217
  return {
101
218
  range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
@@ -124,7 +124,7 @@ export async function aggregateDiagnosticsForDirectory(
124
124
  const maxConcurrency = Math.max(1, options.maxConcurrency ?? DIRECTORY_DIAGNOSTICS_MAX_CONCURRENCY);
125
125
 
126
126
  options.signal?.throwIfAborted();
127
- const client = await manager.getClient(root, server);
127
+ const client = await manager.getClient(root, server, options.signal);
128
128
  try {
129
129
  let nextIndex = 0;
130
130
  const workers = Array.from({ length: Math.min(maxConcurrency, filesToProcess.length) }, async () => {