@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
@@ -239,9 +239,12 @@ function overflowFrame(frame: object): object {
239
239
  };
240
240
  }
241
241
 
242
- /** Serialize a complete JSONL frame while enforcing the transport byte ceiling. */
243
- export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamedMessages?: readonly unknown[]): string {
244
- let json = JSON.stringify(frame);
242
+ function encodeRpcFrameFromJson(
243
+ frame: object,
244
+ json: string,
245
+ streamedMessageCount: number,
246
+ streamedMessages?: readonly unknown[],
247
+ ): string {
245
248
  if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`;
246
249
  if (isRecord(frame) && frame.type === "response") {
247
250
  return `${JSON.stringify(overflowFrame(frame))}\n`;
@@ -259,6 +262,11 @@ export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamed
259
262
  return `${JSON.stringify(overflowFrame(compacted))}\n`;
260
263
  }
261
264
 
265
+ /** Serialize a complete JSONL frame while enforcing the transport byte ceiling. */
266
+ export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamedMessages?: readonly unknown[]): string {
267
+ return encodeRpcFrameFromJson(frame, JSON.stringify(frame), streamedMessageCount, streamedMessages);
268
+ }
269
+
262
270
  /** Stateful encoder that tracks which messages a client has already received. */
263
271
  export class RpcFrameEncoder {
264
272
  #streamedMessages: unknown[] = [];
@@ -292,14 +300,14 @@ export class RpcFrameEncoder {
292
300
  frames = [singleFrame];
293
301
  }
294
302
  } else {
295
- singleFrame = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages);
303
+ singleFrame = encodeRpcFrameFromJson(frame, json, this.#streamedMessages.length, this.#streamedMessages);
296
304
  frames = [singleFrame];
297
305
  }
298
306
  if (!isRecord(frame)) return frames;
299
307
  if (frame.type === "message_end") {
300
308
  const snapshot =
301
309
  this.#protocolVersion === 2 && Object.hasOwn(frame, "message")
302
- ? { message: jsonSnapshot(frame.message) }
310
+ ? (encodedMessageSnapshot(json) ?? { message: jsonSnapshot(frame.message) })
303
311
  : singleFrame !== undefined
304
312
  ? encodedMessageSnapshot(singleFrame)
305
313
  : undefined;
@@ -148,18 +148,17 @@ export function getMarkdownTheme(): MarkdownTheme {
148
148
  }
149
149
  const mermaid = markdownMermaidRendering
150
150
  ? (() => {
151
- // Mermaid ASCII diagrams render with the active palette so they read as
152
- // content rather than raw monochrome. Roles mirror the SVG renderer's
153
- // mapping; `text`/`muted`/`border`/`borderMuted`/`accent` exist in every theme.
151
+ // Diagram geometry is content, so keep every structural stroke on the
152
+ // theme's readable muted foreground instead of subtle UI chrome borders.
154
153
  const mermaidColorMode =
155
154
  theme.getColorMode() === "truecolor" ? ("truecolor" as const) : ("ansi256" as const);
156
155
  const mermaidTheme = {
157
156
  fg: theme.getColorHex("text"),
158
- border: theme.getColorHex("border"),
157
+ border: theme.getColorHex("muted"),
159
158
  line: theme.getColorHex("muted"),
160
159
  arrow: theme.getColorHex("accent"),
161
160
  corner: theme.getColorHex("muted"),
162
- junction: theme.getColorHex("borderMuted"),
161
+ junction: theme.getColorHex("muted"),
163
162
  };
164
163
  return { mermaidColorMode, mermaidTheme };
165
164
  })()
@@ -8,6 +8,7 @@ import type { KeybindingsManager } from "../config/keybindings";
8
8
  import type { Settings } from "../config/settings";
9
9
  import type {
10
10
  AutocompleteProviderFactory,
11
+ ExtensionCustomOptions,
11
12
  ExtensionUIContext,
12
13
  ExtensionUIDialogOptions,
13
14
  ExtensionUISelectItem,
@@ -113,6 +114,7 @@ export interface InteractiveModeContext {
113
114
  omfgContainer: Container;
114
115
  errorBannerContainer: Container;
115
116
  modelCycleContainer: Container;
117
+ deferredCommandContainer: Container;
116
118
  editor: CustomEditor;
117
119
  editorContainer: Container;
118
120
  hookWidgetContainerAbove: Container;
@@ -436,12 +438,16 @@ export interface InteractiveModeContext {
436
438
  toggleToolOutputExpansion(): void;
437
439
  setToolsExpanded(expanded: boolean): void;
438
440
  toggleThinkingBlockVisibility(): void;
439
- openExternalEditor(): void;
440
- registerExtensionShortcuts(): void;
441
- handlePlanModeCommand(initialPrompt?: string): Promise<void>;
442
- handleVibeModeCommand(initialPrompt?: string): Promise<void>;
443
- handleGoalModeCommand(rest?: string): Promise<void>;
444
- handleGuidedGoalCommand(rest?: string): Promise<void>;
441
+ handlePlanModeCommand(
442
+ initialPrompt?: string,
443
+ input?: Pick<SubmittedUserInput, "images" | "imageLinks">,
444
+ ): Promise<boolean>;
445
+ handleVibeModeCommand(
446
+ initialPrompt?: string,
447
+ input?: Pick<SubmittedUserInput, "images" | "imageLinks">,
448
+ ): Promise<boolean>;
449
+ handleGoalModeCommand(rest?: string, input?: Pick<SubmittedUserInput, "images" | "imageLinks">): Promise<boolean>;
450
+ handleGuidedGoalCommand(rest?: string, input?: Pick<SubmittedUserInput, "images" | "imageLinks">): Promise<boolean>;
445
451
  handleLoopCommand(args?: string): Promise<string | undefined>;
446
452
  setLoopPrompt(prompt: string): void;
447
453
  disableLoopMode(): void;
@@ -487,7 +493,7 @@ export interface InteractiveModeContext {
487
493
  keybindings: KeybindingsManager,
488
494
  done: (result: T) => void,
489
495
  ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
490
- options?: { overlay?: boolean },
496
+ options?: ExtensionCustomOptions,
491
497
  ): Promise<T>;
492
498
  showExtensionError(extensionPath: string, error: string): void;
493
499
  showToolError(toolName: string, error: string): void;
@@ -1,4 +1,4 @@
1
- import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
1
+ import { type AppKeybinding, type KeybindingsManager, keyHintPlatform, modifierLabel } from "../../config/keybindings";
2
2
 
3
3
  export interface HotkeysMarkdownBindings {
4
4
  keybindings: Pick<KeybindingsManager, "getDisplayString">;
@@ -9,21 +9,25 @@ function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): strin
9
9
  }
10
10
 
11
11
  export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string {
12
+ const platform = keyHintPlatform();
13
+ const isMac = platform === "darwin";
14
+ const alt = modifierLabel("alt", platform);
15
+ const cmd = modifierLabel("super", platform);
12
16
  return [
13
17
  "**Navigation**",
14
18
  "| Key | Action |",
15
19
  "|-----|--------|",
16
20
  "| `Arrow keys` | Move cursor / browse history (Up when empty) |",
17
- "| `Option+Left/Right` | Move by word |",
18
- "| `Ctrl+A` / `Home` / `Cmd+Left` | Start of line |",
19
- "| `Ctrl+E` / `End` / `Cmd+Right` | End of line |",
21
+ `| \`${alt}+Left/Right\` | Move by word |`,
22
+ isMac ? `| \`Ctrl+A\` / \`Home\` / \`${cmd}+Left\` | Start of line |` : "| `Ctrl+A` / `Home` | Start of line |",
23
+ isMac ? `| \`Ctrl+E\` / \`End\` / \`${cmd}+Right\` | End of line |` : "| `Ctrl+E` / `End` | End of line |",
20
24
  "",
21
25
  "**Editing**",
22
26
  "| Key | Action |",
23
27
  "|-----|--------|",
24
28
  "| `Enter` | Send message |",
25
- "| `Shift+Enter` / `Alt+Enter` | New line |",
26
- "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
29
+ `| \`Shift+Enter\` / \`${alt}+Enter\` | New line |`,
30
+ `| \`Ctrl+W\` / \`${alt}+Backspace\` | Delete word backwards |`,
27
31
  "| `Ctrl+U` | Delete to start of line |",
28
32
  "| `Ctrl+K` | Delete to end of line |",
29
33
  `| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
@@ -120,7 +120,7 @@ You MUST use the specialized tool over its shell equivalent:
120
120
  {{#has tools "edit"}}- Surgical edits → `{{toolRefs.edit}}`.{{/has}}
121
121
  {{#has tools "write"}}- Create or overwrite → `{{toolRefs.write}}`.{{/has}}
122
122
  {{#has tools "lsp"}}- When a language server is available, MUST use `{{toolRefs.lsp}}` for definition, type_definition, implementation, references, and hover; for refactors, imports, and fixes, list code actions then apply one. NEVER use search or manual edits for code intelligence.{{/has}}
123
- {{#has tools "grep"}}- Regex search or locating targets → `{{toolRefs.grep}}`, not `grep`, `rg`, or `awk`.{{/has}}
123
+ {{#has tools "grep"}}- Regex search or locating targets → `{{toolRefs.grep}}`, not shell `grep`, `rg`, or `awk`.{{/has}}
124
124
  {{#has tools "glob"}}- Mapping structure or globbing → `{{toolRefs.glob}}`, not `ls **/*.ext` or `fd`.{{/has}}
125
125
  {{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}}
126
126
  {{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
@@ -1,7 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
4
  import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../advisor/transcript-recorder";
4
5
  import { resolveExplicitModelRole } from "../config/model-resolver";
6
+ import { assistantTurnProducedOutput } from "../session/messages";
5
7
  import { EPHEMERAL_MODEL_CHANGE_ROLE } from "../session/session-entries";
6
8
  import { visitEntriesFromFileStream } from "../session/session-loader";
7
9
  import { loadBundledAgents } from "../task/agents";
@@ -90,6 +92,12 @@ interface AssistantMetrics {
90
92
  cost: number;
91
93
  contextTokens?: number;
92
94
  resolvedModel?: string;
95
+ /**
96
+ * True when this turn produced output, making its model the run's. Uses the
97
+ * same predicate as the live session, so replaying a transcript reaches the
98
+ * same verdict the session reached while running it.
99
+ */
100
+ served: boolean;
93
101
  }
94
102
 
95
103
  function assistantMetrics(message: Record<string, unknown>): AssistantMetrics {
@@ -104,6 +112,10 @@ function assistantMetrics(message: Record<string, unknown>): AssistantMetrics {
104
112
  cost: finiteNumber(cost?.total),
105
113
  contextTokens: finiteNumber(usage.totalTokens) || undefined,
106
114
  resolvedModel: provider && model ? `${provider}/${model}` : undefined,
115
+ served: assistantTurnProducedOutput({
116
+ stopReason: message.stopReason,
117
+ content,
118
+ } as Pick<AssistantMessage, "stopReason" | "content">),
107
119
  };
108
120
  }
109
121
 
@@ -159,27 +171,42 @@ async function readPersistedAgentHistory(
159
171
  ),
160
172
  durationKind: "span",
161
173
  };
174
+ // Attribution walks leaf → root and stops at the newest turn that actually
175
+ // produced output: that model did this run's work. A `model_change` newer
176
+ // than it was never served (a fallback the session died on), so crediting the
177
+ // run to it would report work the previous model did.
162
178
  let resolvedModel: string | undefined;
163
179
  let resolvedModelIsFallback: boolean | undefined;
164
180
  let modelRole: string | undefined;
165
181
  let contextTokens: number | undefined;
166
- let modelChangeFound = false;
182
+ let servedModel: string | undefined;
183
+ let latestModelChange: { model: string; resolvedModelIsFallback: boolean } | undefined;
167
184
  const visited = new Set<string>();
168
185
  for (let id = leafId; id && !visited.has(id); id = parents.get(id)) {
169
186
  visited.add(id);
170
187
  const modelChange = modelChangeById.get(id);
171
- if (modelChange && !modelChangeFound) {
172
- modelChangeFound = true;
173
- resolvedModel = modelChange.model;
174
- resolvedModelIsFallback = modelChange.resolvedModelIsFallback;
188
+ if (modelChange) {
189
+ latestModelChange ??= modelChange;
175
190
  if (modelChange.role && modelChange.role !== EPHEMERAL_MODEL_CHANGE_ROLE) {
176
- modelRole = modelChange.role;
191
+ modelRole ??= modelChange.role;
192
+ }
193
+ // The transition that installed the serving model: it carries the
194
+ // fallback flag the raw message lacks. Every writer records the selector
195
+ // through `formatModelStringWithRouting`, which appends an `@upstream`
196
+ // gateway route the message's bare `provider/model` never has.
197
+ if (
198
+ servedModel !== undefined &&
199
+ resolvedModel === undefined &&
200
+ (modelChange.model === servedModel || modelChange.model.startsWith(`${servedModel}@`))
201
+ ) {
202
+ resolvedModel = modelChange.model;
203
+ resolvedModelIsFallback = modelChange.resolvedModelIsFallback;
177
204
  }
178
205
  }
179
206
  const assistant = assistantById.get(id);
180
207
  if (!assistant) continue;
181
- if (!modelChangeFound && resolvedModel === undefined && assistant.resolvedModel) {
182
- resolvedModel = assistant.resolvedModel;
208
+ if (servedModel === undefined && assistant.served && assistant.resolvedModel) {
209
+ servedModel = assistant.resolvedModel;
183
210
  }
184
211
  metrics.requests++;
185
212
  metrics.tokens += assistant.tokens;
@@ -187,6 +214,14 @@ async function readPersistedAgentHistory(
187
214
  metrics.cost += assistant.cost;
188
215
  contextTokens ??= assistant.contextTokens;
189
216
  }
217
+ // No transition described the serving model (pre-`model_change` transcript, or
218
+ // the spawn record was pruned) — the message's own model still beats a
219
+ // transition that never ran. Nothing served at all leaves only the last
220
+ // transition to report.
221
+ if (resolvedModel === undefined) {
222
+ resolvedModel = servedModel ?? latestModelChange?.model;
223
+ resolvedModelIsFallback = servedModel !== undefined ? false : latestModelChange?.resolvedModelIsFallback;
224
+ }
190
225
  if (contextTokens !== undefined) metrics.contextTokens = contextTokens;
191
226
  return {
192
227
  ...(metrics.requests > 0 ? { metrics } : {}),
package/src/sdk.ts CHANGED
@@ -83,6 +83,7 @@ import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./ex
83
83
  import {
84
84
  discoverAndLoadExtensions,
85
85
  discoverExtensionPaths,
86
+ EXTENSION_HANDLER_TIMEOUT_MS,
86
87
  type ExtensionContext,
87
88
  type ExtensionFactory,
88
89
  ExtensionRunner,
@@ -91,6 +92,7 @@ import {
91
92
  type LoadExtensionsResult,
92
93
  loadExtensionFromFactory,
93
94
  loadExtensions,
95
+ type RegisteredTool,
94
96
  type ToolDefinition,
95
97
  wrapRegisteredTools,
96
98
  } from "./extensibility/extensions";
@@ -108,6 +110,7 @@ import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-e
108
110
  import {
109
111
  deduplicateMCPToolsByName,
110
112
  discoverAndLoadMCPTools,
113
+ getMCPToolOriginKey,
111
114
  type MCPLoadResult,
112
115
  MCPManager,
113
116
  MCPToolCache,
@@ -1824,6 +1827,7 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
1824
1827
  toolSession.enableMCP = enableMCP;
1825
1828
  const deferMCPDiscoveryForUI = enableMCP && !mcpManager && options.hasUI === true;
1826
1829
  const customTools: CustomTool[] = [];
1830
+ const initialMcpManagerTools: CustomTool[] = [];
1827
1831
  let startDeferredMCPDiscovery: ((liveSession: AgentSession) => void) | undefined;
1828
1832
  const startupQuiet = settings.get("startup.quiet");
1829
1833
  const onMCPStatus = (event: McpConnectionStatusEvent) => {
@@ -1895,10 +1899,11 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
1895
1899
  logger.error("MCP tool load failed", { path, error });
1896
1900
  }
1897
1901
 
1898
- if (mcpResult.tools.length > 0) {
1899
- // MCP tools are LoadedCustomTool, extract the tool property
1900
- customTools.push(...mcpResult.tools.map(loaded => loaded.tool));
1901
- }
1902
+ // MCP tools are LoadedCustomTool, extract the tool property while
1903
+ // retaining their origins for initial registry ownership.
1904
+ const loadedMcpTools = mcpResult.tools.map(loaded => loaded.tool);
1905
+ customTools.push(...loadedMcpTools);
1906
+ initialMcpManagerTools.push(...loadedMcpTools);
1902
1907
  }
1903
1908
  }
1904
1909
  // Only top-level sessions own the global MCPManager. Subagents already
@@ -2574,6 +2579,11 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2574
2579
  autoApprove: options.autoApprove ?? false,
2575
2580
  });
2576
2581
  const toolContextStore = new ToolContextStore(getSessionContext);
2582
+ const setSessionActiveToolNames = (names: Iterable<string>): void => {
2583
+ const snapshot = Array.from(names);
2584
+ setActiveToolNames(snapshot);
2585
+ toolContextStore.setToolNames(snapshot);
2586
+ };
2577
2587
  // Native built-in implementations backing same-tool `ctx.invokeTool`, so a tool that
2578
2588
  // re-registers a built-in (e.g. wrapping `write`) can delegate to the original — reaching the
2579
2589
  // unwrapped native execute, which inherits the caller's already-granted approval rather than
@@ -2584,10 +2594,12 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2584
2594
  const nativeToolsByName = new Map<string, Tool>(toolSession.xdev?.tools ?? undefined);
2585
2595
 
2586
2596
  const registeredTools = restrictToolNames ? [] : extensionRunner.getAllRegisteredTools();
2597
+ const initialRegisteredTools = new WeakSet(registeredTools);
2587
2598
  const sdkCustomTools =
2588
2599
  restrictToolNames && options.allowRestrictedCustomTools !== true
2589
2600
  ? []
2590
2601
  : (options.customTools?.filter(tool => !isLegacyBuiltinToolDefinition(tool)) ?? []);
2602
+ const sdkCustomToolNames = new Set(sdkCustomTools.map(tool => tool.name));
2591
2603
  const allCustomTools = [
2592
2604
  ...registeredTools,
2593
2605
  ...sdkCustomTools.map(tool => {
@@ -2602,6 +2614,16 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2602
2614
  const wrappedExtensionTools: Tool[] = deduplicateMCPToolsByName(
2603
2615
  wrapRegisteredTools(allCustomTools, extensionRunner).map(wrapToolWithMetaNotice),
2604
2616
  );
2617
+ const initialMcpManagerToolNames = new Set<string>();
2618
+ for (const tool of wrappedExtensionTools) {
2619
+ const originKey = getMCPToolOriginKey(tool);
2620
+ const matchesManagerOrigin =
2621
+ originKey !== undefined &&
2622
+ initialMcpManagerTools.some(
2623
+ managerTool => managerTool.name === tool.name && getMCPToolOriginKey(managerTool) === originKey,
2624
+ );
2625
+ if (matchesManagerOrigin) initialMcpManagerToolNames.add(tool.name);
2626
+ }
2605
2627
 
2606
2628
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
2607
2629
  const builtInRegistryToolNames = toolSession.xdev?.builtInNames ?? new Set(toolRegistry.keys());
@@ -2635,6 +2657,7 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2635
2657
  for (const name of collectPendingMCPToolNames(options.toolNames)) {
2636
2658
  if (!toolRegistry.has(name)) {
2637
2659
  toolRegistry.set(name, createPendingMCPTool(name));
2660
+ initialMcpManagerToolNames.add(name);
2638
2661
  }
2639
2662
  }
2640
2663
  }
@@ -2785,7 +2808,6 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
2785
2808
  toolNames: string[],
2786
2809
  tools: Map<string, AgentTool>,
2787
2810
  ): Promise<BuildSystemPromptResult> => {
2788
- toolContextStore.setToolNames(toolNames);
2789
2811
  const promptCwd = sessionManager.getCwd();
2790
2812
  const activeRepoContext = hasSession
2791
2813
  ? await logger.time("resolveActiveRepoContext", resolveRepoContext, promptCwd)
@@ -3038,7 +3060,7 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
3038
3060
  if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
3039
3061
  }
3040
3062
 
3041
- setActiveToolNames(initialToolNames);
3063
+ setSessionActiveToolNames(initialToolNames);
3042
3064
  const { systemPrompt } = await logger.time(
3043
3065
  "buildSystemPrompt",
3044
3066
  rebuildSystemPrompt,
@@ -3379,6 +3401,7 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
3379
3401
  ? () => createVibeTools(toolSession)
3380
3402
  : undefined,
3381
3403
  builtInToolNames: builtInRegistryToolNames,
3404
+ mcpManagerToolNames: initialMcpManagerToolNames,
3382
3405
  transformContext,
3383
3406
  transformProviderContext,
3384
3407
  onPayload,
@@ -3391,7 +3414,7 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
3391
3414
  getXdevToolEntries: () => (toolSession.xdev ? xdevEntries(toolSession.xdev) : []),
3392
3415
  xdev: toolSession.xdev,
3393
3416
  presentationPinnedToolNames: explicitlyRequestedToolNameSet,
3394
- setActiveToolNames,
3417
+ setActiveToolNames: setSessionActiveToolNames,
3395
3418
  ensureWriteRegistered,
3396
3419
  getMcpServerInstructions: mcpManager
3397
3420
  ? () => {
@@ -3433,12 +3456,120 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
3433
3456
  titleSystemPrompt: options.titleSystemPrompt,
3434
3457
  });
3435
3458
  hasSession = true;
3459
+ // Extension factories normally register tools before session construction,
3460
+ // but Pi-compatible extensions may discover them asynchronously from a
3461
+ // session_start handler. Install those late registrations into the live
3462
+ // registry and serialize activation so no update can overwrite a sibling.
3463
+ const scheduledToolRegistrations = new WeakMap<RegisteredTool, Promise<void>>();
3464
+ const scheduleToolRegistration = (registered: RegisteredTool, signal?: AbortSignal): Promise<void> => {
3465
+ const scheduled = scheduledToolRegistrations.get(registered);
3466
+ if (scheduled) return scheduled;
3467
+ const activationSignal = signal ?? AbortSignal.timeout(EXTENSION_HANDLER_TIMEOUT_MS);
3468
+
3469
+ const [wrapped] = wrapRegisteredTools([registered], extensionRunner);
3470
+ if (!wrapped) return Promise.resolve();
3471
+ const name = registered.definition.name;
3472
+ const liveTool = new ExtensionToolWrapper(wrapToolWithMetaNotice(wrapped), extensionRunner);
3473
+ // Capture ordinary extension precedence while the listener observes this exact registration.
3474
+ // A later same-name registration may replace the extension map before serialized activation runs.
3475
+ const isEffectiveRegistrant = extensionRunner.getRegisteredTool(name) === registered;
3476
+ const activation = session.runToolRegistryMutation(async () => {
3477
+ activationSignal.throwIfAborted();
3478
+ const existingTool = toolRegistry.get(name);
3479
+ const previousExtensionMcpTool = session.getExtensionMCPTool(name);
3480
+ const wasMcpManagerTool = session.hasMCPManagerTool(name);
3481
+ if (existingTool) {
3482
+ // RPC host tools and SDK custom tools retain their startup precedence when an
3483
+ // extension registers the same name later.
3484
+ if (session.hasRpcHostTool(name) || sdkCustomToolNames.has(name)) return;
3485
+ // Put the replacement first so same-origin MCP re-registration keeps it. Distinct MCP origins still
3486
+ // use the stable winner; ordinary tool collisions retain the extension runner's last-wins precedence.
3487
+ const competingTools = deduplicateMCPToolsByName([liveTool, existingTool]);
3488
+ if (competingTools.length === 1) {
3489
+ if (competingTools[0] !== liveTool) return;
3490
+ } else if (!isEffectiveRegistrant) {
3491
+ return;
3492
+ }
3493
+ } else if (!isEffectiveRegistrant) {
3494
+ return;
3495
+ }
3496
+
3497
+ const enabled = session.getEnabledToolNames();
3498
+ const alreadyEnabled = enabled.includes(name);
3499
+ const explicitlyRequested = explicitlyRequestedToolNameSet?.has(name) === true;
3500
+ const mounted = session.getMountedXdevToolNames();
3501
+ const wasBuiltIn = builtInRegistryToolNames.has(name);
3502
+ toolRegistry.set(name, liveTool);
3503
+ builtInRegistryToolNames.delete(name);
3504
+ session.setToolBuiltIn(name, false);
3505
+ session.setExtensionMCPTool(name, liveTool);
3506
+ try {
3507
+ if (registered.definition.defaultInactive && !explicitlyRequested) {
3508
+ if (!alreadyEnabled) return;
3509
+ await session.setActiveToolPresentation(
3510
+ enabled.filter(enabledName => enabledName !== name),
3511
+ mounted.filter(mountedName => mountedName !== name),
3512
+ existingTool !== undefined,
3513
+ activationSignal,
3514
+ );
3515
+ return;
3516
+ }
3517
+ // Re-registration refreshes the implementation, but it must not reverse an
3518
+ // explicit setActiveTools() decision that disabled the previous definition.
3519
+ if (existingTool && !alreadyEnabled) return;
3520
+ const shouldMount =
3521
+ !explicitlyRequested &&
3522
+ toolSession.xdev !== undefined &&
3523
+ builtInRegistryToolNames.has("read") &&
3524
+ builtInRegistryToolNames.has("write") &&
3525
+ enabled.includes("read") &&
3526
+ enabled.includes("write") &&
3527
+ isMountableUnderXdev(liveTool);
3528
+ const nextMounted = shouldMount
3529
+ ? mounted.includes(name)
3530
+ ? mounted
3531
+ : [...mounted, name]
3532
+ : mounted.filter(mountedName => mountedName !== name);
3533
+ await session.setActiveToolPresentation(
3534
+ alreadyEnabled ? enabled : [...enabled, name],
3535
+ nextMounted,
3536
+ existingTool !== undefined,
3537
+ activationSignal,
3538
+ );
3539
+ } catch (error) {
3540
+ if (existingTool) {
3541
+ toolRegistry.set(name, existingTool);
3542
+ } else {
3543
+ toolRegistry.delete(name);
3544
+ }
3545
+ if (wasBuiltIn) builtInRegistryToolNames.add(name);
3546
+ session.setToolBuiltIn(name, wasBuiltIn);
3547
+ session.setExtensionMCPTool(name, previousExtensionMcpTool);
3548
+ session.setMCPManagerTool(name, wasMcpManagerTool);
3549
+ throw error;
3550
+ }
3551
+ }, activationSignal);
3552
+ scheduledToolRegistrations.set(registered, activation);
3553
+ return activation;
3554
+ };
3555
+ if (!restrictToolNames) {
3556
+ const unsubscribeToolRegistrations = extensionRunner.onToolRegistered(scheduleToolRegistration);
3557
+ disposeCallbacks.add(unsubscribeToolRegistrations);
3558
+
3559
+ // Close the construction race: a background registration can land after
3560
+ // the initial snapshot but before the live listener above is attached.
3561
+ for (const registered of extensionRunner.getAllRegisteredTools()) {
3562
+ if (!initialRegisteredTools.has(registered)) {
3563
+ await scheduleToolRegistration(registered);
3564
+ }
3565
+ }
3566
+ }
3436
3567
  session.yieldQueue.register<McpNotificationEntry>("mcp-notification", {
3437
3568
  build: buildMcpNotificationBatchMessage,
3438
3569
  });
3439
3570
  session.yieldQueue.register<DeferredDiagnosticsEntry>(LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, {
3440
- isStale: entry => entry.isStale(),
3441
3571
  build: buildLateDiagnosticsBatchMessage,
3572
+ isStale: entry => entry.isStale(),
3442
3573
  });
3443
3574
 
3444
3575
  // Attach the live session to the pre-registered ref so peers can route IRC
@@ -174,6 +174,8 @@ export interface AgentSessionConfig {
174
174
  createVibeTools?: () => AgentTool[];
175
175
  /** Names whose current registry entry is the built-in implementation. */
176
176
  builtInToolNames?: Iterable<string>;
177
+ /** MCP names whose initial registry entries came from the manager snapshot. */
178
+ mcpManagerToolNames?: Iterable<string>;
177
179
  /** Updates tool-session predicates from the live active tool set. */
178
180
  setActiveToolNames?: (names: Iterable<string>) => void;
179
181
  /** Registers the write transport when runtime xdev mounts first need it. */