@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
@@ -38,6 +38,7 @@ import type { MCPToolDetails } from "./tool-bridge";
38
38
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
39
39
  import type { MCPToolCache } from "./tool-cache";
40
40
  import type {
41
+ MCPAuthChallenge,
41
42
  MCPGetPromptResult,
42
43
  MCPPrompt,
43
44
  MCPRequestOptions,
@@ -160,6 +161,9 @@ export interface MCPDiscoverOptions {
160
161
  onStatus?: (event: McpConnectionStatusEvent) => void;
161
162
  }
162
163
 
164
+ /** Handles an MCP `WWW-Authenticate` challenge and returns refreshed config. */
165
+ export type MCPAuthHandler = (serverName: string, challenge: MCPAuthChallenge) => Promise<MCPServerConfig | undefined>;
166
+
163
167
  /**
164
168
  * MCP Server Manager.
165
169
  *
@@ -189,6 +193,7 @@ export class MCPManager {
189
193
  #pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
190
194
  #sources = new Map<string, SourceMeta>();
191
195
  #authStorage: AuthStorage | null = null;
196
+ #authHandler?: MCPAuthHandler;
192
197
  #onNotification?: (serverName: string, method: string, params: unknown) => void;
193
198
  #onToolsChanged?: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void;
194
199
  #onResourcesChanged?: (serverName: string, uri: string) => void;
@@ -201,7 +206,7 @@ export class MCPManager {
201
206
  /** Preserved configs for reconnection after connection loss. */
202
207
  #serverConfigs = new Map<string, MCPServerConfig>();
203
208
  /**
204
- * Timestamps of recent `reconnectServer` invocations per server, used by the
209
+ * Timestamps of recent reconnectServer invocations per server, used by the
205
210
  * crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
206
211
  */
207
212
  #reconnectHistory = new Map<string, number[]>();
@@ -312,6 +317,11 @@ export class MCPManager {
312
317
  this.#authStorage = authStorage;
313
318
  }
314
319
 
320
+ /** Set the callback used to complete OAuth after a tool-level auth challenge. */
321
+ setAuthHandler(handler: MCPAuthHandler | undefined): void {
322
+ this.#authHandler = handler;
323
+ }
324
+
315
325
  /**
316
326
  * Discover and connect to all MCP servers from .mcp.json files.
317
327
  * Returns tools and any connection errors.
@@ -474,7 +484,8 @@ export class MCPManager {
474
484
  .then(async ({ connection, serverTools }) => {
475
485
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
476
486
  this.#pendingToolLoads.delete(name);
477
- const reconnect = () => this.reconnectServer(name);
487
+ const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) =>
488
+ this.reconnectServer(name, options);
478
489
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
479
490
  this.#replaceServerTools(name, customTools);
480
491
  this.#onToolsChanged?.(this.#tools);
@@ -810,13 +821,15 @@ export class MCPManager {
810
821
  * the same server share one reconnection attempt. Returns the new
811
822
  * connection, or `null` if reconnection failed or the per-server crash
812
823
  * burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
813
- *
814
824
  * @param options.manual - When `true`, resets the crash-burst window so a
815
825
  * user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
816
826
  * earlier storm. Defaults to `false`; the transport `onClose` callback
817
827
  * and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
818
828
  */
819
- async reconnectServer(name: string, options?: { manual?: boolean }): Promise<MCPServerConnection | null> {
829
+ async reconnectServer(
830
+ name: string,
831
+ options?: { manual?: boolean; authChallenge?: MCPAuthChallenge },
832
+ ): Promise<MCPServerConnection | null> {
820
833
  if (options?.manual) {
821
834
  this.#reconnectHistory.delete(name);
822
835
  }
@@ -828,7 +841,7 @@ export class MCPManager {
828
841
  return null;
829
842
  }
830
843
 
831
- const attempt = this.#doReconnect(name);
844
+ const attempt = this.#doReconnect(name, options?.authChallenge);
832
845
  this.#pendingReconnections.set(name, attempt);
833
846
  return attempt.finally(() => this.#pendingReconnections.delete(name));
834
847
  }
@@ -873,12 +886,30 @@ export class MCPManager {
873
886
  return false;
874
887
  }
875
888
 
876
- async #doReconnect(name: string): Promise<MCPServerConnection | null> {
889
+ async #doReconnect(name: string, authChallenge?: MCPAuthChallenge): Promise<MCPServerConnection | null> {
877
890
  const oldConnection = this.#connections.get(name);
878
- const config = oldConnection?.config ?? this.#serverConfigs.get(name);
891
+ let config = oldConnection?.config ?? this.#serverConfigs.get(name);
879
892
  const source = this.#sources.get(name) ?? oldConnection?._source;
880
893
  if (!config) return null;
881
894
 
895
+ if (authChallenge) {
896
+ if (!this.#authHandler) {
897
+ logger.error("MCP auth challenge cannot be handled; no auth handler is configured", {
898
+ path: `mcp:${name}`,
899
+ });
900
+ return null;
901
+ }
902
+ try {
903
+ const refreshedConfig = await this.#authHandler(name, authChallenge);
904
+ if (!refreshedConfig) return null;
905
+ config = refreshedConfig;
906
+ this.#serverConfigs.set(name, config);
907
+ } catch (error) {
908
+ logger.error("MCP auth challenge handling failed", { path: `mcp:${name}`, error });
909
+ return null;
910
+ }
911
+ }
912
+
882
913
  logger.debug("MCP reconnecting", { path: `mcp:${name}` });
883
914
 
884
915
  // Close the old transport without removing tools or notifying consumers.
@@ -987,7 +1018,7 @@ export class MCPManager {
987
1018
  };
988
1019
  try {
989
1020
  const serverTools = await listTools(connection);
990
- const reconnect = () => this.reconnectServer(name);
1021
+ const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) => this.reconnectServer(name, options);
991
1022
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
992
1023
  void this.toolCache?.set(name, config, serverTools);
993
1024
  this.#replaceServerTools(name, customTools);
package/src/mcp/render.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * Provides structured display of MCP tool calls and results,
5
5
  * showing args and output in JSON tree format similar to task tool.
6
6
  */
7
- import type { Component } from "@oh-my-pi/pi-tui";
7
+ import { type Component, Markdown } from "@oh-my-pi/pi-tui";
8
+ import { settings } from "../config/settings";
8
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
- import type { Theme } from "../modes/theme/theme";
10
+ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
10
11
  import {
11
12
  formatArgsInline,
12
13
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -48,6 +49,61 @@ export function renderMCPCall(args: Record<string, unknown>, theme: Theme, label
48
49
  );
49
50
  }
50
51
 
52
+ /** Render an MCP status/args prefix followed by Markdown-aware text output. */
53
+ function renderMarkdownMCPResult(
54
+ result: { details?: MCPToolDetails; isError?: boolean },
55
+ trimmedOutput: string,
56
+ truncationWarning: string | null,
57
+ options: RenderResultOptions,
58
+ theme: Theme,
59
+ args?: Record<string, unknown>,
60
+ ): Component {
61
+ const markdown = new Markdown(trimmedOutput, 0, 0, getMarkdownTheme(), {
62
+ color: text => theme.fg("toolOutput", text),
63
+ });
64
+ return {
65
+ render(contentWidth: number): readonly string[] {
66
+ const lines: string[] = [];
67
+ const isError = result.isError ?? result.details?.isError ?? false;
68
+ const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP";
69
+ lines.push(
70
+ renderStatusLine(
71
+ isError ? { icon: "error", title } : { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title },
72
+ theme,
73
+ ),
74
+ );
75
+
76
+ if (options.expanded && args && Object.keys(args).length > 0) {
77
+ lines.push(theme.fg("dim", "Args"));
78
+ const tree = renderJsonTreeLines(
79
+ args,
80
+ theme,
81
+ JSON_TREE_MAX_DEPTH_EXPANDED,
82
+ JSON_TREE_MAX_LINES_EXPANDED,
83
+ JSON_TREE_SCALAR_LEN_EXPANDED,
84
+ );
85
+ lines.push(...tree.lines);
86
+ if (tree.truncated) lines.push(theme.fg("dim", "…"));
87
+ lines.push("");
88
+ }
89
+
90
+ const rendered = markdown.render(Math.max(1, contentWidth));
91
+ const maxOutputLines = options.expanded ? 12 : 4;
92
+ lines.push(...rendered.slice(0, maxOutputLines));
93
+ if (rendered.length > maxOutputLines) {
94
+ lines.push(
95
+ `${theme.fg("dim", `… ${rendered.length - maxOutputLines} more lines`)} ${formatExpandHint(theme, options.expanded, true)}`,
96
+ );
97
+ } else if (!options.expanded) {
98
+ lines.push(formatExpandHint(theme, options.expanded, true));
99
+ }
100
+ if (truncationWarning) lines.push(truncationWarning);
101
+ return lines;
102
+ },
103
+ invalidate(): void {},
104
+ };
105
+ }
106
+
51
107
  /**
52
108
  * Render MCP tool result.
53
109
  */
@@ -58,6 +114,24 @@ export function renderMCPResult(
58
114
  args?: Record<string, unknown>,
59
115
  ): Component {
60
116
  const { expanded } = options;
117
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
118
+ const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
119
+ const truncationWarning = result.details?.meta?.truncation
120
+ ? formatStyledTruncationWarning(result.details.meta, theme)
121
+ : null;
122
+ let parsedOutput: unknown;
123
+ let isJsonOutput = false;
124
+ if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
125
+ try {
126
+ parsedOutput = JSON.parse(trimmedOutput);
127
+ isJsonOutput = true;
128
+ } catch {
129
+ // Non-JSON text beginning with a bracket is still eligible for Markdown.
130
+ }
131
+ }
132
+ if (trimmedOutput && settings.get("mcp.renderMarkdownResults") && !isJsonOutput) {
133
+ return renderMarkdownMCPResult(result, trimmedOutput, truncationWarning, options, theme, args);
134
+ }
61
135
  return new WidthAwareText(
62
136
  contentWidth => {
63
137
  const lines: string[] = [];
@@ -86,46 +160,31 @@ export function renderMCPResult(
86
160
  lines.push(""); // Blank line before output
87
161
  }
88
162
 
89
- // Output section
90
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
91
- // Strip the LLM-facing spill notice before parsing/rendering: a spilled
92
- // result appends `[Showing… artifact://N]` to the body, which would break
93
- // JSON detection and bury the recovery link. Surface it as a styled warning
94
- // instead, mirroring the built-in read/bash/ssh/browser renderers.
95
- const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
96
- const truncationWarning = result.details?.meta?.truncation
97
- ? formatStyledTruncationWarning(result.details.meta, theme)
98
- : null;
163
+ // Output section. The body and spill metadata are normalized before
164
+ // component selection so the opt-in Markdown path can use its own renderer.
99
165
 
100
166
  if (!trimmedOutput) {
101
167
  lines.push(theme.fg("dim", "(no output)"));
102
168
  return lines.join("\n");
103
169
  }
104
170
 
105
- // Try to parse as JSON for structured display
106
- if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
107
- try {
108
- const parsed = JSON.parse(trimmedOutput);
109
- const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
110
- const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
111
- const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
112
- const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen);
113
-
114
- if (tree.lines.length > 0) {
115
- for (const line of tree.lines) {
116
- lines.push(line);
117
- }
118
- // Always show expand hint when collapsed (expanded view shows longer values and deeper nesting)
119
- if (!expanded) {
120
- lines.push(formatExpandHint(theme, expanded, true));
121
- } else if (tree.truncated) {
122
- lines.push(theme.fg("dim", "…"));
123
- }
124
- if (truncationWarning) lines.push(truncationWarning);
125
- return lines.join("\n");
171
+ // Preserve the existing structured JSON renderer regardless of the
172
+ // Markdown preference; JSON trees remain more useful than styled source.
173
+ if (isJsonOutput) {
174
+ const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
175
+ const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
176
+ const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
177
+ const tree = renderJsonTreeLines(parsedOutput, theme, maxDepth, maxLines, maxScalarLen);
178
+
179
+ if (tree.lines.length > 0) {
180
+ lines.push(...tree.lines);
181
+ if (!expanded) {
182
+ lines.push(formatExpandHint(theme, expanded, true));
183
+ } else if (tree.truncated) {
184
+ lines.push(theme.fg("dim", "…"));
126
185
  }
127
- } catch {
128
- // Fall through to raw output
186
+ if (truncationWarning) lines.push(truncationWarning);
187
+ return lines.join("\n");
129
188
  }
130
189
  }
131
190
 
@@ -22,10 +22,17 @@ import { normalizeLocalScheme } from "../tools/path-utils";
22
22
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
23
23
  import { callTool } from "./client";
24
24
  import { renderMCPCall, renderMCPResult } from "./render";
25
- import type { MCPContent, MCPServerConnection, MCPToolCallParams, MCPToolCallResult, MCPToolDefinition } from "./types";
25
+ import type {
26
+ MCPAuthChallenge,
27
+ MCPContent,
28
+ MCPServerConnection,
29
+ MCPToolCallParams,
30
+ MCPToolCallResult,
31
+ MCPToolDefinition,
32
+ } from "./types";
26
33
 
27
- /** Reconnect callback: tears down stale connection, returns new one or null. */
28
- export type MCPReconnect = () => Promise<MCPServerConnection | null>;
34
+ /** Reconnect callback: tears down a stale connection, optionally authorizing first. */
35
+ export type MCPReconnect = (options?: { authChallenge?: MCPAuthChallenge }) => Promise<MCPServerConnection | null>;
29
36
 
30
37
  /**
31
38
  * Network-level and stale-session errors that warrant a reconnect + single retry.
@@ -174,6 +181,8 @@ export interface MCPToolDetails {
174
181
  isError?: boolean;
175
182
  /** Raw content from MCP response */
176
183
  rawContent?: MCPContent[];
184
+ /** Structured metadata from the MCP response */
185
+ mcpMeta?: Record<string, unknown>;
177
186
  /** Provider ID (e.g., "claude", "mcp-json") */
178
187
  provider?: string;
179
188
  /** Provider display name (e.g., "Claude Code", "MCP Config") */
@@ -222,6 +231,7 @@ function buildResult(
222
231
  mcpToolName,
223
232
  isError: result.isError,
224
233
  rawContent: result.content,
234
+ mcpMeta: result._meta,
225
235
  provider,
226
236
  providerName,
227
237
  };
@@ -249,6 +259,51 @@ function buildErrorResult(
249
259
  };
250
260
  }
251
261
 
262
+ type MCPToolCallAttempt = {
263
+ connection: MCPServerConnection;
264
+ result?: MCPToolCallResult;
265
+ error?: unknown;
266
+ };
267
+
268
+ function getMcpAuthChallenge(result: MCPToolCallResult): MCPAuthChallenge | undefined {
269
+ if (!result.isError) return undefined;
270
+ const values = result._meta?.["mcp/www_authenticate"];
271
+ if (!Array.isArray(values)) return undefined;
272
+ const wwwAuthenticate = values.filter((value): value is string => typeof value === "string" && value.trim() !== "");
273
+ return wwwAuthenticate.length > 0 ? { wwwAuthenticate } : undefined;
274
+ }
275
+
276
+ async function callToolWithAuthRetry(
277
+ connection: MCPServerConnection,
278
+ toolName: string,
279
+ args: MCPToolArgs,
280
+ reconnect: MCPReconnect | undefined,
281
+ signal?: AbortSignal,
282
+ ): Promise<MCPToolCallAttempt> {
283
+ const result = await callTool(connection, toolName, args, { signal });
284
+ const authChallenge = getMcpAuthChallenge(result);
285
+ if (!authChallenge || !reconnect) return { connection, result };
286
+
287
+ let newConnection: MCPServerConnection | null;
288
+ try {
289
+ newConnection = await reconnectWithAbort(reconnect, signal, { authChallenge });
290
+ } catch (error) {
291
+ rethrowIfAborted(error, signal);
292
+ return { connection, error };
293
+ }
294
+ if (!newConnection) return { connection, result };
295
+
296
+ try {
297
+ return {
298
+ connection: newConnection,
299
+ result: await callTool(newConnection, toolName, args, { signal }),
300
+ };
301
+ } catch (error) {
302
+ rethrowIfAborted(error, signal);
303
+ return { connection: newConnection, error };
304
+ }
305
+ }
306
+
252
307
  /** Re-throw abort-related errors so they bypass error-result handling. */
253
308
  function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
254
309
  if (error instanceof ToolAbortError) throw error;
@@ -256,9 +311,13 @@ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
256
311
  if (signal?.aborted) throw new ToolAbortError();
257
312
  }
258
313
 
259
- async function reconnectWithAbort(reconnect: MCPReconnect, signal?: AbortSignal): Promise<MCPServerConnection | null> {
314
+ async function reconnectWithAbort(
315
+ reconnect: MCPReconnect,
316
+ signal?: AbortSignal,
317
+ options?: { authChallenge?: MCPAuthChallenge },
318
+ ): Promise<MCPServerConnection | null> {
260
319
  try {
261
- return await untilAborted(signal, reconnect);
320
+ return await untilAborted(signal, () => reconnect(options));
262
321
  } catch (error) {
263
322
  rethrowIfAborted(error, signal);
264
323
  return null;
@@ -332,6 +391,13 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
332
391
  readonly approval = "write" as const;
333
392
  /** Render completed MCP calls with the result header replacing the pending call header. */
334
393
  readonly mergeCallAndResult = true;
394
+ /**
395
+ * MCP-backed tools opt out of strict structured-output grammar. The server
396
+ * owns validation, and strict mode makes OpenAI-family models over-fill
397
+ * mutually exclusive optional fields (#4336/#4340). Serializers preserve an
398
+ * explicit `false`; an omitted flag would leave nothing to preserve.
399
+ */
400
+ readonly strict = false as const;
335
401
 
336
402
  /** Create MCPTool instances for all tools from an MCP server connection */
337
403
  static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[], reconnect?: MCPReconnect): MCPTool[] {
@@ -372,8 +438,27 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
372
438
  const providerName = this.connection._source?.providerName;
373
439
 
374
440
  try {
375
- const result = await callTool(this.connection, this.tool.name, args, { signal });
376
- return buildResult(result, this.connection.name, this.tool.name, provider, providerName);
441
+ const attempt = await callToolWithAuthRetry(this.connection, this.tool.name, args, this.reconnect, signal);
442
+ if (attempt.error !== undefined) {
443
+ return buildErrorResult(attempt.error, this.connection.name, this.tool.name, provider, providerName);
444
+ }
445
+ if (!attempt.result) {
446
+ return buildErrorResult(
447
+ new Error("MCP tool call returned no result"),
448
+ this.connection.name,
449
+ this.tool.name,
450
+ provider,
451
+ providerName,
452
+ );
453
+ }
454
+ this.connection = attempt.connection;
455
+ return buildResult(
456
+ attempt.result,
457
+ attempt.connection.name,
458
+ this.tool.name,
459
+ attempt.connection._source?.provider ?? provider,
460
+ attempt.connection._source?.providerName ?? providerName,
461
+ );
377
462
  } catch (error) {
378
463
  rethrowIfAborted(error, signal);
379
464
  if (this.reconnect && isRetriableConnectionError(error)) {
@@ -418,6 +503,8 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
418
503
  readonly approval = "write" as const;
419
504
  /** Render completed MCP calls with the result header replacing the pending call header. */
420
505
  readonly mergeCallAndResult = true;
506
+ /** See {@link MCPTool.strict}: MCP servers own validation, so stay non-strict. */
507
+ readonly strict = false as const;
421
508
 
422
509
  readonly #fallbackProvider: string | undefined;
423
510
  readonly #fallbackProviderName: string | undefined;
@@ -474,13 +561,31 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
474
561
  const connection = await untilAborted(signal, () => this.getConnection());
475
562
  throwIfAborted(signal);
476
563
  try {
477
- const result = await callTool(connection, this.tool.name, args, { signal });
564
+ const attempt = await callToolWithAuthRetry(connection, this.tool.name, args, this.reconnect, signal);
565
+ if (attempt.error !== undefined) {
566
+ return buildErrorResult(
567
+ attempt.error,
568
+ this.serverName,
569
+ this.tool.name,
570
+ attempt.connection._source?.provider ?? provider,
571
+ attempt.connection._source?.providerName ?? providerName,
572
+ );
573
+ }
574
+ if (!attempt.result) {
575
+ return buildErrorResult(
576
+ new Error("MCP tool call returned no result"),
577
+ this.serverName,
578
+ this.tool.name,
579
+ provider,
580
+ providerName,
581
+ );
582
+ }
478
583
  return buildResult(
479
- result,
584
+ attempt.result,
480
585
  this.serverName,
481
586
  this.tool.name,
482
- connection._source?.provider ?? provider,
483
- connection._source?.providerName ?? providerName,
587
+ attempt.connection._source?.provider ?? provider,
588
+ attempt.connection._source?.providerName ?? providerName,
484
589
  );
485
590
  } catch (callError) {
486
591
  rethrowIfAborted(callError, signal);
package/src/mcp/types.ts CHANGED
@@ -206,10 +206,17 @@ export interface MCPResourceContent {
206
206
 
207
207
  export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent;
208
208
 
209
+ /** Structured authentication challenge returned in a tool result. */
210
+ export interface MCPAuthChallenge {
211
+ /** Values from `_meta["mcp/www_authenticate"]`. */
212
+ readonly wwwAuthenticate: readonly string[];
213
+ }
214
+
209
215
  /** tools/call response */
210
216
  export interface MCPToolCallResult {
211
217
  content: MCPContent[];
212
218
  isError?: boolean;
219
+ _meta?: Record<string, unknown>;
213
220
  }
214
221
 
215
222
  // =============================================================================
@@ -158,6 +158,39 @@ export interface MnemopiScopedMemoryHit {
158
158
 
159
159
  type MnemopiRetentionMessage = { role: string; content: string };
160
160
 
161
+ interface MnemopiRetentionCursorRow {
162
+ content: string;
163
+ sourceId: string | null;
164
+ retainedThroughUserTurn: number | null;
165
+ }
166
+
167
+ function countRetainedUserTurns(transcript: string): number {
168
+ let turns = 0;
169
+ for (const line of transcript.split(/\r?\n/)) {
170
+ if (line === "[role: user]") turns++;
171
+ }
172
+ return turns;
173
+ }
174
+
175
+ function deriveRetainedTurnCursor(rows: readonly MnemopiRetentionCursorRow[], sessionId: string): number {
176
+ let cursor = 0;
177
+ for (const row of rows) {
178
+ if (Number.isInteger(row.retainedThroughUserTurn) && row.retainedThroughUserTurn !== null) {
179
+ cursor = Math.max(cursor, row.retainedThroughUserTurn);
180
+ continue;
181
+ }
182
+ if (row.sourceId !== sessionId && !row.sourceId?.startsWith(`${sessionId}-`)) continue;
183
+ // Legacy rows carry no explicit cursor. Summing incremental rows looks
184
+ // right, but pre-fix resumed sessions also wrote cumulative rows under the
185
+ // incremental `${sessionId}-<ts>` id shape, so a sum can overshoot the real
186
+ // retained prefix and permanently skip unseen turns. Per-row max can only
187
+ // under-count, which at worst re-stores one suffix before an explicit
188
+ // cursor row takes over.
189
+ cursor = Math.max(cursor, countRetainedUserTurns(row.content));
190
+ }
191
+ return cursor;
192
+ }
193
+
161
194
  function sliceUnretainedMessages(
162
195
  messages: MnemopiRetentionMessage[],
163
196
  lastRetainedTurn: number,
@@ -208,6 +241,7 @@ export class MnemopiSessionState {
208
241
  hasRecalledForFirstTurn: boolean;
209
242
  lastRecallSnippet?: string;
210
243
  unsubscribe?: () => void;
244
+ #retentionCursorLoaded = false;
211
245
 
212
246
  constructor(options: MnemopiSessionStateOptions) {
213
247
  this.sessionId = options.sessionId;
@@ -222,11 +256,15 @@ export class MnemopiSessionState {
222
256
  }
223
257
 
224
258
  setSessionId(sessionId: string): void {
259
+ if (this.sessionId === sessionId) return;
225
260
  this.sessionId = sessionId;
261
+ this.lastRetainedTurn = 0;
262
+ this.#retentionCursorLoaded = false;
226
263
  }
227
264
 
228
265
  resetConversationTracking(): void {
229
266
  this.lastRetainedTurn = 0;
267
+ this.#retentionCursorLoaded = false;
230
268
  this.hasRecalledForFirstTurn = false;
231
269
  this.lastRecallSnippet = undefined;
232
270
  }
@@ -445,11 +483,13 @@ export class MnemopiSessionState {
445
483
  async maybeRetainOnAgentEnd(_messages: AgentMessage[]): Promise<void> {
446
484
  if (!this.config.autoRetain || this.aliasOf) return;
447
485
  const flat = extractMessages(this.session.sessionManager);
486
+ this.#restoreRetainedTurnCursor();
448
487
  const userTurns = flat.filter(message => message.role === "user").length;
449
488
  if (userTurns - this.lastRetainedTurn < this.config.retainEveryNTurns) return;
450
489
  await this.retainMessages(
451
490
  sliceUnretainedMessages(flat, this.lastRetainedTurn),
452
491
  `${this.sessionId}-${Date.now()}`,
492
+ { retainedThroughUserTurn: userTurns },
453
493
  );
454
494
  this.lastRetainedTurn = userTurns;
455
495
  }
@@ -457,14 +497,19 @@ export class MnemopiSessionState {
457
497
  async forceRetainCurrentSession(options: { extract?: boolean } = {}): Promise<void> {
458
498
  if (this.aliasOf) return;
459
499
  const flat = extractMessages(this.session.sessionManager);
460
- await this.retainMessages(flat, this.sessionId, options);
461
- this.lastRetainedTurn = flat.filter(message => message.role === "user").length;
500
+ this.#restoreRetainedTurnCursor();
501
+ const userTurns = flat.filter(message => message.role === "user").length;
502
+ await this.retainMessages(sliceUnretainedMessages(flat, this.lastRetainedTurn), this.sessionId, {
503
+ ...options,
504
+ retainedThroughUserTurn: userTurns,
505
+ });
506
+ this.lastRetainedTurn = Math.max(this.lastRetainedTurn, userTurns);
462
507
  }
463
508
 
464
509
  async retainMessages(
465
510
  messages: Array<{ role: string; content: string }>,
466
511
  sourceId: string,
467
- options: { extract?: boolean } = {},
512
+ options: { extract?: boolean; retainedThroughUserTurn?: number } = {},
468
513
  ): Promise<void> {
469
514
  const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
470
515
  if (!transcript) return;
@@ -478,6 +523,9 @@ export class MnemopiSessionState {
478
523
  session_id: this.sessionId,
479
524
  source_id: sourceId,
480
525
  message_count: messageCount,
526
+ ...(options.retainedThroughUserTurn === undefined
527
+ ? {}
528
+ : { retained_through_user_turn: options.retainedThroughUserTurn }),
481
529
  cwd: this.session.sessionManager.getCwd(),
482
530
  },
483
531
  scope: "bank",
@@ -490,6 +538,25 @@ export class MnemopiSessionState {
490
538
  });
491
539
  }
492
540
 
541
+ #restoreRetainedTurnCursor(): void {
542
+ if (this.#retentionCursorLoaded) return;
543
+ this.#retentionCursorLoaded = true;
544
+ const rows = this.memory.beam.db
545
+ .prepare<MnemopiRetentionCursorRow, [string]>(`
546
+ SELECT
547
+ content,
548
+ json_extract(metadata_json, '$.source_id') AS sourceId,
549
+ CAST(json_extract(metadata_json, '$.retained_through_user_turn') AS INTEGER)
550
+ AS retainedThroughUserTurn
551
+ FROM working_memory
552
+ WHERE source = 'coding-agent-transcript'
553
+ AND json_extract(metadata_json, '$.session_id') = ?
554
+ ORDER BY rowid
555
+ `)
556
+ .all(this.sessionId);
557
+ this.lastRetainedTurn = Math.max(this.lastRetainedTurn, deriveRetainedTurnCursor(rows, this.sessionId));
558
+ }
559
+
493
560
  attachSessionListeners(): void {
494
561
  this.unsubscribe?.();
495
562
  this.unsubscribe = this.session.subscribe((event: AgentSessionEvent) => {
@@ -49,6 +49,7 @@ import agentCreationArchitectPrompt from "../../prompts/system/agent-creation-ar
49
49
  import agentCreationUserPrompt from "../../prompts/system/agent-creation-user.md" with { type: "text" };
50
50
  import { createAgentSession } from "../../sdk";
51
51
  import { discoverAgents } from "../../task/discovery";
52
+ import { resolveAgentPrewalkDefault } from "../../task/prewalk";
52
53
  import type { AgentDefinition, AgentSource } from "../../task/types";
53
54
  import { shortenPath } from "../../tools/render-utils";
54
55
  import { getEditorTheme, theme } from "../theme/theme";
@@ -602,7 +603,7 @@ export class AgentDashboard extends Container {
602
603
  this.#persistPrewalkOverrides();
603
604
  const pattern = resolveAgentPrewalkPattern({
604
605
  settingsOverride: selected.prewalkOverride,
605
- agentPrewalk: selected.prewalk,
606
+ agentPrewalk: resolveAgentPrewalkDefault(selected, this.#settingsManager?.get("task.prewalk") ?? false),
606
607
  });
607
608
  const state = selected.prewalkOverride ?? "agent default";
608
609
  this.#notice = `Prewalk for ${selected.name}: ${state}${pattern ? ` (into ${pattern})` : ""}`;
@@ -1081,7 +1082,10 @@ export class AgentDashboard extends Container {
1081
1082
  const prewalkPattern = selected
1082
1083
  ? resolveAgentPrewalkPattern({
1083
1084
  settingsOverride: selected.prewalkOverride,
1084
- agentPrewalk: selected.prewalk,
1085
+ agentPrewalk: resolveAgentPrewalkDefault(
1086
+ selected,
1087
+ this.#settingsManager?.get("task.prewalk") ?? false,
1088
+ ),
1085
1089
  })
1086
1090
  : undefined;
1087
1091
  const prewalkResolution = prewalkPattern ? this.#resolvePatterns([prewalkPattern]) : undefined;