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

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  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 +4 -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 +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -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 +24 -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/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -5,8 +5,6 @@
5
5
  * Ruby runners via BaseKernel; this module supplies the Julia binary, runner
6
6
  * script, and the runner's TSV/Base64 wire protocol.
7
7
  */
8
- import * as fs from "node:fs";
9
- import * as os from "node:os";
10
8
  import * as path from "node:path";
11
9
  import { $flag, Snowflake } from "@oh-my-pi/pi-utils";
12
10
  import { $ } from "bun";
@@ -14,6 +12,7 @@ import { Settings } from "../../config/settings";
14
12
  import { BaseKernel, getRemainingTimeMs, type KernelStartOptions } from "../kernel-base";
15
13
  import type { KernelDisplayOutput } from "../py/display";
16
14
  import { hostHasInheritableConsole, shouldDetachKernel, shouldHideKernelWindow } from "../py/spawn-options";
15
+ import { stageRunnerScript } from "../runner-cache";
17
16
  import { JULIA_PRELUDE } from "./prelude";
18
17
  import RUNNER_SCRIPT from "./runner.jl" with { type: "text" };
19
18
  import {
@@ -30,23 +29,6 @@ export type { KernelDisplayOutput };
30
29
 
31
30
  const TRACE_IPC = $flag("PI_JULIA_IPC_TRACE");
32
31
 
33
- // Cache the runner script on disk so the subprocess loads it normally. Cached
34
- // per script hash so installs don't race across versions.
35
- const RUNNER_CACHE_DIR = path.join(os.tmpdir(), "omp-julia-runner");
36
- let RUNNER_SCRIPT_PATH: string | null = null;
37
-
38
- async function ensureRunnerScript(): Promise<string> {
39
- if (RUNNER_SCRIPT_PATH) return RUNNER_SCRIPT_PATH;
40
- await fs.promises.mkdir(RUNNER_CACHE_DIR, { recursive: true });
41
- const hash = Bun.hash(RUNNER_SCRIPT).toString(36);
42
- const target = path.join(RUNNER_CACHE_DIR, `runner-${hash}.jl`);
43
- if (!fs.existsSync(target)) {
44
- await Bun.write(target, RUNNER_SCRIPT);
45
- }
46
- RUNNER_SCRIPT_PATH = target;
47
- return target;
48
- }
49
-
50
32
  const SHUTDOWN_GRACE_MS = 1_000;
51
33
  const STARTUP_TIMEOUT_MS = 15_000; // Julia compile/warmup can be slightly slower
52
34
  const INTERRUPT_ESCALATION_MS = 5_000;
@@ -180,7 +162,7 @@ export class JuliaKernel extends BaseKernel<KernelExecuteOptions> {
180
162
  if (typeof value === "string") spawnEnv[key] = value;
181
163
  }
182
164
 
183
- const scriptPath = await ensureRunnerScript();
165
+ const scriptPath = await stageRunnerScript("omp-julia-runner", "jl", RUNNER_SCRIPT);
184
166
  const kernel = new JuliaKernel(Snowflake.next());
185
167
 
186
168
  const proc = Bun.spawn(
@@ -7,13 +7,12 @@
7
7
  * code. Shutdown writes `{"type":"exit"}` and escalates to SIGTERM/SIGKILL on
8
8
  * timeout.
9
9
  */
10
- import * as fs from "node:fs";
11
- import * as os from "node:os";
12
10
  import * as path from "node:path";
13
11
  import { $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
14
12
  import { $ } from "bun";
15
13
  import { Settings } from "../../config/settings";
16
14
  import { BaseKernel, getRemainingTimeMs, type KernelStartOptions } from "../kernel-base";
15
+ import { stageRunnerScript } from "../runner-cache";
17
16
  import { PYTHON_PRELUDE } from "./prelude";
18
17
  import RUNNER_SCRIPT from "./runner.py" with { type: "text" };
19
18
  import {
@@ -38,23 +37,6 @@ export { renderKernelDisplay } from "./display";
38
37
 
39
38
  const TRACE_IPC = $flag("PI_PYTHON_IPC_TRACE");
40
39
 
41
- // Cache the runner script on disk so the subprocess loads it normally. Cached
42
- // per script hash so installs don't race across versions.
43
- const RUNNER_CACHE_DIR = path.join(os.tmpdir(), "omp-python-runner");
44
- let RUNNER_SCRIPT_PATH: string | null = null;
45
-
46
- async function ensureRunnerScript(): Promise<string> {
47
- if (RUNNER_SCRIPT_PATH) return RUNNER_SCRIPT_PATH;
48
- await fs.promises.mkdir(RUNNER_CACHE_DIR, { recursive: true });
49
- const hash = Bun.hash(RUNNER_SCRIPT).toString(36);
50
- const target = path.join(RUNNER_CACHE_DIR, `runner-${hash}.py`);
51
- if (!fs.existsSync(target)) {
52
- await Bun.write(target, RUNNER_SCRIPT);
53
- }
54
- RUNNER_SCRIPT_PATH = target;
55
- return target;
56
- }
57
-
58
40
  const SHUTDOWN_GRACE_MS = 1_000;
59
41
  const STARTUP_TIMEOUT_MS = 10_000;
60
42
  // How long to wait after SIGINT for the runner to emit `done`. If the cell is
@@ -189,7 +171,7 @@ export class PythonKernel extends BaseKernel {
189
171
  spawnEnv.PYTHONUNBUFFERED = "1";
190
172
  spawnEnv.PYTHONIOENCODING = "utf-8";
191
173
 
192
- const scriptPath = await ensureRunnerScript();
174
+ const scriptPath = await stageRunnerScript("omp-python-runner", "py", RUNNER_SCRIPT);
193
175
  const kernel = new PythonKernel(Snowflake.next());
194
176
 
195
177
  const proc = Bun.spawn([runtime.pythonPath, "-u", scriptPath], {
@@ -8,8 +8,6 @@
8
8
  * (eval/py/kernel.ts); the IPC loop, lifecycle, and display rendering are shared
9
9
  * with it via BaseKernel.
10
10
  */
11
- import * as fs from "node:fs";
12
- import * as os from "node:os";
13
11
  import * as path from "node:path";
14
12
  import { $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
15
13
  import { $ } from "bun";
@@ -17,6 +15,7 @@ import { Settings } from "../../config/settings";
17
15
  import { BaseKernel, getRemainingTimeMs, type KernelRuntimeEnv, type KernelStartOptions } from "../kernel-base";
18
16
  import type { KernelDisplayOutput } from "../py/display";
19
17
  import { hostHasInheritableConsole, shouldDetachKernel, shouldHideKernelWindow } from "../py/spawn-options";
18
+ import { stageRunnerScript } from "../runner-cache";
20
19
  import { RUBY_PRELUDE } from "./prelude";
21
20
  import RUNNER_SCRIPT from "./runner.rb" with { type: "text" };
22
21
  import {
@@ -33,23 +32,6 @@ export { renderKernelDisplay } from "../py/display";
33
32
 
34
33
  const TRACE_IPC = $flag("PI_RUBY_IPC_TRACE");
35
34
 
36
- // Cache the runner script on disk so the subprocess loads it normally. Cached
37
- // per script hash so installs don't race across versions.
38
- const RUNNER_CACHE_DIR = path.join(os.tmpdir(), "omp-ruby-runner");
39
- let RUNNER_SCRIPT_PATH: string | null = null;
40
-
41
- async function ensureRunnerScript(): Promise<string> {
42
- if (RUNNER_SCRIPT_PATH) return RUNNER_SCRIPT_PATH;
43
- await fs.promises.mkdir(RUNNER_CACHE_DIR, { recursive: true });
44
- const hash = Bun.hash(RUNNER_SCRIPT).toString(36);
45
- const target = path.join(RUNNER_CACHE_DIR, `runner-${hash}.rb`);
46
- if (!fs.existsSync(target)) {
47
- await Bun.write(target, RUNNER_SCRIPT);
48
- }
49
- RUNNER_SCRIPT_PATH = target;
50
- return target;
51
- }
52
-
53
35
  const SHUTDOWN_GRACE_MS = 1_000;
54
36
  const STARTUP_TIMEOUT_MS = 10_000;
55
37
  // How long to wait after SIGINT for the runner to emit `done` before escalating
@@ -181,7 +163,7 @@ export class RubyKernel extends BaseKernel<KernelExecuteOptions> {
181
163
  if (typeof value === "string") spawnEnv[key] = value;
182
164
  }
183
165
 
184
- const scriptPath = await ensureRunnerScript();
166
+ const scriptPath = await stageRunnerScript("omp-ruby-runner", "rb", RUNNER_SCRIPT);
185
167
  const kernel = new RubyKernel(Snowflake.next());
186
168
 
187
169
  const proc = Bun.spawn([runtime.rubyPath, scriptPath], {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared on-disk staging for subprocess kernel runner scripts.
3
+ *
4
+ * Each language kernel (Python/Julia/Ruby) ships its runner as a compiled-in
5
+ * text asset, then stages it under `os.tmpdir()` so the interpreter can load it
6
+ * as a normal file. Staging is cached per language directory so repeated kernel
7
+ * starts within a process avoid redundant writes.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ // Memoized staged path per cache directory. The value is re-validated on every
14
+ // call: a tmpdir sweep (e.g. macOS `periodic daily clean_tmps`) or any external
15
+ // clear must self-heal within a long-lived process, not only across restarts.
16
+ const stagedPaths = new Map<string, string>();
17
+
18
+ /**
19
+ * Stage `script` under `os.tmpdir()/<dirName>` and return the runner path.
20
+ *
21
+ * The staged path is memoized per `dirName` but re-checked with `fs.existsSync`
22
+ * before reuse, so a runner deleted mid-session is re-written on the next call
23
+ * instead of handing back a path to a missing file (issue #8140).
24
+ *
25
+ * @param dirName Cache subdirectory under the OS temp dir (unique per language).
26
+ * @param ext Runner file extension without the dot (e.g. `py`, `jl`, `rb`).
27
+ * @param script Runner source, hashed to key the cached file per version.
28
+ */
29
+ export async function stageRunnerScript(dirName: string, ext: string, script: string): Promise<string> {
30
+ const memoized = stagedPaths.get(dirName);
31
+ if (memoized && fs.existsSync(memoized)) return memoized;
32
+ const dir = path.join(os.tmpdir(), dirName);
33
+ await fs.promises.mkdir(dir, { recursive: true });
34
+ const hash = Bun.hash(script).toString(36);
35
+ const target = path.join(dir, `runner-${hash}.${ext}`);
36
+ if (!fs.existsSync(target)) {
37
+ await Bun.write(target, script);
38
+ }
39
+ stagedPaths.set(dirName, target);
40
+ return target;
41
+ }
@@ -23,7 +23,7 @@ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
23
23
  EDITOR: "true",
24
24
  GIT_TERMINAL_PROMPT: "0",
25
25
  SSH_ASKPASS: "/usr/bin/false",
26
- CI: "1",
26
+ CI: "true",
27
27
  AGENT: "1",
28
28
  // Package manager defaults for unattended execution.
29
29
  npm_config_yes: "true",
@@ -96,17 +96,28 @@ function hasEnvGroupValue(
96
96
  return false;
97
97
  }
98
98
 
99
+ /** Copy of the base env with `CI` removed, for the `PI_BASH_NO_CI` opt-out. */
100
+ function withoutCI(env: Readonly<Record<string, string>>): Record<string, string> {
101
+ const { CI: _ci, ...rest } = env;
102
+ return rest;
103
+ }
104
+
99
105
  /** Builds the per-command environment for non-interactive child processes. */
100
106
  export function buildNonInteractiveEnv(
101
107
  overrides?: Record<string, string>,
102
108
  baseEnv: Record<string, string | undefined> = Bun.env,
103
109
  platform: NodeJS.Platform = process.platform,
104
110
  ): Record<string, string> {
111
+ // `PI_BASH_NO_CI` (and its legacy alias) opts out of the automatic `CI=true`
112
+ // injection. Mirrors the session-env gate in `procmgr.ts` so the opt-out
113
+ // reaches the per-command env, which otherwise overrides the session value.
114
+ const base =
115
+ baseEnv.PI_BASH_NO_CI || baseEnv.CLAUDE_BASH_NO_CI ? withoutCI(NON_INTERACTIVE_ENV) : NON_INTERACTIVE_ENV;
105
116
  if (platform !== "win32") {
106
- return overrides ? { ...NON_INTERACTIVE_ENV, ...overrides } : NON_INTERACTIVE_ENV;
117
+ return overrides ? { ...base, ...overrides } : base;
107
118
  }
108
119
 
109
- const env: Record<string, string> = { ...NON_INTERACTIVE_ENV };
120
+ const env: Record<string, string> = { ...base };
110
121
  for (const group of WINDOWS_UTF8_ENV_DEFAULT_GROUPS) {
111
122
  if (hasEnvGroupValue(baseEnv, group, platform) || hasEnvGroupValue(overrides, group, platform)) {
112
123
  continue;
@@ -175,10 +175,12 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
175
175
  }
176
176
 
177
177
  registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void {
178
- this.extension.tools.set(tool.name, {
178
+ const registered = {
179
179
  definition: tool,
180
180
  extensionPath: this.extension.path,
181
- });
181
+ };
182
+ this.extension.tools.set(tool.name, registered);
183
+ for (const listener of this.extension.toolRegistrationListeners ?? []) listener(tool.name);
182
184
  }
183
185
 
184
186
  registerCommand(
@@ -316,6 +318,7 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension
316
318
  resolvedPath,
317
319
  handlers: new Map(),
318
320
  tools: new Map(),
321
+ toolRegistrationListeners: new Set(),
319
322
  assistantThinkingRenderers: [],
320
323
  messageRenderers: new Map(),
321
324
  commands: new Map(),
@@ -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>;