@oh-my-pi/pi-coding-agent 15.11.6 → 15.11.8

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 (102) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/cli.js +431 -381
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/bench-cli.d.ts +78 -0
  5. package/dist/types/collab/crypto.d.ts +12 -0
  6. package/dist/types/collab/guest.d.ts +21 -0
  7. package/dist/types/collab/host.d.ts +13 -0
  8. package/dist/types/collab/protocol.d.ts +100 -0
  9. package/dist/types/collab/relay-client.d.ts +22 -0
  10. package/dist/types/commands/bench.d.ts +29 -0
  11. package/dist/types/commands/join.d.ts +12 -0
  12. package/dist/types/config/model-resolver.d.ts +3 -2
  13. package/dist/types/config/settings-schema.d.ts +93 -1
  14. package/dist/types/edit/renderer.d.ts +1 -0
  15. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  16. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  17. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  18. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  19. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  20. package/dist/types/modes/components/segment-track.d.ts +11 -6
  21. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  22. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  23. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  24. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +13 -9
  26. package/dist/types/modes/interactive-mode.d.ts +7 -0
  27. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  28. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  29. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  30. package/dist/types/modes/types.d.ts +8 -0
  31. package/dist/types/session/agent-session.d.ts +11 -0
  32. package/dist/types/session/session-manager.d.ts +21 -0
  33. package/dist/types/session/snapcompact-inline.d.ts +8 -3
  34. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  35. package/dist/types/tools/bash.d.ts +2 -0
  36. package/dist/types/tools/eval-render.d.ts +1 -0
  37. package/dist/types/tools/renderers.d.ts +13 -0
  38. package/dist/types/tools/ssh.d.ts +1 -0
  39. package/package.json +14 -12
  40. package/scripts/bench-guard.ts +71 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli/bench-cli.ts +437 -0
  43. package/src/cli-commands.ts +2 -0
  44. package/src/collab/crypto.ts +57 -0
  45. package/src/collab/guest.ts +421 -0
  46. package/src/collab/host.ts +494 -0
  47. package/src/collab/protocol.ts +191 -0
  48. package/src/collab/relay-client.ts +216 -0
  49. package/src/commands/bench.ts +42 -0
  50. package/src/commands/join.ts +39 -0
  51. package/src/config/model-registry.ts +74 -19
  52. package/src/config/model-resolver.ts +36 -5
  53. package/src/config/settings-schema.ts +119 -1
  54. package/src/edit/renderer.ts +5 -0
  55. package/src/extensibility/slash-commands.ts +1 -97
  56. package/src/hindsight/client.ts +26 -1
  57. package/src/hindsight/state.ts +6 -2
  58. package/src/internal-urls/docs-index.generated.ts +4 -3
  59. package/src/main.ts +11 -2
  60. package/src/mcp/transports/stdio.ts +81 -7
  61. package/src/modes/components/agent-hub.ts +119 -22
  62. package/src/modes/components/assistant-message.ts +126 -6
  63. package/src/modes/components/collab-prompt-message.ts +30 -0
  64. package/src/modes/components/hook-selector.ts +4 -5
  65. package/src/modes/components/oauth-selector.ts +67 -7
  66. package/src/modes/components/segment-track.ts +44 -7
  67. package/src/modes/components/settings-selector.ts +27 -0
  68. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  69. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  70. package/src/modes/components/status-line/component.ts +21 -1
  71. package/src/modes/components/status-line/presets.ts +1 -1
  72. package/src/modes/components/status-line/segments.ts +13 -0
  73. package/src/modes/components/status-line/types.ts +10 -0
  74. package/src/modes/components/tips.txt +2 -1
  75. package/src/modes/components/tool-execution.ts +18 -10
  76. package/src/modes/controllers/input-controller.ts +80 -12
  77. package/src/modes/controllers/selector-controller.ts +6 -2
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +36 -4
  80. package/src/modes/setup-wizard/index.ts +1 -0
  81. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  82. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  83. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  84. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  85. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  86. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  87. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  88. package/src/modes/types.ts +8 -0
  89. package/src/modes/utils/context-usage.ts +1 -1
  90. package/src/modes/utils/ui-helpers.ts +7 -0
  91. package/src/prompts/bench.md +7 -0
  92. package/src/sdk.ts +240 -36
  93. package/src/session/agent-session.ts +22 -0
  94. package/src/session/session-manager.ts +44 -0
  95. package/src/session/snapcompact-inline.ts +20 -22
  96. package/src/slash-commands/builtin-registry.ts +210 -0
  97. package/src/tools/bash.ts +3 -0
  98. package/src/tools/eval-render.ts +4 -0
  99. package/src/tools/read.ts +38 -5
  100. package/src/tools/renderers.ts +13 -0
  101. package/src/tools/ssh.ts +3 -0
  102. package/src/tools/write.ts +13 -42
@@ -2,6 +2,8 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
4
4
  import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
5
+ import type { CollabGuestLink } from "../collab/guest";
6
+ import type { CollabHost } from "../collab/host";
5
7
  import type { KeybindingsManager } from "../config/keybindings";
6
8
  import type { Settings } from "../config/settings";
7
9
  import type {
@@ -19,6 +21,7 @@ import type { HistoryStorage } from "../session/history-storage";
19
21
  import type { SessionContext, SessionManager } from "../session/session-manager";
20
22
  import type { ShakeMode } from "../session/shake-types";
21
23
  import type { LspStartupServerInfo } from "../tools";
24
+ import type { EventBus } from "../utils/event-bus";
22
25
  import type { AssistantMessageComponent } from "./components/assistant-message";
23
26
  import type { BashExecutionComponent } from "./components/bash-execution";
24
27
  import type { CustomEditor } from "./components/custom-editor";
@@ -29,6 +32,7 @@ import type { HookSelectorComponent, HookSelectorOptions } from "./components/ho
29
32
  import type { StatusLineComponent } from "./components/status-line";
30
33
  import type { ToolExecutionHandle } from "./components/tool-execution";
31
34
  import type { TranscriptContainer } from "./components/transcript-container";
35
+ import type { EventController } from "./controllers/event-controller";
32
36
  import type { LoopLimitRuntime } from "./loop-limit";
33
37
  import type { OAuthManualInputManager } from "./oauth-manual-input";
34
38
  import type { Theme } from "./theme/theme";
@@ -101,6 +105,10 @@ export interface InteractiveModeContext {
101
105
  mcpManager?: MCPManager;
102
106
  lspServers?: LspStartupServerInfo[];
103
107
  titleSystemPrompt?: string;
108
+ collabHost?: CollabHost;
109
+ collabGuest?: CollabGuestLink;
110
+ eventController: EventController;
111
+ eventBus?: EventBus;
104
112
 
105
113
  // State
106
114
  isInitialized: boolean;
@@ -183,7 +183,7 @@ export function computeContextBreakdown(
183
183
  const renderToolResults = session.settings.get("snapcompact.toolResults");
184
184
  if (renderSystemPrompt !== "none" || renderToolResults) {
185
185
  snapcompactSavings = estimateInlineSavings({
186
- options: { renderSystemPrompt, renderToolResults },
186
+ options: { renderSystemPrompt, renderToolResults, shape: session.settings.get("snapcompact.shape") },
187
187
  model,
188
188
  systemPrompt: session.systemPrompt ?? [],
189
189
  messages: session.messages ?? [],
@@ -1,11 +1,13 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
4
+ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
4
5
  import { settings } from "../../config/settings";
5
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
6
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
8
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
8
9
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
10
+ import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message";
9
11
  import { CompactionSummaryMessageComponent } from "../../modes/components/compaction-summary-message";
10
12
  import { CustomMessageComponent } from "../../modes/components/custom-message";
11
13
  import { DynamicBorder } from "../../modes/components/dynamic-border";
@@ -185,6 +187,11 @@ export class UiHelpers {
185
187
  this.ctx.chatContainer.addChild(component);
186
188
  break;
187
189
  }
190
+ if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
191
+ const component = new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>);
192
+ this.ctx.chatContainer.addChild(component);
193
+ break;
194
+ }
188
195
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
189
196
  const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
190
197
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -0,0 +1,7 @@
1
+ Write a continuous, plain-prose technical explanation of how a relational database executes a SQL query: lexing and parsing, semantic analysis, logical plan construction, cost-based optimization, physical operator selection, and row-by-row execution through the iterator model.
2
+
3
+ Form:
4
+ - Plain paragraphs only: no headings, no lists, no code fences, no preamble.
5
+ - Do not wrap up early or summarize; keep writing until you are cut off.
6
+
7
+ Output only the explanation.
package/src/sdk.ts CHANGED
@@ -89,7 +89,14 @@ import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal }
89
89
  import type { HindsightSessionState } from "./hindsight/state";
90
90
  import { LocalProtocolHandler, type LocalProtocolOptions } from "./internal-urls";
91
91
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
92
- import { discoverAndLoadMCPTools, MCPManager, type MCPToolsLoadResult } from "./mcp";
92
+ import {
93
+ discoverAndLoadMCPTools,
94
+ type MCPLoadResult,
95
+ MCPManager,
96
+ MCPToolCache,
97
+ type MCPToolsLoadResult,
98
+ parseMCPToolName,
99
+ } from "./mcp";
93
100
  import { createSessionMemoryRuntimeContext, resolveMemoryBackend } from "./memory-backend";
94
101
  import type { MnemopiSessionState } from "./mnemopi/state";
95
102
  import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
@@ -147,6 +154,7 @@ import {
147
154
  type DiscoverableTool,
148
155
  filterBySource,
149
156
  formatDiscoverableToolServerSummary,
157
+ isMCPToolName,
150
158
  selectDiscoverableToolNamesByServer,
151
159
  summarizeDiscoverableTools,
152
160
  } from "./tool-discovery/tool-index";
@@ -302,6 +310,76 @@ function buildMcpNotificationBatchMessage(entries: McpNotificationEntry[]): Agen
302
310
  };
303
311
  }
304
312
 
313
+ type DeferredMCPActivation = {
314
+ mcpDiscoveryEnabled: boolean;
315
+ explicitlyRequestedMCPToolNames: string[];
316
+ activateAllMCPTools: boolean;
317
+ };
318
+
319
+ function formatMCPConnectingMessage(serverNames: string[]): string {
320
+ return `Connecting to MCP servers: ${serverNames.join(", ")}…`;
321
+ }
322
+
323
+ function createPendingMCPTool(name: string): Tool {
324
+ const parsed = parseMCPToolName(name);
325
+ const serverName = parsed?.serverName;
326
+ const mcpToolName = parsed?.toolName ?? name;
327
+ const label = serverName ? `${serverName}/${mcpToolName}` : name;
328
+ const message = serverName
329
+ ? `MCP server "${serverName}" is still connecting; tool "${name}" is not yet available. Retry after the MCP connection completes.`
330
+ : `MCP discovery is still in progress; tool "${name}" is not yet available. Retry after MCP connection completes.`;
331
+ const tool: Tool & { mcpServerName?: string; mcpToolName?: string } = {
332
+ name,
333
+ label,
334
+ description: `Pending MCP tool. ${message}`,
335
+ parameters: {
336
+ type: "object",
337
+ properties: {},
338
+ additionalProperties: true,
339
+ },
340
+ approval: "write",
341
+ intent: "omit",
342
+ mcpServerName: serverName,
343
+ mcpToolName,
344
+ async execute() {
345
+ return {
346
+ content: [{ type: "text", text: message }],
347
+ details: { serverName, mcpToolName, isError: true },
348
+ isError: true,
349
+ };
350
+ },
351
+ };
352
+ return tool;
353
+ }
354
+
355
+ function collectPendingMCPToolNames(
356
+ explicitToolNames: readonly string[] | undefined,
357
+ restoredSelectedToolNames: readonly string[],
358
+ ): string[] {
359
+ const names = new Set<string>();
360
+ for (const name of explicitToolNames ?? []) {
361
+ const normalized = name.toLowerCase();
362
+ if (isMCPToolName(normalized)) names.add(normalized);
363
+ }
364
+ for (const name of restoredSelectedToolNames) {
365
+ const normalized = name.toLowerCase();
366
+ if (isMCPToolName(normalized)) names.add(normalized);
367
+ }
368
+ return [...names];
369
+ }
370
+
371
+ function logMCPLoadErrors(errors: MCPLoadResult["errors"]): void {
372
+ for (const [serverName, error] of errors) {
373
+ logger.error("MCP tool load failed", { path: `mcp:${serverName}`, error });
374
+ }
375
+ }
376
+
377
+ function applyMCPEnvironment(result: { exaApiKeys: string[] }): void {
378
+ if (result.exaApiKeys.length > 0 && !$env.EXA_API_KEY) {
379
+ Bun.env.EXA_API_KEY = result.exaApiKeys[0];
380
+ }
381
+ }
382
+
305
383
  // Types
306
384
  export interface CreateAgentSessionOptions {
307
385
  /** Working directory for project-local discovery. Default: getProjectDir() */
@@ -1518,46 +1596,131 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1518
1596
  let mcpManager: MCPManager | undefined = options.mcpManager;
1519
1597
  toolSession.mcpManager = mcpManager;
1520
1598
  const enableMCP = options.enableMCP ?? true;
1599
+ const deferMCPDiscoveryForUI = enableMCP && !mcpManager && options.hasUI === true;
1521
1600
  const customTools: CustomTool[] = [];
1601
+ let startDeferredMCPDiscovery:
1602
+ | ((liveSession: AgentSession, activation: DeferredMCPActivation) => void)
1603
+ | undefined;
1604
+ const onMCPConnecting = (serverNames: string[]) => {
1605
+ if (!options.hasUI || serverNames.length === 0) return;
1606
+ process.stderr.write(`${chalk.gray(formatMCPConnectingMessage(serverNames))}\n`);
1607
+ };
1608
+ const mcpDiscoverOptions = {
1609
+ onConnecting: onMCPConnecting,
1610
+ enableProjectConfig: settings.get("mcp.enableProjectConfig") ?? true,
1611
+ // Always filter Exa - we have native integration
1612
+ filterExa: true,
1613
+ // Filter browser MCP servers when builtin browser tool is active
1614
+ filterBrowser: settings.get("browser.enabled") ?? false,
1615
+ };
1522
1616
  if (enableMCP && !mcpManager) {
1523
- const mcpResult = await logger.time("discoverAndLoadMCPTools", discoverAndLoadMCPTools, cwd, {
1524
- onConnecting: serverNames => {
1525
- if (options.hasUI && serverNames.length > 0) {
1526
- process.stderr.write(`${chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}…`)}\n`);
1527
- }
1528
- },
1529
- enableProjectConfig: settings.get("mcp.enableProjectConfig") ?? true,
1530
- // Always filter Exa - we have native integration
1531
- filterExa: true,
1532
- // Filter browser MCP servers when builtin browser tool is active
1533
- filterBrowser: settings.get("browser.enabled") ?? false,
1534
- cacheStorage: settings.getStorage(),
1535
- authStorage,
1536
- });
1537
- mcpManager = mcpResult.manager;
1538
- toolSession.mcpManager = mcpManager;
1617
+ if (deferMCPDiscoveryForUI) {
1618
+ const cacheStorage = settings.getStorage();
1619
+ mcpManager = new MCPManager(cwd, cacheStorage ? new MCPToolCache(cacheStorage) : null);
1620
+ mcpManager.setAuthStorage(authStorage);
1621
+ toolSession.mcpManager = mcpManager;
1622
+
1623
+ if (settings.get("mcp.notifications")) {
1624
+ mcpManager.setNotificationsEnabled(true);
1625
+ }
1539
1626
 
1540
- if (settings.get("mcp.notifications")) {
1541
- mcpManager.setNotificationsEnabled(true);
1542
- }
1543
- // If we extracted Exa API keys from MCP configs and EXA_API_KEY isn't set, use the first one
1544
- if (mcpResult.exaApiKeys.length > 0 && !$env.EXA_API_KEY) {
1545
- Bun.env.EXA_API_KEY = mcpResult.exaApiKeys[0];
1546
- }
1627
+ const deferredMCPManager = mcpManager;
1628
+ startDeferredMCPDiscovery = (liveSession, activation) => {
1629
+ void (async () => {
1630
+ try {
1631
+ const mcpResult = await logger.time("discoverAndLoadMCPTools", () =>
1632
+ deferredMCPManager.discoverAndConnect(mcpDiscoverOptions),
1633
+ );
1634
+ // The session can be torn down while servers are still connecting.
1635
+ // Don't resurrect tools on a disposed session, and don't leak the
1636
+ // transports/subprocesses the connect just spawned.
1637
+ if (liveSession.isDisposed) {
1638
+ await deferredMCPManager.disconnectAll();
1639
+ return;
1640
+ }
1641
+ applyMCPEnvironment(mcpResult);
1642
+ logMCPLoadErrors(mcpResult.errors);
1643
+ // `tools.discoveryMode: "auto"` was resolved against a registry that
1644
+ // held only built-ins plus persisted placeholder names. Recompute with
1645
+ // the real MCP tool count: a large toolset must flip discovery on
1646
+ // BEFORE the refresh, or activateAll would dump every MCP tool into
1647
+ // the active set with no search_tool_bm25 registered.
1648
+ let discoveryEnabled = activation.mcpDiscoveryEnabled;
1649
+ let activateAll = activation.activateAllMCPTools;
1650
+ if (!discoveryEnabled) {
1651
+ const nonMCPToolNames = [...toolRegistry.keys()].filter(name => !isMCPToolName(name));
1652
+ const projectedMode = resolveEffectiveToolDiscoveryMode(
1653
+ settings,
1654
+ countToolsForAutoDiscovery([...nonMCPToolNames, ...mcpResult.tools.map(tool => tool.name)]),
1655
+ );
1656
+ if (projectedMode !== "off") {
1657
+ effectiveDiscoveryMode = projectedMode;
1658
+ mcpDiscoveryEnabled = true;
1659
+ discoveryEnabled = true;
1660
+ activateAll = false;
1661
+ liveSession.enableMCPDiscovery();
1662
+ if (!toolRegistry.has("search_tool_bm25")) {
1663
+ const searchTool: Tool = new SearchToolBm25Tool(toolSession);
1664
+ toolRegistry.set(
1665
+ searchTool.name,
1666
+ new ExtensionToolWrapper(wrapToolWithMetaNotice(searchTool), extensionRunner) as Tool,
1667
+ );
1668
+ }
1669
+ await liveSession.setActiveToolsByName([
1670
+ ...liveSession.getActiveToolNames(),
1671
+ "search_tool_bm25",
1672
+ ]);
1673
+ }
1674
+ }
1675
+ await liveSession.refreshMCPTools(mcpResult.tools, { activateAll });
1676
+ if (activation.explicitlyRequestedMCPToolNames.length > 0) {
1677
+ if (discoveryEnabled && !activation.mcpDiscoveryEnabled) {
1678
+ // Discovery flipped on mid-flight: route the explicit request
1679
+ // through discovery-aware activation so selection persists.
1680
+ await liveSession.activateDiscoveredMCPTools(activation.explicitlyRequestedMCPToolNames);
1681
+ } else if (!discoveryEnabled) {
1682
+ await liveSession.setActiveToolsByName([
1683
+ ...liveSession.getActiveToolNames(),
1684
+ ...activation.explicitlyRequestedMCPToolNames,
1685
+ ]);
1686
+ }
1687
+ }
1688
+ } catch (error) {
1689
+ logger.error("MCP tool load failed", {
1690
+ path: ".mcp.json",
1691
+ error: error instanceof Error ? error.message : String(error),
1692
+ });
1693
+ }
1694
+ })();
1695
+ };
1696
+ } else {
1697
+ const mcpResult = await logger.time("discoverAndLoadMCPTools", discoverAndLoadMCPTools, cwd, {
1698
+ ...mcpDiscoverOptions,
1699
+ cacheStorage: settings.getStorage(),
1700
+ authStorage,
1701
+ });
1702
+ mcpManager = mcpResult.manager;
1703
+ toolSession.mcpManager = mcpManager;
1547
1704
 
1548
- // Log MCP errors
1549
- for (const { path, error } of mcpResult.errors) {
1550
- logger.error("MCP tool load failed", { path, error });
1551
- }
1705
+ if (settings.get("mcp.notifications")) {
1706
+ mcpManager.setNotificationsEnabled(true);
1707
+ }
1708
+ applyMCPEnvironment(mcpResult);
1552
1709
 
1553
- if (mcpResult.tools.length > 0) {
1554
- // MCP tools are LoadedCustomTool, extract the tool property
1555
- customTools.push(...mcpResult.tools.map(loaded => loaded.tool));
1710
+ // Log MCP errors
1711
+ for (const { path, error } of mcpResult.errors) {
1712
+ logger.error("MCP tool load failed", { path, error });
1713
+ }
1714
+
1715
+ if (mcpResult.tools.length > 0) {
1716
+ // MCP tools are LoadedCustomTool, extract the tool property
1717
+ customTools.push(...mcpResult.tools.map(loaded => loaded.tool));
1718
+ }
1556
1719
  }
1557
1720
  }
1558
1721
  // Only top-level sessions own the global MCPManager. Subagents already
1559
1722
  // receive the parent's manager via `options.mcpManager`, and reassigning
1560
- // the singleton to the same value is a no-op \u2014 keep the gate explicit
1723
+ // the singleton to the same value is a no-op keep the gate explicit
1561
1724
  // to mirror the AsyncJobManager ownership rule.
1562
1725
  if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
1563
1726
 
@@ -1852,6 +2015,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1852
2015
  for (const tool of wrappedExtensionTools) {
1853
2016
  toolRegistry.set(tool.name, tool);
1854
2017
  }
2018
+ if (deferMCPDiscoveryForUI && mcpManager) {
2019
+ for (const name of collectPendingMCPToolNames(options.toolNames, existingSession.selectedMCPToolNames)) {
2020
+ if (!toolRegistry.has(name)) {
2021
+ toolRegistry.set(name, createPendingMCPTool(name));
2022
+ }
2023
+ }
2024
+ }
2025
+
1855
2026
  // Wrap every tool with `ExtensionToolWrapper` so the per-tool approval gate runs on every
1856
2027
  // call site, regardless of whether any user extensions are loaded. See the runner-construction
1857
2028
  // comment above for the safety invariant this enforces.
@@ -1879,7 +2050,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1879
2050
  }
1880
2051
  }
1881
2052
 
1882
- const effectiveDiscoveryMode = resolveEffectiveToolDiscoveryMode(
2053
+ // `let`: the deferred MCP discovery closure upgrades these when the real
2054
+ // MCP tool count pushes `auto` past its threshold; `rebuildSystemPrompt`
2055
+ // below reads the live bindings.
2056
+ let effectiveDiscoveryMode = resolveEffectiveToolDiscoveryMode(
1883
2057
  settings,
1884
2058
  countToolsForAutoDiscovery(toolRegistry.keys()),
1885
2059
  );
@@ -1890,7 +2064,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1890
2064
  new ExtensionToolWrapper(wrapToolWithMetaNotice(searchTool), extensionRunner) as Tool,
1891
2065
  );
1892
2066
  }
1893
- const mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
2067
+ let mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
1894
2068
 
1895
2069
  const reloadSshTool = async (): Promise<AgentTool | null> => {
1896
2070
  if (!requestedToolNameSet.has("ssh")) return null;
@@ -1942,7 +2116,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1942
2116
  const memoryBackend = await resolveMemoryBackend(settings);
1943
2117
  const memoryInstructions = await memoryBackend.buildDeveloperInstructions(agentDir, settings, session);
1944
2118
 
1945
- // Build combined append prompt: memory instructions + MCP server instructions
2119
+ // Build combined append prompt: memory instructions + MCP server instructions.
2120
+ // For UI sessions MCP discovery is deferred, so `getServerInstructions()` is
2121
+ // empty until the background connect completes; the rebuild that
2122
+ // `refreshMCPTools` triggers post-discovery then picks up the now-connected
2123
+ // servers' instructions, so they join the prompt for the rest of the session.
1946
2124
  const serverInstructions = mcpManager?.getServerInstructions();
1947
2125
  let appendPrompt: string | undefined = memoryInstructions ?? undefined;
1948
2126
  if (serverInstructions && serverInstructions.size > 0) {
@@ -2168,6 +2346,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2168
2346
  ? new SnapcompactInlineTransformer({
2169
2347
  renderSystemPrompt: snapcompactSystemPromptMode,
2170
2348
  renderToolResults: settings.get("snapcompact.toolResults"),
2349
+ shape: settings.get("snapcompact.shape"),
2171
2350
  })
2172
2351
  : undefined;
2173
2352
  const transformProviderContext =
@@ -2493,7 +2672,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2493
2672
  // Skip when reusing a parent's manager — the parent owns the callbacks.
2494
2673
  if (mcpManager && !options.mcpManager) {
2495
2674
  mcpManager.setOnToolsChanged(tools => {
2496
- void session.refreshMCPTools(tools);
2675
+ void (async () => {
2676
+ try {
2677
+ await session.refreshMCPTools(
2678
+ tools,
2679
+ deferMCPDiscoveryForUI && !mcpDiscoveryEnabled && options.toolNames === undefined
2680
+ ? { activateAll: true }
2681
+ : undefined,
2682
+ );
2683
+ if (deferMCPDiscoveryForUI && !mcpDiscoveryEnabled && explicitlyRequestedMCPToolNames.length > 0) {
2684
+ await session.setActiveToolsByName([
2685
+ ...session.getActiveToolNames(),
2686
+ ...explicitlyRequestedMCPToolNames,
2687
+ ]);
2688
+ }
2689
+ } catch (error) {
2690
+ logger.warn("MCP tool refresh failed", {
2691
+ error: error instanceof Error ? error.message : String(error),
2692
+ });
2693
+ }
2694
+ })();
2497
2695
  });
2498
2696
  // Wire prompt refresh → rebuild MCP prompt slash commands
2499
2697
  mcpManager.setOnPromptsChanged(serverName => {
@@ -2526,6 +2724,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2526
2724
  });
2527
2725
  }
2528
2726
 
2727
+ startDeferredMCPDiscovery?.(session, {
2728
+ mcpDiscoveryEnabled,
2729
+ explicitlyRequestedMCPToolNames,
2730
+ activateAllMCPTools: !mcpDiscoveryEnabled && options.toolNames === undefined,
2731
+ });
2732
+
2529
2733
  return {
2530
2734
  session,
2531
2735
  extensionsResult,
@@ -3140,6 +3140,12 @@ export class AgentSession {
3140
3140
  state.resetConversationTracking();
3141
3141
  }
3142
3142
 
3143
+ /** True once dispose() has begun; deferred background work (e.g. the deferred
3144
+ * MCP discovery task in sdk.ts) must not touch the session past this point. */
3145
+ get isDisposed(): boolean {
3146
+ return this.#isDisposed;
3147
+ }
3148
+
3143
3149
  /**
3144
3150
  * Synchronously mark the session as disposing so new work is rejected
3145
3151
  * immediately: Python/eval starts throw, queued asides are dropped, and the
@@ -3473,6 +3479,17 @@ export class AgentSession {
3473
3479
  return this.#mcpDiscoveryEnabled;
3474
3480
  }
3475
3481
 
3482
+ /**
3483
+ * Flip MCP discovery on after deferred discovery learns the real tool count.
3484
+ * UI sessions resolve `tools.discoveryMode: "auto"` before MCP servers
3485
+ * connect, so a large MCP toolset discovered later must be able to upgrade
3486
+ * the session from the force-activate path to the discovery path. One-way:
3487
+ * discovery is never downgraded mid-session.
3488
+ */
3489
+ enableMCPDiscovery(): void {
3490
+ this.#mcpDiscoveryEnabled = true;
3491
+ }
3492
+
3476
3493
  getSelectedMCPToolNames(): string[] {
3477
3494
  if (!this.#mcpDiscoveryEnabled) {
3478
3495
  return this.getActiveToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name));
@@ -6388,6 +6405,10 @@ export class AgentSession {
6388
6405
  const snapcompactResult = await snapcompact.compact(preparation, {
6389
6406
  convertToLlm,
6390
6407
  model: this.model,
6408
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
6409
+ // Providers with hard image caps (OpenRouter: 8) silently drop
6410
+ // frames past the cap — keep the archive within budget.
6411
+ maxFrames: snapcompact.providerFrameBudget(this.model.provider),
6391
6412
  });
6392
6413
  summary = snapcompactResult.summary;
6393
6414
  shortSummary = snapcompactResult.shortSummary;
@@ -7921,6 +7942,7 @@ export class AgentSession {
7921
7942
  const snapcompactResult = await snapcompact.compact(preparation, {
7922
7943
  convertToLlm,
7923
7944
  model: this.model,
7945
+ maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
7924
7946
  });
7925
7947
  summary = snapcompactResult.summary;
7926
7948
  shortSummary = snapcompactResult.shortSummary;
@@ -1994,6 +1994,12 @@ export class SessionManager {
1994
1994
  #byId: Map<string, SessionEntry> = new Map();
1995
1995
  #labelsById: Map<string, string> = new Map();
1996
1996
  #leafId: string | null = null;
1997
+ /**
1998
+ * Collab replication tap: invoked for every appended entry with the
1999
+ * in-memory (pre-blob-externalization) entry, so inline images survive.
2000
+ * Failures are swallowed — a broadcast error must never break persistence.
2001
+ */
2002
+ onEntryAppended?: (entry: SessionEntry) => void;
1997
2003
  #usageStatistics = {
1998
2004
  input: 0,
1999
2005
  output: 0,
@@ -2927,6 +2933,44 @@ export class SessionManager {
2927
2933
  this.#usageStatistics.cost += usage.cost.total;
2928
2934
  }
2929
2935
  }
2936
+ if (this.onEntryAppended) {
2937
+ try {
2938
+ this.onEntryAppended(entry);
2939
+ } catch (err) {
2940
+ logger.warn("collab entry hook failed", { error: String(err) });
2941
+ }
2942
+ }
2943
+ }
2944
+
2945
+ /**
2946
+ * Append a foreign (host-authored) entry verbatim, preserving its
2947
+ * `id`/`parentId` — no id minting. Used by collab guests to mirror the
2948
+ * host session into the local replica file.
2949
+ */
2950
+ ingestReplicatedEntry(entry: SessionEntry): void {
2951
+ this.#appendEntry(entry);
2952
+ }
2953
+
2954
+ /**
2955
+ * Snapshot the session for collab replication: the live header plus a deep
2956
+ * copy of every entry (the host mutates entries in place on
2957
+ * truncation/rewrite paths, so guests must not share references).
2958
+ */
2959
+ snapshotForReplication(): { header: SessionHeader; entries: SessionEntry[] } {
2960
+ const live = this.getHeader();
2961
+ const header: SessionHeader = live
2962
+ ? structuredClone(live)
2963
+ : {
2964
+ type: "session",
2965
+ version: CURRENT_SESSION_VERSION,
2966
+ id: this.#sessionId,
2967
+ title: this.#sessionName,
2968
+ titleSource: this.#titleSource,
2969
+ timestamp: new Date().toISOString(),
2970
+ cwd: this.cwd,
2971
+ };
2972
+ const entries = structuredClone(this.#fileEntries.filter(e => e.type !== "session")) as SessionEntry[];
2973
+ return { header, entries };
2930
2974
  }
2931
2975
 
2932
2976
  /** Append a message as child of current leaf, then advance leaf. Returns entry id.
@@ -28,22 +28,15 @@ export type SnapcompactSystemPromptMode = "none" | "agents-md" | "all";
28
28
  export interface SnapcompactInlineOptions {
29
29
  renderSystemPrompt: SnapcompactSystemPromptMode;
30
30
  renderToolResults: boolean;
31
+ /** Frame variant override; `"auto"`/omitted picks the provider's eval winner. */
32
+ shape?: snapcompact.ShapeVariantName | "auto";
31
33
  }
32
34
 
33
- /**
34
- * Image-count budget per provider. Snapcompact frames are 1568px (<2000px) so
35
- * dimension/size limits never bind; only COUNT does. Strictest mainstream is
36
- * Groq (~5), so unknown providers get the safe floor.
37
- */
38
- const INLINE_IMAGE_BUDGET_BY_PROVIDER: Record<string, number> = {
39
- anthropic: 90,
40
- "amazon-bedrock": 90,
41
- openai: 200,
42
- google: 200,
43
- "google-vertex": 200,
44
- "google-gemini-cli": 200,
45
- };
46
- const DEFAULT_INLINE_IMAGE_BUDGET = 5;
35
+ // Per-provider image-count budgets live in @oh-my-pi/snapcompact
36
+ // (`providerImageBudget`): snapcompact frames are 1568px (<2000px) so
37
+ // dimension/size limits never bind; only COUNT does. Once the budget is
38
+ // spent (e.g. OpenRouter's hard 8-image cap, already consumed by archive
39
+ // frames), tool results ship verbatim as text.
47
40
  const MAX_SYSTEM_PROMPT_FRAMES = 6;
48
41
  /** Tool results under this many tokens are never rasterized — the swap can't
49
42
  * save enough to justify trading crisp text for an image. */
@@ -130,14 +123,16 @@ function selectSystemPromptImageTarget(
130
123
 
131
124
  /** Tool-result swap candidate, in context order. */
132
125
  export interface InlineToolResultCandidate {
133
- /** toolCallId stable identity for render caching and application. */
126
+ /** Stable identifier for rendering cache key and applying the swap. */
134
127
  id: string;
135
- /** Token count of the joined text blocks (0 when empty or image-carrying). */
128
+ /** Token count of the joined text blocks (0 for empty or image-carrying). */
136
129
  textTokens: number;
137
- /** Frames needed to render the text (0 = empty or below the token floor). */
130
+ /** Frames needed to render the text (0 = empty, below floor, image-carrying, or error). */
138
131
  frames: number;
139
132
  /** Already carries an image (screenshot etc.) — never re-imaged. */
140
133
  hasImage: boolean;
134
+ /** Error tool results must stay text-only for provider API validation. */
135
+ isError?: boolean;
141
136
  }
142
137
 
143
138
  export interface InlineSystemPromptCandidate {
@@ -180,6 +175,7 @@ export function planInlineSwaps(input: InlinePlanInput): InlineSwapPlan {
180
175
  for (let k = 0; k < input.toolResults.length - 1 && budget > 0; k++) {
181
176
  const candidate = input.toolResults[k];
182
177
  if (candidate.hasImage) continue;
178
+ if (candidate.isError) continue;
183
179
  if (candidate.textTokens < MIN_TOOL_RESULT_TOKENS) continue;
184
180
  if (candidate.frames === 0 || candidate.frames > budget) continue;
185
181
  if (!passesSavingsGate(candidate.frames, input.shape, candidate.textTokens)) continue;
@@ -218,6 +214,7 @@ export interface InlineMessageView {
218
214
  role: string;
219
215
  toolCallId?: string;
220
216
  content?: unknown;
217
+ isError?: boolean;
221
218
  }
222
219
 
223
220
  export interface SnapcompactSavingsEstimate {
@@ -273,7 +270,7 @@ export function estimateInlineSavings(input: {
273
270
  return { visionCapable: false, savedTokens: 0 };
274
271
  }
275
272
 
276
- const shape = snapcompact.resolveShape(model.api);
273
+ const shape = snapcompact.resolveShape(model, options.shape);
277
274
  let existingImages = 0;
278
275
  for (const message of input.messages) {
279
276
  if (!Array.isArray(message.content)) continue;
@@ -281,12 +278,13 @@ export function estimateInlineSavings(input: {
281
278
  if (block.type === "image") existingImages++;
282
279
  }
283
280
  }
284
- const budget = (INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - existingImages;
281
+ const budget = snapcompact.providerImageBudget(model.provider) - existingImages;
285
282
 
286
283
  const candidates: InlineToolResultCandidate[] = [];
287
284
  if (options.renderToolResults) {
288
285
  for (const message of input.messages) {
289
286
  if (message.role !== "toolResult" || typeof message.toolCallId !== "string") continue;
287
+ if (message.isError) continue;
290
288
  const blocks: BlockViews = Array.isArray(message.content) ? (message.content as BlockViews) : [];
291
289
  const hasImage = blocks.some(block => block.type === "image");
292
290
  const text = hasImage
@@ -407,9 +405,8 @@ export class SnapcompactInlineTransformer {
407
405
  // rendering would lose the content entirely.
408
406
  if (!model.input.includes("image")) return context;
409
407
 
410
- const shape = snapcompact.resolveShape(model.api);
411
- const budget =
412
- (INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - countContextImages(context);
408
+ const shape = snapcompact.resolveShape(model, this.options.shape);
409
+ const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);
413
410
  if (budget <= 0) return context;
414
411
 
415
412
  const messages = [...context.messages];
@@ -426,6 +423,7 @@ export class SnapcompactInlineTransformer {
426
423
  liveToolCallIds.add(message.toolCallId);
427
424
  // Don't re-image results that already carry images (screenshots etc.).
428
425
  const hasImage = message.content.some(block => block.type === "image");
426
+ if (message.isError) continue;
429
427
  const text = hasImage
430
428
  ? ""
431
429
  : message.content