@oh-my-pi/pi-coding-agent 17.3.5 → 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.
@@ -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";
@@ -375,10 +376,12 @@ export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled:
375
376
  export type ShutdownHandler = () => void;
376
377
 
377
378
  /**
378
- * 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.
379
381
  *
380
- * Returns whether any shutdown handlers were present. Timer cleanup runs even
381
- * 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.
382
385
  */
383
386
  export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise<boolean> {
384
387
  if (!extensionRunner) return false;
@@ -389,6 +392,7 @@ export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner
389
392
  });
390
393
  return true;
391
394
  } finally {
395
+ extensionRunner.disposeFileFallbacks();
392
396
  extensionRunner.clearManagedTimers();
393
397
  }
394
398
  }
@@ -486,6 +490,23 @@ export class ExtensionRunner {
486
490
  #managedTimers = new ManagedTimers((event, error, stack) =>
487
491
  this.emitError({ extensionPath: "<timer>", event, error, stack }),
488
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> = [];
489
510
  /**
490
511
  * Dedup markers for `tool_call` emission, keyed `${toolCallId}:${toolName}`.
491
512
  * The agent loop emits `tool_call` at arg-prep time (before scheduling and
@@ -609,6 +630,18 @@ export class ExtensionRunner {
609
630
  return this.sessionManager.getCwd();
610
631
  }
611
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
+
612
645
  initialize(
613
646
  actions: ExtensionActions,
614
647
  contextActions: ExtensionContextActions,
@@ -666,6 +699,75 @@ export class ExtensionRunner {
666
699
  this.#mode = mode;
667
700
  this.#initialized = true;
668
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
+
669
771
  // Drain events buffered by emitCredentialDisabled() before initialize ran. The
670
772
  // spread adds the `type` discriminator — `event` is the pi-ai shape (no `type`).
671
773
  // Deferred by one microtask so callers that register an onError listener
@@ -1098,6 +1200,16 @@ export class ExtensionRunner {
1098
1200
  this.#managedTimers.clearAll();
1099
1201
  }
1100
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
+
1101
1213
  createCommandContext(): ExtensionCommandContext {
1102
1214
  return {
1103
1215
  ...this.createContext(),
@@ -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,
@@ -1256,6 +1257,63 @@ export interface ExtensionAPI {
1256
1257
  /** Register a tool that the LLM can call. */
1257
1258
  registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
1258
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
+
1259
1317
  // =========================================================================
1260
1318
  // Command, Shortcut, Flag Registration
1261
1319
  // =========================================================================
@@ -1632,6 +1690,8 @@ export interface Extension {
1632
1690
  tools: Map<string, RegisteredTool<any, any>>;
1633
1691
  toolRegistrationListeners?: Set<ToolRegistrationListener>;
1634
1692
  assistantThinkingRenderers: AssistantThinkingRenderer[];
1693
+ fileWriteFallbackHandlers: FileWriteFallbackHandler[];
1694
+ fileDeleteFallbackHandlers: FileDeleteFallbackHandler[];
1635
1695
  messageRenderers: Map<string, MessageRenderer>;
1636
1696
  commands: Map<string, RegisteredCommand>;
1637
1697
  flags: Map<string, ExtensionFlag>;
@@ -14,6 +14,7 @@ import type { Settings } from "../../config/settings";
14
14
  import type { Theme } from "../../modes/theme/theme";
15
15
  import { type ApprovalMode, formatApprovalPrompt, resolveApproval, truncateForPrompt } from "../../tools/approval";
16
16
  import { defaultLoadModeForToolName } from "../../tools/essential-tools";
17
+ import { withFileMutationSession } from "../../tools/file-write-fallback";
17
18
  import { normalizeToolEventInput, resolveToolEventInput } from "../tool-event-input";
18
19
  import { applyToolProxy } from "../tool-proxy";
19
20
  import type { ExtensionRunner } from "./runner";
@@ -349,7 +350,15 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
349
350
  let executionError: Error | undefined;
350
351
 
351
352
  try {
352
- result = await this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context);
353
+ // A denied file write or delete inside this tool can be brokered to an
354
+ // extension handler, and that registry is PROCESS-WIDE — so the session is
355
+ // named here, the one place where every tool's execution and the runner
356
+ // that owns the handlers are both in scope (`sdk.ts` wraps the whole tool
357
+ // registry with this class whenever a runner exists). Inert with no
358
+ // fallback registered: no scope is entered.
359
+ result = await withFileMutationSession(this.runner.sessionId, () =>
360
+ this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context),
361
+ );
353
362
  } catch (err) {
354
363
  executionError = err instanceof Error ? err : new Error(String(err));
355
364
  result = {
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { isEnoent, logger, once, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import type { BunFile } from "bun";
4
+ import { isPermissionDeniedError, writeFileWithFallback } from "../tools/file-write-fallback";
4
5
  import { FileChangeType, notifyWorkspaceWatchedFiles } from "./client";
5
6
  import { getServersForFile } from "./config";
6
7
  import {
@@ -66,11 +67,7 @@ export async function writethroughNoop(
66
67
  _batch?: LspWritethroughBatchRequest,
67
68
  _getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
68
69
  ): Promise<FileDiagnosticsResult | undefined> {
69
- if (file) {
70
- await file.write(content);
71
- } else {
72
- await Bun.write(dst, content);
73
- }
70
+ await writeFileWithFallback(dst, content, file);
74
71
  return undefined;
75
72
  }
76
73
 
@@ -78,6 +75,12 @@ interface PendingWritethrough {
78
75
  dst: string;
79
76
  file?: BunFile;
80
77
  changeType: FileChangeType;
78
+ /**
79
+ * The bytes this entry committed. The flush prefers a fresh read of `dst` so
80
+ * post-processing sees whatever else in the batch touched the file, and falls
81
+ * back to these when that read is denied.
82
+ */
83
+ content: string;
81
84
  }
82
85
 
83
86
  interface RunLspWritethroughOptions {
@@ -288,7 +291,7 @@ async function runLspWritethrough(
288
291
  const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false;
289
292
 
290
293
  let finalContent = content;
291
- const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
294
+ const writeContent = async (value: string) => writeFileWithFallback(dst, value, file);
292
295
  const getWritePromise = once(() =>
293
296
  contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent),
294
297
  );
@@ -458,9 +461,16 @@ async function flushWritethroughBatch(
458
461
  try {
459
462
  content = await fs.promises.readFile(entry.dst, "utf8");
460
463
  } catch (error) {
461
- if (!isEnoent(error)) throw error;
462
- bundle?.finalize(undefined);
463
- continue;
464
+ if (isEnoent(error)) {
465
+ bundle?.finalize(undefined);
466
+ continue;
467
+ }
468
+ // A brokered write lands bytes this process may not be able to read
469
+ // back: a sandbox that denies the write commonly denies the read too.
470
+ // Failing here would fail a flush whose every write succeeded, so the
471
+ // content this entry committed stands in for the unreadable file.
472
+ if (!isPermissionDeniedError(error)) throw error;
473
+ content = entry.content;
464
474
  }
465
475
  const deferredInner =
466
476
  bundle &&
@@ -552,7 +562,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
552
562
  }
553
563
 
554
564
  const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
555
- state.entries.set(dst, { dst, file, changeType });
565
+ state.entries.set(dst, { dst, file, changeType, content });
556
566
  if (!batch.flush) return undefined;
557
567
 
558
568
  writethroughBatches.delete(batch.id);
@@ -336,7 +336,7 @@ export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
336
336
  {
337
337
  name: "stats",
338
338
  description: "Launch the local stats dashboard",
339
- inlineHint: "[--port <port>]",
339
+ inlineHint: "[--port <port>] [--host <host>]",
340
340
  allowArgs: true,
341
341
  handle: async (command, runtime) => {
342
342
  const parsed = parseStatsDashboardArgs(command.args);
@@ -11,6 +11,7 @@ interface StatsDashboardServer {
11
11
 
12
12
  export interface StatsDashboardArgs {
13
13
  port: number;
14
+ host: string;
14
15
  }
15
16
 
16
17
  export interface StatsDashboardLaunchResult {
@@ -20,7 +21,7 @@ export interface StatsDashboardLaunchResult {
20
21
 
21
22
  let activeStatsServer: StatsDashboardServer | undefined;
22
23
 
23
- const STATS_DASHBOARD_USAGE = "Usage: /stats [--port <port>]";
24
+ const STATS_DASHBOARD_USAGE = "Usage: /stats [--port <port>] [--host <host>]";
24
25
 
25
26
  function parsePort(value: string | undefined): number | string {
26
27
  if (!value) return `Missing port. ${STATS_DASHBOARD_USAGE}`;
@@ -33,6 +34,7 @@ function parsePort(value: string | undefined): number | string {
33
34
  export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { error: string } {
34
35
  const tokens = args.split(/\s+/).filter(Boolean);
35
36
  let port = DEFAULT_STATS_DASHBOARD_PORT;
37
+ let host = "127.0.0.1";
36
38
 
37
39
  for (let i = 0; i < tokens.length; i++) {
38
40
  const token = tokens[i];
@@ -48,28 +50,40 @@ export function parseStatsDashboardArgs(args: string): StatsDashboardArgs | { er
48
50
  port = parsed;
49
51
  continue;
50
52
  }
53
+ if (token === "--host") {
54
+ const value = tokens[++i];
55
+ if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` };
56
+ host = value;
57
+ continue;
58
+ }
59
+ if (token.startsWith("--host=")) {
60
+ const value = token.slice("--host=".length);
61
+ if (!value) return { error: `Missing host. ${STATS_DASHBOARD_USAGE}` };
62
+ host = value;
63
+ continue;
64
+ }
51
65
  return { error: `Unknown option: ${token}. ${STATS_DASHBOARD_USAGE}` };
52
66
  }
53
67
 
54
- return { port };
68
+ return { port, host };
55
69
  }
56
70
 
57
71
  export async function launchStatsDashboard(args: StatsDashboardArgs): Promise<StatsDashboardLaunchResult> {
58
72
  const { processed, files } = await stats.syncAllSessions();
59
73
  const total = await stats.getTotalMessageCount();
60
- let requestedPortIgnored = false;
74
+ let requestedAddressIgnored = false;
61
75
 
62
76
  if (!activeStatsServer) {
63
- activeStatsServer = await stats.startServer(args.port);
64
- } else if (args.port !== activeStatsServer.port) {
65
- requestedPortIgnored = true;
77
+ activeStatsServer = await stats.startServer(args.port, args.host);
78
+ } else if (args.port !== activeStatsServer.port || args.host !== activeStatsServer.hostname) {
79
+ requestedAddressIgnored = true;
66
80
  }
67
81
 
68
- const url = `http://${activeStatsServer.hostname}:${activeStatsServer.port}`;
82
+ const url = stats.formatStatsDashboardUrl(activeStatsServer.hostname, activeStatsServer.port);
69
83
  openUtils.openPath(url);
70
84
 
71
- const serverLine = requestedPortIgnored
72
- ? `Dashboard already running at: ${url} (requested port ${args.port} ignored)`
85
+ const serverLine = requestedAddressIgnored
86
+ ? `Dashboard already running at: ${url} (requested ${args.host}:${args.port} ignored)`
73
87
  : `Dashboard available at: ${url}`;
74
88
 
75
89
  return {