@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.
- package/CHANGELOG.md +88 -0
- package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
- package/dist/cli.js +3156 -3163
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/stats-cli.d.ts +1 -6
- package/dist/types/commands/stats.d.ts +4 -0
- package/dist/types/config/model-discovery.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/debug/report-bundle.d.ts +7 -2
- package/dist/types/extensibility/extensions/loader.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +24 -6
- package/dist/types/extensibility/extensions/types.d.ts +60 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/launch/presence.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +22 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
- package/dist/types/mcp/config.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +3 -1
- package/dist/types/registry/agent-lifecycle.d.ts +11 -0
- package/dist/types/registry/agent-registry.d.ts +4 -0
- package/dist/types/session/agent-session-types.d.ts +0 -2
- package/dist/types/session/agent-session.d.ts +20 -0
- package/dist/types/session/date-cwd-reminder.d.ts +22 -0
- package/dist/types/session/irc-bridge.d.ts +2 -0
- package/dist/types/session/messages.d.ts +4 -0
- package/dist/types/session/session-loader.d.ts +18 -11
- package/dist/types/session/session-maintenance.d.ts +6 -2
- package/dist/types/session/session-manager.d.ts +19 -3
- package/dist/types/session/session-tools.d.ts +0 -1
- package/dist/types/session/turn-recovery.d.ts +7 -7
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
- package/dist/types/tools/browser/attach.d.ts +16 -0
- package/dist/types/tools/browser/launch.d.ts +2 -2
- package/dist/types/tools/browser.d.ts +1 -1
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/file-write-fallback.d.ts +124 -0
- package/dist/types/tools/hub/jobs.d.ts +6 -0
- package/dist/types/tools/hub/types.d.ts +6 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +23 -0
- package/dist/types/vibe/runtime.d.ts +3 -3
- package/package.json +13 -13
- package/src/auto-thinking/classifier.ts +37 -23
- package/src/cli/models-cli.ts +1 -1
- package/src/cli/stats-cli.ts +6 -72
- package/src/commands/stats.ts +7 -4
- package/src/commit/analysis/conventional.ts +15 -9
- package/src/commit/analysis/summary.ts +15 -9
- package/src/commit/changelog/generate.ts +15 -9
- package/src/commit/map-reduce/map-phase.ts +3 -19
- package/src/commit/map-reduce/reduce-phase.ts +15 -9
- package/src/config/model-discovery.ts +3 -3
- package/src/config/model-registry.ts +29 -16
- package/src/config/settings-schema.ts +13 -0
- package/src/debug/report-bundle.ts +25 -59
- package/src/discovery/claude-plugins.ts +2 -1
- package/src/discovery/claude.ts +4 -2
- package/src/discovery/helpers.ts +5 -0
- package/src/edit/hashline/filesystem.ts +9 -3
- package/src/edit/modes/patch.ts +31 -5
- package/src/edit/renderer.ts +48 -19
- package/src/extensibility/extensions/loader.ts +20 -5
- package/src/extensibility/extensions/runner.ts +239 -24
- package/src/extensibility/extensions/types.ts +62 -0
- package/src/extensibility/extensions/wrapper.ts +26 -11
- package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
- package/src/extensibility/plugins/marketplace/manager.ts +15 -18
- package/src/irc/bus.ts +1 -1
- package/src/launch/broker.ts +8 -1
- package/src/launch/presence.ts +77 -1
- package/src/lsp/client.ts +35 -3
- package/src/lsp/clients/biome-client.ts +47 -93
- package/src/lsp/servers.ts +17 -11
- package/src/lsp/tool.ts +2 -4
- package/src/lsp/workspace-diagnostics.ts +24 -2
- package/src/lsp/writethrough.ts +20 -10
- package/src/mcp/config.ts +50 -6
- package/src/memories/index.ts +29 -25
- package/src/mnemopi/backend.ts +13 -11
- package/src/modes/components/ask-dialog.ts +25 -5
- package/src/modes/components/tool-execution.ts +10 -6
- package/src/modes/components/welcome.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +52 -32
- package/src/modes/controllers/selector-controller.ts +6 -0
- package/src/modes/controllers/tan-command-controller.ts +1 -0
- package/src/modes/interactive-mode.ts +49 -10
- package/src/modes/theme/defaults/birch.json +2 -2
- package/src/prompts/system/checkpoint-active-notice.md +5 -0
- package/src/prompts/system/date-cwd-reminder.md +3 -0
- package/src/prompts/system/project-prompt.md +0 -1
- package/src/prompts/system/rewind-report.md +1 -3
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/rewind.md +1 -13
- package/src/registry/agent-lifecycle.ts +34 -0
- package/src/registry/agent-registry.ts +27 -2
- package/src/registry/persisted-agents.ts +40 -20
- package/src/sdk.ts +32 -2
- package/src/session/agent-session-types.ts +0 -2
- package/src/session/agent-session.ts +178 -46
- package/src/session/date-cwd-reminder.ts +77 -0
- package/src/session/irc-bridge.ts +5 -0
- package/src/session/messages.ts +5 -1
- package/src/session/session-loader.ts +106 -64
- package/src/session/session-maintenance.ts +189 -115
- package/src/session/session-manager.ts +142 -35
- package/src/session/session-persistence.ts +4 -4
- package/src/session/session-storage.ts +18 -5
- package/src/session/session-tools.ts +5 -10
- package/src/session/turn-recovery.ts +102 -20
- package/src/session/unexpected-stop-classifier.ts +33 -21
- package/src/slash-commands/builtin-session.ts +1 -1
- package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
- package/src/system-prompt.ts +0 -5
- package/src/task/executor.ts +4 -15
- package/src/task/persisted-revive.ts +3 -6
- package/src/tools/ask.ts +10 -3
- package/src/tools/browser/attach.ts +80 -25
- package/src/tools/browser/launch.ts +44 -15
- package/src/tools/browser/relay/daemon.ts +3 -7
- package/src/tools/browser/render.ts +1 -1
- package/src/tools/browser/shared-daemon.ts +3 -7
- package/src/tools/browser.ts +5 -5
- package/src/tools/builtin-names.ts +7 -3
- package/src/tools/checkpoint.ts +1 -7
- package/src/tools/file-write-fallback.ts +467 -0
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +20 -3
- package/src/tools/hub/types.ts +6 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/path-utils.ts +79 -0
- package/src/tools/render-utils.ts +53 -21
- package/src/tools/think.ts +15 -3
- package/src/tts/speech-enhancer.ts +19 -14
- package/src/utils/commit-message-generator.ts +14 -12
- package/src/utils/title-generator.ts +23 -19
- package/src/vibe/runtime.ts +32 -17
package/src/edit/renderer.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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.
|
|
294
|
-
* when a handler fails so extension background work
|
|
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
|
-
|
|
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(
|
|
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 `
|
|
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 =
|
|
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>;
|