@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3

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 (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
@@ -12,6 +12,7 @@ import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
12
12
  import { $ } from "bun";
13
13
  import chalk from "chalk";
14
14
  import { theme } from "../modes/theme/theme";
15
+ import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
15
16
 
16
17
  const REPO = "can1357/oh-my-pi";
17
18
  const PACKAGE = "@oh-my-pi/pi-coding-agent";
@@ -29,6 +30,8 @@ const MISE_TOOL = "github:can1357/oh-my-pi";
29
30
  * See #1686.
30
31
  */
31
32
  const NPM_REGISTRY = "https://registry.npmjs.org/";
33
+ const RELEASE_METADATA_TIMEOUT_MS = 30_000;
34
+ const BINARY_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
32
35
 
33
36
  /**
34
37
  * Core native addon package. Bumped in lock-step with {@link PACKAGE} so the
@@ -82,7 +85,7 @@ export interface BinaryReplacementOptions {
82
85
  * Parse update subcommand arguments.
83
86
  * Returns undefined if not an update command.
84
87
  */
85
- export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean } | undefined {
88
+ export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean; plugins: boolean } | undefined {
86
89
  if (args.length === 0 || args[0] !== "update") {
87
90
  return undefined;
88
91
  }
@@ -90,6 +93,7 @@ export function parseUpdateArgs(args: string[]): { force: boolean; check: boolea
90
93
  return {
91
94
  force: args.includes("--force") || args.includes("-f"),
92
95
  check: args.includes("--check") || args.includes("-c"),
96
+ plugins: args.includes("--plugins") || args.includes("-l"),
93
97
  };
94
98
  }
95
99
 
@@ -239,7 +243,17 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
239
243
  * Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
240
244
  */
241
245
  async function getLatestRelease(): Promise<ReleaseInfo> {
242
- const response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`);
246
+ let response: Response;
247
+ try {
248
+ response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`, {
249
+ signal: withTimeoutSignal(RELEASE_METADATA_TIMEOUT_MS),
250
+ });
251
+ } catch (err) {
252
+ if (isTimeoutError(err)) {
253
+ throw new Error("Timed out fetching release info after 30s", { cause: err });
254
+ }
255
+ throw err;
256
+ }
243
257
  if (!response.ok) {
244
258
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
245
259
  }
@@ -832,7 +846,18 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
832
846
  const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
833
847
  console.log(chalk.dim(`Downloading ${binaryName}…`));
834
848
 
835
- const response = await fetch(url, { redirect: "follow" });
849
+ let response: Response;
850
+ try {
851
+ response = await fetch(url, {
852
+ redirect: "follow",
853
+ signal: withTimeoutSignal(BINARY_DOWNLOAD_TIMEOUT_MS),
854
+ });
855
+ } catch (err) {
856
+ if (isTimeoutError(err)) {
857
+ throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
858
+ }
859
+ throw err;
860
+ }
836
861
  if (!response.ok || !response.body) {
837
862
  throw new Error(`Download failed: ${response.statusText}`);
838
863
  }
@@ -914,12 +939,14 @@ ${chalk.bold("Usage:")}
914
939
  ${APP_NAME} update [options]
915
940
 
916
941
  ${chalk.bold("Options:")}
917
- -c, --check Check for updates without installing
918
- -f, --force Force reinstall even if up to date
942
+ -c, --check Check for updates without installing
943
+ -f, --force Force reinstall even if up to date
944
+ -l, --plugins Update installed plugins
919
945
 
920
946
  ${chalk.bold("Examples:")}
921
- ${APP_NAME} update Update to latest version
922
- ${APP_NAME} update --check Check if updates are available
923
- ${APP_NAME} update --force Force reinstall
947
+ ${APP_NAME} update Update to latest version
948
+ ${APP_NAME} update --check Check if updates are available
949
+ ${APP_NAME} update --force Force reinstall
950
+ ${APP_NAME} update -l Update installed plugins
924
951
  `);
925
952
  }
package/src/cli.ts CHANGED
@@ -173,14 +173,20 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
173
173
  * worker is idle, and hard-kills the process on parent `disconnect`.
174
174
  */
175
175
  async function runIpcSubprocessWorker<In, Out>(
176
- start: (transport: { send(message: Out): void; onMessage(handler: (message: In) => void): () => void }) => void,
176
+ start: (transport: {
177
+ send(message: Out): void;
178
+ sendAndFlush(message: Out): Promise<void>;
179
+ onMessage(handler: (message: In) => void): () => void;
180
+ }) => void,
177
181
  ): Promise<void> {
178
182
  const { promise: shuttingDown, resolve: shutdown } = Promise.withResolvers<void>();
183
+ type IpcSend = (this: NodeJS.Process, message: unknown, callback?: (error: Error | null) => void) => boolean;
184
+ // `process.send` only exists when spawned with an IPC channel; the parent
185
+ // always spawns us that way. If it's missing, the parent vanished and
186
+ // there's no one to talk to.
187
+ const ipcSend = (): IpcSend | undefined => (process as NodeJS.Process & { send?: IpcSend }).send;
179
188
  const send = (message: Out): void => {
180
- // `process.send` only exists when spawned with an IPC channel; the
181
- // parent always spawns us that way. If it's missing, the parent
182
- // vanished and there's no one to talk to.
183
- const sender = (process as NodeJS.Process & { send?: (m: unknown) => boolean }).send;
189
+ const sender = ipcSend();
184
190
  if (!sender) {
185
191
  shutdown();
186
192
  return;
@@ -191,8 +197,24 @@ async function runIpcSubprocessWorker<In, Out>(
191
197
  shutdown();
192
198
  }
193
199
  };
200
+ const sendAndFlush = (message: Out): Promise<void> => {
201
+ const sender = ipcSend();
202
+ if (!sender) {
203
+ shutdown();
204
+ return Promise.resolve();
205
+ }
206
+ const { promise, resolve } = Promise.withResolvers<void>();
207
+ try {
208
+ sender.call(process, message, () => resolve());
209
+ } catch {
210
+ shutdown();
211
+ resolve();
212
+ }
213
+ return promise;
214
+ };
194
215
  start({
195
216
  send,
217
+ sendAndFlush,
196
218
  onMessage(handler) {
197
219
  const wrap = (data: unknown): void => handler(data as In);
198
220
  process.on("message", wrap);
@@ -2,7 +2,8 @@
2
2
  * Check for and install updates.
3
3
  */
4
4
  import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
5
- import { runUpdateCommand } from "../cli/update-cli";
5
+ import * as pluginCli from "../cli/plugin-cli";
6
+ import * as updateCli from "../cli/update-cli";
6
7
  import { initTheme } from "../modes/theme/theme";
7
8
 
8
9
  export default class Update extends Command {
@@ -11,11 +12,16 @@ export default class Update extends Command {
11
12
  static flags = {
12
13
  force: Flags.boolean({ char: "f", description: "Force update", default: false }),
13
14
  check: Flags.boolean({ char: "c", description: "Check for updates without installing", default: false }),
15
+ plugins: Flags.boolean({ char: "l", description: "Update installed plugins", default: false }),
14
16
  };
15
17
 
16
18
  async run(): Promise<void> {
17
19
  const { flags } = await this.parse(Update);
18
20
  await initTheme();
19
- await runUpdateCommand({ force: flags.force, check: flags.check });
21
+ if (flags.plugins) {
22
+ await pluginCli.runPluginCommand({ action: "upgrade", args: [], flags: {} });
23
+ } else {
24
+ await updateCli.runUpdateCommand({ force: flags.force, check: flags.check });
25
+ }
20
26
  }
21
27
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
4
- import type { Type } from "arktype";
4
+ import { ArkErrors, type Type } from "arktype";
5
5
  import { JSONC, YAML } from "bun";
6
6
 
7
7
  /** Minimal subset of the AJV ConfigSchemaError shape this module actually relies on. */
@@ -220,11 +220,11 @@ export class ConfigFile<T> implements IConfigFile<T> {
220
220
  }
221
221
 
222
222
  const checked = this.schema(parsed);
223
- if (checked instanceof Error) {
224
- const schemaErrors: ConfigSchemaError[] = [];
225
- // arktype errors are Error instances with a message property
226
- // Extract the error message as a single schema error
227
- schemaErrors.push({ instancePath: "root", message: checked.message });
223
+ if (checked instanceof ArkErrors) {
224
+ const schemaErrors: ConfigSchemaError[] = checked.map(error => ({
225
+ instancePath: error.path.length === 0 ? "root" : error.path.join("."),
226
+ message: error.problem,
227
+ }));
228
228
  const error = new ConfigError(this.id, schemaErrors);
229
229
  logger.warn("Failed to parse config file", { path: this.path(), error });
230
230
  return this.#storeCache({ error, status: "error" });
@@ -630,7 +630,8 @@ function matchModel(
630
630
  // fuzzy matching so raw IDs that contain slashes (for example OpenRouter model
631
631
  // IDs like "openai/gpt-4o:extended") still resolve as IDs instead of being
632
632
  // misread as a provider-qualified selector.
633
- const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
633
+ const lowerPattern = modelPattern.toLowerCase();
634
+ const exactMatches = availableModels.filter(m => m.id.toLowerCase() === lowerPattern);
634
635
  if (exactMatches.length > 0) {
635
636
  return pickPreferredModel(exactMatches, context);
636
637
  }
@@ -647,7 +648,8 @@ function matchModel(
647
648
  const bareAlias = resolveBareVariantAlias(modelPattern);
648
649
  const bareAliasTargetId = bareAlias?.id ?? stripThinkingVariantToken(modelPattern);
649
650
  if (bareAliasTargetId) {
650
- const aliasMatches = availableModels.filter(m => m.id.toLowerCase() === bareAliasTargetId.toLowerCase());
651
+ const lowerAliasTarget = bareAliasTargetId.toLowerCase();
652
+ const aliasMatches = availableModels.filter(m => m.id.toLowerCase() === lowerAliasTarget);
651
653
  if (aliasMatches.length > 0) {
652
654
  const preferred = bareAlias ? aliasMatches.filter(m => bareAlias.providers.includes(m.provider)) : [];
653
655
  return pickPreferredModel(preferred.length > 0 ? preferred : aliasMatches, context);
@@ -658,7 +660,8 @@ function matchModel(
658
660
  if (slashIndex !== -1) {
659
661
  const provider = modelPattern.substring(0, slashIndex);
660
662
  const modelId = modelPattern.substring(slashIndex + 1);
661
- const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase());
663
+ const lowerProvider = provider.toLowerCase();
664
+ const providerModels = availableModels.filter(m => m.provider.toLowerCase() === lowerProvider);
662
665
  if (providerModels.length === 0) {
663
666
  // The prefix is not a known provider in this candidate set, so treat the
664
667
  // slash as part of the raw model ID and continue with generic matching.
@@ -699,9 +702,7 @@ function matchModel(
699
702
 
700
703
  // No exact match - fall back to partial matching
701
704
  const matches = availableModels.filter(
702
- m =>
703
- m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
704
- m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
705
+ m => m.id.toLowerCase().includes(lowerPattern) || m.name?.toLowerCase().includes(lowerPattern),
705
706
  );
706
707
 
707
708
  if (matches.length === 0) {
@@ -808,13 +809,15 @@ function parseModelPatternWithContext(
808
809
  return result;
809
810
  }
810
811
 
811
- export function parseModelPattern(
812
+ /** Match a single pattern with a pre-built preference context (direct match plus
813
+ * the `@upstream` routing fallback), so role resolution can reuse one context
814
+ * across every fallback pattern instead of rebuilding it per pattern. */
815
+ function matchPatternWithContext(
812
816
  pattern: string,
813
817
  availableModels: Model<Api>[],
814
- preferences?: ModelMatchPreferences,
818
+ context: ModelPreferenceContext,
815
819
  options?: { allowInvalidThinkingSelectorFallback?: boolean },
816
820
  ): ParsedModelResult {
817
- const context = buildPreferenceContext(availableModels, preferences);
818
821
  const direct = parseModelPatternWithContext(pattern, availableModels, context, options);
819
822
  if (direct.model) return direct;
820
823
 
@@ -831,6 +834,20 @@ export function parseModelPattern(
831
834
  return direct;
832
835
  }
833
836
 
837
+ export function parseModelPattern(
838
+ pattern: string,
839
+ availableModels: Model<Api>[],
840
+ preferences?: ModelMatchPreferences,
841
+ options?: { allowInvalidThinkingSelectorFallback?: boolean },
842
+ ): ParsedModelResult {
843
+ return matchPatternWithContext(
844
+ pattern,
845
+ availableModels,
846
+ buildPreferenceContext(availableModels, preferences),
847
+ options,
848
+ );
849
+ }
850
+
834
851
  const PREFIX_MODEL_ROLE = "pi/";
835
852
  const DEFAULT_MODEL_ROLE = "default";
836
853
 
@@ -1035,8 +1052,12 @@ export function resolveModelRoleValue(
1035
1052
 
1036
1053
  let warning: string | undefined;
1037
1054
  const matchPreferences = mergeModelMatchPreferences(options?.settings, options?.matchPreferences);
1055
+ // Build the O(n) preference context (model-order map over all available
1056
+ // models) once and reuse it across every fallback pattern instead of
1057
+ // rebuilding it per pattern inside parseModelPattern.
1058
+ const preferenceContext = buildPreferenceContext(availableModels, matchPreferences);
1038
1059
  for (const effectivePattern of effectivePatterns) {
1039
- const resolved = parseModelPattern(effectivePattern, availableModels, matchPreferences);
1060
+ const resolved = matchPatternWithContext(effectivePattern, availableModels, preferenceContext);
1040
1061
  if (resolved.model) {
1041
1062
  return {
1042
1063
  model: resolved.model,
@@ -4337,6 +4337,16 @@ export const SETTINGS_SCHEMA = {
4337
4337
  description: "Providers that web_search should never use, even as fallbacks",
4338
4338
  },
4339
4339
  },
4340
+ "providers.webSearchGeminiModel": {
4341
+ type: "string",
4342
+ default: undefined,
4343
+ ui: {
4344
+ tab: "providers",
4345
+ group: "Services",
4346
+ label: "Gemini web_search model",
4347
+ description: "Model ID for Gemini Google Search grounding. Defaults to gemini-2.5-flash.",
4348
+ },
4349
+ },
4340
4350
  "providers.antigravityEndpoint": {
4341
4351
  type: "enum",
4342
4352
  values: ["auto", "production", "sandbox"] as const,
@@ -4491,6 +4501,17 @@ export const SETTINGS_SCHEMA = {
4491
4501
  ],
4492
4502
  },
4493
4503
  },
4504
+ "speech.enhanced": {
4505
+ type: "boolean",
4506
+ default: false,
4507
+ ui: {
4508
+ tab: "providers",
4509
+ group: "Services",
4510
+ label: "Enhanced Speech Rewriting",
4511
+ description:
4512
+ "Rewrite assistant output into natural spoken prose with the tiny/smol model before synthesis (describes code, drops links and markdown). Falls back to mechanical cleanup on failure",
4513
+ },
4514
+ },
4494
4515
  "speech.voice": {
4495
4516
  type: "enum",
4496
4517
  values: TTS_LOCAL_VOICE_VALUES,
package/src/cursor.ts CHANGED
@@ -180,7 +180,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
180
180
  const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
181
181
  const toolResultMessage = await executeTool(this.options, "grep", toolCallId, {
182
182
  pattern: args.pattern,
183
- paths: [searchPath],
183
+ path: searchPath,
184
184
  case: args.caseInsensitive === true ? false : undefined,
185
185
  });
186
186
  return toolResultMessage;
package/src/dap/client.ts CHANGED
@@ -17,6 +17,14 @@ import type {
17
17
  interface DapSpawnOptions {
18
18
  adapter: DapResolvedAdapter;
19
19
  cwd: string;
20
+ /**
21
+ * Cap on how long the socket-mode helpers wait for the adapter to open its
22
+ * socket (unix) or dial back into our listener (TCP). Exposed for tests;
23
+ * production callers rely on the default.
24
+ *
25
+ * @internal
26
+ */
27
+ socketReadyTimeoutMs?: number;
20
28
  }
21
29
 
22
30
  /** Minimal write interface shared by Bun.FileSink and Bun TCP sockets. */
@@ -29,13 +37,14 @@ type DapEventHandler = (body: unknown, event: DapEventMessage) => void | Promise
29
37
  type DapReverseRequestHandler = (args: unknown) => unknown | Promise<unknown>;
30
38
 
31
39
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
32
-
33
- async function writeMessage(sink: DapWriteSink, message: DapRequestMessage | DapResponseMessage): Promise<void> {
34
- const content = JSON.stringify(message);
35
- sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`);
36
- sink.write(content);
37
- await sink.flush();
38
- }
40
+ /**
41
+ * Hard cap on a single message write. A wedged adapter stdin used to hang the
42
+ * whole client forever; on hitting this cap the client disposes itself so the
43
+ * next request fails fast instead of piling more work onto a broken adapter.
44
+ */
45
+ const WRITE_MESSAGE_TIMEOUT_MS = 30_000;
46
+ /** Default wait for socket-mode adapters to become reachable. */
47
+ const SOCKET_READY_TIMEOUT_MS = 10_000;
39
48
 
40
49
  function toErrorMessage(value: unknown): string {
41
50
  if (value instanceof Error) return value.message;
@@ -62,6 +71,8 @@ export class DapClient {
62
71
  #eventHandlers = new Map<string, Set<DapEventHandler>>();
63
72
  #anyEventHandlers = new Set<DapEventHandler>();
64
73
  #reverseRequestHandlers = new Map<string, DapReverseRequestHandler>();
74
+ #adapterExited = false;
75
+ #pendingWriteExitRejectors = new Set<() => void>();
65
76
 
66
77
  constructor(
67
78
  adapter: DapResolvedAdapter,
@@ -75,11 +86,15 @@ export class DapClient {
75
86
  this.#readable = options?.readable ?? (proc.stdout as ReadableStream<Uint8Array>);
76
87
  this.#writeSink = options?.writeSink ?? proc.stdin;
77
88
  this.#socket = options?.socket;
89
+ this.proc.exited.then(
90
+ () => this.#rejectPendingWritesForExit(),
91
+ () => this.#rejectPendingWritesForExit(),
92
+ );
78
93
  }
79
94
 
80
- static async spawn({ adapter, cwd }: DapSpawnOptions): Promise<DapClient> {
95
+ static async spawn({ adapter, cwd, socketReadyTimeoutMs }: DapSpawnOptions): Promise<DapClient> {
81
96
  if (adapter.connectMode === "socket") {
82
- return DapClient.#spawnSocket({ adapter, cwd });
97
+ return DapClient.#spawnSocket({ adapter, cwd, socketReadyTimeoutMs });
83
98
  }
84
99
  // Merge non-interactive env and start in a new session (detached → setsid)
85
100
  // so the adapter process tree has no controlling terminal. Without this,
@@ -108,17 +123,18 @@ export class DapClient {
108
123
  * Linux: connect to a unix domain socket via --listen=unix:<path>
109
124
  * macOS/other: the adapter dials into our TCP listener via --client-addr
110
125
  */
111
- static async #spawnSocket({ adapter, cwd }: DapSpawnOptions): Promise<DapClient> {
126
+ static async #spawnSocket({ adapter, cwd, socketReadyTimeoutMs }: DapSpawnOptions): Promise<DapClient> {
112
127
  const env = {
113
128
  ...Bun.env,
114
129
  ...NON_INTERACTIVE_ENV,
115
130
  };
131
+ const timeoutMs = socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS;
116
132
  const isLinux = process.platform === "linux";
117
133
 
118
134
  if (isLinux) {
119
- return DapClient.#spawnSocketUnix({ adapter, cwd, env });
135
+ return DapClient.#spawnSocketUnix({ adapter, cwd, env, timeoutMs });
120
136
  }
121
- return DapClient.#spawnSocketClientAddr({ adapter, cwd, env });
137
+ return DapClient.#spawnSocketClientAddr({ adapter, cwd, env, timeoutMs });
122
138
  }
123
139
 
124
140
  /** Linux: spawn adapter with --listen=unix:<path>, then connect to the socket. */
@@ -126,10 +142,12 @@ export class DapClient {
126
142
  adapter,
127
143
  cwd,
128
144
  env,
145
+ timeoutMs,
129
146
  }: {
130
147
  adapter: DapResolvedAdapter;
131
148
  cwd: string;
132
149
  env: Record<string, string | undefined>;
150
+ timeoutMs: number;
133
151
  }): Promise<DapClient> {
134
152
  const socketPath = `/tmp/dap-${adapter.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`;
135
153
  const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], {
@@ -139,13 +157,23 @@ export class DapClient {
139
157
  detached: true,
140
158
  });
141
159
 
142
- await waitForCondition(() => isUnixSocketReady(socketPath), 10_000, proc);
143
-
144
- const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
145
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
146
- proc.exited.then(() => client.#handleProcessExit());
147
- void client.#startMessageReader();
148
- return client;
160
+ // If waitForCondition throws (timeout, or adapter exited early) or the
161
+ // socket connect fails, we must not leak the detached adapter process.
162
+ try {
163
+ await waitForCondition(() => isUnixSocketReady(socketPath), timeoutMs, proc);
164
+ const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
165
+ const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
166
+ proc.exited.then(() => client.#handleProcessExit());
167
+ void client.#startMessageReader();
168
+ return client;
169
+ } catch (error) {
170
+ try {
171
+ proc.kill();
172
+ } catch {
173
+ /* proc may already be dead */
174
+ }
175
+ throw error;
176
+ }
149
177
  }
150
178
 
151
179
  /** macOS/other: listen on a random TCP port, spawn adapter with --client-addr, accept connection. */
@@ -153,10 +181,12 @@ export class DapClient {
153
181
  adapter,
154
182
  cwd,
155
183
  env,
184
+ timeoutMs,
156
185
  }: {
157
186
  adapter: DapResolvedAdapter;
158
187
  cwd: string;
159
188
  env: Record<string, string | undefined>;
189
+ timeoutMs: number;
160
190
  }): Promise<DapClient> {
161
191
  const { promise: connPromise, resolve: resolveConn } = Promise.withResolvers<Bun.Socket<undefined>>();
162
192
 
@@ -182,25 +212,32 @@ export class DapClient {
182
212
  detached: true,
183
213
  });
184
214
 
185
- // Wait for dlv to connect (with timeout)
186
- let rawSocket: Bun.Socket<undefined>;
215
+ // Wait for the adapter to dial back. On timeout (or any other failure
216
+ // before we've wired up the client) kill `proc` — otherwise the detached
217
+ // adapter process is orphaned.
187
218
  const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
188
219
  const connectTimeout = setTimeout(
189
- () => rejectTimeout(new Error(`${adapter.name} did not connect within 10s`)),
190
- 10_000,
220
+ () => rejectTimeout(new Error(`${adapter.name} did not connect within ${timeoutMs}ms`)),
221
+ timeoutMs,
191
222
  );
192
223
  try {
193
- rawSocket = await Promise.race([connPromise, timeoutPromise]);
224
+ const rawSocket = await Promise.race([connPromise, timeoutPromise]);
225
+ const { readable, writeSink, socket } = wrapBunSocket(rawSocket);
226
+ const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
227
+ proc.exited.then(() => client.#handleProcessExit());
228
+ void client.#startMessageReader();
229
+ return client;
230
+ } catch (error) {
231
+ try {
232
+ proc.kill();
233
+ } catch {
234
+ /* proc may already be dead */
235
+ }
236
+ throw error;
194
237
  } finally {
195
238
  clearTimeout(connectTimeout);
196
239
  server.stop();
197
240
  }
198
-
199
- const { readable, writeSink, socket } = wrapBunSocket(rawSocket);
200
- const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
201
- proc.exited.then(() => client.#handleProcessExit());
202
- void client.#startMessageReader();
203
- return client;
204
241
  }
205
242
 
206
243
  get capabilities(): DapCapabilities | undefined {
@@ -309,6 +346,12 @@ export class DapClient {
309
346
  arguments: args,
310
347
  };
311
348
  const { promise, resolve, reject } = Promise.withResolvers<TBody>();
349
+ // Suppress "unhandled rejection" if the request timer or abort fires
350
+ // before the caller's `await` subscribes — e.g. while #writeMessage is
351
+ // still racing a wedged stdin flush. The caller's own `await` still
352
+ // receives the rejection normally; this handler is a passive guard.
353
+ promise.catch(() => {});
354
+
312
355
  let timeout: NodeJS.Timeout | undefined;
313
356
  const cleanup = () => {
314
357
  if (timeout) clearTimeout(timeout);
@@ -342,13 +385,15 @@ export class DapClient {
342
385
  },
343
386
  });
344
387
  this.#lastActivity = Date.now();
345
- try {
346
- await writeMessage(this.#writeSink, request);
347
- } catch (error) {
388
+ // Fire the write in the background. Awaiting it here would let a wedged
389
+ // stdin flush block the caller's `timeoutMs`; if it fails, propagate the
390
+ // failure into `promise` — the timer or abort may still win the race.
391
+ void this.#writeMessage(request).catch(error => {
392
+ if (!this.#pendingRequests.has(requestSeq)) return;
348
393
  this.#pendingRequests.delete(requestSeq);
349
394
  cleanup();
350
- throw error;
351
- }
395
+ reject(error);
396
+ });
352
397
  return promise;
353
398
  }
354
399
 
@@ -362,7 +407,60 @@ export class DapClient {
362
407
  ...(message ? { message } : {}),
363
408
  ...(body !== undefined ? { body } : {}),
364
409
  };
365
- await writeMessage(this.#writeSink, response);
410
+ await this.#writeMessage(response);
411
+ }
412
+
413
+ /**
414
+ * Framed write to the adapter, bounded by {@link WRITE_MESSAGE_TIMEOUT_MS}
415
+ * and by adapter exit. Without this bound a wedged adapter stdin used to
416
+ * hang the whole client forever. On timeout or exit-before-flush the client
417
+ * disposes itself and rethrows.
418
+ */
419
+ async #writeMessage(message: DapRequestMessage | DapResponseMessage): Promise<void> {
420
+ const content = JSON.stringify(message);
421
+ this.#writeSink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`);
422
+ this.#writeSink.write(content);
423
+ const flushResult = this.#writeSink.flush();
424
+ if (!(flushResult instanceof Promise)) return;
425
+
426
+ if (this.#adapterExited) {
427
+ throw new Error(`DAP adapter ${this.adapter.name} exited before write completed`);
428
+ }
429
+
430
+ const { promise: guardPromise, reject: guardReject, resolve: guardResolve } = Promise.withResolvers<void>();
431
+ const timer = setTimeout(
432
+ () =>
433
+ guardReject(
434
+ new Error(`DAP adapter ${this.adapter.name} write timed out after ${WRITE_MESSAGE_TIMEOUT_MS}ms`),
435
+ ),
436
+ WRITE_MESSAGE_TIMEOUT_MS,
437
+ );
438
+ const rejectOnExit = () => {
439
+ guardReject(new Error(`DAP adapter ${this.adapter.name} exited before write completed`));
440
+ };
441
+ this.#pendingWriteExitRejectors.add(rejectOnExit);
442
+
443
+ try {
444
+ await Promise.race([flushResult, guardPromise]);
445
+ } catch (error) {
446
+ // The client is now known-broken. Kick off dispose in the background;
447
+ // callers will see subsequent sendRequest calls fail fast.
448
+ void this.dispose();
449
+ throw error;
450
+ } finally {
451
+ clearTimeout(timer);
452
+ this.#pendingWriteExitRejectors.delete(rejectOnExit);
453
+ // Release the guard so any late timeout callback becomes a no-op.
454
+ guardResolve();
455
+ }
456
+ }
457
+
458
+ #rejectPendingWritesForExit(): void {
459
+ this.#adapterExited = true;
460
+ for (const reject of this.#pendingWriteExitRejectors) {
461
+ reject();
462
+ }
463
+ this.#pendingWriteExitRejectors.clear();
366
464
  }
367
465
 
368
466
  async dispose(): Promise<void> {
@@ -830,6 +830,13 @@ export async function resolveOrDefaultProjectRegistryPath(cwd: string): Promise<
830
830
 
831
831
  const pluginRootsCache = new Map<string, { roots: ClaudePluginRoot[]; warnings: string[] }>();
832
832
 
833
+ const pluginCacheInvalidators = new Set<() => void>();
834
+
835
+ /** Register a process-global plugin cache invalidator called whenever plugin roots are cleared. */
836
+ export function registerPluginCacheInvalidator(invalidator: () => void): void {
837
+ pluginCacheInvalidators.add(invalidator);
838
+ }
839
+
833
840
  /**
834
841
  * List all installed Claude Code plugin roots from the plugin cache.
835
842
  * Reads ~/.claude/plugins/installed_plugins.json and ~/.omp/plugins/installed_plugins.json,
@@ -1009,6 +1016,7 @@ export async function listClaudePluginRoots(
1009
1016
  */
1010
1017
  export function clearClaudePluginRootsCache(): void {
1011
1018
  pluginRootsCache.clear();
1019
+ for (const invalidate of pluginCacheInvalidators) invalidate();
1012
1020
  preloadedPluginRoots = [...injectedPluginDirRoots];
1013
1021
  // Re-warm preloaded roots asynchronously so sync LSP config reads stay valid
1014
1022
  if (lastPreloadHome) {
@@ -132,12 +132,23 @@ export function recordSeenLines(
132
132
  * can reject edits anchored on lines the model never saw. Best-effort: a no-op
133
133
  * when the body has no numbered rows or the snapshot already aged out. `tag`
134
134
  * must be the tag returned when this exact content was recorded.
135
+ *
136
+ * `excludedLines` prunes 1-indexed line numbers whose displayed text was
137
+ * column-truncated (or otherwise not shown in full). A column-clipped row
138
+ * still carries a `NN:` prefix — the parser sees the number and would
139
+ * otherwise mark the line "seen" even though only its prefix ever reached
140
+ * the model. Producers that apply per-line column truncation MUST supply
141
+ * the clipped line set so the patcher's seen-line guard keeps rejecting
142
+ * edits against those lines until a full-width read of them occurs.
135
143
  */
136
144
  export function recordSeenLinesFromBody(
137
145
  session: FileSnapshotStoreOwner,
138
146
  absolutePath: string,
139
147
  tag: string,
140
148
  body: string,
149
+ excludedLines?: ReadonlySet<number>,
141
150
  ): void {
142
- recordSeenLines(session, absolutePath, tag, parseSeenLinesFromHashlineBody(body));
151
+ const parsed = parseSeenLinesFromHashlineBody(body);
152
+ const filtered = excludedLines && excludedLines.size > 0 ? parsed.filter(line => !excludedLines.has(line)) : parsed;
153
+ recordSeenLines(session, absolutePath, tag, filtered);
143
154
  }