@oh-my-pi/pi-coding-agent 15.11.7 → 15.12.0

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 (107) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/cli.js +8106 -7708
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +7 -0
  5. package/dist/types/collab/guest.d.ts +23 -0
  6. package/dist/types/collab/host.d.ts +29 -0
  7. package/dist/types/collab/protocol.d.ts +113 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +60 -5
  11. package/dist/types/export/custom-share.d.ts +1 -2
  12. package/dist/types/export/html/index.d.ts +39 -1
  13. package/dist/types/export/share.d.ts +43 -0
  14. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  15. package/dist/types/main.d.ts +2 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +32 -1
  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/segment-track.d.ts +11 -6
  20. package/dist/types/modes/components/status-line/component.d.ts +10 -2
  21. package/dist/types/modes/components/status-line/types.d.ts +11 -0
  22. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  23. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  24. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  25. package/dist/types/modes/interactive-mode.d.ts +16 -0
  26. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  27. package/dist/types/modes/theme/theme.d.ts +2 -1
  28. package/dist/types/modes/types.d.ts +20 -0
  29. package/dist/types/session/agent-session.d.ts +13 -0
  30. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  31. package/dist/types/session/session-manager.d.ts +21 -0
  32. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  33. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  34. package/dist/types/task/executor.d.ts +7 -0
  35. package/dist/types/task/types.d.ts +9 -0
  36. package/package.json +14 -13
  37. package/scripts/bench-guard.ts +71 -0
  38. package/scripts/build-binary.ts +4 -0
  39. package/scripts/bundle-dist.ts +4 -0
  40. package/scripts/generate-share-viewer.ts +34 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli-commands.ts +1 -0
  43. package/src/collab/crypto.ts +63 -0
  44. package/src/collab/guest.ts +450 -0
  45. package/src/collab/host.ts +556 -0
  46. package/src/collab/protocol.ts +232 -0
  47. package/src/collab/relay-client.ts +216 -0
  48. package/src/commands/join.ts +39 -0
  49. package/src/config/model-registry.ts +22 -14
  50. package/src/config/settings-schema.ts +67 -5
  51. package/src/config/settings.ts +12 -0
  52. package/src/export/custom-share.ts +1 -1
  53. package/src/export/html/index.ts +122 -17
  54. package/src/export/html/share-loader.js +102 -0
  55. package/src/export/html/template.css +745 -459
  56. package/src/export/html/template.html +6 -3
  57. package/src/export/html/template.js +240 -915
  58. package/src/export/html/tool-views.generated.js +38 -0
  59. package/src/export/share.ts +268 -0
  60. package/src/extensibility/slash-commands.ts +1 -97
  61. package/src/internal-urls/docs-index.generated.ts +74 -73
  62. package/src/main.ts +33 -11
  63. package/src/modes/components/agent-hub.ts +659 -431
  64. package/src/modes/components/assistant-message.ts +126 -6
  65. package/src/modes/components/collab-prompt-message.ts +30 -0
  66. package/src/modes/components/hook-selector.ts +4 -5
  67. package/src/modes/components/segment-track.ts +44 -7
  68. package/src/modes/components/status-line/component.ts +59 -6
  69. package/src/modes/components/status-line/presets.ts +1 -1
  70. package/src/modes/components/status-line/segments.ts +18 -1
  71. package/src/modes/components/status-line/types.ts +12 -0
  72. package/src/modes/components/tips.txt +4 -1
  73. package/src/modes/controllers/command-controller.ts +55 -96
  74. package/src/modes/controllers/event-controller.ts +45 -16
  75. package/src/modes/controllers/input-controller.ts +175 -9
  76. package/src/modes/controllers/selector-controller.ts +13 -15
  77. package/src/modes/controllers/session-focus-controller.ts +112 -0
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +56 -6
  80. package/src/modes/session-observer-registry.ts +11 -0
  81. package/src/modes/theme/theme.ts +6 -0
  82. package/src/modes/types.ts +20 -0
  83. package/src/modes/utils/ui-helpers.ts +23 -13
  84. package/src/prompts/tools/job.md +1 -1
  85. package/src/sdk.ts +239 -36
  86. package/src/session/agent-session.ts +82 -7
  87. package/src/session/codex-auto-reset.ts +23 -11
  88. package/src/session/session-manager.ts +44 -0
  89. package/src/session/snapcompact-inline.ts +9 -3
  90. package/src/slash-commands/builtin-registry.ts +261 -24
  91. package/src/task/executor.ts +14 -0
  92. package/src/task/index.ts +5 -1
  93. package/src/task/render.ts +76 -5
  94. package/src/task/types.ts +9 -0
  95. package/src/tiny/worker.ts +17 -95
  96. package/src/tools/job.ts +6 -9
  97. package/src/tools/read.ts +38 -5
  98. package/src/tools/write.ts +13 -42
  99. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  100. package/dist/types/export/html/template.generated.d.ts +0 -1
  101. package/dist/types/export/html/template.macro.d.ts +0 -5
  102. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  103. package/scripts/generate-template.ts +0 -33
  104. package/src/bun-imports.d.ts +0 -28
  105. package/src/export/html/template.generated.ts +0 -2
  106. package/src/export/html/template.macro.ts +0 -25
  107. package/src/tiny/compiled-runtime.ts +0 -179
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) {
@@ -2494,7 +2672,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2494
2672
  // Skip when reusing a parent's manager — the parent owns the callbacks.
2495
2673
  if (mcpManager && !options.mcpManager) {
2496
2674
  mcpManager.setOnToolsChanged(tools => {
2497
- 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
+ })();
2498
2695
  });
2499
2696
  // Wire prompt refresh → rebuild MCP prompt slash commands
2500
2697
  mcpManager.setOnPromptsChanged(serverName => {
@@ -2527,6 +2724,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2527
2724
  });
2528
2725
  }
2529
2726
 
2727
+ startDeferredMCPDiscovery?.(session, {
2728
+ mcpDiscoveryEnabled,
2729
+ explicitlyRequestedMCPToolNames,
2730
+ activateAllMCPTools: !mcpDiscoveryEnabled && options.toolNames === undefined,
2731
+ });
2732
+
2530
2733
  return {
2531
2734
  session,
2532
2735
  extensionsResult,
@@ -100,6 +100,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
100
100
  import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
101
101
  import {
102
102
  extractRetryHint,
103
+ formatDuration,
103
104
  getAgentDbPath,
104
105
  getInstallId,
105
106
  isBunTestRuntime,
@@ -240,7 +241,13 @@ import { normalizeModelContextImages } from "../utils/image-loading";
240
241
  import { buildNamedToolChoice } from "../utils/tool-choice";
241
242
  import type { AuthStorage } from "./auth-storage";
242
243
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
243
- import { defaultCodexAutoRedeemCoordinator, evaluateCodexAutoRedeem } from "./codex-auto-reset";
244
+ import {
245
+ type CodexAutoRedeemRedeemDecision,
246
+ defaultCodexAutoRedeemCoordinator,
247
+ evaluateCodexAutoRedeem,
248
+ shouldEvaluateCodexAutoRedeem,
249
+ shouldPromptCodexAutoRedeem,
250
+ } from "./codex-auto-reset";
244
251
  import {
245
252
  type BashExecutionMessage,
246
253
  type CustomMessage,
@@ -1418,6 +1425,11 @@ export class AgentSession {
1418
1425
  return this.#ttsrManager;
1419
1426
  }
1420
1427
 
1428
+ /** Secret obfuscator, when secrets are configured; /share redaction reuses it. */
1429
+ get obfuscator(): SecretObfuscator | undefined {
1430
+ return this.#obfuscator;
1431
+ }
1432
+
1421
1433
  /** Whether a TTSR abort is pending (stream was aborted to inject rules) */
1422
1434
  get isTtsrAbortPending(): boolean {
1423
1435
  return this.#ttsrAbortPending;
@@ -3140,6 +3152,12 @@ export class AgentSession {
3140
3152
  state.resetConversationTracking();
3141
3153
  }
3142
3154
 
3155
+ /** True once dispose() has begun; deferred background work (e.g. the deferred
3156
+ * MCP discovery task in sdk.ts) must not touch the session past this point. */
3157
+ get isDisposed(): boolean {
3158
+ return this.#isDisposed;
3159
+ }
3160
+
3143
3161
  /**
3144
3162
  * Synchronously mark the session as disposing so new work is rejected
3145
3163
  * immediately: Python/eval starts throw, queued asides are dropped, and the
@@ -3473,6 +3491,17 @@ export class AgentSession {
3473
3491
  return this.#mcpDiscoveryEnabled;
3474
3492
  }
3475
3493
 
3494
+ /**
3495
+ * Flip MCP discovery on after deferred discovery learns the real tool count.
3496
+ * UI sessions resolve `tools.discoveryMode: "auto"` before MCP servers
3497
+ * connect, so a large MCP toolset discovered later must be able to upgrade
3498
+ * the session from the force-activate path to the discovery path. One-way:
3499
+ * discovery is never downgraded mid-session.
3500
+ */
3501
+ enableMCPDiscovery(): void {
3502
+ this.#mcpDiscoveryEnabled = true;
3503
+ }
3504
+
3476
3505
  getSelectedMCPToolNames(): string[] {
3477
3506
  if (!this.#mcpDiscoveryEnabled) {
3478
3507
  return this.getActiveToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name));
@@ -10176,20 +10205,63 @@ export class AgentSession {
10176
10205
  signal,
10177
10206
  });
10178
10207
  }
10208
+ async #confirmCodexAutoRedeem(decision: CodexAutoRedeemRedeemDecision): Promise<boolean> {
10209
+ const runner = this.#extensionRunner;
10210
+ if (!runner?.hasUI()) {
10211
+ this.emitNotice(
10212
+ "warning",
10213
+ "Codex saved reset is eligible, but auto-redeem is unset and no prompt UI is available. Run `/usage reset` or set codexResets.autoRedeem.",
10214
+ "codex-auto-reset",
10215
+ );
10216
+ return false;
10217
+ }
10218
+
10219
+ const who = decision.target.email ?? decision.target.accountId ?? "the active account";
10220
+ const resetLabel = decision.availableCount === 1 ? "reset" : "resets";
10221
+ try {
10222
+ const choice = await runner
10223
+ .getUIContext()
10224
+ .select(
10225
+ `Do you wanna redeem your reset?\n${who} is blocked by the weekly Codex limit for about ${formatDuration(decision.remainingMs)}. Spend 1 of ${decision.availableCount} saved ${resetLabel}?`,
10226
+ [
10227
+ {
10228
+ label: "Yes",
10229
+ description: "Redeem now and remember yes for future eligible Codex weekly blocks.",
10230
+ },
10231
+ {
10232
+ label: "No",
10233
+ description: "Do not auto-redeem saved Codex resets.",
10234
+ },
10235
+ ],
10236
+ );
10237
+ if (choice === "Yes") {
10238
+ this.settings.set("codexResets.autoRedeem", "yes");
10239
+ return true;
10240
+ }
10241
+ if (choice === "No") {
10242
+ this.settings.set("codexResets.autoRedeem", "no");
10243
+ }
10244
+ } catch (error) {
10245
+ logger.warn("codex-auto-reset prompt failed", { error: String(error) });
10246
+ }
10247
+ return false;
10248
+ }
10179
10249
 
10180
10250
  /**
10181
10251
  * Auto-redeem hook for {@link AgentSession.#handleRetryableError}'s
10182
10252
  * usage-limit branch. Returns `true` only when a saved Codex reset was
10183
- * actually spent (so the caller retries immediately). Opt-in, reactive, and
10184
- * heavily gated see `./codex-auto-reset` and the design in
10185
- * `local://autoreset-spec.md`. Per-account in-flight dedup lets concurrent
10186
- * sessions adopt one redeem instead of double-spending.
10253
+ * actually spent (so the caller retries immediately). The "unset" mode is
10254
+ * reactive but asks before spending; "yes" skips that prompt, and "no" avoids
10255
+ * the eligibility IO entirely. The decision remains heavily gated — see
10256
+ * `./codex-auto-reset` and the design in `local://autoreset-spec.md`.
10257
+ * Per-account in-flight dedup lets concurrent sessions adopt one redeem
10258
+ * instead of double-spending.
10187
10259
  */
10188
10260
  async #maybeAutoRedeemCodexReset(coordinator = defaultCodexAutoRedeemCoordinator): Promise<boolean> {
10189
10261
  const cfg = this.settings.getGroup("codexResets");
10190
10262
  const model = this.model;
10191
10263
  // Cheap exits before any IO.
10192
- if (!cfg.autoRedeem || !model || model.provider !== "openai-codex") return false;
10264
+ if (!shouldEvaluateCodexAutoRedeem(cfg.autoRedeem) || !model || model.provider !== "openai-codex") return false;
10193
10265
  const authStorage = this.#modelRegistry.authStorage;
10194
10266
  // Capture identity BEFORE awaits: markUsageLimitReached leaves the
10195
10267
  // usage-limit session credential sticky, so this names the blocked account.
@@ -10206,7 +10278,7 @@ export class AgentSession {
10206
10278
  provider: model.provider,
10207
10279
  modelId: model.id,
10208
10280
  settings: {
10209
- autoRedeem: cfg.autoRedeem,
10281
+ autoRedeem: true,
10210
10282
  minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes),
10211
10283
  keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)),
10212
10284
  },
@@ -10219,6 +10291,9 @@ export class AgentSession {
10219
10291
  logger.debug("codex-auto-reset: skipped", { reason: decision.reason });
10220
10292
  return false;
10221
10293
  }
10294
+ if (shouldPromptCodexAutoRedeem(cfg.autoRedeem) && !(await this.#confirmCodexAutoRedeem(decision))) {
10295
+ return false;
10296
+ }
10222
10297
  // Commit the attempt BEFORE acting so this block can never re-enter.
10223
10298
  coordinator.attemptedBlockKeys.add(decision.blockKey);
10224
10299
  coordinator.lastAttemptAtByAccount.set(decision.accountKey, Date.now());
@@ -24,8 +24,8 @@
24
24
  * eligibility off exact limit ids (`openai-codex:primary` /
25
25
  * `openai-codex:secondary`) and `usedFraction`, never off `status`.
26
26
  *
27
- * ANTI-WASTE GATES (in evaluation order): the policy must be OFF unless opted
28
- * in; the active model must be Codex (not Spark — a Spark block lives on a
27
+ * ANTI-WASTE GATES (in evaluation order): the policy must not be set to "no";
28
+ * the active model must be Codex (not Spark — a Spark block lives on a
29
29
  * separate meter and it is unknown whether a credit even resets it); a fresh
30
30
  * usage report for the active account must confirm `limitReached`; the WEEKLY
31
31
  * (secondary) window must be genuinely exhausted — a 5h-only block self-heals
@@ -38,12 +38,14 @@
38
38
  * read-only views are passed in so the predicate itself stays deterministic.
39
39
  */
40
40
  import type { OAuthAccountIdentity, ResetCreditTarget, UsageReport } from "@oh-my-pi/pi-ai";
41
+ import type { CodexAutoRedeemMode } from "../config/settings-schema";
41
42
  import { reportMatchesActiveAccount } from "../slash-commands/helpers/active-oauth-account";
42
43
 
43
44
  /** Weekly window counts as exhausted at `usedFraction >= 0.999` (used_percent >= 99.9). */
44
45
  export const WEEKLY_EXHAUSTED_MIN_FRACTION = 0.999;
45
46
  /** A weekly reset can never be more than one window length (7d) away; +1h slack for skew. */
46
47
  export const MAX_PLAUSIBLE_REMAINING_MS = 7 * 24 * 3_600_000 + 60 * 60_000;
48
+
47
49
  /** Report must be no older than the 5-min usage cache TTL plus slack. */
48
50
  export const REPORT_FRESHNESS_MS = 10 * 60_000;
49
51
  /** Per-account cooldown that catches blockKey drift across a minute boundary. */
@@ -51,6 +53,14 @@ export const ATTEMPT_COOLDOWN_MS = 60_000;
51
53
  /** Minute bucket for blockKey, absorbing `reset_after_seconds`-derived jitter. */
52
54
  export const DEBOUNCE_BUCKET_MS = 60_000;
53
55
 
56
+ export function shouldEvaluateCodexAutoRedeem(mode: CodexAutoRedeemMode): boolean {
57
+ return mode !== "no";
58
+ }
59
+
60
+ export function shouldPromptCodexAutoRedeem(mode: CodexAutoRedeemMode): boolean {
61
+ return mode === "unset";
62
+ }
63
+
54
64
  export type CodexAutoRedeemSkipReason =
55
65
  | "disabled"
56
66
  | "wrong-provider"
@@ -83,16 +93,18 @@ export interface CodexAutoRedeemInput {
83
93
  lastAttemptAtByAccount: ReadonlyMap<string, number>;
84
94
  }
85
95
 
96
+ export interface CodexAutoRedeemRedeemDecision {
97
+ redeem: true;
98
+ target: ResetCreditTarget;
99
+ accountKey: string;
100
+ blockKey: string;
101
+ weeklyResetAtMs: number;
102
+ remainingMs: number;
103
+ availableCount: number;
104
+ }
105
+
86
106
  export type CodexAutoRedeemDecision =
87
- | {
88
- redeem: true;
89
- target: ResetCreditTarget;
90
- accountKey: string;
91
- blockKey: string;
92
- weeklyResetAtMs: number;
93
- remainingMs: number;
94
- availableCount: number;
95
- }
107
+ | CodexAutoRedeemRedeemDecision
96
108
  | { redeem: false; reason: CodexAutoRedeemSkipReason };
97
109
 
98
110
  /** Trimmed lowercase, or undefined when blank. Mirrors `normalizeIdentityValue` in active-oauth-account.ts. */
@@ -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.
@@ -123,14 +123,16 @@ function selectSystemPromptImageTarget(
123
123
 
124
124
  /** Tool-result swap candidate, in context order. */
125
125
  export interface InlineToolResultCandidate {
126
- /** toolCallId stable identity for render caching and application. */
126
+ /** Stable identifier for rendering cache key and applying the swap. */
127
127
  id: string;
128
- /** 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). */
129
129
  textTokens: number;
130
- /** 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). */
131
131
  frames: number;
132
132
  /** Already carries an image (screenshot etc.) — never re-imaged. */
133
133
  hasImage: boolean;
134
+ /** Error tool results must stay text-only for provider API validation. */
135
+ isError?: boolean;
134
136
  }
135
137
 
136
138
  export interface InlineSystemPromptCandidate {
@@ -173,6 +175,7 @@ export function planInlineSwaps(input: InlinePlanInput): InlineSwapPlan {
173
175
  for (let k = 0; k < input.toolResults.length - 1 && budget > 0; k++) {
174
176
  const candidate = input.toolResults[k];
175
177
  if (candidate.hasImage) continue;
178
+ if (candidate.isError) continue;
176
179
  if (candidate.textTokens < MIN_TOOL_RESULT_TOKENS) continue;
177
180
  if (candidate.frames === 0 || candidate.frames > budget) continue;
178
181
  if (!passesSavingsGate(candidate.frames, input.shape, candidate.textTokens)) continue;
@@ -211,6 +214,7 @@ export interface InlineMessageView {
211
214
  role: string;
212
215
  toolCallId?: string;
213
216
  content?: unknown;
217
+ isError?: boolean;
214
218
  }
215
219
 
216
220
  export interface SnapcompactSavingsEstimate {
@@ -280,6 +284,7 @@ export function estimateInlineSavings(input: {
280
284
  if (options.renderToolResults) {
281
285
  for (const message of input.messages) {
282
286
  if (message.role !== "toolResult" || typeof message.toolCallId !== "string") continue;
287
+ if (message.isError) continue;
283
288
  const blocks: BlockViews = Array.isArray(message.content) ? (message.content as BlockViews) : [];
284
289
  const hasImage = blocks.some(block => block.type === "image");
285
290
  const text = hasImage
@@ -418,6 +423,7 @@ export class SnapcompactInlineTransformer {
418
423
  liveToolCallIds.add(message.toolCallId);
419
424
  // Don't re-image results that already carry images (screenshots etc.).
420
425
  const hasImage = message.content.some(block => block.type === "image");
426
+ if (message.isError) continue;
421
427
  const text = hasImage
422
428
  ? ""
423
429
  : message.content