@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -172,6 +172,7 @@ export class InputController {
172
172
 
173
173
  #enhancedPaste?: EnhancedPasteController;
174
174
  #focusedLeftTapListenerInstalled = false;
175
+ #focusedPasteListenerInstalled = false;
175
176
  #btwBranchListenerInstalled = false;
176
177
  #btwCopyListenerInstalled = false;
177
178
  // Tap counter for the double-← gesture; reset whenever a quiet gap
@@ -265,6 +266,16 @@ export class InputController {
265
266
  return { consume: true };
266
267
  });
267
268
  }
269
+ if (!this.#focusedPasteListenerInstalled) {
270
+ this.#focusedPasteListenerInstalled = true;
271
+ this.ctx.ui.addInputListener(data => {
272
+ const focused = this.ctx.ui.getFocused();
273
+ if (!focused || focused === this.ctx.editor || !hasPasteText(focused)) return undefined;
274
+ if (!this.ctx.keybindings.matches(data, "app.clipboard.pasteImage")) return undefined;
275
+ void this.handleImagePaste();
276
+ return { consume: true };
277
+ });
278
+ }
268
279
  this.ctx.editor.onEscape = () => {
269
280
  // Side-channel panels are the topmost view. Esc dismisses them before
270
281
  // touching loop mode, maintenance, or the underlying main turn.
@@ -308,6 +319,14 @@ export class InputController {
308
319
  if (aborted) return;
309
320
  }
310
321
 
322
+ if (vocalizer.isSpeaking()) {
323
+ // Playback from the completed response can overlap the next agent
324
+ // turn. Silence it before interrupting any ongoing main-turn work.
325
+ vocalizer.clear();
326
+ this.ctx.lastEscapeTime = 0;
327
+ return;
328
+ }
329
+
311
330
  if (this.ctx.loopModeEnabled) {
312
331
  this.ctx.pauseLoop();
313
332
  if (this.ctx.session.isStreaming) {
@@ -360,13 +379,6 @@ export class InputController {
360
379
  } else if (this.ctx.editor.getText().trim()) {
361
380
  // Esc must not destroy an in-progress draft.
362
381
  this.ctx.lastEscapeTime = 0;
363
- } else if (vocalizer.isSpeaking()) {
364
- // TTS buffers seconds of PCM past the streaming abort, so an Esc
365
- // arriving after the model stopped would otherwise fall through to
366
- // the double-Esc gesture while Kokoro reads on. Silence first;
367
- // tree/branch stays reachable via a second Esc.
368
- vocalizer.clear();
369
- this.ctx.lastEscapeTime = 0;
370
382
  } else {
371
383
  // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
372
384
  const action = settings.get("doubleEscapeAction");
@@ -822,12 +834,25 @@ export class InputController {
822
834
  // First, move any pending bash components to chat
823
835
  this.ctx.flushPendingBashComponents();
824
836
 
837
+ // AgentSession.prompt() consumes registered extension commands locally.
838
+ // Classify them here because title generation starts before prompt dispatch.
839
+ const extensionCommandSpace = text.indexOf(" ");
840
+ const isLocalExtensionCommand =
841
+ text.startsWith("/") &&
842
+ runner?.getCommand(extensionCommandSpace === -1 ? text.slice(1) : text.slice(1, extensionCommandSpace)) !==
843
+ undefined;
844
+
825
845
  // Auto-generate a session title while the session is still unnamed.
826
846
  // Greetings / acknowledgements / empty input carry no task, so they are
827
847
  // skipped deterministically (no model invoked, no download-progress UI)
828
848
  // and the session stays unnamed — the next user message gets a fresh
829
849
  // chance, so titling defers past "hi" instead of latching onto it.
830
- if (!this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE && !isLowSignalTitleInput(text)) {
850
+ if (
851
+ !isLocalExtensionCommand &&
852
+ !this.ctx.sessionManager.getSessionName() &&
853
+ !$env.PI_NO_TITLE &&
854
+ !isLowSignalTitleInput(text)
855
+ ) {
831
856
  this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
832
857
  this.ctx.session
833
858
  .generateTitle(text)
@@ -1558,8 +1583,19 @@ export class InputController {
1558
1583
 
1559
1584
  async handleImagePaste(): Promise<boolean> {
1560
1585
  try {
1586
+ // When a modal paste-capable prompt (login/API-key Input) owns focus,
1587
+ // only clipboard text may land there. Image payloads must not mutate
1588
+ // the hidden main editor — mirror the enhanced-paste `pasteImage`
1589
+ // behavior and surface the unsupported-status instead (#6057).
1590
+ const focusedNow = this.ctx.ui.getFocused();
1591
+ const promptTarget =
1592
+ focusedNow && focusedNow !== this.ctx.editor && hasPasteText(focusedNow) ? focusedNow : null;
1561
1593
  const image = await this.clipboard.readImage();
1562
1594
  if (image) {
1595
+ if (promptTarget) {
1596
+ this.ctx.showStatus("Image paste is not supported in this prompt");
1597
+ return false;
1598
+ }
1563
1599
  return await this.#normalizeAndInsertPastedImage(
1564
1600
  {
1565
1601
  type: "image",
@@ -1581,7 +1617,7 @@ export class InputController {
1581
1617
  // selections must not silently drop after the first attach.
1582
1618
  // `readMacFileUrls` returns an empty list off Darwin, so the
1583
1619
  // check is free on every other platform.
1584
- const fileUrls = (await this.clipboard.readMacFileUrls?.()) ?? [];
1620
+ const fileUrls = promptTarget ? [] : ((await this.clipboard.readMacFileUrls?.()) ?? []);
1585
1621
  let attachedFromFileUrls = false;
1586
1622
  for (const url of fileUrls) {
1587
1623
  const candidate = extractImagePathFromText(url);
@@ -1606,15 +1642,14 @@ export class InputController {
1606
1642
  // text. Covers terminals that paste the Finder file path as
1607
1643
  // plain text rather than as a `public.file-url` (most macOS
1608
1644
  // terminals do this for image clipboards).
1609
- const imagePath = extractImagePathFromText(text);
1645
+ const imagePath = promptTarget ? null : extractImagePathFromText(text);
1610
1646
  if (imagePath) {
1611
1647
  await this.handleImagePathPaste(imagePath);
1612
1648
  return true;
1613
1649
  }
1614
1650
  // Route to the focused component when it accepts pastes (modal
1615
1651
  // Input prompts), matching the enhanced-paste text path (#2127).
1616
- const focused = this.ctx.ui.getFocused();
1617
- const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
1652
+ const target = promptTarget ?? this.ctx.editor;
1618
1653
  target.pasteText(text);
1619
1654
  this.ctx.ui.requestRender();
1620
1655
  return true;
@@ -47,7 +47,7 @@ import {
47
47
  searchSmitheryRegistry,
48
48
  toConfigName,
49
49
  } from "../../mcp/smithery-registry";
50
- import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
50
+ import type { MCPAuthChallenge, MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
51
51
  import { shortenPath } from "../../tools/render-utils";
52
52
  import { urlHyperlinkAlways } from "../../tui";
53
53
  import { copyToClipboard } from "../../utils/clipboard";
@@ -1042,7 +1042,10 @@ export class MCPCommandController {
1042
1042
  return next;
1043
1043
  }
1044
1044
 
1045
- async #resolveOAuthEndpointsFromServer(config: MCPServerConfig): Promise<OAuthEndpoints> {
1045
+ async #resolveOAuthEndpointsFromServer(
1046
+ config: MCPServerConfig,
1047
+ authChallenge?: MCPAuthChallenge,
1048
+ ): Promise<OAuthEndpoints> {
1046
1049
  // Stdio servers manage credentials inside the child process; OMP's OAuth
1047
1050
  // flow only applies to http/sse transports. Without this guard the
1048
1051
  // unauthenticated preflight below spawns the child, which happily reuses
@@ -1068,13 +1071,20 @@ export class MCPCommandController {
1068
1071
  connectionError = error as Error;
1069
1072
  }
1070
1073
 
1071
- // Server connected fine without auth — reauth is not needed
1072
- if (connectionSucceeded) {
1074
+ // Server connected fine without auth — reauth is not needed. A tool-level
1075
+ // challenge overrides this: servers may allow the anonymous handshake yet
1076
+ // protect individual tool calls with `_meta["mcp/www_authenticate"]`.
1077
+ if (connectionSucceeded && !authChallenge) {
1073
1078
  throw new Error("Server connection succeeded without OAuth; reauthorization is not required.");
1074
1079
  }
1075
1080
 
1076
- // Analyze the connection error to extract OAuth endpoints
1077
- const authResult = analyzeAuthError(connectionError!, "url" in config ? config.url : undefined);
1081
+ // Tool calls can carry richer RFC 6750/RFC 9728 hints than the original
1082
+ // connection error. Feed those hints through the same analyzer so
1083
+ // resource_metadata and scope reach protected-resource discovery.
1084
+ const authError = authChallenge
1085
+ ? new Error(`${connectionError?.message ?? "HTTP 401"}\n${authChallenge.wwwAuthenticate.join("\n")}`)
1086
+ : connectionError!;
1087
+ const authResult = analyzeAuthError(authError, "url" in config ? config.url : undefined);
1078
1088
  let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
1079
1089
 
1080
1090
  if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
@@ -1678,21 +1688,29 @@ export class MCPCommandController {
1678
1688
  }
1679
1689
  }
1680
1690
 
1681
- async #handleReauth(name: string | undefined): Promise<void> {
1691
+ /** Reauthorize a server after a tool-level OAuth challenge. */
1692
+ async handleMCPAuthChallenge(name: string, challenge: MCPAuthChallenge): Promise<MCPServerConfig | undefined> {
1693
+ return this.#handleReauth(name, { silent: true, reload: false, authChallenge: challenge });
1694
+ }
1695
+
1696
+ async #handleReauth(
1697
+ name: string | undefined,
1698
+ options: { silent?: boolean; reload?: boolean; authChallenge?: MCPAuthChallenge } = {},
1699
+ ): Promise<MCPServerConfig | undefined> {
1682
1700
  if (!name) {
1683
- this.ctx.showError("Server name required. Usage: /mcp reauth <name>");
1701
+ if (!options.silent) this.ctx.showError("Server name required. Usage: /mcp reauth <name>");
1684
1702
  return;
1685
1703
  }
1686
1704
 
1687
1705
  try {
1688
1706
  const found = await this.#resolveServerForAuth(name);
1689
1707
  if (!found) {
1690
- this.ctx.showError(`Server "${name}" not found.`);
1708
+ if (!options.silent) this.ctx.showError(`Server "${name}" not found.`);
1691
1709
  return;
1692
1710
  }
1693
1711
 
1694
1712
  if (found.config.enabled === false) {
1695
- this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`);
1713
+ if (!options.silent) this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`);
1696
1714
  return;
1697
1715
  }
1698
1716
 
@@ -1705,7 +1723,7 @@ export class MCPCommandController {
1705
1723
  // happened yet if the server turns out not to need (or support) OAuth.
1706
1724
  // Use the same env-expanded config shape runtime discovery passes to
1707
1725
  // MCPManager; the raw file value may contain `${...}` placeholders.
1708
- const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig);
1726
+ const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig, options.authChallenge);
1709
1727
  const serverUrl =
1710
1728
  runtimeBaseConfig.type === "http" || runtimeBaseConfig.type === "sse" ? runtimeBaseConfig.url : undefined;
1711
1729
  // A user-supplied client secret may live in either block (the wizard
@@ -1719,7 +1737,9 @@ export class MCPCommandController {
1719
1737
  const userClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret;
1720
1738
  const flowClientSecret = userClientSecret ?? storedClientSecret ?? "";
1721
1739
 
1722
- this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1740
+ if (!options.silent) {
1741
+ this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1742
+ }
1723
1743
 
1724
1744
  const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined;
1725
1745
  const oauthResource =
@@ -1755,39 +1775,49 @@ export class MCPCommandController {
1755
1775
  // Definition-only entries resolve through the url-keyed binding alone;
1756
1776
  // skip the write-back so a committed project mcp.json stays clean.
1757
1777
  const urlKeyedId = serverUrl ? mcpOAuthCredentialId(serverUrl) : undefined;
1758
- if (currentAuth || oauthResult.credentialId !== urlKeyedId) {
1759
- const updated = this.#persistOAuthResult(baseConfig, oauthResult, {
1760
- tokenUrl: oauth.tokenUrl,
1761
- clientId: oauth.clientId,
1762
- userClientSecret,
1763
- resource: oauthResource,
1764
- stripSameOriginResource: oauthResourceIsFallback,
1765
- });
1766
- await updateMCPServer(found.filePath, name, updated);
1767
- }
1768
- await this.#reloadMCP();
1769
- const state = await this.#waitForServerConnectionWithAnimation(name);
1778
+ const shouldPersist = currentAuth || oauthResult.credentialId !== urlKeyedId;
1779
+ const updatedConfig = shouldPersist
1780
+ ? this.#persistOAuthResult(baseConfig, oauthResult, {
1781
+ tokenUrl: oauth.tokenUrl,
1782
+ clientId: oauth.clientId,
1783
+ userClientSecret,
1784
+ resource: oauthResource,
1785
+ stripSameOriginResource: oauthResourceIsFallback,
1786
+ })
1787
+ : baseConfig;
1788
+ if (shouldPersist) {
1789
+ await updateMCPServer(found.filePath, name, updatedConfig);
1790
+ }
1791
+ if (options.reload !== false) {
1792
+ await this.#reloadMCP();
1793
+ const state = await this.#waitForServerConnectionWithAnimation(name);
1770
1794
 
1771
- const lines = [
1772
- "",
1773
- theme.fg("success", `✓ Reauthorized "${name}" (${found.scope} config)`),
1774
- "",
1775
- ` Status: ${
1776
- state === "connected"
1777
- ? theme.fg("success", "connected")
1778
- : state === "connecting"
1779
- ? theme.fg("muted", "connecting")
1780
- : theme.fg("warning", "not connected")
1781
- }`,
1782
- "",
1783
- ];
1784
- this.#showMessage(lines.join("\n"));
1795
+ const lines = [
1796
+ "",
1797
+ theme.fg("success", `✓ Reauthorized "${name}" (${found.scope} config)`),
1798
+ "",
1799
+ ` Status: ${
1800
+ state === "connected"
1801
+ ? theme.fg("success", "connected")
1802
+ : state === "connecting"
1803
+ ? theme.fg("muted", "connecting")
1804
+ : theme.fg("warning", "not connected")
1805
+ }`,
1806
+ "",
1807
+ ];
1808
+ this.#showMessage(lines.join("\n"));
1809
+ }
1810
+ return updatedConfig;
1785
1811
  } catch (error) {
1786
1812
  if (error instanceof MCPOAuthCancelledError) {
1787
- this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
1813
+ if (!options.silent) this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
1788
1814
  return;
1789
1815
  }
1790
- this.ctx.showError(`Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`);
1816
+ if (!options.silent) {
1817
+ this.ctx.showError(
1818
+ `Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`,
1819
+ );
1820
+ }
1791
1821
  }
1792
1822
  }
1793
1823
 
@@ -1,4 +1,4 @@
1
- import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
1
+ import { type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
3
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
4
4
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
@@ -63,8 +63,11 @@ import {
63
63
  setExcludedSearchProviders,
64
64
  setPreferredImageProvider,
65
65
  setPreferredSearchProvider,
66
+ type ToolSession,
66
67
  } from "../../tools";
68
+ import { AskTool, type AskToolDetails, type AskToolInput } from "../../tools/ask";
67
69
  import { shortenPath } from "../../tools/render-utils";
70
+ import { ToolAbortError } from "../../tools/tool-errors";
68
71
  import { copyToClipboard } from "../../utils/clipboard";
69
72
  import { repo } from "../../utils/git";
70
73
  import { setSessionTerminalTitle } from "../../utils/title-generator";
@@ -1138,11 +1141,21 @@ export class SelectorController {
1138
1141
  realLeafId,
1139
1142
  this.ctx.ui.terminal.rows,
1140
1143
  async (entryId, options) => {
1141
- // Selecting the current leaf is a no-op (already there)
1144
+ // Selecting the current leaf is normally a no-op (already there)
1145
+ // unless it's an `ask` toolResult, in which case the re-answer flow
1146
+ // must still be allowed to reopen the picker even though the leaf
1147
+ // doesn't move (chatgpt-codex review on #5895).
1142
1148
  if (entryId === realLeafId) {
1143
- done();
1144
- this.ctx.showStatus("Already at this point");
1145
- return;
1149
+ const currentEntry = this.ctx.sessionManager.getEntry(entryId);
1150
+ const currentIsAskResult =
1151
+ currentEntry?.type === "message" &&
1152
+ currentEntry.message.role === "toolResult" &&
1153
+ currentEntry.message.toolName === "ask";
1154
+ if (!currentIsAskResult) {
1155
+ done();
1156
+ this.ctx.showStatus("Already at this point");
1157
+ return;
1158
+ }
1146
1159
  }
1147
1160
 
1148
1161
  // Ask about summarization
@@ -1204,11 +1217,29 @@ export class SelectorController {
1204
1217
  }
1205
1218
 
1206
1219
  try {
1207
- const result = await this.ctx.session.navigateTree(entryId, {
1220
+ let result = await this.ctx.session.navigateTree(entryId, {
1208
1221
  summarize: wantsSummary,
1209
1222
  customInstructions,
1223
+ allowAskReopen: true,
1210
1224
  });
1211
1225
 
1226
+ // Selecting an `ask` toolResult doesn't land the leaf directly —
1227
+ // re-open the picker with the original questions first, then
1228
+ // complete the navigation as a new sibling branch (issue #5642).
1229
+ if (result.reopenAsk) {
1230
+ const reanswer = await this.#reanswerAsk(result.reopenAsk.questions);
1231
+ if (!reanswer) {
1232
+ this.ctx.showStatus("Re-answer cancelled");
1233
+ return;
1234
+ }
1235
+ result = await this.ctx.session.navigateTree(entryId, {
1236
+ summarize: wantsSummary,
1237
+ customInstructions,
1238
+ allowAskReopen: true,
1239
+ reanswerAskResult: reanswer,
1240
+ });
1241
+ }
1242
+
1212
1243
  if (result.aborted) {
1213
1244
  // Summarization aborted - re-show tree selector
1214
1245
  this.ctx.showStatus("Branch summarization cancelled");
@@ -1252,6 +1283,51 @@ export class SelectorController {
1252
1283
  });
1253
1284
  }
1254
1285
 
1286
+ /**
1287
+ * Re-open the `ask` picker with the original `questions` (issue #5642):
1288
+ * runs a standalone `AskTool.execute()` outside a normal agent turn,
1289
+ * reusing the same picker/dialog primitives a live `ask` tool call gets.
1290
+ * Returns `undefined` when the user cancels — mirrors `navigateTree`'s
1291
+ * cancellation contract instead of throwing.
1292
+ */
1293
+ async #reanswerAsk(questions: AskToolInput["questions"]): Promise<AgentToolResult<AskToolDetails> | undefined> {
1294
+ const uiContext = this.ctx.getToolUIContext();
1295
+ if (!uiContext) {
1296
+ this.ctx.showError("Ask tool UI is not ready");
1297
+ return undefined;
1298
+ }
1299
+ const toolSession: ToolSession = {
1300
+ cwd: this.ctx.sessionManager.getCwd(),
1301
+ hasUI: true,
1302
+ settings: this.ctx.settings,
1303
+ getSessionFile: () => this.ctx.sessionManager.getSessionFile() ?? null,
1304
+ getSessionSpawns: () => null,
1305
+ getPlanModeState: () => this.ctx.session.getPlanModeState(),
1306
+ };
1307
+ const askTool = new AskTool(toolSession);
1308
+ const context = this.ctx.session.buildAskReanswerContext(uiContext);
1309
+ let result: AgentToolResult<AskToolDetails>;
1310
+ try {
1311
+ result = await askTool.execute("tree-reanswer", { questions }, undefined, undefined, context);
1312
+ } catch (error) {
1313
+ if (error instanceof ToolAbortError) return undefined;
1314
+ throw error;
1315
+ }
1316
+ // The rich ask dialog can race a collab guest choosing "Chat about this"
1317
+ // (`AskTool`'s `chatRedirect` result); that's meaningful inside a live
1318
+ // agent turn, where the model sees the redirect and starts a
1319
+ // conversation, but this standalone re-answer has no turn to hand it
1320
+ // to — completing the navigation with it would silently drop the
1321
+ // user's intent to chat (roboomp review on #5895).
1322
+ if (result.details?.chatRedirect) {
1323
+ this.ctx.showError(
1324
+ "Chat about this isn't available when re-answering from the tree — pick an option or type a custom answer instead.",
1325
+ );
1326
+ return undefined;
1327
+ }
1328
+ return result;
1329
+ }
1330
+
1255
1331
  async showSessionSelector(): Promise<void> {
1256
1332
  const sessions = await SessionManager.list(
1257
1333
  this.ctx.sessionManager.getCwd(),
@@ -481,6 +481,7 @@ export class InteractiveMode implements InteractiveModeContext {
481
481
  proseOnlyThinking = true;
482
482
  compactionQueuedMessages: CompactionQueuedMessage[] = [];
483
483
  pendingTools = new Map<string, ToolExecutionHandle>();
484
+ transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
484
485
  pendingBashComponents: BashExecutionComponent[] = [];
485
486
  bashComponent: BashExecutionComponent | undefined = undefined;
486
487
  pendingPythonComponents: EvalExecutionComponent[] = [];
@@ -553,6 +554,7 @@ export class InteractiveMode implements InteractiveModeContext {
553
554
  #planModeHasEntered = false;
554
555
  #planReviewOverlay: PlanReviewOverlay | undefined;
555
556
  #planReviewOverlayHandle: OverlayHandle | undefined;
557
+ #planReviewCancel: (() => void) | undefined;
556
558
  readonly lspServers: LspStartupServerInfo[] | undefined = undefined;
557
559
  mcpManager?: MCPManager;
558
560
  readonly #toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
@@ -654,6 +656,9 @@ export class InteractiveMode implements InteractiveModeContext {
654
656
  this.#toolUiContextSetter = setToolUIContext;
655
657
  this.lspServers = lspServers;
656
658
  this.mcpManager = mcpManager;
659
+ this.mcpManager?.setAuthHandler((serverName, challenge) =>
660
+ new MCPCommandController(this).handleMCPAuthChallenge(serverName, challenge),
661
+ );
657
662
  this.#eventBus = eventBus;
658
663
  if (eventBus) {
659
664
  this.#eventBusUnsubscribers.push(
@@ -1657,7 +1662,7 @@ export class InteractiveMode implements InteractiveModeContext {
1657
1662
  if (options.requestRender !== false) this.ui.requestRender();
1658
1663
  }
1659
1664
 
1660
- rebuildChatFromMessages(): void {
1665
+ rebuildChatFromMessages(options: { reuseSettledComponents?: boolean } = {}): void {
1661
1666
  // Mid-stream rebuilds (e.g. `/shake`, theme/setting changes that touch the
1662
1667
  // transcript) replay only committed `state.messages`. The agent's in-flight
1663
1668
  // `streamMessage` and its still-pending tool calls live OUTSIDE
@@ -1690,7 +1695,18 @@ export class InteractiveMode implements InteractiveModeContext {
1690
1695
  const context = this.viewSession.buildTranscriptSessionContext({
1691
1696
  collapseCompactedHistory: settings.get("display.collapseCompacted"),
1692
1697
  });
1693
- this.renderSessionContext(context);
1698
+ // Prune the settled-component cache to the messages this rebuild will
1699
+ // actually render. Message objects stay strongly reachable through
1700
+ // session entries for the whole session, so entries for compacted-away
1701
+ // history would otherwise pin their components' rendered layout caches
1702
+ // forever — exactly the memory a collapsed compaction used to release.
1703
+ const retained = new WeakMap<AgentMessage, Component>();
1704
+ for (const message of context.messages) {
1705
+ const component = this.transcriptMessageComponents.get(message);
1706
+ if (component) retained.set(message, component);
1707
+ }
1708
+ this.transcriptMessageComponents = retained;
1709
+ this.renderSessionContext(context, { reuseSettledComponents: options.reuseSettledComponents });
1694
1710
  for (const child of liveComponents) {
1695
1711
  this.chatContainer.addChild(child);
1696
1712
  }
@@ -2642,6 +2658,7 @@ export class InteractiveMode implements InteractiveModeContext {
2642
2658
  settled = true;
2643
2659
  resolve(choice);
2644
2660
  };
2661
+ this.#planReviewCancel = () => finish(undefined);
2645
2662
  const overlay = new PlanReviewOverlay(
2646
2663
  planContent,
2647
2664
  {
@@ -2678,11 +2695,19 @@ export class InteractiveMode implements InteractiveModeContext {
2678
2695
  }
2679
2696
 
2680
2697
  #hidePlanReview(): void {
2698
+ this.#planReviewCancel = undefined;
2681
2699
  this.#planReviewOverlayHandle?.hide();
2682
2700
  this.#planReviewOverlayHandle = undefined;
2683
2701
  this.#planReviewOverlay = undefined;
2684
2702
  }
2685
2703
 
2704
+ #dismissPlanReview(): void {
2705
+ const cancel = this.#planReviewCancel;
2706
+ this.#planReviewCancel = undefined;
2707
+ cancel?.();
2708
+ this.#hidePlanReview();
2709
+ }
2710
+
2686
2711
  #getEditorTerminalPath(): string | null {
2687
2712
  if (process.platform === "win32") {
2688
2713
  return null;
@@ -3912,6 +3937,7 @@ export class InteractiveMode implements InteractiveModeContext {
3912
3937
  }
3913
3938
 
3914
3939
  resetTranscript(): void {
3940
+ this.transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
3915
3941
  this.chatContainer.dispose();
3916
3942
  this.chatContainer.clear();
3917
3943
  }
@@ -3931,6 +3957,7 @@ export class InteractiveMode implements InteractiveModeContext {
3931
3957
  }
3932
3958
 
3933
3959
  showPinnedError(message: string): void {
3960
+ this.#dismissPlanReview();
3934
3961
  this.errorBannerContainer.clear();
3935
3962
  this.errorBannerContainer.addChild(new ErrorBannerComponent(message));
3936
3963
  this.ui.requestRender();
@@ -4137,14 +4164,18 @@ export class InteractiveMode implements InteractiveModeContext {
4137
4164
 
4138
4165
  addMessageToChat(
4139
4166
  message: AgentMessage,
4140
- options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
4167
+ options?: {
4168
+ populateHistory?: boolean;
4169
+ imageLinks?: readonly (string | undefined)[];
4170
+ reuseSettledComponent?: boolean;
4171
+ },
4141
4172
  ): Component[] {
4142
4173
  return this.#uiHelpers.addMessageToChat(message, options);
4143
4174
  }
4144
4175
 
4145
4176
  renderSessionContext(
4146
4177
  sessionContext: SessionContext,
4147
- options?: { updateFooter?: boolean; populateHistory?: boolean },
4178
+ options?: { updateFooter?: boolean; populateHistory?: boolean; reuseSettledComponents?: boolean },
4148
4179
  ): void {
4149
4180
  for (const message of sessionContext.messages) {
4150
4181
  this.noteDisplayableThinkingContent(message);
@@ -4635,6 +4666,10 @@ export class InteractiveMode implements InteractiveModeContext {
4635
4666
  return this.#extensionUiController.initHooksAndCustomTools();
4636
4667
  }
4637
4668
 
4669
+ getToolUIContext(): ExtensionUIContext | undefined {
4670
+ return this.#extensionUiController.getToolUIContext();
4671
+ }
4672
+
4638
4673
  emitCustomToolSessionEvent(
4639
4674
  reason: "start" | "switch" | "branch" | "tree" | "shutdown",
4640
4675
  previousSessionFile?: string,