@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.6

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 (119) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/dist/types/config/settings-schema.d.ts +12 -22
  3. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  4. package/dist/types/eval/heartbeat.d.ts +45 -0
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  11. package/dist/types/internal-urls/types.d.ts +14 -0
  12. package/dist/types/lsp/client.d.ts +3 -0
  13. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  14. package/dist/types/lsp/index.d.ts +2 -0
  15. package/dist/types/lsp/utils.d.ts +4 -0
  16. package/dist/types/mcp/manager.d.ts +14 -5
  17. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  18. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  19. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +2 -3
  22. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +2 -2
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/theme/theme.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -2
  27. package/dist/types/session/agent-session.d.ts +10 -7
  28. package/dist/types/session/shake-types.d.ts +3 -3
  29. package/dist/types/task/repair-args.d.ts +52 -0
  30. package/dist/types/tiny/models.d.ts +0 -14
  31. package/dist/types/tiny/title-client.d.ts +28 -2
  32. package/dist/types/tiny/title-protocol.d.ts +8 -9
  33. package/dist/types/tools/ask.d.ts +8 -6
  34. package/dist/types/tools/eval-backends.d.ts +12 -0
  35. package/dist/types/tools/eval-render.d.ts +52 -0
  36. package/dist/types/tools/eval.d.ts +2 -35
  37. package/dist/types/tools/find.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +4 -11
  39. package/dist/types/tools/path-utils.d.ts +7 -0
  40. package/dist/types/tui/output-block.d.ts +11 -10
  41. package/examples/extensions/README.md +1 -0
  42. package/examples/extensions/thinking-note.ts +13 -0
  43. package/package.json +9 -9
  44. package/scripts/build-binary.ts +0 -1
  45. package/src/cli.ts +59 -0
  46. package/src/config/model-registry.ts +33 -4
  47. package/src/config/settings-schema.ts +13 -24
  48. package/src/config/settings.ts +10 -0
  49. package/src/discovery/claude.ts +41 -22
  50. package/src/edit/index.ts +23 -3
  51. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  52. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  53. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  54. package/src/eval/agent-bridge.ts +44 -38
  55. package/src/eval/heartbeat.ts +74 -0
  56. package/src/eval/js/executor.ts +13 -9
  57. package/src/eval/llm-bridge.ts +20 -14
  58. package/src/eval/py/executor.ts +14 -18
  59. package/src/exec/bash-executor.ts +31 -5
  60. package/src/extensibility/custom-tools/types.ts +2 -2
  61. package/src/extensibility/extensions/loader.ts +16 -18
  62. package/src/extensibility/extensions/runner.ts +22 -17
  63. package/src/extensibility/extensions/types.ts +39 -5
  64. package/src/extensibility/shared-events.ts +2 -2
  65. package/src/internal-urls/docs-index.generated.ts +5 -5
  66. package/src/internal-urls/local-protocol.ts +23 -11
  67. package/src/internal-urls/types.ts +15 -0
  68. package/src/lsp/client.ts +28 -5
  69. package/src/lsp/diagnostics-ledger.ts +51 -0
  70. package/src/lsp/index.ts +9 -22
  71. package/src/lsp/utils.ts +21 -0
  72. package/src/mcp/manager.ts +87 -4
  73. package/src/modes/acp/acp-agent.ts +8 -4
  74. package/src/modes/components/assistant-message.ts +28 -1
  75. package/src/modes/components/custom-editor.ts +9 -7
  76. package/src/modes/components/hook-selector.ts +159 -32
  77. package/src/modes/components/tool-execution.ts +20 -4
  78. package/src/modes/controllers/command-controller.ts +7 -39
  79. package/src/modes/controllers/event-controller.ts +38 -28
  80. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  81. package/src/modes/controllers/input-controller.ts +0 -15
  82. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  83. package/src/modes/interactive-mode.ts +2 -1
  84. package/src/modes/rpc/rpc-mode.ts +17 -6
  85. package/src/modes/theme/theme-schema.json +30 -0
  86. package/src/modes/theme/theme.ts +39 -2
  87. package/src/modes/types.ts +2 -1
  88. package/src/modes/utils/ui-helpers.ts +5 -2
  89. package/src/prompts/system/project-prompt.md +3 -2
  90. package/src/prompts/system/subagent-system-prompt.md +12 -8
  91. package/src/prompts/system/system-prompt.md +8 -6
  92. package/src/prompts/tools/ask.md +2 -1
  93. package/src/prompts/tools/eval.md +1 -1
  94. package/src/session/agent-session.ts +75 -103
  95. package/src/session/shake-types.ts +4 -5
  96. package/src/slash-commands/builtin-registry.ts +2 -4
  97. package/src/task/executor.ts +14 -4
  98. package/src/task/index.ts +3 -2
  99. package/src/task/repair-args.ts +117 -0
  100. package/src/tiny/models.ts +0 -28
  101. package/src/tiny/title-client.ts +133 -43
  102. package/src/tiny/title-protocol.ts +11 -16
  103. package/src/tiny/worker.ts +6 -61
  104. package/src/tools/ask.ts +74 -32
  105. package/src/tools/ast-edit.ts +3 -0
  106. package/src/tools/ast-grep.ts +3 -0
  107. package/src/tools/eval-backends.ts +38 -0
  108. package/src/tools/eval-render.ts +750 -0
  109. package/src/tools/eval.ts +27 -754
  110. package/src/tools/find.ts +20 -6
  111. package/src/tools/gh.ts +1 -0
  112. package/src/tools/index.ts +7 -37
  113. package/src/tools/path-utils.ts +13 -2
  114. package/src/tools/read.ts +1 -0
  115. package/src/tools/renderers.ts +1 -1
  116. package/src/tools/search.ts +12 -1
  117. package/src/tools/write.ts +9 -1
  118. package/src/tui/output-block.ts +42 -79
  119. package/src/utils/git.ts +9 -3
@@ -5,7 +5,7 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
5
5
  import { AgentRegistry } from "../registry/agent-registry";
6
6
  import { parseInternalUrl } from "./parse";
7
7
  import { validateRelativePath } from "./skill-protocol";
8
- import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
8
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
9
9
 
10
10
  export interface LocalProtocolOptions {
11
11
  getArtifactsDir?: () => string | null;
@@ -164,13 +164,25 @@ export class LocalProtocolHandler implements ProtocolHandler {
164
164
  * Returns the active local-protocol options.
165
165
  *
166
166
  * Resolution order:
167
- * 1. Explicit override installed via {@link setOverride} (used by subagents
168
- * that share their parent's root and by SDK consumers with a custom
169
- * artifacts/session id mapping).
170
- * 2. The main session in `AgentRegistry.global()`. Its `SessionManager`
171
- * supplies both `getArtifactsDir` and `getSessionId`.
167
+ * 1. **Caller-supplied** `context.localProtocolOptions` (the actual session
168
+ * that initiated the `read`/`find`/`search`/`router.resolve` call). This
169
+ * is what keeps `local://` reads pinned to the calling session in
170
+ * multi-session hosts (cmux/ACP, embedded SDK consumers) where every
171
+ * session registers as `kind: "main"` and "first one wins" would route
172
+ * to the wrong artifacts directory.
173
+ * 2. Explicit process-global override installed via {@link setOverride}
174
+ * (used by SDK consumers with a custom artifacts/session-id mapping and
175
+ * by code paths that do not have a calling session, e.g. TUI hyperlink
176
+ * resolution).
177
+ * 3. The first `main`-kind session in `AgentRegistry.global()`. Its
178
+ * `SessionManager` supplies both `getArtifactsDir` and `getSessionId`.
179
+ * Last-resort fallback — every caller that has a session reference
180
+ * SHOULD thread it through `context` so this branch is never taken in
181
+ * multi-session setups.
172
182
  */
173
- static resolveOptions(): LocalProtocolOptions | undefined {
183
+ static resolveOptions(context?: ResolveContext): LocalProtocolOptions | undefined {
184
+ const fromContext = context?.localProtocolOptions;
185
+ if (fromContext) return fromContext;
174
186
  const override = LocalProtocolHandler.#override;
175
187
  if (override) return override;
176
188
  const main = AgentRegistry.global()
@@ -184,8 +196,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
184
196
  };
185
197
  }
186
198
 
187
- async resolve(url: InternalUrl): Promise<InternalResource> {
188
- const opts = LocalProtocolHandler.resolveOptions();
199
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
200
+ const opts = LocalProtocolHandler.resolveOptions(context);
189
201
  if (!opts) {
190
202
  throw new Error("No session - local:// unavailable");
191
203
  }
@@ -247,8 +259,8 @@ export class LocalProtocolHandler implements ProtocolHandler {
247
259
  };
248
260
  }
249
261
 
250
- async complete(): Promise<UrlCompletion[]> {
251
- const opts = LocalProtocolHandler.resolveOptions();
262
+ async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
263
+ const opts = LocalProtocolHandler.resolveOptions(context);
252
264
  if (!opts) return [];
253
265
  const localRoot = path.resolve(resolveLocalRoot(opts));
254
266
  try {
@@ -5,6 +5,8 @@
5
5
  * providing access to agent outputs and server resources without exposing filesystem paths.
6
6
  */
7
7
 
8
+ import type { LocalProtocolOptions } from "./local-protocol";
9
+
8
10
  /**
9
11
  * Raw resource payload returned by protocol handlers. The `immutable` flag is
10
12
  * applied by the router from {@link ProtocolHandler.immutable}, so handlers do
@@ -77,6 +79,17 @@ export interface ResolveContext {
77
79
  settings?: unknown;
78
80
  /** Caller's abort signal. */
79
81
  signal?: AbortSignal;
82
+ /**
83
+ * Calling session's `local://` root mapping. When present, the local-protocol
84
+ * handler resolves the URL against THIS session's artifacts dir instead of
85
+ * picking the first `main`-kind session from the global `AgentRegistry`.
86
+ *
87
+ * Required for correctness in multi-session hosts (cmux/ACP, embedded SDK
88
+ * consumers) where multiple sessions are registered as `main` and the
89
+ * "first one wins" lookup picks the wrong artifacts directory — see
90
+ * [#1608](https://github.com/can1357/oh-my-pi/issues/1608).
91
+ */
92
+ localProtocolOptions?: LocalProtocolOptions;
80
93
  }
81
94
 
82
95
  /**
@@ -89,6 +102,8 @@ export interface WriteContext {
89
102
  cwd?: string;
90
103
  /** Caller's abort signal. */
91
104
  signal?: AbortSignal;
105
+ /** Calling session's `local://` root mapping — see {@link ResolveContext.localProtocolOptions}. */
106
+ localProtocolOptions?: LocalProtocolOptions;
92
107
  }
93
108
 
94
109
  /**
package/src/lsp/client.ts CHANGED
@@ -415,6 +415,10 @@ export const WARMUP_TIMEOUT_MS = 5000;
415
415
  /** Max time to wait for the server to report project loading completion via $/progress */
416
416
  const PROJECT_LOAD_TIMEOUT_MS = 15_000;
417
417
 
418
+ /** Max time to wait for graceful LSP shutdown and process exit. */
419
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
420
+ const EXIT_TIMEOUT_MS = 1_000;
421
+
418
422
  /**
419
423
  * Get or create an LSP client for the given server configuration and working directory.
420
424
  * @param config - Server configuration
@@ -768,8 +772,18 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
768
772
  }
769
773
  }
770
774
 
775
+ async function waitForExit(client: LspClient, timeoutMs: number): Promise<boolean> {
776
+ return await Promise.race([
777
+ client.proc.exited.then(
778
+ () => true,
779
+ () => true,
780
+ ),
781
+ Bun.sleep(timeoutMs).then(() => false),
782
+ ]);
783
+ }
784
+
771
785
  /**
772
- * Shutdown a specific client by key.
786
+ * Shutdown a specific client instance using the LSP shutdown/exit handshake.
773
787
  */
774
788
  async function shutdownClientInstance(client: LspClient): Promise<void> {
775
789
  const err = new Error("LSP client shutdown");
@@ -778,13 +792,22 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
778
792
  }
779
793
  client.pendingRequests.clear();
780
794
 
781
- const timeout = Bun.sleep(5_000);
782
- const shutdown = sendRequest(client, "shutdown", null).catch(() => {});
783
- await Promise.race([shutdown, timeout]);
795
+ const shutdownCompleted = await sendRequest(client, "shutdown", null, undefined, SHUTDOWN_TIMEOUT_MS).then(
796
+ () => true,
797
+ () => false,
798
+ );
799
+ if (shutdownCompleted) {
800
+ await sendNotification(client, "exit", undefined).catch(() => {});
801
+ if (await waitForExit(client, EXIT_TIMEOUT_MS)) return;
802
+ }
803
+
784
804
  client.proc.kill();
785
- await Promise.race([client.proc.exited.catch(() => {}), Bun.sleep(1_000)]);
805
+ await waitForExit(client, EXIT_TIMEOUT_MS);
786
806
  }
787
807
 
808
+ /**
809
+ * Shutdown a specific client by key.
810
+ */
788
811
  export async function shutdownClient(key: string): Promise<void> {
789
812
  const client = clients.get(key);
790
813
  if (!client) return;
@@ -0,0 +1,51 @@
1
+ import type { FileDiagnosticsResult } from "./index";
2
+ import { summarizeDiagnosticMessages } from "./utils";
3
+
4
+ const DIAGNOSTIC_LOCATION_PREFIX_RE = /^.*?:\d+:\d+\s+/;
5
+
6
+ export function diagnosticIdentity(message: string): string {
7
+ return message.replace(DIAGNOSTIC_LOCATION_PREFIX_RE, "");
8
+ }
9
+
10
+ export class DiagnosticsLedger {
11
+ readonly #seen = new Map<string, Set<string>>();
12
+
13
+ reduce(absPath: string, result: FileDiagnosticsResult): FileDiagnosticsResult {
14
+ const previous = this.#seen.get(absPath);
15
+ const currentIdentities = new Set<string>();
16
+ const fresh: string[] = [];
17
+
18
+ for (const message of result.messages) {
19
+ const identity = diagnosticIdentity(message);
20
+ currentIdentities.add(identity);
21
+ if (!previous?.has(identity)) {
22
+ fresh.push(message);
23
+ }
24
+ }
25
+
26
+ if (currentIdentities.size === 0) {
27
+ this.#seen.delete(absPath);
28
+ } else {
29
+ this.#seen.set(absPath, currentIdentities);
30
+ }
31
+
32
+ if (fresh.length === result.messages.length) {
33
+ return result;
34
+ }
35
+
36
+ return {
37
+ ...result,
38
+ messages: fresh,
39
+ ...summarizeDiagnosticMessages(fresh),
40
+ };
41
+ }
42
+ }
43
+
44
+ export interface DiagnosticsLedgerOwner {
45
+ diagnosticsLedger?: DiagnosticsLedger;
46
+ }
47
+
48
+ export function getDiagnosticsLedger(owner: DiagnosticsLedgerOwner): DiagnosticsLedger {
49
+ owner.diagnosticsLedger ??= new DiagnosticsLedger();
50
+ return owner.diagnosticsLedger;
51
+ }
package/src/lsp/index.ts CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  resolveDiagnosticTargets,
80
80
  resolveSymbolColumn,
81
81
  sortDiagnostics,
82
+ summarizeDiagnosticMessages,
82
83
  symbolKindToIcon,
83
84
  uriToFile,
84
85
  } from "./utils";
@@ -816,12 +817,15 @@ export interface WritethroughOptions {
816
817
  onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void;
817
818
  /** Signal to cancel a pending deferred diagnostics fetch. */
818
819
  deferredSignal?: AbortSignal;
820
+ /** Transform diagnostics before surfacing them after a successful fetch. */
821
+ transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
819
822
  }
820
823
 
821
824
  /** Internal resolved form of {@link WritethroughOptions} that the writethrough machinery operates on. */
822
825
  type ResolvedWritethroughOptions = {
823
826
  enableFormat: boolean;
824
827
  enableDiagnostics: boolean;
828
+ transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
825
829
  };
826
830
 
827
831
  /** Per-file deferred LSP diagnostics wiring for {@link WritethroughCallback}. */
@@ -881,6 +885,7 @@ function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughO
881
885
  if (existing) {
882
886
  existing.options.enableFormat ||= options.enableFormat;
883
887
  existing.options.enableDiagnostics ||= options.enableDiagnostics;
888
+ existing.options.transformDiagnostics ??= options.transformDiagnostics;
884
889
  return existing;
885
890
  }
886
891
  const batch: LspWritethroughBatchState = {
@@ -904,27 +909,6 @@ export async function flushLspWritethroughBatch(
904
909
  return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
905
910
  }
906
911
 
907
- function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
908
- const counts = { error: 0, warning: 0, info: 0, hint: 0 };
909
- for (const message of messages) {
910
- const match = message.match(/\[(error|warning|info|hint)\]/i);
911
- if (!match) continue;
912
- const key = match[1].toLowerCase() as keyof typeof counts;
913
- counts[key] += 1;
914
- }
915
-
916
- const parts: string[] = [];
917
- if (counts.error > 0) parts.push(`${counts.error} error(s)`);
918
- if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
919
- if (counts.info > 0) parts.push(`${counts.info} info(s)`);
920
- if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
921
-
922
- return {
923
- summary: parts.length > 0 ? parts.join(", ") : "no issues",
924
- errored: counts.error > 0,
925
- };
926
- }
927
-
928
912
  function mergeDiagnostics(
929
913
  results: Array<FileDiagnosticsResult | undefined>,
930
914
  options: ResolvedWritethroughOptions,
@@ -1083,12 +1067,14 @@ async function runLspWritethrough(
1083
1067
 
1084
1068
  // 6. Get diagnostics from all servers (wait for fresh results)
1085
1069
  if (enableDiagnostics) {
1086
- diagnostics = await getDiagnosticsForFile(dst, cwd, servers, {
1070
+ const fetched = await getDiagnosticsForFile(dst, cwd, servers, {
1087
1071
  signal: operationSignal,
1088
1072
  minVersions,
1089
1073
  expectedDocumentVersions,
1090
1074
  allowUnversionedLspDiagnostics: false,
1091
1075
  });
1076
+ diagnostics =
1077
+ fetched && options.transformDiagnostics ? options.transformDiagnostics(dst, fetched) : fetched;
1092
1078
  }
1093
1079
  });
1094
1080
  } catch {
@@ -1155,6 +1141,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
1155
1141
  const resolvedOptions: ResolvedWritethroughOptions = {
1156
1142
  enableFormat: options?.enableFormat ?? false,
1157
1143
  enableDiagnostics: options?.enableDiagnostics ?? false,
1144
+ transformDiagnostics: options?.transformDiagnostics,
1158
1145
  };
1159
1146
  if (!resolvedOptions.enableFormat && !resolvedOptions.enableDiagnostics) {
1160
1147
  return writethroughNoop;
package/src/lsp/utils.ts CHANGED
@@ -221,6 +221,27 @@ export function formatDiagnosticsSummary(diagnostics: Diagnostic[]): string {
221
221
  return parts.length > 0 ? parts.join(", ") : "no issues";
222
222
  }
223
223
 
224
+ export function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
225
+ const counts = { error: 0, warning: 0, info: 0, hint: 0 };
226
+ for (const message of messages) {
227
+ const match = message.match(/\[(error|warning|info|hint)\]/i);
228
+ if (!match) continue;
229
+ const key = match[1].toLowerCase() as keyof typeof counts;
230
+ counts[key] += 1;
231
+ }
232
+
233
+ const parts: string[] = [];
234
+ if (counts.error > 0) parts.push(`${counts.error} error(s)`);
235
+ if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
236
+ if (counts.info > 0) parts.push(`${counts.info} info(s)`);
237
+ if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
238
+
239
+ return {
240
+ summary: parts.length > 0 ? parts.join(", ") : "no issues",
241
+ errored: counts.error > 0,
242
+ };
243
+ }
244
+
224
245
  // =============================================================================
225
246
  // Location Formatting
226
247
  // =============================================================================
@@ -59,6 +59,27 @@ type TrackedPromise<T> = {
59
59
 
60
60
  const STARTUP_TIMEOUT_MS = 250;
61
61
 
62
+ /**
63
+ * Per-server reconnect-storm circuit breaker.
64
+ *
65
+ * `transport.onClose` (wired in {@link MCPManager.connectServers} and
66
+ * {@link MCPManager.#connectAndWireServer}) fires `reconnectServer` on every
67
+ * clean process exit, so a stdio MCP server that completes the
68
+ * `initialize` + `tools/list` handshake and then exits will pull the agent
69
+ * into a fork loop with no rate limit. That pathology shipped in issue #1592
70
+ * (a `php`-shebang MCP fork-bombing macOS, parented directly to the agent's
71
+ * `bun` PID via shebang exec).
72
+ *
73
+ * We keep the sliding window short — older crashes age out so a single
74
+ * transient failure stays cheap — but cap the burst tightly enough that the
75
+ * agent never spawns more than `RECONNECT_BURST_LIMIT * #doReconnect retries`
76
+ * (≤ 25) processes per stuck server per window. Manual `/mcp reconnect`
77
+ * resets the window so users can recover after fixing the underlying
78
+ * misconfiguration.
79
+ */
80
+ const RECONNECT_BURST_WINDOW_MS = 30_000;
81
+ const RECONNECT_BURST_LIMIT = 5;
82
+
62
83
  function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
63
84
  const tracked: TrackedPromise<T> = { promise, status: "pending" };
64
85
  promise.then(
@@ -166,6 +187,11 @@ export class MCPManager {
166
187
  #pendingReconnections = new Map<string, Promise<MCPServerConnection | null>>();
167
188
  /** Preserved configs for reconnection after connection loss. */
168
189
  #serverConfigs = new Map<string, MCPServerConfig>();
190
+ /**
191
+ * Timestamps of recent `reconnectServer` invocations per server, used by the
192
+ * crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
193
+ */
194
+ #reconnectHistory = new Map<string, number[]>();
169
195
  /** Monotonic epoch incremented on disconnectAll to invalidate stale reconnections. */
170
196
  #epoch = 0;
171
197
 
@@ -666,6 +692,7 @@ export class MCPManager {
666
692
  this.#sources.delete(name);
667
693
  this.#serverConfigs.delete(name);
668
694
  this.#pendingResourceRefresh.delete(name);
695
+ this.#reconnectHistory.delete(name);
669
696
 
670
697
  const connection = this.#connections.get(name);
671
698
 
@@ -714,24 +741,80 @@ export class MCPManager {
714
741
  this.#connections.clear();
715
742
  this.#tools = [];
716
743
  this.#subscribedResources.clear();
744
+ this.#reconnectHistory.clear();
717
745
  }
718
746
 
719
747
  /**
720
748
  * Reconnect to a server after a connection failure.
749
+ *
721
750
  * Tears down the stale connection, re-resolves auth, establishes a new
722
- * connection, reloads tools, and notifies consumers.
723
- * Concurrent calls for the same server share one reconnection attempt.
724
- * Returns the new connection, or null if reconnection failed.
751
+ * connection, reloads tools, and notifies consumers. Concurrent calls for
752
+ * the same server share one reconnection attempt. Returns the new
753
+ * connection, or `null` if reconnection failed or the per-server crash
754
+ * burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
755
+ *
756
+ * @param options.manual - When `true`, resets the crash-burst window so a
757
+ * user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
758
+ * earlier storm. Defaults to `false`; the transport `onClose` callback
759
+ * and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
725
760
  */
726
- async reconnectServer(name: string): Promise<MCPServerConnection | null> {
761
+ async reconnectServer(name: string, options?: { manual?: boolean }): Promise<MCPServerConnection | null> {
762
+ if (options?.manual) {
763
+ this.#reconnectHistory.delete(name);
764
+ }
765
+
727
766
  const pending = this.#pendingReconnections.get(name);
728
767
  if (pending) return pending;
729
768
 
769
+ if (this.#tripReconnectBreaker(name)) {
770
+ return null;
771
+ }
772
+
730
773
  const attempt = this.#doReconnect(name);
731
774
  this.#pendingReconnections.set(name, attempt);
732
775
  return attempt.finally(() => this.#pendingReconnections.delete(name));
733
776
  }
734
777
 
778
+ /**
779
+ * Record a reconnect attempt against the per-server crash window and report
780
+ * whether the circuit breaker is now open. Sliding window: entries older
781
+ * than {@link RECONNECT_BURST_WINDOW_MS} are pruned before the new
782
+ * timestamp is appended, so a single transient failure ages out cheaply
783
+ * but repeated rapid crashes accumulate until the limit is hit.
784
+ */
785
+ #tripReconnectBreaker(name: string): boolean {
786
+ const now = Date.now();
787
+ const previous = this.#reconnectHistory.get(name) ?? [];
788
+ const recent = previous.filter(ts => now - ts < RECONNECT_BURST_WINDOW_MS);
789
+ recent.push(now);
790
+ this.#reconnectHistory.set(name, recent);
791
+
792
+ if (recent.length > RECONNECT_BURST_LIMIT) {
793
+ logger.error("MCP server crashed too many times; suspending automatic reconnects", {
794
+ path: `mcp:${name}`,
795
+ crashes: recent.length,
796
+ windowMs: RECONNECT_BURST_WINDOW_MS,
797
+ });
798
+ // Tear down the stale connection so `getConnectionStatus()` no
799
+ // longer reports it as "connected" and `waitForConnection()` does
800
+ // not hand a closed transport to callers. Tools stay registered
801
+ // in `#tools` — the user can recover with `/mcp reconnect <name>`
802
+ // once they've fixed the underlying misconfiguration. Mirrors the
803
+ // teardown in `#doReconnect`: detach `onClose` first so the
804
+ // transport's own `close()` cannot re-arm this path.
805
+ const stale = this.#connections.get(name);
806
+ if (stale) {
807
+ stale.transport.onClose = undefined;
808
+ void stale.transport.close().catch(() => {});
809
+ this.#connections.delete(name);
810
+ }
811
+ this.#pendingConnections.delete(name);
812
+ this.#pendingToolLoads.delete(name);
813
+ return true;
814
+ }
815
+ return false;
816
+ }
817
+
735
818
  async #doReconnect(name: string): Promise<MCPServerConnection | null> {
736
819
  const oldConnection = this.#connections.get(name);
737
820
  const config = oldConnection?.config ?? this.#serverConfigs.get(name);
@@ -47,7 +47,11 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils";
47
47
  import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
48
48
  import { Settings } from "../../config/settings";
49
49
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
50
- import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
50
+ import {
51
+ type ExtensionUIContext,
52
+ type ExtensionUIDialogOptions,
53
+ getExtensionUISelectOptionLabel,
54
+ } from "../../extensibility/extensions";
51
55
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
52
56
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
53
57
  import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
@@ -302,7 +306,7 @@ export function createAcpExtensionUiContext(
302
306
  getSessionId(),
303
307
  "select",
304
308
  title,
305
- { type: "string", enum: options },
309
+ { type: "string", enum: options.map(getExtensionUISelectOptionLabel) },
306
310
  dialogOptions,
307
311
  );
308
312
  return typeof value === "string" ? value : undefined;
@@ -1981,7 +1985,7 @@ export class AcpAgent implements Agent {
1981
1985
  }
1982
1986
  if (servers.length === 0) {
1983
1987
  record.mcpManager = undefined;
1984
- await record.session.refreshMCPTools([]);
1988
+ await record.session.refreshMCPTools([], { activateAll: true });
1985
1989
  return;
1986
1990
  }
1987
1991
 
@@ -2008,7 +2012,7 @@ export class AcpAgent implements Agent {
2008
2012
  }
2009
2013
 
2010
2014
  record.mcpManager = manager;
2011
- await record.session.refreshMCPTools(result.tools);
2015
+ await record.session.refreshMCPTools(result.tools, { activateAll: true });
2012
2016
  }
2013
2017
 
2014
2018
  #toMcpConfig(server: McpServer): MCPServerConfig {
@@ -2,6 +2,7 @@ import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
2
2
  import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
3
3
  import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
+ import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
5
6
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
6
7
  import { isSilentAbort } from "../../session/messages";
7
8
  import { resolveImageOptions } from "../../tools/render-utils";
@@ -21,6 +22,7 @@ export class AssistantMessageComponent extends Container {
21
22
  message?: AssistantMessage,
22
23
  private hideThinkingBlock = false,
23
24
  private readonly onImageUpdate?: () => void,
25
+ private readonly thinkingRenderers: readonly AssistantThinkingRenderer[] = [],
24
26
  ) {
25
27
  super();
26
28
 
@@ -131,6 +133,27 @@ export class AssistantMessageComponent extends Container {
131
133
  }
132
134
  }
133
135
 
136
+ #appendThinkingExtensions(contentIndex: number, thinkingIndex: number, text: string): void {
137
+ for (const renderer of this.thinkingRenderers) {
138
+ try {
139
+ const component = renderer(
140
+ {
141
+ contentIndex,
142
+ thinkingIndex,
143
+ text,
144
+ requestRender: () => this.onImageUpdate?.(),
145
+ },
146
+ theme,
147
+ );
148
+ if (component) {
149
+ this.#contentContainer.addChild(component);
150
+ }
151
+ } catch {
152
+ // Ignore extension renderer failures and keep the original thinking block visible.
153
+ }
154
+ }
155
+ }
156
+
134
157
  updateContent(message: AssistantMessage): void {
135
158
  this.#lastMessage = message;
136
159
 
@@ -146,6 +169,7 @@ export class AssistantMessageComponent extends Container {
146
169
  }
147
170
 
148
171
  // Render content in order
172
+ let thinkingIndex = 0;
149
173
  for (let i = 0; i < message.content.length; i++) {
150
174
  const content = message.content[i];
151
175
  if (content.type === "text" && content.text.trim()) {
@@ -166,13 +190,16 @@ export class AssistantMessageComponent extends Container {
166
190
  this.#contentContainer.addChild(new Spacer(1));
167
191
  }
168
192
  } else {
193
+ const thinkingText = content.thinking.trim();
169
194
  // Thinking traces in thinkingText color, italic
170
195
  this.#contentContainer.addChild(
171
- new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
196
+ new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
172
197
  color: (text: string) => theme.fg("thinkingText", text),
173
198
  italic: true,
174
199
  }),
175
200
  );
201
+ this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
202
+ thinkingIndex += 1;
176
203
  if (hasVisibleContentAfter) {
177
204
  this.#contentContainer.addChild(new Spacer(1));
178
205
  }
@@ -49,7 +49,6 @@ export class CustomEditor extends Editor {
49
49
  * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. */
50
50
  decorateText = (text: string): string => highlightMagicKeywords(text);
51
51
  onEscape?: () => void;
52
- shouldBypassAutocompleteOnEscape?: () => boolean;
53
52
  onClear?: () => void;
54
53
  onExit?: () => void;
55
54
  onCycleThinkingLevel?: () => void;
@@ -186,12 +185,15 @@ export class CustomEditor extends Editor {
186
185
  }
187
186
 
188
187
  // Intercept configured interrupt shortcut.
189
- // Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
190
- if (this.#matchesAction(data, "app.interrupt") && this.onEscape) {
191
- if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
192
- this.onEscape();
193
- return;
194
- }
188
+ // When the autocomplete popup is visible, ESC's first job is to dismiss
189
+ // the popup — let super.handleInput() route it to #cancelAutocomplete().
190
+ // The user can press ESC again afterward to fire the global interrupt
191
+ // handler. This matches the standard TUI/IDE pattern and prevents a
192
+ // single ESC from both closing an @ completion and aborting an active
193
+ // agent run (#1655).
194
+ if (this.#matchesAction(data, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
195
+ this.onEscape();
196
+ return;
195
197
  }
196
198
 
197
199
  // Intercept configured clear shortcut