@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.7

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 (137) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
  3. package/dist/cli.js +3156 -3163
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/stats-cli.d.ts +1 -6
  6. package/dist/types/commands/stats.d.ts +4 -0
  7. package/dist/types/config/model-discovery.d.ts +2 -0
  8. package/dist/types/config/model-registry.d.ts +6 -1
  9. package/dist/types/config/settings-schema.d.ts +10 -0
  10. package/dist/types/debug/report-bundle.d.ts +7 -2
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +24 -6
  13. package/dist/types/extensibility/extensions/types.d.ts +60 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/launch/presence.d.ts +11 -0
  16. package/dist/types/lsp/client.d.ts +22 -0
  17. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  18. package/dist/types/mcp/config.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  21. package/dist/types/registry/agent-registry.d.ts +4 -0
  22. package/dist/types/session/agent-session-types.d.ts +0 -2
  23. package/dist/types/session/agent-session.d.ts +20 -0
  24. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  25. package/dist/types/session/irc-bridge.d.ts +2 -0
  26. package/dist/types/session/messages.d.ts +4 -0
  27. package/dist/types/session/session-loader.d.ts +18 -11
  28. package/dist/types/session/session-maintenance.d.ts +6 -2
  29. package/dist/types/session/session-manager.d.ts +19 -3
  30. package/dist/types/session/session-tools.d.ts +0 -1
  31. package/dist/types/session/turn-recovery.d.ts +7 -7
  32. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  33. package/dist/types/tools/browser/attach.d.ts +16 -0
  34. package/dist/types/tools/browser/launch.d.ts +2 -2
  35. package/dist/types/tools/browser.d.ts +1 -1
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  38. package/dist/types/tools/hub/jobs.d.ts +6 -0
  39. package/dist/types/tools/hub/types.d.ts +6 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/vibe/runtime.d.ts +3 -3
  43. package/package.json +13 -13
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/stats-cli.ts +6 -72
  47. package/src/commands/stats.ts +7 -4
  48. package/src/commit/analysis/conventional.ts +15 -9
  49. package/src/commit/analysis/summary.ts +15 -9
  50. package/src/commit/changelog/generate.ts +15 -9
  51. package/src/commit/map-reduce/map-phase.ts +3 -19
  52. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  53. package/src/config/model-discovery.ts +3 -3
  54. package/src/config/model-registry.ts +29 -16
  55. package/src/config/settings-schema.ts +13 -0
  56. package/src/debug/report-bundle.ts +25 -59
  57. package/src/discovery/claude-plugins.ts +2 -1
  58. package/src/discovery/claude.ts +4 -2
  59. package/src/discovery/helpers.ts +5 -0
  60. package/src/edit/hashline/filesystem.ts +9 -3
  61. package/src/edit/modes/patch.ts +31 -5
  62. package/src/edit/renderer.ts +48 -19
  63. package/src/extensibility/extensions/loader.ts +20 -5
  64. package/src/extensibility/extensions/runner.ts +239 -24
  65. package/src/extensibility/extensions/types.ts +62 -0
  66. package/src/extensibility/extensions/wrapper.ts +26 -11
  67. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  68. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  69. package/src/irc/bus.ts +1 -1
  70. package/src/launch/broker.ts +8 -1
  71. package/src/launch/presence.ts +77 -1
  72. package/src/lsp/client.ts +35 -3
  73. package/src/lsp/clients/biome-client.ts +47 -93
  74. package/src/lsp/servers.ts +17 -11
  75. package/src/lsp/tool.ts +2 -4
  76. package/src/lsp/workspace-diagnostics.ts +24 -2
  77. package/src/lsp/writethrough.ts +20 -10
  78. package/src/mcp/config.ts +50 -6
  79. package/src/memories/index.ts +29 -25
  80. package/src/mnemopi/backend.ts +13 -11
  81. package/src/modes/components/ask-dialog.ts +25 -5
  82. package/src/modes/components/tool-execution.ts +10 -6
  83. package/src/modes/components/welcome.ts +4 -1
  84. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  85. package/src/modes/controllers/selector-controller.ts +6 -0
  86. package/src/modes/controllers/tan-command-controller.ts +1 -0
  87. package/src/modes/interactive-mode.ts +49 -10
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  90. package/src/prompts/system/date-cwd-reminder.md +3 -0
  91. package/src/prompts/system/project-prompt.md +0 -1
  92. package/src/prompts/system/rewind-report.md +1 -3
  93. package/src/prompts/tools/browser.md +1 -0
  94. package/src/prompts/tools/rewind.md +1 -13
  95. package/src/registry/agent-lifecycle.ts +34 -0
  96. package/src/registry/agent-registry.ts +27 -2
  97. package/src/registry/persisted-agents.ts +40 -20
  98. package/src/sdk.ts +32 -2
  99. package/src/session/agent-session-types.ts +0 -2
  100. package/src/session/agent-session.ts +178 -46
  101. package/src/session/date-cwd-reminder.ts +77 -0
  102. package/src/session/irc-bridge.ts +5 -0
  103. package/src/session/messages.ts +5 -1
  104. package/src/session/session-loader.ts +106 -64
  105. package/src/session/session-maintenance.ts +189 -115
  106. package/src/session/session-manager.ts +142 -35
  107. package/src/session/session-persistence.ts +4 -4
  108. package/src/session/session-storage.ts +18 -5
  109. package/src/session/session-tools.ts +5 -10
  110. package/src/session/turn-recovery.ts +102 -20
  111. package/src/session/unexpected-stop-classifier.ts +33 -21
  112. package/src/slash-commands/builtin-session.ts +1 -1
  113. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  114. package/src/system-prompt.ts +0 -5
  115. package/src/task/executor.ts +4 -15
  116. package/src/task/persisted-revive.ts +3 -6
  117. package/src/tools/ask.ts +10 -3
  118. package/src/tools/browser/attach.ts +80 -25
  119. package/src/tools/browser/launch.ts +44 -15
  120. package/src/tools/browser/relay/daemon.ts +3 -7
  121. package/src/tools/browser/render.ts +1 -1
  122. package/src/tools/browser/shared-daemon.ts +3 -7
  123. package/src/tools/browser.ts +5 -5
  124. package/src/tools/builtin-names.ts +7 -3
  125. package/src/tools/checkpoint.ts +1 -7
  126. package/src/tools/file-write-fallback.ts +467 -0
  127. package/src/tools/hub/index.ts +1 -1
  128. package/src/tools/hub/jobs.ts +20 -3
  129. package/src/tools/hub/types.ts +6 -0
  130. package/src/tools/index.ts +1 -0
  131. package/src/tools/path-utils.ts +79 -0
  132. package/src/tools/render-utils.ts +53 -21
  133. package/src/tools/think.ts +15 -3
  134. package/src/tts/speech-enhancer.ts +19 -14
  135. package/src/utils/commit-message-generator.ts +14 -12
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
@@ -412,6 +412,42 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string
412
412
  }
413
413
  return preview.trimEnd();
414
414
  }
415
+
416
+ interface StreamingDiffTail {
417
+ content: string;
418
+ hidden: boolean;
419
+ }
420
+
421
+ /**
422
+ * Select the trailing physical lines that fit the live preview budget.
423
+ *
424
+ * Walk backward from the end instead of splitting the complete diff. This keeps
425
+ * allocation and scanning proportional to the visible suffix. One physical line
426
+ * may exceed the budget, but it is still kept so the newest change is visible.
427
+ */
428
+ function sliceStreamingDiffTail(diff: string, innerWidth: number, budget: number): StreamingDiffTail {
429
+ let end = diff.length;
430
+ while (end > 0 && diff.charCodeAt(end - 1) === 10) end--;
431
+ if (end === 0) return { content: "", hidden: false };
432
+
433
+ const rowLimit = Math.max(1, budget);
434
+ let start = end;
435
+ let cursor = end;
436
+ let visualRows = 0;
437
+ while (cursor >= 0) {
438
+ const newline = cursor > 0 ? diff.lastIndexOf("\n", cursor - 1) : -1;
439
+ const lineStart = newline + 1;
440
+ const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(diff.slice(lineStart, cursor)), innerWidth).length);
441
+ if (visualRows > 0 && visualRows + lineRows > rowLimit) break;
442
+ visualRows += lineRows;
443
+ start = lineStart;
444
+ if (newline < 0) break;
445
+ cursor = newline;
446
+ }
447
+
448
+ return { content: diff.slice(start, end), hidden: start > 0 };
449
+ }
450
+
415
451
  function formatStreamingDiff(
416
452
  diff: string,
417
453
  rawPath: string,
@@ -442,26 +478,14 @@ function formatStreamingDiff(
442
478
  // its Myers alignment is not monotonic in payload length, so a hunk-aware
443
479
  // window stutters as rows move between hunks. Expanded widens the window
444
480
  // to the viewport; the full diff appears once the result finalizes.
445
- const allLines = diff.replace(/\n+$/u, "").split("\n");
446
- let visualUsed = 0;
447
- let cut = allLines.length;
448
- for (let i = allLines.length - 1; i >= 0; i--) {
449
- const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(allLines[i]!), innerWidth).length);
450
- if (visualUsed + lineRows > budget && visualUsed > 0) break;
451
- visualUsed += lineRows;
452
- cut = i;
453
- }
454
- const hiddenLines = cut;
455
- const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
481
+ const tail = sliceStreamingDiffTail(diff, innerWidth, budget);
456
482
  let rendered = "\n\n";
457
- if (hiddenLines > 0) {
458
- const hiddenHunks = getDiffStats(allLines.slice(0, hiddenLines).join("\n")).hunks;
459
- const remainder: string[] = [];
460
- if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
461
- remainder.push(`${hiddenLines} more lines`);
462
- rendered += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
483
+ if (tail.hidden) {
484
+ // Exact hidden line/hunk counts require scanning the discarded prefix,
485
+ // which would make every streaming update scale with the complete diff.
486
+ rendered += `${uiTheme.fg("dim", "… (content above)")}\n`;
463
487
  }
464
- rendered += renderDiffColored(visible.join("\n"), { filePath: rawPath });
488
+ rendered += renderDiffColored(tail.content, { filePath: rawPath });
465
489
  return rendered;
466
490
  });
467
491
  // The animated glyph rides this trailing line — inside the transcript's
@@ -864,6 +888,7 @@ function renderSingleFileResult(
864
888
 
865
889
  let diffSectionRenderDiffFn: ((t: string, o?: { filePath?: string }) => string) | undefined;
866
890
  const diffSectionCache = createRenderedStringCache();
891
+ const statsSuffixCache = createRenderedStringCache();
867
892
 
868
893
  return framedBlock(uiTheme, width => {
869
894
  const { expanded, renderContext } = options;
@@ -887,7 +912,11 @@ function renderSingleFileResult(
887
912
  // Change stats ride inline on the header bar next to the path.
888
913
  const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
889
914
  const headerDiff = isError ? undefined : details?.diff || previewDiff;
890
- const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
915
+ const statsSuffix = headerDiff
916
+ ? cachedRenderedString(statsSuffixCache, uiTheme, false, "", headerDiff, () =>
917
+ formatDiffStatsSuffix(headerDiff, uiTheme),
918
+ )
919
+ : "";
891
920
  const header = renderEditHeader(width, uiTheme, {
892
921
  icon: isError ? "error" : "success",
893
922
  iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined,
@@ -28,6 +28,7 @@ import { execCommand } from "../../exec/exec";
28
28
  // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
29
29
  import * as PiCodingAgent from "../../index";
30
30
  import type { CustomMessagePayload } from "../../session/messages";
31
+ import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback";
31
32
  import { EventBus } from "../../utils/event-bus";
32
33
  import * as TypeBox from "../legacy-typebox";
33
34
  import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat";
@@ -183,6 +184,14 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
183
184
  for (const listener of this.extension.toolRegistrationListeners ?? []) listener(tool.name);
184
185
  }
185
186
 
187
+ registerFileWriteFallback(handler: FileWriteFallbackHandler): void {
188
+ this.extension.fileWriteFallbackHandlers.push(handler);
189
+ }
190
+
191
+ registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void {
192
+ this.extension.fileDeleteFallbackHandlers.push(handler);
193
+ }
194
+
186
195
  registerCommand(
187
196
  name: string,
188
197
  options: {
@@ -320,6 +329,8 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension
320
329
  tools: new Map(),
321
330
  toolRegistrationListeners: new Set(),
322
331
  assistantThinkingRenderers: [],
332
+ fileWriteFallbackHandlers: [],
333
+ fileDeleteFallbackHandlers: [],
323
334
  messageRenderers: new Map(),
324
335
  commands: new Map(),
325
336
  flags: new Map(),
@@ -613,6 +624,8 @@ async function discoverHooksInPackageRoot(root: string): Promise<string[]> {
613
624
  export interface DiscoverExtensionPathOptions {
614
625
  /** Include ambient native extensions, hooks, and installed plugins. */
615
626
  ambient?: boolean;
627
+ /** Include ambient hook factories. Disable for read-only catalog commands. */
628
+ includeAmbientHooks?: boolean;
616
629
  }
617
630
 
618
631
  export async function discoverExtensionPaths(
@@ -665,11 +678,13 @@ export async function discoverExtensionPaths(
665
678
  // scans only this invocation's configured package roots; it must not consult
666
679
  // settings, installed packages, or process-global CLI injection state.
667
680
  if (ambient) {
668
- const hooks = await loadCapability<Hook>(hookCapability.id, loadOptions);
669
- for (const hookPath of hooks.items
670
- .map(hook => hook.path)
671
- .filter(hookPath => isExtensionFile(path.basename(hookPath)))) {
672
- addPath(hookPath);
681
+ if (options.includeAmbientHooks !== false) {
682
+ const hooks = await loadCapability<Hook>(hookCapability.id, loadOptions);
683
+ for (const hookPath of hooks.items
684
+ .map(hook => hook.path)
685
+ .filter(hookPath => isExtensionFile(path.basename(hookPath)))) {
686
+ addPath(hookPath);
687
+ }
673
688
  }
674
689
  } else {
675
690
  for (const configuredPath of configuredPaths) {
@@ -19,6 +19,7 @@ import type { MemoryRuntimeContext } from "../../memory-backend";
19
19
  import { type Theme, theme } from "../../modes/theme/theme";
20
20
  import type { AsyncJobSnapshot } from "../../session/agent-session";
21
21
  import type { SessionManager } from "../../session/session-manager";
22
+ import { addFileDeleteFallback, addFileWriteFallback } from "../../tools/file-write-fallback";
22
23
  import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
23
24
  import { ManagedTimers } from "./managed-timers";
24
25
  import { createExtensionModelQuery } from "./model-api";
@@ -92,6 +93,10 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
92
93
  extensionHandlerTimeoutMs = timeoutMs;
93
94
  }
94
95
 
96
+ function normalizeHandlerTimeout(timeoutMs: number): number {
97
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : EXTENSION_HANDLER_TIMEOUT_MS;
98
+ }
99
+
95
100
  /**
96
101
  * Dedicated cap for `session_shutdown` handlers. The generic 30s budget is
97
102
  * appropriate for events extensions can observe (e.g. `session_start`,
@@ -117,6 +122,11 @@ function handlerTimeoutForEvent(eventType: string): number {
117
122
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
118
123
  const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
119
124
 
125
+ interface HandlerTimeoutBudget {
126
+ pause(): void;
127
+ resume(): void;
128
+ }
129
+
120
130
  function attachHandlerSignal(
121
131
  dialogOptions: ExtensionUIDialogOptions | undefined,
122
132
  handlerSignal: AbortSignal,
@@ -127,22 +137,57 @@ function attachHandlerSignal(
127
137
  return { ...dialogOptions, signal: AbortSignal.any([dialogOptions.signal, handlerSignal]) };
128
138
  }
129
139
 
130
- function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSignal): ExtensionUIContext {
140
+ function createHandlerUIContext(
141
+ ui: ExtensionUIContext,
142
+ handlerSignal: AbortSignal,
143
+ timeoutBudget?: HandlerTimeoutBudget,
144
+ ): ExtensionUIContext {
131
145
  const askDialog = ui.askDialog;
146
+ const runDialog = async <T>(dialog: () => Promise<T>): Promise<T> => {
147
+ timeoutBudget?.pause();
148
+ try {
149
+ return await dialog();
150
+ } finally {
151
+ timeoutBudget?.resume();
152
+ }
153
+ };
132
154
  const dialogMethods = {
133
155
  select: (title, options, dialogOptions) =>
134
- ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal)),
156
+ runDialog(() => ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal))),
135
157
  confirm: (title, message, dialogOptions) =>
136
- ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal)),
158
+ runDialog(() => ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal))),
137
159
  input: (title, placeholder, dialogOptions) =>
138
- ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal)),
160
+ runDialog(() => ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal))),
139
161
  askDialog: askDialog
140
162
  ? (questions, dialogOptions) =>
141
- askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal))
163
+ runDialog(() => askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal)))
142
164
  : undefined,
165
+ custom: async (factory, options) => {
166
+ let customSettled = false;
167
+ let componentReady = false;
168
+ try {
169
+ return await ui.custom(
170
+ async (...args) => {
171
+ const component = await factory(...args);
172
+ if (!customSettled) {
173
+ timeoutBudget?.pause();
174
+ componentReady = true;
175
+ }
176
+ return component;
177
+ },
178
+ {
179
+ ...options,
180
+ signal: options?.signal ? AbortSignal.any([options.signal, handlerSignal]) : handlerSignal,
181
+ },
182
+ );
183
+ } finally {
184
+ customSettled = true;
185
+ if (componentReady) timeoutBudget?.resume();
186
+ }
187
+ },
143
188
  editor: (title, prefill, dialogOptions, editorOptions) =>
144
- ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions),
145
- } satisfies Pick<ExtensionUIContext, "select" | "confirm" | "input" | "askDialog" | "editor">;
189
+ runDialog(() => ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions)),
190
+ } satisfies Pick<ExtensionUIContext, "select" | "confirm" | "input" | "askDialog" | "custom" | "editor">;
146
191
  const delegatedMethods = new Map<PropertyKey, unknown>();
147
192
 
148
193
  return new Proxy(ui, {
@@ -167,10 +212,14 @@ function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSign
167
212
  * `pi.setModel()` and then reading `ctx.model` would see a stale model.
168
213
  * Prototype delegation keeps every getter live while overriding `ui`.
169
214
  */
170
- function createHandlerContext(ctx: ExtensionContext, handlerSignal: AbortSignal): ExtensionContext {
215
+ function createHandlerContext(
216
+ ctx: ExtensionContext,
217
+ handlerSignal: AbortSignal,
218
+ timeoutBudget?: HandlerTimeoutBudget,
219
+ ): ExtensionContext {
171
220
  const scoped: ExtensionContext = Object.create(ctx);
172
221
  Object.defineProperty(scoped, "ui", {
173
- value: createHandlerUIContext(ctx.ui, handlerSignal),
222
+ value: createHandlerUIContext(ctx.ui, handlerSignal, timeoutBudget),
174
223
  enumerable: true,
175
224
  configurable: true,
176
225
  });
@@ -190,7 +239,7 @@ function createHandlerContext(ctx: ExtensionContext, handlerSignal: AbortSignal)
190
239
  * can `clearTimeout` on the winning branch.
191
240
  */
192
241
  async function raceHandlerWithTimeout<T>(
193
- work: (handlerSignal: AbortSignal) => Promise<T> | T,
242
+ work: (handlerSignal: AbortSignal, timeoutBudget: HandlerTimeoutBudget) => Promise<T> | T,
194
243
  timeoutMs: number,
195
244
  signal?: AbortSignal,
196
245
  ): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED> {
@@ -203,13 +252,52 @@ async function raceHandlerWithTimeout<T>(
203
252
  >();
204
253
  const onAbort = () => resolveInterrupt(EXTENSION_HANDLER_ABORTED);
205
254
  signal?.addEventListener("abort", onAbort, { once: true });
206
- const timer = setTimeout(() => {
255
+ let timer: Timer | undefined;
256
+ let remainingMs = timeoutMs;
257
+ let activeSince = performance.now();
258
+ let pauseDepth = 0;
259
+ let settled = false;
260
+ const clearTimer = () => {
261
+ if (timer === undefined) return;
262
+ clearTimeout(timer);
263
+ timer = undefined;
264
+ };
265
+ const expire = () => {
266
+ if (settled) return;
267
+ settled = true;
268
+ clearTimer();
207
269
  timeoutController.abort(new DOMException(`Handler timed out after ${timeoutMs}ms`, "TimeoutError"));
208
270
  resolveInterrupt(EXTENSION_HANDLER_TIMEOUT);
209
- }, timeoutMs);
271
+ };
272
+ const armTimer = () => {
273
+ if (settled || pauseDepth > 0) return;
274
+ activeSince = performance.now();
275
+ timer = setTimeout(expire, Math.max(0, remainingMs));
276
+ };
277
+ const settle = () => {
278
+ if (settled) return;
279
+ settled = true;
280
+ clearTimer();
281
+ };
282
+ const timeoutBudget: HandlerTimeoutBudget = {
283
+ pause: () => {
284
+ if (settled) return;
285
+ pauseDepth++;
286
+ if (pauseDepth !== 1) return;
287
+ remainingMs = Math.max(0, remainingMs - (performance.now() - activeSince));
288
+ clearTimer();
289
+ if (remainingMs <= 0) expire();
290
+ },
291
+ resume: () => {
292
+ if (settled || pauseDepth === 0) return;
293
+ pauseDepth--;
294
+ if (pauseDepth === 0) armTimer();
295
+ },
296
+ };
297
+ armTimer();
210
298
  try {
211
299
  if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
212
- const workPromise = Promise.resolve(work(handlerSignal));
300
+ const workPromise = Promise.resolve(work(handlerSignal, timeoutBudget));
213
301
  const result = await Promise.race([workPromise, interruptPromise]);
214
302
  if (result === EXTENSION_HANDLER_TIMEOUT) {
215
303
  await Promise.race([
@@ -222,7 +310,7 @@ async function raceHandlerWithTimeout<T>(
222
310
  }
223
311
  return result;
224
312
  } finally {
225
- clearTimeout(timer);
313
+ settle();
226
314
  signal?.removeEventListener("abort", onAbort);
227
315
  }
228
316
  }
@@ -288,10 +376,12 @@ export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled:
288
376
  export type ShutdownHandler = () => void;
289
377
 
290
378
  /**
291
- * Emit `session_shutdown` and clear timers owned by an extension runner.
379
+ * Emit `session_shutdown`, dispose file-write-fallback registrations, and clear
380
+ * timers owned by an extension runner.
292
381
  *
293
- * Returns whether any shutdown handlers were present. Timer cleanup runs even
294
- * when a handler fails so extension background work cannot outlive its host.
382
+ * Returns whether any shutdown handlers were present. Fallback disposal and timer
383
+ * cleanup run even when a handler fails so extension background work and a
384
+ * fallback bound to this session's context — cannot outlive its host.
295
385
  */
296
386
  export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise<boolean> {
297
387
  if (!extensionRunner) return false;
@@ -302,6 +392,7 @@ export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner
302
392
  });
303
393
  return true;
304
394
  } finally {
395
+ extensionRunner.disposeFileFallbacks();
305
396
  extensionRunner.clearManagedTimers();
306
397
  }
307
398
  }
@@ -399,6 +490,23 @@ export class ExtensionRunner {
399
490
  #managedTimers = new ManagedTimers((event, error, stack) =>
400
491
  this.emitError({ extensionPath: "<timer>", event, error, stack }),
401
492
  );
493
+ /**
494
+ * Disposers for the trampolines installed via {@link addFileWriteFallback} and
495
+ * {@link addFileDeleteFallback} — one per extension per seam it registered for.
496
+ * Installed during {@link initialize} (after the UI/runtime context is live, so
497
+ * the bound handler sees a working `ctx.ui`) and drained by
498
+ * {@link disposeFileFallbacks} on session shutdown so a handler from a
499
+ * torn-down session can never fire for a later one sharing the same process.
500
+ *
501
+ * Each trampoline re-reads its extension's handler list at call time rather than
502
+ * closing over a snapshot, matching how `ext.handlers` is re-read on every emit,
503
+ * so an extension that already had a handler for that seam at `initialize` picks
504
+ * up later additions to it. A seam the extension registered NOTHING for gets no
505
+ * trampoline at all, which keeps the registry empty for a host with no fallbacks;
506
+ * the cost is that a first registration for that seam after `initialize` never
507
+ * takes effect, which is why the API documents load-time registration.
508
+ */
509
+ #fileFallbackDisposers: Array<() => void> = [];
402
510
  /**
403
511
  * Dedup markers for `tool_call` emission, keyed `${toolCallId}:${toolName}`.
404
512
  * The agent loop emits `tool_call` at arg-prep time (before scheduling and
@@ -522,6 +630,18 @@ export class ExtensionRunner {
522
630
  return this.sessionManager.getCwd();
523
631
  }
524
632
 
633
+ /**
634
+ * Stable id of the session this runner serves. Read through `sessionManager`
635
+ * for the same reason as {@link cwd}: it is this session's own, never a
636
+ * process-global, so a subagent runner reports itself and not its parent.
637
+ *
638
+ * Used to attribute a denied file write or delete to the session that issued
639
+ * it, since the fallback registry those handlers live in is process-wide.
640
+ */
641
+ get sessionId(): string {
642
+ return this.sessionManager.getSessionId();
643
+ }
644
+
525
645
  initialize(
526
646
  actions: ExtensionActions,
527
647
  contextActions: ExtensionContextActions,
@@ -579,6 +699,75 @@ export class ExtensionRunner {
579
699
  this.#mode = mode;
580
700
  this.#initialized = true;
581
701
 
702
+ // Re-initialize (e.g. a mode switch rewiring UI/runtime actions) must not
703
+ // accumulate duplicate global registrations — drop the prior generation before
704
+ // installing this one's trampolines.
705
+ this.disposeFileFallbacks();
706
+ for (const ext of this.extensions) {
707
+ // Nothing registered by this extension means no trampoline, so a host with
708
+ // no fallback-registering extension leaves the seam genuinely empty and
709
+ // `hasFileWriteFallback()`/`hasFileDeleteFallback()` false — the invariant
710
+ // the whole feature rests on. Each seam is checked separately, so an
711
+ // extension that only brokers writes never appears in the delete registry.
712
+ if (ext.fileWriteFallbackHandlers.length === 0 && ext.fileDeleteFallbackHandlers.length === 0) continue;
713
+ // One trampoline per extension per seam, not per handler: the list is walked
714
+ // at mutation time so a handler this extension adds later still takes effect,
715
+ // and `createContext()` takes no extension argument, so within one invocation
716
+ // a single context is all any of this extension's handlers would have
717
+ // received anyway.
718
+ //
719
+ // The context is built PER INVOCATION rather than captured here, matching
720
+ // every other dispatch site. `createContext()` materializes `cwd` and
721
+ // `hasUI` as values, so a trampoline holding one context for the life of the
722
+ // session would keep handing handlers the workspace this runner initialized
723
+ // in — wrong the moment `SessionManager.moveTo()` relocates the session
724
+ // (`/move`), and a handler that scopes or prompts against `ctx.cwd` would
725
+ // then allow the old workspace and deny the new one. A denied mutation is a
726
+ // rare path, so the extra object costs nothing that matters.
727
+ //
728
+ // Isolation is per HANDLER, not per extension. The registry only sees one
729
+ // trampoline per extension, so a throw escaping this loop would advance the
730
+ // registry to the NEXT extension and skip every later handler this one
731
+ // registered — breaking both the documented "a throwing handler is skipped"
732
+ // contract and registration order for a backup-handler setup.
733
+ if (ext.fileWriteFallbackHandlers.length > 0) {
734
+ this.#fileFallbackDisposers.push(
735
+ addFileWriteFallback(async req => {
736
+ const ctx = this.createContext();
737
+ for (const handler of ext.fileWriteFallbackHandlers) {
738
+ try {
739
+ if (await handler(req, ctx)) return true;
740
+ } catch (error) {
741
+ logger.warn("Extension file write fallback handler threw; trying next handler", {
742
+ extension: ext.path,
743
+ error: error instanceof Error ? error.message : String(error),
744
+ });
745
+ }
746
+ }
747
+ return false;
748
+ }),
749
+ );
750
+ }
751
+ if (ext.fileDeleteFallbackHandlers.length > 0) {
752
+ this.#fileFallbackDisposers.push(
753
+ addFileDeleteFallback(async req => {
754
+ const ctx = this.createContext();
755
+ for (const handler of ext.fileDeleteFallbackHandlers) {
756
+ try {
757
+ if (await handler(req, ctx)) return true;
758
+ } catch (error) {
759
+ logger.warn("Extension file delete fallback handler threw; trying next handler", {
760
+ extension: ext.path,
761
+ error: error instanceof Error ? error.message : String(error),
762
+ });
763
+ }
764
+ }
765
+ return false;
766
+ }),
767
+ );
768
+ }
769
+ }
770
+
582
771
  // Drain events buffered by emitCredentialDisabled() before initialize ran. The
583
772
  // spread adds the `type` discriminator — `event` is the pi-ai shape (no `type`).
584
773
  // Deferred by one microtask so callers that register an onError listener
@@ -1011,6 +1200,16 @@ export class ExtensionRunner {
1011
1200
  this.#managedTimers.clearAll();
1012
1201
  }
1013
1202
 
1203
+ /**
1204
+ * Remove every file write and delete fallback this runner installed into the
1205
+ * process-wide registries. Called on session shutdown (and before reinstalling
1206
+ * on a re-{@link initialize}) so a handler bound to a torn-down session's
1207
+ * context can never fire for another session sharing this process.
1208
+ */
1209
+ disposeFileFallbacks(): void {
1210
+ for (const dispose of this.#fileFallbackDisposers.splice(0)) dispose();
1211
+ }
1212
+
1014
1213
  createCommandContext(): ExtensionCommandContext {
1015
1214
  return {
1016
1215
  ...this.createContext(),
@@ -1043,23 +1242,33 @@ export class ExtensionRunner {
1043
1242
  ext: Extension,
1044
1243
  timeoutMs: number,
1045
1244
  onFailure?: (kind: "timeout" | "error", message: string) => TResult,
1245
+ outerSignal?: AbortSignal,
1046
1246
  ): Promise<TResult | undefined> {
1047
- const signal =
1247
+ // `session_stop` carries its own signal on the event; `tool_call` receives
1248
+ // the outer dispatch signal (loop request or wrapper execute) so an abort
1249
+ // while a handler awaits a human dialog cancels the dialog and settles the
1250
+ // gate without executing the underlying tool. Compose whichever apply.
1251
+ const sessionStopSignal =
1048
1252
  event.type === "session_stop" && "signal" in event && event.signal instanceof AbortSignal
1049
1253
  ? event.signal
1050
1254
  : undefined;
1255
+ const signals = [outerSignal, sessionStopSignal].filter((s): s is AbortSignal => s !== undefined);
1256
+ const signal = signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals);
1051
1257
  if (signal?.aborted) return undefined;
1052
1258
  const registrationScope: ToolRegistrationScope = { pending: new Set(), closed: false };
1053
1259
  let handlerResult: TResult | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED | undefined;
1054
1260
  let handlerFailure: { error: unknown } | undefined;
1055
1261
  try {
1056
1262
  handlerResult = await raceHandlerWithTimeout(
1057
- async handlerSignal => {
1263
+ async (handlerSignal, budget) => {
1058
1264
  registrationScope.signal = handlerSignal;
1059
1265
  let result: TResult | undefined;
1060
1266
  try {
1061
1267
  result = await this.#toolRegistrationScope.run(registrationScope, () =>
1062
- handler(event, createHandlerContext(ctx, handlerSignal)),
1268
+ handler(
1269
+ event,
1270
+ createHandlerContext(ctx, handlerSignal, event.type === "tool_call" ? budget : undefined),
1271
+ ),
1063
1272
  );
1064
1273
  } catch (error) {
1065
1274
  handlerFailure = { error };
@@ -1220,8 +1429,8 @@ export class ExtensionRunner {
1220
1429
  /**
1221
1430
  * Emit a `tool_call` event to every subscribed extension before the tool executes.
1222
1431
  *
1223
- * Each handler is bounded by `extensionHandlerTimeoutMs` (default 30s). This
1224
- * matches the timeout policy already applied to `emitToolResult` and every
1432
+ * Each handler is bounded by `extensionHandlers.toolCallTimeoutMs` (default
1433
+ * 30s). This matches the timeout policy already applied to `emitToolResult` and every
1225
1434
  * other handler routed through `#runHandlerWithTimeout`; without it a single
1226
1435
  * hung extension (unresolved `await`, network call with no timeout) would
1227
1436
  * park `ExtensionToolWrapper.execute` indefinitely and freeze tool
@@ -1232,9 +1441,11 @@ export class ExtensionRunner {
1232
1441
  * pre-execution gate — an unresponsive extension MUST NOT be treated as
1233
1442
  * silent consent to run the tool.
1234
1443
  */
1235
- async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
1444
+ async emitToolCall(event: ToolCallEvent, signal?: AbortSignal): Promise<ToolCallEventResult | undefined> {
1236
1445
  const ctx = this.createContext();
1237
- const timeoutMs = extensionHandlerTimeoutMs;
1446
+ const timeoutMs = normalizeHandlerTimeout(
1447
+ this.settings?.get("extensionHandlers.toolCallTimeoutMs") ?? extensionHandlerTimeoutMs,
1448
+ );
1238
1449
  let result: ToolCallEventResult | undefined;
1239
1450
 
1240
1451
  for (const ext of this.extensions) {
@@ -1255,6 +1466,7 @@ export class ExtensionRunner {
1255
1466
  ? `Extension ${ext.path} timed out after ${timeoutMs}ms`
1256
1467
  : `Extension ${ext.path} failed: ${message}`,
1257
1468
  }),
1469
+ signal,
1258
1470
  );
1259
1471
 
1260
1472
  if (handlerResult) {
@@ -1266,6 +1478,9 @@ export class ExtensionRunner {
1266
1478
  }
1267
1479
  }
1268
1480
 
1481
+ if (signal?.aborted) {
1482
+ return { block: true, reason: `Tool execution was cancelled while an extension handler was pending` };
1483
+ }
1269
1484
  return result;
1270
1485
  }
1271
1486
 
@@ -76,6 +76,7 @@ import type {
76
76
  WriteToolInput,
77
77
  } from "../../tools";
78
78
  import type { ApprovalMode } from "../../tools/approval";
79
+ import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback";
79
80
  import type { EventBus } from "../../utils/event-bus";
80
81
  import type {
81
82
  AgentEndEvent,
@@ -234,6 +235,8 @@ export interface ExtensionCustomOptions {
234
235
  overlayOptions?: OverlayOptions | (() => OverlayOptions);
235
236
  /** Invoked with the overlay handle once the overlay is created (overlay mode only). */
236
237
  onHandle?: (handle: OverlayHandle) => void;
238
+ /** Abort the custom UI and reject its promise. */
239
+ signal?: AbortSignal;
237
240
  }
238
241
 
239
242
  /** Wrap the current autocomplete provider with additional behavior (pi-compatible). */
@@ -1254,6 +1257,63 @@ export interface ExtensionAPI {
1254
1257
  /** Register a tool that the LLM can call. */
1255
1258
  registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
1256
1259
 
1260
+ /**
1261
+ * Register a fallback writer consulted when a native `write`/`edit` byte-write is
1262
+ * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Every other write
1263
+ * error is unaffected. Handlers run in registration order; the first one to
1264
+ * resolve `true` counts as the bytes being durably on disk, and the native tool
1265
+ * continues as if its own write had succeeded — including recording its file
1266
+ * snapshot under the real destination path, so a later hashline `edit` on that
1267
+ * path keeps working. Intended for a host embedding the agent inside a sandbox
1268
+ * that denies direct filesystem writes but exposes a privileged write channel.
1269
+ *
1270
+ * A denial that `Bun.write` masks as `ENOENT` — a write into a directory the host
1271
+ * may not create — also diverts here, with `req.dst`'s parent absent and the
1272
+ * handler responsible for creating it.
1273
+ *
1274
+ * `req.dst` is symlink-RESOLVED: the path the failed write itself acted on, not
1275
+ * the one the tool was given. A link anywhere in a lexical path redirects the
1276
+ * bytes while still passing a prefix allowlist, so treat `req.dst` as
1277
+ * authoritative. A destination that cannot be resolved is never brokered.
1278
+ *
1279
+ * Call this during extension load, like the other `register*` methods: handlers
1280
+ * are installed when the runner initializes, so an extension that has registered
1281
+ * none by then is skipped and a first registration made later never takes effect.
1282
+ *
1283
+ * The underlying registry is process-wide, so a handler may be consulted for a
1284
+ * denied write from any session in the process, not only its own.
1285
+ * `req.sessionId` names the session that issued the write and
1286
+ * `ctx.sessionManager.getSessionId()` names the handler's own; compare them
1287
+ * before prompting, because `ctx.ui` belongs to the latter. See
1288
+ * `docs/extensions.md`.
1289
+ */
1290
+ registerFileWriteFallback(handler: FileWriteFallbackHandler): void;
1291
+
1292
+ /**
1293
+ * Register a fallback deleter consulted when a native `edit`/`apply_patch` unlink is
1294
+ * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Covers `edit`'s `REM`,
1295
+ * the source side of a hashline `MV`, and `apply_patch`'s delete op. Return `true`
1296
+ * once `dst` is gone from disk.
1297
+ *
1298
+ * A handler MUST remove `dst` with a plain unlink and MUST NOT fall back to a
1299
+ * recursive removal. `unlink` on a directory reports `EPERM` on Darwin, so the seam
1300
+ * checks the target before diverting — but when the target's own metadata is behind
1301
+ * the same boundary that denied the unlink, which is the common sandbox case, that
1302
+ * check cannot be resolved and `dst` may be a directory. `req.confirmedFile` says
1303
+ * which situation the handler is in.
1304
+ *
1305
+ * `req.dst` resolves every component ABOVE the last, for the same reason the
1306
+ * write seam resolves all of them; the last is left alone because `unlink`
1307
+ * removes a link rather than its target, so `req.dst` may name a link.
1308
+ *
1309
+ * Separate from {@link registerFileWriteFallback} on purpose. A write handler
1310
+ * brokers `req.content` to `req.dst`, so a delete request reaching it with no
1311
+ * content invites brokering an empty write and truncating the file instead of
1312
+ * removing it. Registering for deletes is therefore an explicit opt-in, and the
1313
+ * same load-time and process-wide notes above apply.
1314
+ */
1315
+ registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void;
1316
+
1257
1317
  // =========================================================================
1258
1318
  // Command, Shortcut, Flag Registration
1259
1319
  // =========================================================================
@@ -1630,6 +1690,8 @@ export interface Extension {
1630
1690
  tools: Map<string, RegisteredTool<any, any>>;
1631
1691
  toolRegistrationListeners?: Set<ToolRegistrationListener>;
1632
1692
  assistantThinkingRenderers: AssistantThinkingRenderer[];
1693
+ fileWriteFallbackHandlers: FileWriteFallbackHandler[];
1694
+ fileDeleteFallbackHandlers: FileDeleteFallbackHandler[];
1633
1695
  messageRenderers: Map<string, MessageRenderer>;
1634
1696
  commands: Map<string, RegisteredCommand>;
1635
1697
  flags: Map<string, ExtensionFlag>;