@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.8

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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/stats-cli.d.ts +1 -6
  9. package/dist/types/cli/update-cli.d.ts +8 -0
  10. package/dist/types/cli-commands.d.ts +10 -2
  11. package/dist/types/commands/stats.d.ts +4 -0
  12. package/dist/types/config/settings-schema.d.ts +28 -0
  13. package/dist/types/config/settings.d.ts +9 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +23 -4
  15. package/dist/types/extensibility/extensions/types.d.ts +58 -0
  16. package/dist/types/launch/presence.d.ts +4 -1
  17. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  18. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  19. package/dist/types/mnemopi/backend.d.ts +12 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  21. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  22. package/dist/types/modes/interactive-mode.d.ts +25 -3
  23. package/dist/types/modes/types.d.ts +10 -0
  24. package/dist/types/session/agent-session.d.ts +3 -0
  25. package/dist/types/session/prewalk.d.ts +4 -0
  26. package/dist/types/session/session-entries.d.ts +0 -1
  27. package/dist/types/session/session-manager.d.ts +12 -0
  28. package/dist/types/session/session-stats.d.ts +13 -1
  29. package/dist/types/session/skill-title-input.d.ts +13 -0
  30. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  31. package/dist/types/subprocess/worker-client.d.ts +7 -4
  32. package/dist/types/task/label.d.ts +2 -0
  33. package/dist/types/task/render.d.ts +2 -0
  34. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  35. package/dist/types/tiny/title-client.d.ts +6 -4
  36. package/dist/types/tiny/title-protocol.d.ts +1 -0
  37. package/dist/types/tiny/worker.d.ts +27 -0
  38. package/dist/types/tools/bash.d.ts +1 -1
  39. package/dist/types/tools/file-write-fallback.d.ts +124 -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/tools/read-format.d.ts +6 -0
  43. package/dist/types/tools/read-summary.d.ts +7 -1
  44. package/dist/types/utils/block-context.d.ts +14 -0
  45. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  46. package/dist/types/utils/git.d.ts +25 -1
  47. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  48. package/package.json +13 -13
  49. package/src/advisor/advise-tool.ts +5 -3
  50. package/src/cli/auth-broker-cli.ts +36 -1
  51. package/src/cli/profile-bootstrap.ts +2 -6
  52. package/src/cli/stats-cli.ts +6 -72
  53. package/src/cli/update-cli.ts +63 -11
  54. package/src/cli-commands.ts +61 -7
  55. package/src/commands/completions.ts +2 -1
  56. package/src/commands/stats.ts +7 -4
  57. package/src/commit/agentic/index.ts +15 -2
  58. package/src/commit/git/diff.ts +6 -2
  59. package/src/config/model-resolver.ts +52 -6
  60. package/src/config/models-config.ts +2 -2
  61. package/src/config/settings-schema.ts +33 -0
  62. package/src/config/settings.ts +159 -30
  63. package/src/discovery/helpers.ts +45 -2
  64. package/src/discovery/omp-plugins.ts +2 -1
  65. package/src/discovery/opencode.ts +56 -3
  66. package/src/edit/hashline/filesystem.ts +9 -3
  67. package/src/edit/modes/patch.ts +31 -5
  68. package/src/eval/js/process-entry.ts +4 -4
  69. package/src/export/html/tool-views.generated.js +19 -19
  70. package/src/extensibility/extensions/loader.ts +11 -0
  71. package/src/extensibility/extensions/runner.ts +118 -5
  72. package/src/extensibility/extensions/types.ts +60 -0
  73. package/src/extensibility/extensions/wrapper.ts +10 -1
  74. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  75. package/src/launch/client.ts +9 -4
  76. package/src/launch/presence.ts +19 -4
  77. package/src/lsp/defaults.json +1 -1
  78. package/src/lsp/writethrough.ts +20 -10
  79. package/src/mcp/manager.ts +41 -20
  80. package/src/mcp/oauth-credentials.ts +38 -0
  81. package/src/mcp/oauth-flow.ts +21 -0
  82. package/src/mcp/tool-bridge.ts +32 -16
  83. package/src/mnemopi/backend.ts +35 -3
  84. package/src/modes/components/model-hub.ts +37 -4
  85. package/src/modes/components/settings-selector.ts +17 -11
  86. package/src/modes/components/tool-execution.ts +97 -29
  87. package/src/modes/components/tree-selector.ts +7 -2
  88. package/src/modes/controllers/event-controller.ts +12 -2
  89. package/src/modes/controllers/input-controller.ts +64 -27
  90. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  91. package/src/modes/interactive-mode.ts +79 -11
  92. package/src/modes/types.ts +11 -0
  93. package/src/prompts/system/memory-extraction-system.md +5 -22
  94. package/src/prompts/system/system-prompt.md +1 -1
  95. package/src/session/agent-session.ts +52 -6
  96. package/src/session/messages.ts +6 -0
  97. package/src/session/prewalk.ts +25 -7
  98. package/src/session/session-entries.ts +0 -1
  99. package/src/session/session-maintenance.ts +10 -1
  100. package/src/session/session-manager.ts +15 -0
  101. package/src/session/session-stats.ts +24 -3
  102. package/src/session/settings-stream-fn.ts +7 -0
  103. package/src/session/skill-title-input.ts +32 -0
  104. package/src/session/turn-recovery.ts +23 -18
  105. package/src/slash-commands/builtin-session.ts +1 -1
  106. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  107. package/src/subprocess/worker-client.ts +8 -5
  108. package/src/task/executor.ts +11 -0
  109. package/src/task/index.ts +2 -0
  110. package/src/task/label.ts +14 -1
  111. package/src/task/persisted-revive.ts +13 -0
  112. package/src/task/render.ts +1 -1
  113. package/src/task/structured-subagent.ts +5 -2
  114. package/src/tiny/completion-prompt.ts +16 -0
  115. package/src/tiny/title-client.ts +15 -6
  116. package/src/tiny/title-protocol.ts +8 -1
  117. package/src/tiny/worker.ts +21 -19
  118. package/src/tools/bash.ts +7 -1
  119. package/src/tools/file-write-fallback.ts +467 -0
  120. package/src/tools/index.ts +1 -0
  121. package/src/tools/path-utils.ts +79 -0
  122. package/src/tools/read-format.ts +16 -2
  123. package/src/tools/read-summary.ts +9 -4
  124. package/src/tools/read.ts +306 -72
  125. package/src/utils/block-context.ts +15 -1
  126. package/src/utils/fetch-timeout.ts +33 -0
  127. package/src/utils/git.ts +54 -11
  128. package/src/web/search/providers/browser-page.ts +21 -3
  129. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -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(),
@@ -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(),
@@ -1553,8 +1665,9 @@ export class ExtensionRunner {
1553
1665
  return currentPayload;
1554
1666
  }
1555
1667
 
1556
- async emitAfterProviderResponse(response: ProviderResponseMetadata, _model?: Model): Promise<void> {
1557
- const ctx = this.createContext();
1668
+ /** Runs response hooks with the model that produced that provider response. */
1669
+ async emitAfterProviderResponse(response: ProviderResponseMetadata, model?: Model): Promise<void> {
1670
+ const ctx = this.createContext(model);
1558
1671
 
1559
1672
  for (const ext of this.extensions) {
1560
1673
  const handlers = ext.handlers.get("after_provider_response");
@@ -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 = {
@@ -1351,6 +1351,10 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st
1351
1351
  if (await pathExists(path.join(candidate, "package.json"))) {
1352
1352
  return candidate;
1353
1353
  }
1354
+ const workspaceMember = await findWorkspaceMemberPackageRoot(dir, packageName);
1355
+ if (workspaceMember) {
1356
+ return workspaceMember;
1357
+ }
1354
1358
  const parent = path.dirname(dir);
1355
1359
  if (parent === dir) {
1356
1360
  return null;
@@ -1359,6 +1363,49 @@ async function findNodePackageRootUncached(packageName: string, importerPath: st
1359
1363
  }
1360
1364
  }
1361
1365
 
1366
+ /**
1367
+ * Resolve `packageName` as a workspace member when `dir` is a workspace root.
1368
+ *
1369
+ * An installed git dependency of a monorepo plugin contains the full
1370
+ * workspace tree but no node_modules links: `bun install` materializes a git
1371
+ * dependency's regular npm dependencies into the host tree and skips its
1372
+ * `workspace:*` / `file:` edges. Bare imports between workspace siblings
1373
+ * therefore never resolve through the node_modules walk above. When a
1374
+ * directory on that walk declares `workspaces` (array form or the yarn-style
1375
+ * `{ packages: [...] }` object), scan the member manifests for the requested
1376
+ * package name. node_modules candidates at the same level win, so an
1377
+ * explicitly installed copy still shadows the workspace member.
1378
+ */
1379
+ async function findWorkspaceMemberPackageRoot(dir: string, packageName: string): Promise<string | null> {
1380
+ if (!(await pathExists(path.join(dir, "package.json")))) {
1381
+ return null;
1382
+ }
1383
+ const manifest = await readPackageManifest(dir);
1384
+ const rawWorkspaces = manifest?.workspaces;
1385
+ const patterns = Array.isArray(rawWorkspaces)
1386
+ ? rawWorkspaces
1387
+ : isRecord(rawWorkspaces) && Array.isArray(rawWorkspaces.packages)
1388
+ ? rawWorkspaces.packages
1389
+ : null;
1390
+ if (!patterns) {
1391
+ return null;
1392
+ }
1393
+ for (const pattern of patterns) {
1394
+ if (typeof pattern !== "string" || pattern.startsWith("!")) {
1395
+ continue;
1396
+ }
1397
+ const glob = new Bun.Glob(path.join(pattern, "package.json"));
1398
+ for await (const match of glob.scan({ cwd: dir, onlyFiles: true })) {
1399
+ const memberRoot = path.dirname(path.join(dir, match));
1400
+ const memberManifest = await readPackageManifest(memberRoot);
1401
+ if (memberManifest?.name === packageName) {
1402
+ return memberRoot;
1403
+ }
1404
+ }
1405
+ }
1406
+ return null;
1407
+ }
1408
+
1362
1409
  async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
1363
1410
  const cached = packageManifestCache.get(packageRoot);
1364
1411
  if (cached) return cached;
@@ -516,8 +516,14 @@ export async function closeDaemonClients(): Promise<void> {
516
516
 
517
517
  /** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */
518
518
  export async function smokeTestDaemonBroker(): Promise<void> {
519
- const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-"));
520
- const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-"));
519
+ // Keep the broker's runtime dir under a private parent this process owns, so
520
+ // the broker's dead-scope sweep (pruneDeadDaemonRuntimeDirs, fired on startup)
521
+ // can only ever reclaim siblings inside it — never unrelated neighbours in
522
+ // os.tmpdir() such as tmux/ssh sockets or build trees (issue #8721).
523
+ const smokeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-"));
524
+ const projectDir = path.join(smokeRoot, "project");
525
+ const runtimeDir = path.join(smokeRoot, "run");
526
+ await fs.mkdir(projectDir, { recursive: true });
521
527
  const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
522
528
  try {
523
529
  const ping = await client.request({ op: "ping" });
@@ -525,7 +531,6 @@ export async function smokeTestDaemonBroker(): Promise<void> {
525
531
  await client.request({ op: "shutdown" });
526
532
  } finally {
527
533
  client.close();
528
- await fs.rm(projectDir, { recursive: true, force: true });
529
- await fs.rm(runtimeDir, { recursive: true, force: true });
534
+ await fs.rm(smokeRoot, { recursive: true, force: true });
530
535
  }
531
536
  }
@@ -6,7 +6,19 @@ import { daemonRuntimeDir } from "./paths";
6
6
 
7
7
  const CLIENTS_DIR = "clients";
8
8
  const BROKER_PID_FILE = "broker.pid";
9
- const GLOBAL_DAEMON_DIR = "global";
9
+ /**
10
+ * Basename of the container holding per-project daemon scopes
11
+ * (`<state>/run/daemons`). {@link pruneDeadDaemonRuntimeDirs} refuses to sweep
12
+ * any other root so a runtime dir passed from outside the state tree cannot
13
+ * turn the reclaim into an rm -rf of unrelated neighbours (issue #8721).
14
+ */
15
+ const DAEMONS_DIR = "daemons";
16
+ /**
17
+ * Name shape of a project daemon scope: the 16-hex wyhash of the project dir
18
+ * produced by `getDaemonRuntimeDir`. Only entries matching this are pruned,
19
+ * which excludes the machine-global `global` container and any foreign dir.
20
+ */
21
+ const DAEMON_SCOPE_KEY = /^[0-9a-f]{16}$/;
10
22
  /**
11
23
  * Grace before a dead daemon runtime dir becomes prune-eligible. Guards against
12
24
  * deleting a scope whose owning omp process is mid-startup (token written, broker
@@ -118,11 +130,14 @@ async function hasLiveDaemonBroker(runtimeDir: string): Promise<boolean> {
118
130
  * Best-effort and non-throwing: a scope is deleted only when its `broker.pid`
119
131
  * is absent/dead, no live client presence remains, and it has been untouched
120
132
  * for {@link DAEMON_RUNTIME_STALE_GRACE_MS}. The caller's own `currentRuntimeDir`
121
- * and the machine-global daemon container are always skipped.
133
+ * is always skipped, and the sweep runs only inside the {@link DAEMONS_DIR}
134
+ * container over entries named like a {@link DAEMON_SCOPE_KEY} — so a runtime
135
+ * dir relocated elsewhere (e.g. the smoke test under `os.tmpdir()`) never
136
+ * reclaims unrelated neighbours (issue #8721).
122
137
  */
123
138
  export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Promise<void> {
124
139
  const root = path.dirname(currentRuntimeDir);
125
- if (path.basename(root) === GLOBAL_DAEMON_DIR) return;
140
+ if (path.basename(root) !== DAEMONS_DIR) return;
126
141
  const current = path.resolve(currentRuntimeDir);
127
142
  let entries: Dirent[];
128
143
  try {
@@ -138,7 +153,7 @@ export async function pruneDeadDaemonRuntimeDirs(currentRuntimeDir: string): Pro
138
153
  }
139
154
  const now = Date.now();
140
155
  for (const entry of entries) {
141
- if (!entry.isDirectory() || entry.name === GLOBAL_DAEMON_DIR) continue;
156
+ if (!entry.isDirectory() || !DAEMON_SCOPE_KEY.test(entry.name)) continue;
142
157
  const dir = path.join(root, entry.name);
143
158
  if (path.resolve(dir) === current) continue;
144
159
  try {
@@ -69,7 +69,7 @@
69
69
  "biome": {
70
70
  "command": "biome",
71
71
  "args": ["lsp-proxy"],
72
- "fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc"],
72
+ "fileTypes": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".css"],
73
73
  "rootMarkers": ["biome.json", "biome.jsonc"],
74
74
  "isLinter": true
75
75
  },
@@ -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);
@@ -7,6 +7,7 @@
7
7
  import * as path from "node:path";
8
8
  import * as url from "node:url";
9
9
  import { isDefinitiveOAuthFailure, type TSchema } from "@oh-my-pi/pi-ai";
10
+ import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
10
11
  import { logger } from "@oh-my-pi/pi-utils";
11
12
  import type { SourceMeta } from "../capability/types";
12
13
  import { resolveConfigValue } from "../config/resolve-config-value";
@@ -30,9 +31,10 @@ import { type LoadMCPConfigsResult, loadAllMCPConfigs, validateServerConfig } fr
30
31
  import {
31
32
  lookupMcpOAuthCredential,
32
33
  type MCPOAuthCredentialLookup,
34
+ refreshManagedMcpOAuthCredential,
33
35
  selectMcpOAuthRefreshMaterial,
34
36
  } from "./oauth-credentials";
35
- import { type MCPStoredOAuthCredential, refreshMCPOAuthToken } from "./oauth-flow";
37
+ import type { MCPStoredOAuthCredential } from "./oauth-flow";
36
38
  import type { McpConnectionStatusEvent } from "./startup-events";
37
39
  import type { MCPToolDetails } from "./tool-bridge";
38
40
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
@@ -1404,6 +1406,36 @@ export class MCPManager {
1404
1406
  };
1405
1407
  }
1406
1408
 
1409
+ /**
1410
+ * Refresh a broker-redacted MCP OAuth credential through the auth-broker.
1411
+ *
1412
+ * When running in broker mode the client only ever holds the redacted
1413
+ * refresh sentinel; the real refresh token lives on the broker. Delegating
1414
+ * to {@link AuthStorage.forceRefreshCredentialById} makes the broker run the
1415
+ * `refresh_token` grant and return a fresh access token, which the client
1416
+ * uses while keeping {@link REMOTE_REFRESH_SENTINEL} in the refresh slot.
1417
+ */
1418
+ async #refreshBrokeredMcpCredential(credentialId: string, signal?: AbortSignal): Promise<OAuthCredentials> {
1419
+ const storage = this.#authStorage;
1420
+ if (!storage) throw new Error("MCP OAuth broker refresh requires an auth storage");
1421
+ const row = storage.listStoredCredentials(credentialId).find(entry => entry.credential.type === "oauth");
1422
+ if (!row) throw new Error(`No broker credential row for ${credentialId}`);
1423
+ const entry = await storage.forceRefreshCredentialById(row.id, signal);
1424
+ if (entry.credential.type !== "oauth") {
1425
+ throw new Error(`Broker returned non-OAuth credential for ${credentialId}`);
1426
+ }
1427
+ const refreshed = entry.credential;
1428
+ return {
1429
+ access: refreshed.access,
1430
+ refresh: REMOTE_REFRESH_SENTINEL,
1431
+ expires: refreshed.expires,
1432
+ accountId: refreshed.accountId,
1433
+ email: refreshed.email,
1434
+ projectId: refreshed.projectId,
1435
+ enterpriseUrl: refreshed.enterpriseUrl,
1436
+ };
1437
+ }
1438
+
1407
1439
  /**
1408
1440
  * Resolve OAuth credentials and shell commands in config.
1409
1441
  * `oauth: false` skips credential injection (reauth's unauthenticated probe);
@@ -1435,24 +1467,15 @@ export class MCPManager {
1435
1467
  return Boolean(current.refresh && material?.tokenUrl);
1436
1468
  },
1437
1469
  refresh: (current, signal) => {
1470
+ // Broker-backed credentials redact the refresh token
1471
+ // (REMOTE_REFRESH_SENTINEL); the broker holds the real one, so
1472
+ // route the refresh through it instead of failing locally.
1438
1473
  if (current.refresh === REMOTE_REFRESH_SENTINEL) {
1439
- throw new Error("MCP OAuth refresh token is broker-redacted; local refresh is unavailable");
1474
+ return this.#refreshBrokeredMcpCredential(credentialId, signal);
1440
1475
  }
1441
- const material = selectMcpOAuthRefreshMaterial(current, auth);
1442
- const tokenUrl = material?.tokenUrl;
1443
- if (!current.refresh || !tokenUrl) {
1444
- throw new Error("MCP OAuth credential is missing refresh material");
1445
- }
1446
- const clientId = material?.clientId;
1447
- const clientSecret = material?.clientSecret;
1448
- const authorizationUrl =
1449
- material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
1450
- const resourceIsFallback =
1451
- !material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
1452
- const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
1453
- return refreshMCPOAuthToken(tokenUrl, current.refresh, clientId, clientSecret, resource, {
1454
- authorizationUrl,
1455
- stripSameOriginResource: resourceIsFallback,
1476
+ return refreshManagedMcpOAuthCredential(current, {
1477
+ serverUrl: config.type === "http" || config.type === "sse" ? config.url : undefined,
1478
+ auth,
1456
1479
  signal,
1457
1480
  });
1458
1481
  },
@@ -1480,10 +1503,8 @@ export class MCPManager {
1480
1503
  isDefinitiveOAuthFailure(error instanceof Error ? error.message : String(error)),
1481
1504
  disabledCause: error =>
1482
1505
  `oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`,
1483
- keepCredentialOnRefreshFailure: error =>
1484
- !(error instanceof Error && error.message.includes("broker-redacted")),
1506
+ keepCredentialOnRefreshFailure: true,
1485
1507
  onRefreshFailure: refreshError => {
1486
- if (refreshError instanceof Error && refreshError.message.includes("broker-redacted")) return;
1487
1508
  logger.warn("MCP OAuth refresh failed, using existing token", {
1488
1509
  credentialId,
1489
1510
  error: refreshError,