@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.14

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 (143) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-za420td8.md} +63 -0
  3. package/dist/cli.js +3658 -3627
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +14 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +4 -0
  25. package/dist/types/session/agent-session.d.ts +24 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +35 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/builtin-names.d.ts +1 -2
  37. package/dist/types/tools/index.d.ts +1 -0
  38. package/dist/types/tools/think.d.ts +38 -0
  39. package/dist/types/tools/todo.d.ts +14 -15
  40. package/dist/types/tools/write.d.ts +2 -2
  41. package/dist/types/utils/local-date.d.ts +2 -0
  42. package/dist/types/vibe/runtime.d.ts +1 -1
  43. package/dist/types/web/parallel.d.ts +1 -0
  44. package/dist/types/web/search/providers/brave.d.ts +8 -3
  45. package/dist/types/web/search/providers/codex.d.ts +6 -0
  46. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  47. package/dist/types/web/search/providers/jina.d.ts +3 -3
  48. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  49. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  50. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  51. package/package.json +13 -13
  52. package/src/advisor/delta-split.ts +98 -0
  53. package/src/advisor/runtime.ts +321 -69
  54. package/src/async/job-manager.ts +14 -3
  55. package/src/cli/gallery-fixtures/agentic.ts +16 -0
  56. package/src/cli/plugin-cli.ts +30 -2
  57. package/src/cli/update-cli.ts +259 -24
  58. package/src/config/keybindings.ts +52 -9
  59. package/src/config/model-resolver.ts +19 -3
  60. package/src/config/settings-schema.ts +16 -0
  61. package/src/cursor.ts +10 -5
  62. package/src/discovery/agents-md.ts +61 -23
  63. package/src/eval/jl/kernel.ts +2 -20
  64. package/src/eval/py/kernel.ts +2 -20
  65. package/src/eval/rb/kernel.ts +2 -20
  66. package/src/eval/runner-cache.ts +41 -0
  67. package/src/exec/non-interactive-env.ts +14 -3
  68. package/src/extensibility/extensions/loader.ts +5 -2
  69. package/src/extensibility/extensions/runner.ts +184 -66
  70. package/src/extensibility/extensions/types.ts +26 -2
  71. package/src/extensibility/extensions/wrapper.ts +13 -7
  72. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  73. package/src/hindsight/client.ts +1 -1
  74. package/src/lib/xai-http.ts +0 -4
  75. package/src/lsp/client.ts +2 -0
  76. package/src/lsp/servers.ts +1 -1
  77. package/src/mcp/tool-bridge.ts +15 -6
  78. package/src/modes/components/agent-hub-renderer.ts +9 -3
  79. package/src/modes/components/agent-hub.ts +2 -1
  80. package/src/modes/components/status-line/component.ts +58 -6
  81. package/src/modes/components/status-line/segments.ts +12 -1
  82. package/src/modes/components/status-line/types.ts +1 -0
  83. package/src/modes/components/user-message.ts +20 -5
  84. package/src/modes/controllers/event-controller.ts +48 -0
  85. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  86. package/src/modes/controllers/input-controller.ts +25 -6
  87. package/src/modes/controllers/selector-controller.ts +5 -0
  88. package/src/modes/interactive-mode.ts +315 -129
  89. package/src/modes/rpc/rpc-frame.ts +13 -5
  90. package/src/modes/theme/tui-adapters.ts +4 -5
  91. package/src/modes/types.ts +13 -7
  92. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  93. package/src/prompts/system/system-prompt.md +10 -1
  94. package/src/registry/persisted-agents.ts +43 -8
  95. package/src/sdk.ts +146 -9
  96. package/src/session/agent-session-types.ts +4 -0
  97. package/src/session/agent-session.ts +91 -10
  98. package/src/session/messages.ts +98 -28
  99. package/src/session/retry-fallback-chains.ts +14 -0
  100. package/src/session/session-advisors.ts +32 -15
  101. package/src/session/session-history-format.ts +15 -1
  102. package/src/session/session-maintenance.ts +8 -8
  103. package/src/session/session-manager.ts +6 -2
  104. package/src/session/session-tools.ts +355 -183
  105. package/src/session/turn-recovery.ts +225 -47
  106. package/src/slash-commands/builtin-modes.ts +41 -12
  107. package/src/slash-commands/types.ts +5 -1
  108. package/src/task/executor.ts +92 -45
  109. package/src/task/structured-subagent.ts +5 -5
  110. package/src/tools/approval.ts +44 -10
  111. package/src/tools/builtin-names.ts +1 -2
  112. package/src/tools/fetch.ts +21 -2
  113. package/src/tools/image-gen.ts +6 -8
  114. package/src/tools/index.ts +8 -0
  115. package/src/tools/renderers.ts +2 -0
  116. package/src/tools/think.ts +62 -0
  117. package/src/tools/todo.ts +70 -26
  118. package/src/tools/tts.ts +3 -2
  119. package/src/tools/write.ts +7 -3
  120. package/src/utils/local-date.ts +13 -0
  121. package/src/utils/tools-manager.ts +2 -2
  122. package/src/vibe/runtime.ts +22 -14
  123. package/src/web/kagi.ts +91 -34
  124. package/src/web/parallel.ts +11 -2
  125. package/src/web/scrapers/crates-io.ts +2 -2
  126. package/src/web/scrapers/discogs.ts +2 -2
  127. package/src/web/scrapers/docs-rs.ts +2 -2
  128. package/src/web/scrapers/github.ts +2 -2
  129. package/src/web/scrapers/musicbrainz.ts +1 -2
  130. package/src/web/scrapers/pubmed.ts +2 -2
  131. package/src/web/scrapers/sec-edgar.ts +2 -2
  132. package/src/web/search/providers/brave.ts +121 -46
  133. package/src/web/search/providers/codex.ts +88 -12
  134. package/src/web/search/providers/exa.ts +45 -10
  135. package/src/web/search/providers/firecrawl.ts +53 -11
  136. package/src/web/search/providers/gemini.ts +139 -27
  137. package/src/web/search/providers/jina.ts +48 -25
  138. package/src/web/search/providers/parallel.ts +23 -9
  139. package/src/web/search/providers/perplexity.ts +24 -7
  140. package/src/web/search/providers/searxng.ts +77 -1
  141. package/src/web/search/providers/tavily.ts +23 -22
  142. package/src/web/search/providers/tinyfish.ts +44 -10
  143. package/src/web/search/providers/xai.ts +85 -14
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Extension runner - executes extensions and manages their lifecycle.
3
3
  */
4
+ import { AsyncLocalStorage } from "node:async_hooks";
4
5
  import type {
5
6
  AgentMessage,
6
7
  AgentTool,
@@ -62,6 +63,7 @@ import type {
62
63
  SessionStopEventResult,
63
64
  ToolCallEvent,
64
65
  ToolCallEventResult,
66
+ ToolRegistrationListener,
65
67
  ToolResultEvent,
66
68
  ToolResultEventResult,
67
69
  UserBashEvent,
@@ -332,8 +334,15 @@ const noOpUIContext: ExtensionUIContext = {
332
334
  setToolsExpanded: () => {},
333
335
  };
334
336
 
337
+ interface ToolRegistrationScope {
338
+ pending: Set<Promise<void>>;
339
+ signal?: AbortSignal;
340
+ closed: boolean;
341
+ }
342
+
335
343
  export class ExtensionRunner {
336
344
  #uiContext: ExtensionUIContext;
345
+ #toolApprovalPreviewWaiter?: (toolCallId: string) => Promise<void>;
337
346
  #errorListeners: Set<ExtensionErrorListener> = new Set();
338
347
  #getModel: () => Model | undefined = () => undefined;
339
348
  #isIdleFn: () => boolean = () => true;
@@ -352,6 +361,8 @@ export class ExtensionRunner {
352
361
  #shutdownHandler: ShutdownHandler = () => {};
353
362
  #getMemoryFn?: () => MemoryRuntimeContext | undefined;
354
363
  #commandDiagnostics: Array<{ type: string; message: string; path: string }> = [];
364
+ #toolRegistrationScope = new AsyncLocalStorage<ToolRegistrationScope>();
365
+ #toolRegistrationBarrier: Promise<void> | undefined;
355
366
  #initialized = false;
356
367
  /**
357
368
  * Buffer for `credential_disabled` events received via {@link emitCredentialDisabled}
@@ -521,7 +532,11 @@ export class ExtensionRunner {
521
532
  this.runtime.appendEntry = actions.appendEntry;
522
533
  this.runtime.getActiveTools = actions.getActiveTools;
523
534
  this.runtime.getAllTools = actions.getAllTools;
524
- this.runtime.setActiveTools = actions.setActiveTools;
535
+ this.runtime.setActiveTools = async toolNames => {
536
+ const registrationBarrier = this.#toolRegistrationBarrier;
537
+ if (registrationBarrier) await registrationBarrier;
538
+ await actions.setActiveTools(toolNames);
539
+ };
525
540
  this.runtime.getCommands = actions.getCommands;
526
541
  this.runtime.setModel = actions.setModel;
527
542
  this.runtime.getThinkingLevel = actions.getThinkingLevel;
@@ -650,6 +665,18 @@ export class ExtensionRunner {
650
665
  if (event.signal.aborted) return undefined;
651
666
  return await this.emit({ type: "session_stop", ...event });
652
667
  }
668
+ /** Registers the interactive transcript gate that must settle before a tool approval is presented. */
669
+ setToolApprovalPreviewWaiter(waiter: (toolCallId: string) => Promise<void>): () => void {
670
+ this.#toolApprovalPreviewWaiter = waiter;
671
+ return () => {
672
+ if (this.#toolApprovalPreviewWaiter === waiter) this.#toolApprovalPreviewWaiter = undefined;
673
+ };
674
+ }
675
+
676
+ /** Waits until the interactive transcript can show the tool call being approved. */
677
+ async waitForToolApprovalPreview(toolCallId: string): Promise<void> {
678
+ await this.#toolApprovalPreviewWaiter?.(toolCallId);
679
+ }
653
680
 
654
681
  getUIContext(): ExtensionUIContext {
655
682
  return this.#uiContext;
@@ -674,6 +701,88 @@ export class ExtensionRunner {
674
701
  return tools;
675
702
  }
676
703
 
704
+ /** Get the effective registered tool for a name using normal last-extension-wins precedence. */
705
+ getRegisteredTool(name: string): RegisteredTool | undefined {
706
+ for (let index = this.extensions.length - 1; index >= 0; index -= 1) {
707
+ const tool = this.extensions[index]?.tools.get(name);
708
+ if (tool) return tool;
709
+ }
710
+ return undefined;
711
+ }
712
+
713
+ /**
714
+ * Observe tools registered after extension factories have loaded. Listener
715
+ * promises are drained before the lifecycle handler that registered them
716
+ * completes, keeping the model tool snapshot and system prompt coherent.
717
+ */
718
+ onToolRegistered(listener: (tool: RegisteredTool, signal?: AbortSignal) => void | Promise<void>): () => void {
719
+ const subscriptions: Array<{ extension: Extension; listener: ToolRegistrationListener }> = [];
720
+ for (const extension of this.extensions) {
721
+ const trackRegistration = (pending: Promise<void>): void => {
722
+ const registrationBarrier = pending.then(
723
+ () => undefined,
724
+ () => undefined,
725
+ );
726
+ this.#toolRegistrationBarrier = registrationBarrier;
727
+ void registrationBarrier.then(() => {
728
+ if (this.#toolRegistrationBarrier === registrationBarrier) this.#toolRegistrationBarrier = undefined;
729
+ });
730
+ const scope = this.#toolRegistrationScope.getStore();
731
+ if (scope && !scope.closed) {
732
+ scope.pending.add(pending);
733
+ void pending.then(
734
+ () => scope.pending.delete(pending),
735
+ () => {},
736
+ );
737
+ return;
738
+ }
739
+ void pending.catch(error => {
740
+ this.emitError({
741
+ extensionPath: extension.path,
742
+ event: "tool_registration",
743
+ error: error instanceof Error ? error.message : String(error),
744
+ stack: error instanceof Error ? error.stack : undefined,
745
+ });
746
+ });
747
+ };
748
+ const wrapped: ToolRegistrationListener = toolName => {
749
+ const tool = extension.tools.get(toolName);
750
+ if (!tool) return;
751
+ try {
752
+ const scope = this.#toolRegistrationScope.getStore();
753
+ const registrationSignal =
754
+ scope && !scope.closed ? scope.signal : AbortSignal.timeout(extensionHandlerTimeoutMs);
755
+ const pending = listener(tool, registrationSignal);
756
+ if (pending) trackRegistration(pending);
757
+ } catch (error) {
758
+ trackRegistration(Promise.reject(error));
759
+ }
760
+ };
761
+ extension.toolRegistrationListeners ??= new Set();
762
+ extension.toolRegistrationListeners.add(wrapped);
763
+ subscriptions.push({ extension, listener: wrapped });
764
+ }
765
+ return () => {
766
+ for (const subscription of subscriptions) {
767
+ subscription.extension.toolRegistrationListeners?.delete(subscription.listener);
768
+ }
769
+ };
770
+ }
771
+
772
+ async #flushToolRegistrations(pendingRegistrations: Set<Promise<void>>): Promise<void> {
773
+ let firstFailure: PromiseRejectedResult | undefined;
774
+ while (pendingRegistrations.size > 0) {
775
+ const pending = Array.from(pendingRegistrations);
776
+ const settled = await Promise.allSettled(pending);
777
+ for (let index = 0; index < settled.length; index += 1) {
778
+ pendingRegistrations.delete(pending[index]);
779
+ const result = settled[index];
780
+ if (!firstFailure && result?.status === "rejected") firstFailure = result;
781
+ }
782
+ }
783
+ if (firstFailure) throw firstFailure.reason;
784
+ }
785
+
677
786
  /**
678
787
  * Aggregate the registered CLI flags across a set of extensions (last write
679
788
  * wins on name collision). Static so callers that need the flag set before a
@@ -928,45 +1037,73 @@ export class ExtensionRunner {
928
1037
  ctx: ExtensionContext,
929
1038
  ext: Extension,
930
1039
  timeoutMs: number,
1040
+ onFailure?: (kind: "timeout" | "error", message: string) => TResult,
931
1041
  ): Promise<TResult | undefined> {
932
1042
  const signal =
933
1043
  event.type === "session_stop" && "signal" in event && event.signal instanceof AbortSignal
934
1044
  ? event.signal
935
1045
  : undefined;
936
1046
  if (signal?.aborted) return undefined;
1047
+ const registrationScope: ToolRegistrationScope = { pending: new Set(), closed: false };
1048
+ let handlerResult: TResult | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED | undefined;
1049
+ let handlerFailure: { error: unknown } | undefined;
937
1050
  try {
938
- const handlerResult = await raceHandlerWithTimeout(
939
- handlerSignal => handler(event, createHandlerContext(ctx, handlerSignal)),
1051
+ handlerResult = await raceHandlerWithTimeout(
1052
+ async handlerSignal => {
1053
+ registrationScope.signal = handlerSignal;
1054
+ let result: TResult | undefined;
1055
+ try {
1056
+ result = await this.#toolRegistrationScope.run(registrationScope, () =>
1057
+ handler(event, createHandlerContext(ctx, handlerSignal)),
1058
+ );
1059
+ } catch (error) {
1060
+ handlerFailure = { error };
1061
+ } finally {
1062
+ registrationScope.closed = true;
1063
+ }
1064
+ try {
1065
+ await this.#flushToolRegistrations(registrationScope.pending);
1066
+ } catch (error) {
1067
+ handlerFailure ??= { error };
1068
+ }
1069
+ return result;
1070
+ },
940
1071
  timeoutMs,
941
1072
  signal,
942
1073
  );
943
- if (handlerResult === EXTENSION_HANDLER_ABORTED) return undefined;
944
- if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
945
- const error = `handler timed out after ${timeoutMs}ms`;
946
- logger.warn("Extension handler timed out", {
947
- extensionPath: ext.path,
948
- event: event.type,
949
- timeoutMs,
950
- });
951
- this.emitError({
952
- extensionPath: ext.path,
953
- event: event.type,
954
- error,
955
- });
956
- return undefined;
957
- }
958
- return handlerResult as TResult | undefined;
959
- } catch (err) {
960
- const message = err instanceof Error ? err.message : String(err);
961
- const stack = err instanceof Error ? err.stack : undefined;
1074
+ } catch (error) {
1075
+ handlerFailure = { error };
1076
+ } finally {
1077
+ registrationScope.closed = true;
1078
+ }
1079
+ if (handlerResult === EXTENSION_HANDLER_ABORTED) return undefined;
1080
+ if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
1081
+ const error = `handler timed out after ${timeoutMs}ms`;
1082
+ logger.warn("Extension handler timed out", {
1083
+ extensionPath: ext.path,
1084
+ event: event.type,
1085
+ timeoutMs,
1086
+ });
1087
+ this.emitError({
1088
+ extensionPath: ext.path,
1089
+ event: event.type,
1090
+ error,
1091
+ });
1092
+ return onFailure?.("timeout", error);
1093
+ }
1094
+ if (handlerFailure) {
1095
+ const message =
1096
+ handlerFailure.error instanceof Error ? handlerFailure.error.message : String(handlerFailure.error);
1097
+ const stack = handlerFailure.error instanceof Error ? handlerFailure.error.stack : undefined;
962
1098
  this.emitError({
963
1099
  extensionPath: ext.path,
964
1100
  event: event.type,
965
1101
  error: message,
966
1102
  stack,
967
1103
  });
968
- return undefined;
1104
+ return onFailure?.("error", message);
969
1105
  }
1106
+ return handlerResult as TResult | undefined;
970
1107
  }
971
1108
 
972
1109
  async emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>> {
@@ -1100,46 +1237,26 @@ export class ExtensionRunner {
1100
1237
  if (!handlers || handlers.length === 0) continue;
1101
1238
 
1102
1239
  for (const handler of handlers) {
1103
- try {
1104
- const handlerResult = await raceHandlerWithTimeout(
1105
- handlerSignal => handler(event, createHandlerContext(ctx, handlerSignal)),
1106
- timeoutMs,
1107
- );
1108
-
1109
- if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
1110
- const error = `handler timed out after ${timeoutMs}ms`;
1111
- logger.warn("Extension handler timed out", {
1112
- extensionPath: ext.path,
1113
- event: "tool_call",
1114
- timeoutMs,
1115
- });
1116
- this.emitError({
1117
- extensionPath: ext.path,
1118
- event: "tool_call",
1119
- error,
1120
- });
1121
- return {
1122
- block: true,
1123
- reason: `Extension ${ext.path} timed out after ${timeoutMs}ms`,
1124
- };
1125
- }
1240
+ const handlerResult = await this.#runHandlerWithTimeout(
1241
+ handler,
1242
+ event,
1243
+ ctx,
1244
+ ext,
1245
+ timeoutMs,
1246
+ (kind, message) => ({
1247
+ block: true,
1248
+ reason:
1249
+ kind === "timeout"
1250
+ ? `Extension ${ext.path} timed out after ${timeoutMs}ms`
1251
+ : `Extension ${ext.path} failed: ${message}`,
1252
+ }),
1253
+ );
1126
1254
 
1127
- if (handlerResult) {
1128
- result = handlerResult as ToolCallEventResult;
1129
- if (result.block) {
1130
- return result;
1131
- }
1255
+ if (handlerResult) {
1256
+ result = handlerResult;
1257
+ if (result.block) {
1258
+ return result;
1132
1259
  }
1133
- } catch (err) {
1134
- const message = err instanceof Error ? err.message : String(err);
1135
- const stack = err instanceof Error ? err.stack : undefined;
1136
- this.emitError({
1137
- extensionPath: ext.path,
1138
- event: "tool_call",
1139
- error: message,
1140
- stack,
1141
- });
1142
- return { block: true, reason: `Extension ${ext.path} failed: ${message}` };
1143
1260
  }
1144
1261
  }
1145
1262
  }
@@ -1242,13 +1359,14 @@ export class ExtensionRunner {
1242
1359
  | InputEventResult
1243
1360
  | undefined;
1244
1361
  if (result?.handled) return result;
1245
- if (result?.text !== undefined) {
1246
- currentText = result.text;
1247
- currentImages = result.images ?? currentImages;
1248
- }
1362
+ if (result?.text !== undefined) currentText = result.text;
1363
+ if (result?.images !== undefined) currentImages = result.images;
1249
1364
  }
1250
1365
  }
1251
- return currentText !== text || currentImages !== images ? { text: currentText, images: currentImages } : {};
1366
+ const transformed: InputEventResult = {};
1367
+ if (currentText !== text) transformed.text = currentText;
1368
+ if (currentImages !== images) transformed.images = currentImages;
1369
+ return transformed;
1252
1370
  }
1253
1371
 
1254
1372
  async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
@@ -38,7 +38,16 @@ import type {
38
38
  TSchema,
39
39
  } from "@oh-my-pi/pi-ai";
40
40
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
41
- import type { AutocompleteItem, AutocompleteProvider, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
41
+ import type {
42
+ AutocompleteItem,
43
+ AutocompleteProvider,
44
+ Component,
45
+ EditorTheme,
46
+ KeyId,
47
+ OverlayHandle,
48
+ OverlayOptions,
49
+ TUI,
50
+ } from "@oh-my-pi/pi-tui";
42
51
  import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
43
52
  import type { KeybindingsManager } from "../../config/keybindings";
44
53
  import type { ModelRegistry } from "../../config/model-registry";
@@ -105,6 +114,7 @@ import type {
105
114
  } from "../shared-events";
106
115
  import type { SlashCommandInfo } from "../slash-commands";
107
116
 
117
+ export type { OverlayHandle, OverlayOptions } from "@oh-my-pi/pi-tui";
108
118
  export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
109
119
  export type { ExecOptions, ExecResult } from "../../exec/exec";
110
120
  export type { AgentToolResult, AgentToolUpdateCallback };
@@ -214,6 +224,16 @@ export type ExtensionUiComponent = Component & { dispose?(): void };
214
224
  export type ExtensionUiComponentFactory = (tui: TUI, theme: Theme) => ExtensionUiComponent;
215
225
  export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | undefined;
216
226
 
227
+ /** Options for `ExtensionUIContext.custom()` (overlay rendering of a custom component). */
228
+ export interface ExtensionCustomOptions {
229
+ /** Render the component as an overlay over the transcript instead of replacing the editor area. */
230
+ overlay?: boolean;
231
+ /** Static or lazily resolved overlay positioning/sizing options forwarded to `showOverlay`. */
232
+ overlayOptions?: OverlayOptions | (() => OverlayOptions);
233
+ /** Invoked with the overlay handle once the overlay is created (overlay mode only). */
234
+ onHandle?: (handle: OverlayHandle) => void;
235
+ }
236
+
217
237
  /** Wrap the current autocomplete provider with additional behavior (pi-compatible). */
218
238
  export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider;
219
239
 
@@ -280,7 +300,7 @@ export interface ExtensionUIContext {
280
300
  keybindings: KeybindingsManager,
281
301
  done: (result: T) => void,
282
302
  ) => ExtensionUiComponent | Promise<ExtensionUiComponent>,
283
- options?: { overlay?: boolean },
303
+ options?: ExtensionCustomOptions,
284
304
  ): Promise<T>;
285
305
 
286
306
  /** Set the text in the core input editor. */
@@ -1467,6 +1487,9 @@ export interface RegisteredTool<TParams extends TSchema = TSchema, TDetails = un
1467
1487
  extensionPath: string;
1468
1488
  }
1469
1489
 
1490
+ /** Internal observer invoked when an already-loaded extension registers or replaces a tool. */
1491
+ export type ToolRegistrationListener = (toolName: string) => void;
1492
+
1470
1493
  export interface ExtensionFlag {
1471
1494
  name: string;
1472
1495
  description?: string;
@@ -1589,6 +1612,7 @@ export interface Extension {
1589
1612
  label?: string;
1590
1613
  handlers: Map<string, HandlerFn[]>;
1591
1614
  tools: Map<string, RegisteredTool<any, any>>;
1615
+ toolRegistrationListeners?: Set<ToolRegistrationListener>;
1592
1616
  assistantThinkingRenderers: AssistantThinkingRenderer[];
1593
1617
  messageRenderers: Map<string, MessageRenderer>;
1594
1618
  commands: Map<string, RegisteredCommand>;
@@ -9,7 +9,7 @@ import type {
9
9
  ToolLoadMode,
10
10
  } from "@oh-my-pi/pi-agent-core";
11
11
  import type { ComputerSafetyCheck, ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai";
12
- import { sanitizeText } from "@oh-my-pi/pi-utils";
12
+ import { sanitizeText, untilAborted } from "@oh-my-pi/pi-utils";
13
13
  import type { Settings } from "../../config/settings";
14
14
  import type { Theme } from "../../modes/theme/theme";
15
15
  import { type ApprovalMode, formatApprovalPrompt, resolveApproval, truncateForPrompt } from "../../tools/approval";
@@ -190,10 +190,11 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
190
190
  const configuredMode = (settings?.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
191
191
  const approvalMode: ApprovalMode = cliAutoApprove ? "yolo" : configuredMode;
192
192
  const userPolicies = (settings?.get("tools.approval") ?? {}) as Record<string, unknown>;
193
- if (resolveApproval(this.tool, approvalArgs(params, context), approvalMode, userPolicies).policy === "deny") {
193
+ const preResolved = resolveApproval(this.tool, approvalArgs(params, context), approvalMode, userPolicies);
194
+ if (preResolved.policy === "deny") {
194
195
  throw new Error(
195
- `Tool "${this.tool.name}" is blocked by user policy.\n` +
196
- `To allow: remove "tools.approval.${this.tool.name}: deny" from config.`,
196
+ `Tool "${preResolved.policyKey ?? this.tool.name}" is blocked by user policy.\n` +
197
+ `To allow: remove "tools.approval.${preResolved.policyKey ?? this.tool.name}: deny" from config.`,
197
198
  );
198
199
  }
199
200
 
@@ -242,8 +243,8 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
242
243
  context?.xdevTierResolved?.(resolved.tier);
243
244
  if (resolved.policy === "deny") {
244
245
  throw new Error(
245
- `Tool "${this.tool.name}" is blocked by user policy.\n` +
246
- `To allow: remove "tools.approval.${this.tool.name}: deny" from config.`,
246
+ `Tool "${resolved.policyKey ?? this.tool.name}" is blocked by user policy.\n` +
247
+ `To allow: remove "tools.approval.${resolved.policyKey ?? this.tool.name}: deny" from config.`,
247
248
  );
248
249
  }
249
250
  const pendingSafetyChecks = computerSafetyChecks(context);
@@ -255,7 +256,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
255
256
  // and tool-demanded overrides still prompt. Provider safety checks are
256
257
  // stronger: yolo, per-tool allow, and xdev approval never acknowledge
257
258
  // them on the user's behalf.
258
- const explicitPrompt = resolved.override || Object.hasOwn(userPolicies, this.tool.name);
259
+ const explicitPrompt = resolved.override || Object.hasOwn(userPolicies, resolved.policyKey ?? this.tool.name);
259
260
  const xdevBypass = context?.xdevApproved === true && effectiveParams === params;
260
261
  const approvalCheck = {
261
262
  required: pendingSafetyChecks.length > 0 || (resolved.policy === "prompt" && (explicitPrompt || !xdevBypass)),
@@ -263,6 +264,11 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
263
264
  };
264
265
 
265
266
  if (approvalCheck.required) {
267
+ const scheduledCall = context?.toolCall?.toolCalls[context.toolCall.index];
268
+ if (scheduledCall?.id === toolCallId && scheduledCall.name === this.tool.name) {
269
+ await untilAborted(signal, () => this.runner.waitForToolApprovalPreview(toolCallId));
270
+ }
271
+
266
272
  const hasApprovalHandlers =
267
273
  this.runner.hasHandlers("tool_approval_requested") || this.runner.hasHandlers("tool_approval_resolved");
268
274
  const sessionId = context?.sessionManager?.getSessionId() ?? "";
@@ -440,14 +440,14 @@ export class MarketplaceManager {
440
440
  return "0.0.0";
441
441
  }
442
442
 
443
- async uninstallPlugin(pluginId: string, scope?: "user" | "project"): Promise<void> {
443
+ /** Validates and removes a marketplace plugin, or only validates when `dryRun` is set. */
444
+ async uninstallPlugin(pluginId: string, scope?: "user" | "project", options?: { dryRun?: boolean }): Promise<void> {
444
445
  const parsed = parsePluginId(pluginId);
445
446
  if (!parsed) {
446
447
  throw new Error(`Invalid plugin ID format: "${pluginId}". Expected "name@marketplace".`);
447
448
  }
448
449
 
449
450
  const { userEntries, projectEntries, userReg, projectReg } = await this.#findInBothRegistries(pluginId);
450
-
451
451
  const inUser = userEntries && userEntries.length > 0;
452
452
  const inProject = projectEntries && projectEntries.length > 0;
453
453
 
@@ -481,6 +481,10 @@ export class MarketplaceManager {
481
481
  const registryPath = this.#registryPath(targetScope);
482
482
  const packageNames = await this.#resolveInstalledPackageNames(targetEntries, parsed.name);
483
483
 
484
+ if (options?.dryRun) {
485
+ return;
486
+ }
487
+
484
488
  const updatedReg = removeInstalledPlugin(targetReg, pluginId);
485
489
  await writeInstalledPluginsRegistry(registryPath, updatedReg);
486
490
 
@@ -8,10 +8,10 @@
8
8
  * tests to spy on.
9
9
  */
10
10
 
11
+ import { USER_AGENT } from "@oh-my-pi/pi-utils";
11
12
  import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
12
13
  import type { HindsightConfig } from "./config";
13
14
 
14
- const USER_AGENT = "oh-my-pi-coding-agent";
15
15
  const DEFAULT_USER_AGENT = USER_AGENT;
16
16
  /** Fallback deadlines (ms) applied when the caller supplies no override. */
17
17
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
@@ -12,10 +12,6 @@ interface XAICredentials {
12
12
  baseURL: string;
13
13
  }
14
14
 
15
- export function ohMyPiXAIUserAgent(): string {
16
- return "oh-my-pi/xai";
17
- }
18
-
19
15
  /** xAI provider ids supported by shared HTTP tool transport resolution. */
20
16
  export type XAIHttpProvider = "xai-oauth" | "xai";
21
17
 
package/src/lsp/client.ts CHANGED
@@ -1339,6 +1339,7 @@ export async function sendRequest(
1339
1339
  timeout = setTimeout(() => {
1340
1340
  if (client.pendingRequests.has(id)) {
1341
1341
  client.pendingRequests.delete(id);
1342
+ void sendNotification(client, "$/cancelRequest", { id }).catch(() => {});
1342
1343
  const err = new Error(`LSP request ${method} timed out after ${effectiveTimeoutMs}ms`);
1343
1344
  cleanup();
1344
1345
  reject(err);
@@ -1405,6 +1406,7 @@ export async function sendNotification(
1405
1406
  * Shutdown all LSP clients.
1406
1407
  */
1407
1408
  export async function shutdownAll(): Promise<void> {
1409
+ stopIdleChecker();
1408
1410
  const clientsToShutdown = Array.from(clients.values());
1409
1411
  clients.clear();
1410
1412
  // Mid-initialize clients live only in clientLocks (publication is deferred
@@ -197,9 +197,9 @@ export function getConfig(cwd: string): LspConfig {
197
197
  let config = configCache.get(cwd);
198
198
  if (!config) {
199
199
  config = loadConfig(cwd);
200
- setIdleTimeout(config.idleTimeoutMs);
201
200
  configCache.set(cwd, config);
202
201
  }
202
+ setIdleTimeout(config.idleTimeoutMs);
203
203
  return config;
204
204
  }
205
205
 
@@ -357,6 +357,18 @@ export function createMCPToolName(serverName: string, toolName: string): string
357
357
  return `mcp__${sanitizedServerName}_${normalizedToolName}`;
358
358
  }
359
359
 
360
+ export interface MCPToolOriginSource {
361
+ readonly name: string;
362
+ readonly mcpServerName?: unknown;
363
+ readonly mcpToolName?: unknown;
364
+ }
365
+
366
+ /** Stable identity for a tool's original MCP route, before its public name was normalized. */
367
+ export function getMCPToolOriginKey(tool: MCPToolOriginSource): string | undefined {
368
+ if (typeof tool.mcpServerName !== "string" || typeof tool.mcpToolName !== "string") return undefined;
369
+ return `${tool.mcpServerName}\u0000${tool.mcpToolName}`;
370
+ }
371
+
360
372
  /**
361
373
  * Keeps one MCP tool per minted name and logs collisions between distinct MCP
362
374
  * origins. The winner is chosen by a stable origin key (server name + original
@@ -365,19 +377,16 @@ export function createMCPToolName(serverName: string, toolName: string): string
365
377
  * silently flip ownership of the minted name. Non-MCP tools pass through
366
378
  * unchanged.
367
379
  */
368
- export function deduplicateMCPToolsByName<T extends { name: string; mcpServerName?: unknown; mcpToolName?: unknown }>(
369
- tools: readonly T[],
370
- ): T[] {
380
+ export function deduplicateMCPToolsByName<T extends MCPToolOriginSource>(tools: readonly T[]): T[] {
371
381
  const deduplicated: T[] = [];
372
382
  const registered = new Map<string, { tool: T; originKey: string; index: number }>();
373
383
 
374
384
  for (const tool of tools) {
375
- if (typeof tool.mcpServerName !== "string" || typeof tool.mcpToolName !== "string") {
385
+ const originKey = getMCPToolOriginKey(tool);
386
+ if (originKey === undefined) {
376
387
  deduplicated.push(tool);
377
388
  continue;
378
389
  }
379
-
380
- const originKey = `${tool.mcpServerName}\u0000${tool.mcpToolName}`;
381
390
  const existing = registered.get(tool.name);
382
391
  if (!existing) {
383
392
  registered.set(tool.name, { tool, originKey, index: deduplicated.length });
@@ -95,19 +95,25 @@ function formatResolvedModelBadge(resolved: string, preserveProvider = false, fa
95
95
  /**
96
96
  * Resolved model + reasoning level for a hub row. Exact executor progress is
97
97
  * authoritative (and survives completion); direct live sessions are the
98
- * fallback for agents without an observer snapshot.
98
+ * fallback for agents without an observer snapshot — the main session has no
99
+ * snapshot at all, so its row is read straight off the live session.
100
+ *
101
+ * Every source reports the model that produced the row's work, never the one
102
+ * the session merely points at: an armed fallback that has not served yet stays
103
+ * attributed to whichever model last actually spoke.
99
104
  */
100
105
  export function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
101
106
  const progress = observed?.progress;
102
107
  const liveThinkingLevel = ref.session?.thinkingLevel;
108
+ const serving = ref.session?.servingModel;
103
109
  const fallbackSelector =
104
- ref.session?.retryFallbackModel ??
110
+ (serving?.isFallback ? serving.selector : undefined) ??
105
111
  (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined) ??
106
112
  (ref.history?.resolvedModelIsFallback ? ref.history.resolvedModel : undefined);
107
113
  if (fallbackSelector) {
108
114
  return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true, liveThinkingLevel)}`;
109
115
  }
110
- const resolvedModel = progress?.resolvedModel ?? ref.history?.resolvedModel;
116
+ const resolvedModel = progress?.resolvedModel ?? ref.history?.resolvedModel ?? serving?.selector;
111
117
  if (resolvedModel) return formatResolvedModelBadge(resolvedModel, false, liveThinkingLevel);
112
118
  const model = ref.session?.model;
113
119
  if (!model) return undefined;
@@ -36,6 +36,7 @@ import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from ".
36
36
  import { registerPersistedSubagents } from "../../registry/persisted-agents";
37
37
  import { USER_INTERRUPT_LABEL } from "../../session/messages";
38
38
  import { shortenPath, truncateToWidth } from "../../tools/render-utils";
39
+ import { formatLocalDateTimeWithOffset } from "../../utils/local-date";
39
40
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
40
41
  import { theme } from "../theme/theme";
41
42
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
@@ -829,7 +830,7 @@ export class AgentHubOverlayComponent extends Container implements SelectListMou
829
830
  `Spawned by ${sanitizeDisplayText(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`,
830
831
  );
831
832
  if (children.length > 0) add(theme.fg("dim", formatChildIds(children, width)));
832
- add(theme.fg("dim", `Registered ${new Date(ref.createdAt).toISOString().slice(0, 16).replace("T", " ")}Z`));
833
+ add(theme.fg("dim", `Registered ${formatLocalDateTimeWithOffset(new Date(ref.createdAt))}`));
833
834
 
834
835
  section("Changes");
835
836
  add(