@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

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/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -1,6 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import type { Agent, AgentTool } from "@oh-my-pi/pi-agent-core";
2
3
  import type { Model } from "@oh-my-pi/pi-ai";
3
- import { isRecord, logger, prompt, stringProperty } from "@oh-my-pi/pi-utils";
4
+ import { isRecord, logger, prompt, stringProperty, untilAborted } from "@oh-my-pi/pi-utils";
4
5
  import { reset as resetCapabilities } from "../capability";
5
6
  import type { ModelRegistry } from "../config/model-registry";
6
7
  import { formatModelString } from "../config/model-resolver";
@@ -71,6 +72,8 @@ interface SessionToolsOptions {
71
72
  createInspectImageTool?: () => Promise<AgentTool | null>;
72
73
  builtInToolNames?: Iterable<string>;
73
74
  presentationPinnedToolNames?: ReadonlySet<string>;
75
+ /** MCP tool names whose current registry entries came from the manager snapshot. */
76
+ mcpManagerToolNames?: Iterable<string>;
74
77
  ensureWriteRegistered?: () => Promise<boolean>;
75
78
  rebuildSystemPrompt?: (
76
79
  toolNames: string[],
@@ -185,6 +188,8 @@ export class SessionTools {
185
188
  #installedVibeToolNames = new Set<string>();
186
189
  #builtInToolNames: Set<string>;
187
190
  #rpcHostToolNames = new Set<string>();
191
+ #mcpManagerToolNames = new Set<string>();
192
+ #extensionMcpTools = new Map<string, AgentTool>();
188
193
  #xdev: XdevState | undefined;
189
194
  #pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
190
195
  /**
@@ -216,7 +221,8 @@ export class SessionTools {
216
221
  * prompt carries no catalog (no mounts, or a custom prompt that omits the section).
217
222
  */
218
223
  #basePromptXdevNames: ReadonlySet<string> = new Set();
219
- #mcpRefreshTail: Promise<void> = Promise.resolve();
224
+ #toolRegistryMutationScope = new AsyncLocalStorage<boolean>();
225
+ #toolRegistryMutationTail: Promise<void> = Promise.resolve();
220
226
  #promptModelKey: string | undefined;
221
227
  #rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"];
222
228
  #getLocalCalendarDate: () => string;
@@ -237,6 +243,17 @@ export class SessionTools {
237
243
  this.#createComputerTool = options.createComputerTool;
238
244
  this.#createInspectImageTool = options.createInspectImageTool;
239
245
  this.#builtInToolNames = new Set(options.builtInToolNames ?? []);
246
+ this.#mcpManagerToolNames = new Set(options.mcpManagerToolNames ?? []);
247
+ if (options.mcpManagerToolNames === undefined) {
248
+ for (const name of this.#toolRegistry.keys()) {
249
+ if (isMCPToolName(name)) this.#mcpManagerToolNames.add(name);
250
+ }
251
+ }
252
+ for (const [name, tool] of this.#toolRegistry) {
253
+ if (isMCPToolName(name) && !this.#mcpManagerToolNames.has(name)) {
254
+ this.#extensionMcpTools.set(name, tool);
255
+ }
256
+ }
240
257
  this.#presentationPinnedToolNames = options.presentationPinnedToolNames;
241
258
  this.#ensureWriteRegistered = options.ensureWriteRegistered;
242
259
  this.#rebuildSystemPrompt = options.rebuildSystemPrompt;
@@ -362,6 +379,65 @@ export class SessionTools {
362
379
  return this.#builtInToolNames.has(name);
363
380
  }
364
381
 
382
+ /** Updates source provenance when a live registry entry is replaced or restored. */
383
+ setToolBuiltIn(name: string, builtIn: boolean): void {
384
+ if (builtIn) {
385
+ this.#builtInToolNames.add(name);
386
+ } else {
387
+ this.#builtInToolNames.delete(name);
388
+ }
389
+ }
390
+
391
+ /** Whether the live registry entry is owned by the RPC host. */
392
+ hasRpcHostTool(name: string): boolean {
393
+ return this.#rpcHostToolNames.has(name);
394
+ }
395
+
396
+ /** Whether the current MCP entry came from the manager snapshot. */
397
+ hasMCPManagerTool(name: string): boolean {
398
+ return this.#mcpManagerToolNames.has(name);
399
+ }
400
+
401
+ /** Restores manager ownership after a lifecycle registration rollback. */
402
+ setMCPManagerTool(name: string, managerOwned: boolean): void {
403
+ if (managerOwned) {
404
+ this.#mcpManagerToolNames.add(name);
405
+ } else {
406
+ this.#mcpManagerToolNames.delete(name);
407
+ }
408
+ }
409
+
410
+ /** Current extension-owned MCP entry retained across manager refreshes. */
411
+ getExtensionMCPTool(name: string): AgentTool | undefined {
412
+ return this.#extensionMcpTools.get(name);
413
+ }
414
+
415
+ /** Updates extension ownership when a lifecycle registration commits or rolls back. */
416
+ setExtensionMCPTool(name: string, tool: AgentTool | undefined): void {
417
+ if (!isMCPToolName(name)) return;
418
+ if (tool) {
419
+ this.#extensionMcpTools.set(name, tool);
420
+ this.#mcpManagerToolNames.delete(name);
421
+ } else {
422
+ this.#extensionMcpTools.delete(name);
423
+ }
424
+ }
425
+
426
+ /** Serializes every registry and presentation mutation for this session. */
427
+ runToolRegistryMutation<T>(mutation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
428
+ if (this.#toolRegistryMutationScope.getStore()) return untilAborted(signal, mutation);
429
+ const serialized = this.#toolRegistryMutationTail.then(() => {
430
+ signal?.throwIfAborted();
431
+ return this.#toolRegistryMutationScope.run(true, mutation);
432
+ });
433
+ const operation = untilAborted(signal, serialized);
434
+ this.#toolRegistryMutationTail = serialized.then(
435
+ () => undefined,
436
+ () => undefined,
437
+ );
438
+ return operation;
439
+ }
440
+
365
441
  /** Names of every registered tool. */
366
442
  getAllToolNames(): string[] {
367
443
  return Array.from(this.#toolRegistry.keys());
@@ -401,40 +477,46 @@ export class SessionTools {
401
477
  }
402
478
 
403
479
  /** Installs and activates the ephemeral vibe tool set. */
404
- async activateVibeTools(baseToolNames: string[]): Promise<void> {
405
- const createVibeTools = this.#createVibeTools;
406
- if (!createVibeTools) {
407
- throw new Error("Vibe tools are unavailable in this session.");
408
- }
480
+ activateVibeTools(baseToolNames: string[]): Promise<void> {
481
+ return this.runToolRegistryMutation(async () => {
482
+ const createVibeTools = this.#createVibeTools;
483
+ if (!createVibeTools) {
484
+ throw new Error("Vibe tools are unavailable in this session.");
485
+ }
409
486
 
410
- const tools = createVibeTools();
411
- const vibeToolNames = tools.map(tool => tool.name);
412
- if (new Set(vibeToolNames).size !== vibeToolNames.length) {
413
- throw new Error("Vibe tool names must be unique.");
414
- }
487
+ const tools = createVibeTools();
488
+ const vibeToolNames = tools.map(tool => tool.name);
489
+ if (new Set(vibeToolNames).size !== vibeToolNames.length) {
490
+ throw new Error("Vibe tool names must be unique.");
491
+ }
415
492
 
416
- for (const tool of tools) {
417
- if (this.#toolRegistry.has(tool.name)) continue;
418
- this.#toolRegistry.set(tool.name, this.#wrapRuntimeTool(tool));
419
- this.#builtInToolNames.add(tool.name);
420
- this.#installedVibeToolNames.add(tool.name);
421
- }
493
+ for (const tool of tools) {
494
+ if (this.#toolRegistry.has(tool.name)) continue;
495
+ this.#toolRegistry.set(tool.name, this.#wrapRuntimeTool(tool));
496
+ this.#builtInToolNames.add(tool.name);
497
+ this.#installedVibeToolNames.add(tool.name);
498
+ }
422
499
 
423
- await this.applyActiveToolsByName([...new Set([...baseToolNames, ...vibeToolNames])]);
500
+ await this.#applyActiveToolsByName([...new Set([...baseToolNames, ...vibeToolNames])]);
501
+ });
424
502
  }
425
503
 
426
504
  /** Uninstalls vibe tools and activates the replacement set. */
427
- async deactivateVibeTools(nextToolNames: string[]): Promise<void> {
428
- this.#uninstallVibeTools();
429
- await this.applyActiveToolsByName(nextToolNames);
505
+ deactivateVibeTools(nextToolNames: string[]): Promise<void> {
506
+ return this.runToolRegistryMutation(async () => {
507
+ this.#uninstallVibeTools();
508
+ await this.#applyActiveToolsByName(nextToolNames);
509
+ });
430
510
  }
431
511
 
432
512
  /** Removes vibe tools without restoring a source-session snapshot. */
433
- async removeVibeToolsPreservingActive(): Promise<void> {
434
- const removed = new Set(this.#installedVibeToolNames);
435
- this.#uninstallVibeTools();
436
- const nextActive = this.getActiveToolNames().filter(name => !removed.has(name));
437
- await this.applyActiveToolsByName(nextActive);
513
+ removeVibeToolsPreservingActive(): Promise<void> {
514
+ return this.runToolRegistryMutation(async () => {
515
+ const removed = new Set(this.#installedVibeToolNames);
516
+ this.#uninstallVibeTools();
517
+ const nextActive = this.getActiveToolNames().filter(name => !removed.has(name));
518
+ await this.#applyActiveToolsByName(nextActive);
519
+ });
438
520
  }
439
521
 
440
522
  #uninstallVibeTools(): void {
@@ -633,11 +715,20 @@ export class SessionTools {
633
715
  }
634
716
 
635
717
  /** Applies an enabled tool set and reconciles its `xd://` partition. */
636
- async applyActiveToolsByName(toolNames: string[]): Promise<void> {
718
+ applyActiveToolsByName(toolNames: string[], forcePromptRefresh = false, signal?: AbortSignal): Promise<void> {
719
+ return this.runToolRegistryMutation(
720
+ () => this.#applyActiveToolsByName(toolNames, forcePromptRefresh, signal),
721
+ signal,
722
+ );
723
+ }
724
+
725
+ async #applyActiveToolsByName(toolNames: string[], forcePromptRefresh = false, signal?: AbortSignal): Promise<void> {
726
+ signal?.throwIfAborted();
637
727
  toolNames = normalizeToolNames(toolNames);
638
728
  let builtInWriteAvailable = this.#builtInToolNames.has("write");
639
729
  if (toolNames.includes("write") && !builtInWriteAvailable) {
640
- builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
730
+ const writeRegistration = this.#ensureWriteRegistered?.();
731
+ builtInWriteAvailable = writeRegistration ? (await untilAborted(signal, writeRegistration)) === true : false;
641
732
  if (builtInWriteAvailable) this.#builtInToolNames.add("write");
642
733
  }
643
734
  const selectedTools = toolNames.flatMap(name => {
@@ -669,7 +760,8 @@ export class SessionTools {
669
760
  const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
670
761
  const transportNeeded = mountNames.size > 0 || activeDeferrableTool || this.#host.planModeEnabled();
671
762
  if (transportNeeded && !builtInWriteAvailable) {
672
- builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
763
+ const writeRegistration = this.#ensureWriteRegistered?.();
764
+ builtInWriteAvailable = writeRegistration ? (await untilAborted(signal, writeRegistration)) === true : false;
673
765
  if (builtInWriteAvailable) this.#builtInToolNames.add("write");
674
766
  }
675
767
  if (transportNeeded && builtInWriteAvailable) {
@@ -699,13 +791,14 @@ export class SessionTools {
699
791
  try {
700
792
  if (this.#rebuildSystemPrompt) {
701
793
  const signature = this.#computeAppliedToolSignature(validToolNames, tools);
702
- if (signature !== this.#lastAppliedToolSignature) {
703
- const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
794
+ if (forcePromptRefresh || signature !== this.#lastAppliedToolSignature) {
795
+ const built = await untilAborted(signal, this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry));
704
796
  rebuiltSystemPrompt = built.systemPrompt;
705
797
  rebuiltSignature = signature;
706
798
  rebuiltXdevCatalogNames = built.xdevCatalogNames;
707
799
  }
708
800
  }
801
+ signal?.throwIfAborted();
709
802
  } catch (error) {
710
803
  this.#setMountedNames(previousMounted);
711
804
  this.#setActiveToolNames?.(previousActiveToolNames);
@@ -917,15 +1010,17 @@ export class SessionTools {
917
1010
  }
918
1011
 
919
1012
  /** Selects enabled tools, ignoring names absent from the registry. */
920
- async setActiveToolsByName(toolNames: string[]): Promise<void> {
921
- const normalized = normalizeToolNames(toolNames);
922
- // Transport-write eligibility keys off the *current* active set: an ordinary
923
- // selection change should not demote `write` unless it is already active.
924
- await this.#applyToolPresentation(
925
- normalized,
926
- this.#xdev?.mountedNames ?? new Set(),
927
- this.getActiveToolNames().includes("write"),
928
- );
1013
+ setActiveToolsByName(toolNames: string[]): Promise<void> {
1014
+ return this.runToolRegistryMutation(async () => {
1015
+ const normalized = normalizeToolNames(toolNames);
1016
+ // Transport-write eligibility keys off the *current* active set: an ordinary
1017
+ // selection change should not demote `write` unless it is already active.
1018
+ await this.#applyToolPresentation(
1019
+ normalized,
1020
+ this.#xdev?.mountedNames ?? new Set(),
1021
+ this.getActiveToolNames().includes("write"),
1022
+ );
1023
+ });
929
1024
  }
930
1025
 
931
1026
  /**
@@ -938,19 +1033,31 @@ export class SessionTools {
938
1033
  * `xd://` remain mount-eligible, even when the live mount set has drifted.
939
1034
  *
940
1035
  * Names outside `mountedToolNames` are pinned top-level for this application;
941
- * names in the mounted subset remain eligible for xdev mounting. Delegates the
942
- * actual apply through {@link applyActiveToolsByName} and restores the prior runtime
943
- * selection if that apply throws.
1036
+ * names in the mounted subset remain eligible for xdev mounting. Set
1037
+ * `forcePromptRefresh` when an enabled tool's schema or prompt-visible metadata
1038
+ * changed without changing its name or presentation.
1039
+ *
1040
+ * Delegates the actual apply through {@link applyActiveToolsByName} and restores
1041
+ * the prior runtime selection if that apply throws.
944
1042
  */
945
- async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
946
- const normalized = normalizeToolNames(toolNames);
947
- // Restoration targets a snapshot, so write eligibility comes from the
948
- // *target* set rather than whatever happens to be active mid-rollback.
949
- await this.#applyToolPresentation(
950
- normalized,
951
- new Set(normalizeToolNames(mountedToolNames)),
952
- normalized.includes("write"),
953
- );
1043
+ setActiveToolPresentation(
1044
+ toolNames: string[],
1045
+ mountedToolNames: string[],
1046
+ forcePromptRefresh = false,
1047
+ signal?: AbortSignal,
1048
+ ): Promise<void> {
1049
+ return this.runToolRegistryMutation(async () => {
1050
+ const normalized = normalizeToolNames(toolNames);
1051
+ // Restoration targets a snapshot, so write eligibility comes from the
1052
+ // *target* set rather than whatever happens to be active mid-rollback.
1053
+ await this.#applyToolPresentation(
1054
+ normalized,
1055
+ new Set(normalizeToolNames(mountedToolNames)),
1056
+ normalized.includes("write"),
1057
+ forcePromptRefresh,
1058
+ signal,
1059
+ );
1060
+ }, signal);
954
1061
  }
955
1062
 
956
1063
  /**
@@ -962,6 +1069,8 @@ export class SessionTools {
962
1069
  normalized: string[],
963
1070
  mounted: ReadonlySet<string>,
964
1071
  writeSelected: boolean,
1072
+ forcePromptRefresh = false,
1073
+ signal?: AbortSignal,
965
1074
  ): Promise<void> {
966
1075
  const transportWriteActive =
967
1076
  writeSelected &&
@@ -974,7 +1083,7 @@ export class SessionTools {
974
1083
  normalized.filter(name => !mounted.has(name) && !(name === "write" && transportWriteActive)),
975
1084
  );
976
1085
  try {
977
- await this.applyActiveToolsByName(normalized);
1086
+ await this.#applyActiveToolsByName(normalized, forcePromptRefresh, signal);
978
1087
  } catch (error) {
979
1088
  this.#runtimeSelectedToolNames = previousRuntimeSelectedToolNames;
980
1089
  throw error;
@@ -982,24 +1091,26 @@ export class SessionTools {
982
1091
  }
983
1092
 
984
1093
  /** Replaces memory-backend tools while preserving unrelated selections. */
985
- async replaceMemoryTools(tools: AgentTool[]): Promise<void> {
986
- const removed = new Set<string>(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name)));
987
- const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name));
988
- for (const name of removed) {
989
- this.#toolRegistry.delete(name);
990
- this.#builtInToolNames.delete(name);
991
- }
1094
+ replaceMemoryTools(tools: AgentTool[]): Promise<void> {
1095
+ return this.runToolRegistryMutation(async () => {
1096
+ const removed = new Set<string>(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name)));
1097
+ const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name));
1098
+ for (const name of removed) {
1099
+ this.#toolRegistry.delete(name);
1100
+ this.#builtInToolNames.delete(name);
1101
+ }
992
1102
 
993
- for (const tool of tools) {
994
- if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) {
995
- continue;
1103
+ for (const tool of tools) {
1104
+ if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) {
1105
+ continue;
1106
+ }
1107
+ const wrapped = this.#wrapRuntimeTool(tool);
1108
+ this.#toolRegistry.set(wrapped.name, wrapped);
1109
+ this.#builtInToolNames.add(wrapped.name);
1110
+ nextActive.push(wrapped.name);
996
1111
  }
997
- const wrapped = this.#wrapRuntimeTool(tool);
998
- this.#toolRegistry.set(wrapped.name, wrapped);
999
- this.#builtInToolNames.add(wrapped.name);
1000
- nextActive.push(wrapped.name);
1001
- }
1002
- await this.applyActiveToolsByName([...new Set(nextActive)]);
1112
+ await this.#applyActiveToolsByName([...new Set(nextActive)]);
1113
+ });
1003
1114
  }
1004
1115
 
1005
1116
  /**
@@ -1015,34 +1126,36 @@ export class SessionTools {
1015
1126
  * @returns false when enabling was requested but this session cannot build the
1016
1127
  * tool (e.g. restricted child sessions have no factory).
1017
1128
  */
1018
- async setComputerToolEnabled(enabled: boolean): Promise<boolean> {
1019
- const logState = (): void => this.#logComputerState("Computer tool state changed", enabled);
1020
- const active = this.getEnabledToolNames();
1021
- if (!enabled) {
1022
- if (active.includes("computer")) {
1023
- await this.applyActiveToolsByName(active.filter(name => name !== "computer"));
1129
+ setComputerToolEnabled(enabled: boolean): Promise<boolean> {
1130
+ return this.runToolRegistryMutation(async () => {
1131
+ const logState = (): void => this.#logComputerState("Computer tool state changed", enabled);
1132
+ const active = this.getEnabledToolNames();
1133
+ if (!enabled) {
1134
+ if (active.includes("computer")) {
1135
+ await this.#applyActiveToolsByName(active.filter(name => name !== "computer"));
1136
+ }
1137
+ logState();
1138
+ return true;
1139
+ }
1140
+ if (!this.#toolRegistry.has("computer")) {
1141
+ const tool = await this.#createComputerTool?.();
1142
+ if (tool?.name !== "computer") {
1143
+ const model = this.#host.model();
1144
+ logger.warn("Computer tool could not be created", {
1145
+ model: model ? formatModelString(model) : undefined,
1146
+ });
1147
+ return false;
1148
+ }
1149
+ const wrapped = this.#wrapRuntimeTool(tool);
1150
+ this.#toolRegistry.set(wrapped.name, wrapped);
1151
+ this.#builtInToolNames.add(wrapped.name);
1152
+ }
1153
+ if (!active.includes("computer")) {
1154
+ await this.#applyActiveToolsByName([...active, "computer"]);
1024
1155
  }
1025
1156
  logState();
1026
1157
  return true;
1027
- }
1028
- if (!this.#toolRegistry.has("computer")) {
1029
- const tool = await this.#createComputerTool?.();
1030
- if (tool?.name !== "computer") {
1031
- const model = this.#host.model();
1032
- logger.warn("Computer tool could not be created", {
1033
- model: model ? formatModelString(model) : undefined,
1034
- });
1035
- return false;
1036
- }
1037
- const wrapped = this.#wrapRuntimeTool(tool);
1038
- this.#toolRegistry.set(wrapped.name, wrapped);
1039
- this.#builtInToolNames.add(wrapped.name);
1040
- }
1041
- if (!active.includes("computer")) {
1042
- await this.applyActiveToolsByName([...active, "computer"]);
1043
- }
1044
- logState();
1045
- return true;
1158
+ });
1046
1159
  }
1047
1160
 
1048
1161
  /** Current effective inspect_image state for `/vision status`. */
@@ -1065,48 +1178,50 @@ export class SessionTools {
1065
1178
  * @returns false when the tool should be active but this session cannot
1066
1179
  * build it (e.g. restricted child sessions have no factory).
1067
1180
  */
1068
- async reconcileInspectImageTool(): Promise<boolean> {
1069
- const expected = isInspectImageToolActive({
1070
- settings: this.#host.settings,
1071
- getActiveModel: () => this.#host.model(),
1072
- getInspectImageModeOverride: () => this.#host.getInspectImageModeOverride(),
1073
- });
1074
- // Keep the read tool's advertised description in sync BEFORE any prompt
1075
- // rebuild below, passing the post-change availability so the prompt never
1076
- // lags a flip in either direction. Per-read lazy sync is the backstop.
1077
- const syncReadDescription = (available: boolean): void => {
1078
- const readTool = this.#toolRegistry.get("read") as
1079
- | { syncInspectImageState?: (available?: boolean) => boolean }
1080
- | undefined;
1081
- readTool?.syncInspectImageState?.(available);
1082
- };
1083
- const active = this.getEnabledToolNames();
1084
- const isActive = active.includes("inspect_image");
1085
- if (expected === isActive) {
1086
- syncReadDescription(isActive);
1087
- return true;
1088
- }
1089
- if (!expected) {
1090
- syncReadDescription(false);
1091
- await this.applyActiveToolsByName(active.filter(name => name !== "inspect_image"));
1092
- return true;
1093
- }
1094
- if (!this.#toolRegistry.has("inspect_image")) {
1095
- const tool = await this.#createInspectImageTool?.();
1096
- if (tool?.name !== "inspect_image") {
1097
- logger.warn("inspect_image tool could not be created", {
1098
- model: this.#host.model()?.id,
1099
- });
1181
+ reconcileInspectImageTool(): Promise<boolean> {
1182
+ return this.runToolRegistryMutation(async () => {
1183
+ const expected = isInspectImageToolActive({
1184
+ settings: this.#host.settings,
1185
+ getActiveModel: () => this.#host.model(),
1186
+ getInspectImageModeOverride: () => this.#host.getInspectImageModeOverride(),
1187
+ });
1188
+ // Keep the read tool's advertised description in sync BEFORE any prompt
1189
+ // rebuild below, passing the post-change availability so the prompt never
1190
+ // lags a flip in either direction. Per-read lazy sync is the backstop.
1191
+ const syncReadDescription = (available: boolean): void => {
1192
+ const readTool = this.#toolRegistry.get("read") as
1193
+ | { syncInspectImageState?: (available?: boolean) => boolean }
1194
+ | undefined;
1195
+ readTool?.syncInspectImageState?.(available);
1196
+ };
1197
+ const active = this.getEnabledToolNames();
1198
+ const isActive = active.includes("inspect_image");
1199
+ if (expected === isActive) {
1200
+ syncReadDescription(isActive);
1201
+ return true;
1202
+ }
1203
+ if (!expected) {
1100
1204
  syncReadDescription(false);
1101
- return false;
1205
+ await this.#applyActiveToolsByName(active.filter(name => name !== "inspect_image"));
1206
+ return true;
1102
1207
  }
1103
- const wrapped = this.#wrapRuntimeTool(tool);
1104
- this.#toolRegistry.set(wrapped.name, wrapped);
1105
- this.#builtInToolNames.add(wrapped.name);
1106
- }
1107
- syncReadDescription(true);
1108
- await this.applyActiveToolsByName([...active, "inspect_image"]);
1109
- return true;
1208
+ if (!this.#toolRegistry.has("inspect_image")) {
1209
+ const tool = await this.#createInspectImageTool?.();
1210
+ if (tool?.name !== "inspect_image") {
1211
+ logger.warn("inspect_image tool could not be created", {
1212
+ model: this.#host.model()?.id,
1213
+ });
1214
+ syncReadDescription(false);
1215
+ return false;
1216
+ }
1217
+ const wrapped = this.#wrapRuntimeTool(tool);
1218
+ this.#toolRegistry.set(wrapped.name, wrapped);
1219
+ this.#builtInToolNames.add(wrapped.name);
1220
+ }
1221
+ syncReadDescription(true);
1222
+ await this.#applyActiveToolsByName([...active, "inspect_image"]);
1223
+ return true;
1224
+ });
1110
1225
  }
1111
1226
 
1112
1227
  /**
@@ -1115,20 +1230,22 @@ export class SessionTools {
1115
1230
  * path — including retry-fallback switches that bypass
1116
1231
  * {@link syncAfterModelChange}.
1117
1232
  */
1118
- async reconcileInspectImageAfterModelChange(): Promise<void> {
1119
- const before = this.getEnabledToolNames().includes("inspect_image");
1120
- const reconciled = await this.reconcileInspectImageTool();
1121
- const after = this.getEnabledToolNames().includes("inspect_image");
1122
- if (!reconciled || before === after) return;
1123
- const model = this.#host.model();
1124
- const modelName = model ? formatModelString(model) : "the current model";
1125
- this.#host.emitNotice(
1126
- "info",
1127
- after
1128
- ? `inspect_image is now available: ${modelName} has no native image input.`
1129
- : `inspect_image is now hidden: ${modelName} supports image input natively. Override with /vision on.`,
1130
- "vision",
1131
- );
1233
+ reconcileInspectImageAfterModelChange(): Promise<void> {
1234
+ return this.runToolRegistryMutation(async () => {
1235
+ const before = this.getEnabledToolNames().includes("inspect_image");
1236
+ const reconciled = await this.reconcileInspectImageTool();
1237
+ const after = this.getEnabledToolNames().includes("inspect_image");
1238
+ if (!reconciled || before === after) return;
1239
+ const model = this.#host.model();
1240
+ const modelName = model ? formatModelString(model) : "the current model";
1241
+ this.#host.emitNotice(
1242
+ "info",
1243
+ after
1244
+ ? `inspect_image is now available: ${modelName} has no native image input.`
1245
+ : `inspect_image is now hidden: ${modelName} supports image input natively. Override with /vision on.`,
1246
+ "vision",
1247
+ );
1248
+ });
1132
1249
  }
1133
1250
 
1134
1251
  /**
@@ -1139,16 +1256,22 @@ export class SessionTools {
1139
1256
  *
1140
1257
  * @returns false when `on` was requested but the tool cannot be built here.
1141
1258
  */
1142
- async setInspectImageMode(mode: InspectImageMode): Promise<boolean> {
1143
- this.#host.setInspectImageModeOverride(mode === "auto" ? undefined : mode);
1144
- const applied = await this.reconcileInspectImageTool();
1145
- const { active, model } = this.inspectImageState();
1146
- logger.debug("inspect_image mode changed", { mode, active, model });
1147
- return applied;
1259
+ setInspectImageMode(mode: InspectImageMode): Promise<boolean> {
1260
+ return this.runToolRegistryMutation(async () => {
1261
+ this.#host.setInspectImageModeOverride(mode === "auto" ? undefined : mode);
1262
+ const applied = await this.reconcileInspectImageTool();
1263
+ const { active, model } = this.inspectImageState();
1264
+ logger.debug("inspect_image mode changed", { mode, active, model });
1265
+ return applied;
1266
+ });
1148
1267
  }
1149
1268
 
1150
1269
  /** Rebuilds the stable base prompt for the current tools and model. */
1151
- async refreshBaseSystemPrompt(): Promise<void> {
1270
+ refreshBaseSystemPrompt(): Promise<void> {
1271
+ return this.runToolRegistryMutation(() => this.#refreshBaseSystemPrompt());
1272
+ }
1273
+
1274
+ async #refreshBaseSystemPrompt(): Promise<void> {
1152
1275
  if (this.#host.isDisposed() || !this.#rebuildSystemPrompt) return;
1153
1276
  const activeToolNames = this.getActiveToolNames();
1154
1277
  this.#setActiveToolNames?.(activeToolNames);
@@ -1299,32 +1422,25 @@ export class SessionTools {
1299
1422
  */
1300
1423
  refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
1301
1424
  const snapshot = [...mcpTools];
1302
- const refresh = this.#mcpRefreshTail.then(() =>
1303
- this.#host.isDisposed() ? undefined : this.#applyMCPToolRefresh(snapshot),
1425
+ return this.runToolRegistryMutation(() =>
1426
+ this.#host.isDisposed() ? Promise.resolve() : this.#applyMCPToolRefresh(snapshot),
1304
1427
  );
1305
- this.#mcpRefreshTail = refresh.catch(() => {});
1306
- return refresh;
1307
1428
  }
1308
1429
 
1309
1430
  async #applyMCPToolRefresh(mcpTools: CustomTool[]): Promise<void> {
1310
- const existingNames = Array.from(this.#toolRegistry.keys());
1311
- const previousMcpTools = new Map(
1312
- existingNames.flatMap(name => {
1313
- const tool = this.#toolRegistry.get(name);
1314
- return isMCPToolName(name) && tool ? [[name, tool] as const] : [];
1315
- }),
1316
- );
1431
+ const previousMcpTools = new Map<string, AgentTool>();
1432
+ for (const [name, tool] of this.#toolRegistry) {
1433
+ if (isMCPToolName(name)) previousMcpTools.set(name, tool);
1434
+ }
1435
+ const previousMcpManagerToolNames = new Set(this.#mcpManagerToolNames);
1436
+ const previousActiveMcpToolNames = this.getEnabledToolNames().filter(isMCPToolName);
1317
1437
  const restorePreviousMcpTools = () => {
1318
1438
  for (const name of this.#toolRegistry.keys()) {
1319
1439
  if (isMCPToolName(name)) this.#toolRegistry.delete(name);
1320
1440
  }
1321
1441
  for (const [name, tool] of previousMcpTools) this.#toolRegistry.set(name, tool);
1442
+ this.#mcpManagerToolNames = previousMcpManagerToolNames;
1322
1443
  };
1323
- for (const name of existingNames) {
1324
- if (isMCPToolName(name)) {
1325
- this.#toolRegistry.delete(name);
1326
- }
1327
- }
1328
1444
 
1329
1445
  const getCustomToolContext = (): CustomToolContext => ({
1330
1446
  sessionManager: this.#host.sessionManager,
@@ -1340,20 +1456,36 @@ export class SessionTools {
1340
1456
  });
1341
1457
 
1342
1458
  const extensionRunner = this.#host.extensionRunner();
1343
- const uniqueMcpTools = deduplicateMCPToolsByName(mcpTools);
1344
- for (const customTool of uniqueMcpTools) {
1459
+ const managerTools = deduplicateMCPToolsByName(mcpTools).map(customTool => {
1345
1460
  const wrapped = wrapToolWithMetaNotice(CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool);
1346
- const finalTool = (
1347
- extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped
1348
- ) as AgentTool;
1349
- this.#toolRegistry.set(finalTool.name, finalTool);
1461
+ return (extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped) as AgentTool;
1462
+ });
1463
+ const managerToolSet = new Set(managerTools);
1464
+ const reconciledTools = deduplicateMCPToolsByName([...this.#extensionMcpTools.values(), ...managerTools]);
1465
+
1466
+ for (const name of this.#toolRegistry.keys()) {
1467
+ if (isMCPToolName(name)) this.#toolRegistry.delete(name);
1468
+ }
1469
+ this.#mcpManagerToolNames.clear();
1470
+ for (const tool of reconciledTools) {
1471
+ this.#toolRegistry.set(tool.name, tool);
1472
+ if (managerToolSet.has(tool)) this.#mcpManagerToolNames.add(tool.name);
1350
1473
  }
1351
1474
 
1352
- // Every connected MCP tool is selected; centralized repartitioning owns
1353
- // presentation pins and write-transport activation/removal.
1354
- const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...uniqueMcpTools.map(tool => tool.name)])];
1475
+ // Connected manager tools become active immediately. Extension-owned MCP
1476
+ // tools retain their prior selection while both sets share one registry.
1477
+ const retainedActiveExtensionToolNames = previousActiveMcpToolNames.filter(
1478
+ name => this.#extensionMcpTools.has(name) && this.#toolRegistry.has(name),
1479
+ );
1480
+ const nextActive = [
1481
+ ...new Set([
1482
+ ...this.#getActiveNonMCPToolNames(),
1483
+ ...this.#mcpManagerToolNames,
1484
+ ...retainedActiveExtensionToolNames,
1485
+ ]),
1486
+ ];
1355
1487
  try {
1356
- await this.applyActiveToolsByName(nextActive);
1488
+ await this.#applyActiveToolsByName(nextActive);
1357
1489
  if (this.#host.isDisposed()) restorePreviousMcpTools();
1358
1490
  } catch (error) {
1359
1491
  restorePreviousMcpTools();
@@ -1362,7 +1494,12 @@ export class SessionTools {
1362
1494
  }
1363
1495
 
1364
1496
  /** Replaces RPC host-owned tools and refreshes the active set before the next model call. */
1365
- async refreshRpcHostTools(rpcTools: AgentTool[]): Promise<void> {
1497
+ refreshRpcHostTools(rpcTools: AgentTool[]): Promise<void> {
1498
+ const snapshot = [...rpcTools];
1499
+ return this.runToolRegistryMutation(() => this.#applyRpcHostToolRefresh(snapshot));
1500
+ }
1501
+
1502
+ async #applyRpcHostToolRefresh(rpcTools: AgentTool[]): Promise<void> {
1366
1503
  const nextToolNames = rpcTools.map(tool => tool.name);
1367
1504
  const uniqueToolNames = new Set(nextToolNames);
1368
1505
  if (uniqueToolNames.size !== nextToolNames.length) {
@@ -1406,7 +1543,7 @@ export class SessionTools {
1406
1543
  .filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
1407
1544
  .map(tool => tool.name);
1408
1545
  try {
1409
- await this.applyActiveToolsByName(
1546
+ await this.#applyActiveToolsByName(
1410
1547
  Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
1411
1548
  );
1412
1549
  } catch (error) {