@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.0

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 (285) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4659 -4261
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +316 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  25. package/dist/types/live/controller.d.ts +40 -0
  26. package/dist/types/live/protocol.d.ts +85 -0
  27. package/dist/types/live/protocol.test.d.ts +1 -0
  28. package/dist/types/live/transport.d.ts +48 -0
  29. package/dist/types/live/visualizer.d.ts +33 -0
  30. package/dist/types/lsp/client.d.ts +16 -1
  31. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  32. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  33. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  34. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  35. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  36. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  37. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  38. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  39. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  41. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  43. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  44. package/dist/types/modes/interactive-mode.d.ts +5 -0
  45. package/dist/types/modes/theme/theme.d.ts +1 -1
  46. package/dist/types/modes/types.d.ts +3 -1
  47. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  48. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  49. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  50. package/dist/types/registry/agent-registry.d.ts +11 -4
  51. package/dist/types/sdk.d.ts +15 -1
  52. package/dist/types/secrets/index.d.ts +15 -2
  53. package/dist/types/secrets/obfuscator.d.ts +113 -15
  54. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  55. package/dist/types/session/agent-session-events.d.ts +84 -0
  56. package/dist/types/session/agent-session-types.d.ts +314 -0
  57. package/dist/types/session/agent-session.d.ts +118 -755
  58. package/dist/types/session/async-job-delivery.d.ts +28 -0
  59. package/dist/types/session/bash-runner.d.ts +75 -0
  60. package/dist/types/session/blob-store.d.ts +11 -1
  61. package/dist/types/session/blob-store.test.d.ts +1 -0
  62. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  63. package/dist/types/session/eval-runner.d.ts +49 -0
  64. package/dist/types/session/irc-bridge.d.ts +46 -0
  65. package/dist/types/session/messages.d.ts +22 -0
  66. package/dist/types/session/model-controls.d.ts +180 -0
  67. package/dist/types/session/prewalk.d.ts +46 -0
  68. package/dist/types/session/queued-messages.d.ts +22 -0
  69. package/dist/types/session/redis-session-storage.d.ts +1 -0
  70. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  71. package/dist/types/session/role-models.d.ts +15 -0
  72. package/dist/types/session/session-advisors.d.ts +228 -0
  73. package/dist/types/session/session-entries.d.ts +8 -0
  74. package/dist/types/session/session-handoff.d.ts +70 -0
  75. package/dist/types/session/session-history-format.d.ts +6 -1
  76. package/dist/types/session/session-maintenance.d.ts +246 -0
  77. package/dist/types/session/session-manager.d.ts +41 -0
  78. package/dist/types/session/session-memory.d.ts +56 -0
  79. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  80. package/dist/types/session/session-stats.d.ts +48 -0
  81. package/dist/types/session/session-tools.d.ts +143 -0
  82. package/dist/types/session/session-workspace.d.ts +32 -0
  83. package/dist/types/session/stream-guards.d.ts +44 -0
  84. package/dist/types/session/todo-tracker.d.ts +56 -0
  85. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  86. package/dist/types/session/turn-recovery.d.ts +225 -0
  87. package/dist/types/system-prompt.d.ts +2 -0
  88. package/dist/types/task/executor.d.ts +4 -1
  89. package/dist/types/task/output-manager.d.ts +3 -1
  90. package/dist/types/tools/approval.d.ts +1 -0
  91. package/dist/types/tools/browser/launch.d.ts +5 -0
  92. package/dist/types/tools/image-gen.d.ts +4 -4
  93. package/dist/types/tools/image-providers.d.ts +38 -0
  94. package/dist/types/tools/index.d.ts +5 -0
  95. package/dist/types/tools/path-utils.d.ts +8 -0
  96. package/dist/types/tools/todo.d.ts +10 -4
  97. package/dist/types/tools/xdev.d.ts +5 -1
  98. package/dist/types/tts/streaming-player.d.ts +14 -0
  99. package/dist/types/tts/vocalizer.d.ts +5 -0
  100. package/dist/types/utils/jj.d.ts +29 -0
  101. package/dist/types/utils/lang-from-path.d.ts +1 -0
  102. package/dist/types/utils/title-generator.d.ts +30 -0
  103. package/dist/types/vibe/runtime.d.ts +36 -8
  104. package/dist/types/web/search/index.d.ts +1 -1
  105. package/dist/types/web/search/provider.d.ts +15 -5
  106. package/dist/types/web/search/types.d.ts +94 -0
  107. package/package.json +12 -12
  108. package/scripts/generate-share-viewer.ts +4 -6
  109. package/src/advisor/__tests__/advisor.test.ts +643 -0
  110. package/src/advisor/runtime.ts +144 -43
  111. package/src/async/job-manager.ts +90 -2
  112. package/src/cli/args.ts +2 -0
  113. package/src/cli/auth-gateway-cli.ts +18 -3
  114. package/src/cli/bench-cli.ts +395 -29
  115. package/src/cli/flag-tables.ts +3 -0
  116. package/src/cli/gc-cli.ts +2 -2
  117. package/src/cli/usage-cli.ts +10 -1
  118. package/src/collab/host.ts +3 -2
  119. package/src/commands/bench.ts +18 -4
  120. package/src/commands/launch.ts +4 -0
  121. package/src/config/model-registry.ts +10 -2
  122. package/src/config/models-config-schema.ts +15 -4
  123. package/src/config/provider-globals.ts +9 -9
  124. package/src/config/service-tier.ts +26 -1
  125. package/src/config/settings-schema.ts +209 -51
  126. package/src/config/settings.ts +81 -2
  127. package/src/dap/client.ts +37 -4
  128. package/src/edit/index.ts +49 -12
  129. package/src/eval/py/executor.ts +103 -33
  130. package/src/exec/bash-executor.ts +89 -6
  131. package/src/exec/direnv.ts +145 -0
  132. package/src/export/html/args.ts +20 -0
  133. package/src/export/html/index.ts +45 -47
  134. package/src/export/html/template.css +19 -1
  135. package/src/export/html/template.html +6 -0
  136. package/src/export/html/template.js +21 -0
  137. package/src/export/html/tool-views.generated.js +31 -31
  138. package/src/export/html/web-palette.ts +116 -132
  139. package/src/export/share.ts +249 -49
  140. package/src/extensibility/extensions/loader.ts +29 -1
  141. package/src/extensibility/extensions/runner.ts +6 -0
  142. package/src/extensibility/extensions/types.ts +33 -2
  143. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  144. package/src/hindsight/state.ts +64 -6
  145. package/src/internal-urls/local-protocol.ts +2 -1
  146. package/src/internal-urls/memory-protocol.ts +2 -2
  147. package/src/internal-urls/skill-protocol.ts +2 -2
  148. package/src/internal-urls/ssh-protocol.ts +2 -1
  149. package/src/internal-urls/vault-protocol.ts +2 -1
  150. package/src/live/audio-worklet.txt +59 -0
  151. package/src/live/browser-runtime.txt +221 -0
  152. package/src/live/controller.ts +464 -0
  153. package/src/live/prompts/agent-final-message.md +3 -0
  154. package/src/live/prompts/live-instructions.md +23 -0
  155. package/src/live/protocol.test.ts +140 -0
  156. package/src/live/protocol.ts +233 -0
  157. package/src/live/transport.ts +490 -0
  158. package/src/live/visualizer.ts +234 -0
  159. package/src/lsp/client.ts +29 -9
  160. package/src/lsp/index.ts +19 -4
  161. package/src/main.ts +8 -1
  162. package/src/mcp/config-writer.test.ts +43 -0
  163. package/src/mcp/config-writer.ts +109 -82
  164. package/src/mcp/oauth-discovery.ts +10 -2
  165. package/src/mcp/smithery-auth.test.ts +29 -0
  166. package/src/mcp/smithery-auth.ts +3 -2
  167. package/src/memories/index.ts +40 -20
  168. package/src/memory-backend/tool-names.ts +2 -0
  169. package/src/mnemopi/backend.ts +24 -7
  170. package/src/modes/acp/acp-agent.ts +56 -10
  171. package/src/modes/acp/acp-event-mapper.ts +8 -1
  172. package/src/modes/components/agent-hub.ts +1 -1
  173. package/src/modes/components/login-dialog.ts +14 -4
  174. package/src/modes/components/plan-review-overlay.ts +350 -35
  175. package/src/modes/components/settings-defs.ts +23 -1
  176. package/src/modes/components/settings-selector.ts +169 -1
  177. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  178. package/src/modes/components/status-line/component.ts +123 -2
  179. package/src/modes/components/tool-execution.ts +11 -2
  180. package/src/modes/controllers/command-controller.ts +56 -15
  181. package/src/modes/controllers/event-controller.ts +111 -16
  182. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  183. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  184. package/src/modes/controllers/live-command-controller.ts +178 -0
  185. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  186. package/src/modes/controllers/omfg-controller.ts +44 -40
  187. package/src/modes/controllers/selector-controller.ts +18 -10
  188. package/src/modes/controllers/session-focus-controller.ts +6 -1
  189. package/src/modes/interactive-mode.ts +157 -38
  190. package/src/modes/runtime-init.ts +2 -0
  191. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  192. package/src/modes/theme/theme.ts +1 -1
  193. package/src/modes/types.ts +3 -1
  194. package/src/modes/utils/context-usage.ts +23 -7
  195. package/src/modes/utils/ui-helpers.ts +10 -5
  196. package/src/plan-mode/plan-files.ts +40 -0
  197. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  198. package/src/prompts/bench/cache-prefix.md +3 -0
  199. package/src/prompts/bench/cache-suffix.md +1 -0
  200. package/src/prompts/system/custom-system-prompt.md +1 -1
  201. package/src/prompts/system/project-prompt.md +8 -1
  202. package/src/prompts/system/subagent-async-pending.md +6 -0
  203. package/src/prompts/system/system-prompt.md +1 -1
  204. package/src/prompts/system/workflow-notice.md +48 -40
  205. package/src/prompts/system/xdev-mount-notice.md +4 -0
  206. package/src/prompts/tools/todo.md +3 -1
  207. package/src/registry/agent-lifecycle.ts +116 -46
  208. package/src/registry/agent-registry.ts +35 -8
  209. package/src/registry/persisted-agents.ts +26 -2
  210. package/src/sdk.ts +210 -136
  211. package/src/secrets/index.ts +127 -4
  212. package/src/secrets/obfuscator.ts +2328 -105
  213. package/src/session/acp-permission-gate.ts +165 -0
  214. package/src/session/agent-session-error-log.test.ts +1 -1
  215. package/src/session/agent-session-events.ts +66 -0
  216. package/src/session/agent-session-types.ts +334 -0
  217. package/src/session/agent-session.ts +1892 -12226
  218. package/src/session/artifacts.ts +6 -5
  219. package/src/session/async-job-delivery.ts +74 -0
  220. package/src/session/bash-runner.ts +326 -0
  221. package/src/session/blob-store.test.ts +56 -0
  222. package/src/session/blob-store.ts +18 -2
  223. package/src/session/checkpoint-entries.ts +81 -0
  224. package/src/session/eval-runner.ts +212 -0
  225. package/src/session/indexed-session-storage.ts +9 -2
  226. package/src/session/irc-bridge.ts +203 -0
  227. package/src/session/messages.ts +263 -1
  228. package/src/session/model-controls.ts +714 -0
  229. package/src/session/prewalk.ts +252 -0
  230. package/src/session/queued-messages.ts +93 -0
  231. package/src/session/redis-session-storage.ts +51 -11
  232. package/src/session/retry-fallback-chains.ts +455 -0
  233. package/src/session/role-models.ts +85 -0
  234. package/src/session/session-advisors.ts +1679 -0
  235. package/src/session/session-context.ts +6 -3
  236. package/src/session/session-entries.ts +8 -0
  237. package/src/session/session-handoff.ts +308 -0
  238. package/src/session/session-history-format.ts +20 -9
  239. package/src/session/session-loader.ts +0 -46
  240. package/src/session/session-maintenance.ts +2983 -0
  241. package/src/session/session-manager.ts +390 -30
  242. package/src/session/session-memory.ts +222 -0
  243. package/src/session/session-provider-boundary.ts +307 -0
  244. package/src/session/session-stats.ts +293 -0
  245. package/src/session/session-tools.ts +943 -0
  246. package/src/session/session-workspace.ts +53 -0
  247. package/src/session/stream-guards.ts +417 -0
  248. package/src/session/todo-tracker.ts +380 -0
  249. package/src/session/ttsr-coordinator.ts +496 -0
  250. package/src/session/turn-recovery.ts +1629 -0
  251. package/src/slash-commands/builtin-registry.ts +92 -11
  252. package/src/slash-commands/helpers/usage-report.ts +22 -3
  253. package/src/system-prompt.ts +41 -17
  254. package/src/task/executor.ts +285 -70
  255. package/src/task/index.ts +98 -105
  256. package/src/task/output-manager.ts +25 -3
  257. package/src/task/persisted-revive.ts +4 -3
  258. package/src/task/structured-subagent.ts +1 -0
  259. package/src/tools/approval.ts +55 -10
  260. package/src/tools/bash-interactive.ts +26 -0
  261. package/src/tools/bash-skill-urls.ts +28 -2
  262. package/src/tools/bash.ts +339 -123
  263. package/src/tools/browser/launch.ts +9 -1
  264. package/src/tools/browser/tab-supervisor.ts +8 -5
  265. package/src/tools/browser.ts +94 -47
  266. package/src/tools/image-gen.ts +20 -28
  267. package/src/tools/image-providers.ts +50 -0
  268. package/src/tools/index.ts +9 -0
  269. package/src/tools/path-utils.ts +49 -2
  270. package/src/tools/read.ts +156 -88
  271. package/src/tools/renderers.ts +7 -1
  272. package/src/tools/todo.ts +101 -11
  273. package/src/tools/vibe.ts +1 -2
  274. package/src/tools/write.ts +51 -1
  275. package/src/tools/xdev.ts +67 -3
  276. package/src/tts/streaming-player.ts +62 -4
  277. package/src/tts/vocalizer.ts +18 -1
  278. package/src/utils/jj.ts +125 -4
  279. package/src/utils/lang-from-path.ts +7 -0
  280. package/src/utils/title-generator.ts +137 -1
  281. package/src/vibe/runtime.ts +852 -72
  282. package/src/web/search/index.ts +3 -6
  283. package/src/web/search/provider.ts +31 -16
  284. package/src/web/search/providers/xai.ts +7 -1
  285. package/src/web/search/types.ts +3 -0
@@ -14,6 +14,7 @@
14
14
  import * as fs from "node:fs";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
+ import { configureCredentialRedaction } from "@oh-my-pi/pi-ai/providers/transform-messages";
17
18
  import { configureProviderMaxInFlightRequests } from "@oh-my-pi/pi-ai/stream";
18
19
  import {
19
20
  getAgentDbPath,
@@ -33,7 +34,9 @@ import type { ModelRole } from "../config/model-roles";
33
34
  import { loadCapability } from "../discovery";
34
35
  import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
35
36
  import { AgentStorage } from "../session/agent-storage";
37
+ import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-providers";
36
38
  import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
39
+ import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types";
37
40
  import { withFileLock } from "./file-lock";
38
41
  import {
39
42
  type BashInterceptorRule,
@@ -361,6 +364,7 @@ export class Settings {
361
364
  if (options.configFiles) configFiles.push(...options.configFiles);
362
365
  this.#configFiles = configFiles.map(file => path.resolve(this.#cwd, expandTilde(file)));
363
366
  this.#persist = !options.inMemory && options.readOnly !== true;
367
+ liveSettingsInstances.add(new WeakRef(this));
364
368
 
365
369
  if (options.overrides) {
366
370
  for (const [key, value] of Object.entries(options.overrides)) {
@@ -534,6 +538,23 @@ export class Settings {
534
538
  }
535
539
  }
536
540
 
541
+ /** Set once this instance is discarded; background saves become no-ops. */
542
+ #savesCancelled = false;
543
+
544
+ /**
545
+ * Drop pending debounced saves and refuse any further background writes.
546
+ * Used when an instance is being discarded (test teardown): an armed timer
547
+ * or a chained in-flight save on a dropped instance would otherwise fire
548
+ * later and race the successor's file locks.
549
+ */
550
+ cancelPendingSaves(): void {
551
+ this.#savesCancelled = true;
552
+ clearTimeout(this.#saveTimer);
553
+ this.#saveTimer = undefined;
554
+ clearTimeout(this.#projectSaveTimer);
555
+ this.#projectSaveTimer = undefined;
556
+ }
557
+
537
558
  /**
538
559
  * Flush any pending saves to disk.
539
560
  * Call before exit to ensure all changes are persisted.
@@ -1595,6 +1616,45 @@ export class Settings {
1595
1616
  delete raw["mcp.discoveryMode"];
1596
1617
  delete raw["mcp.discoveryDefaultServers"];
1597
1618
 
1619
+ // providers.webSearch / providers.image (single preferred provider) →
1620
+ // providers.webSearchOrder / providers.imageOrder (priority lists). A
1621
+ // concrete legacy choice becomes the head of the new list with every
1622
+ // remaining provider appended in its built-in order, so the old
1623
+ // preference stays #1 and the fallback chain is written out explicitly.
1624
+ // "auto" (or an unknown id) just drops the key — the default chain.
1625
+ const providerPrefsObj = raw.providers as Record<string, unknown> | undefined;
1626
+ const migrateProviderPreference = (
1627
+ legacyKey: string,
1628
+ orderKey: string,
1629
+ expand: (value: string) => string[] | undefined,
1630
+ ): void => {
1631
+ const flatLegacyKey = `providers.${legacyKey}`;
1632
+ const legacy = providerPrefsObj?.[legacyKey] ?? raw[flatLegacyKey];
1633
+ if (legacy === undefined) return;
1634
+ const existingOrder = providerPrefsObj?.[orderKey] ?? raw[`providers.${orderKey}`];
1635
+ const orderAlreadySet = Array.isArray(existingOrder) && existingOrder.length > 0;
1636
+ if (!orderAlreadySet && typeof legacy === "string") {
1637
+ const expanded = expand(legacy);
1638
+ if (expanded) {
1639
+ const root = providerPrefsObj ?? {};
1640
+ root[orderKey] = expanded;
1641
+ raw.providers = root;
1642
+ }
1643
+ }
1644
+ if (providerPrefsObj) delete providerPrefsObj[legacyKey];
1645
+ delete raw[flatLegacyKey];
1646
+ };
1647
+ migrateProviderPreference("webSearch", "webSearchOrder", value =>
1648
+ value !== "auto" && isSearchProviderId(value)
1649
+ ? [value, ...SEARCH_PROVIDER_ORDER.filter(id => id !== value)]
1650
+ : undefined,
1651
+ );
1652
+ migrateProviderPreference("image", "imageOrder", value =>
1653
+ value !== "auto" && isImageProviderId(value)
1654
+ ? [value, ...AUTO_IMAGE_PROVIDER_ORDER.filter(id => id !== value)]
1655
+ : undefined,
1656
+ );
1657
+
1598
1658
  return raw;
1599
1659
  }
1600
1660
 
@@ -1646,7 +1706,7 @@ export class Settings {
1646
1706
  }
1647
1707
 
1648
1708
  async #saveNow(): Promise<void> {
1649
- if (!this.#persist || !this.#configPath) return;
1709
+ if (this.#savesCancelled || !this.#persist || !this.#configPath) return;
1650
1710
  if (this.#modified.size === 0 && this.#modifiedGlobalModelRoles.size === 0) return;
1651
1711
 
1652
1712
  const configPath = this.#configPath;
@@ -1750,7 +1810,7 @@ export class Settings {
1750
1810
  }
1751
1811
 
1752
1812
  async #saveProjectNow(): Promise<void> {
1753
- if (!this.#persist || this.#modifiedProjectModelRoles.size === 0) return;
1813
+ if (this.#savesCancelled || !this.#persist || this.#modifiedProjectModelRoles.size === 0) return;
1754
1814
 
1755
1815
  const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml");
1756
1816
  const modifiedModelRoles = [...this.#modifiedProjectModelRoles];
@@ -1919,6 +1979,9 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
1919
1979
  "providers.maxInFlightRequests": value => {
1920
1980
  configureProviderMaxInFlightRequests(validateProviderMaxInFlightRequests(value));
1921
1981
  },
1982
+ "secrets.enabled": value => {
1983
+ configureCredentialRedaction(value === true);
1984
+ },
1922
1985
  "hindsight.bankId": () => hindsightScopeSignal.fire(),
1923
1986
  "hindsight.bankIdPrefix": () => hindsightScopeSignal.fire(),
1924
1987
  "hindsight.scoping": () => hindsightScopeSignal.fire(),
@@ -1978,6 +2041,13 @@ export const onHindsightScopeChanged = (cb: () => void) => hindsightScopeSignal.
1978
2041
  // Global Singleton
1979
2042
  // ═══════════════════════════════════════════════════════════════════════════
1980
2043
 
2044
+ /**
2045
+ * Weak registry of every constructed instance so `resetSettingsForTest` can
2046
+ * disarm stray background saves on isolated instances too. WeakRefs never
2047
+ * retain instances; the set is cleared on every test reset.
2048
+ */
2049
+ const liveSettingsInstances = new Set<WeakRef<Settings>>();
2050
+
1981
2051
  let globalInstance: Settings | null = null;
1982
2052
  let globalInstancePromise: Promise<Settings> | null = null;
1983
2053
  let boundSettingsInstance: Settings | null = null;
@@ -1997,10 +2067,19 @@ export function isSettingsInitialized(): boolean {
1997
2067
  * @internal
1998
2068
  */
1999
2069
  export function resetSettingsForTest(): void {
2070
+ // Disarm every constructed instance's debounced saves — including isolated
2071
+ // (non-singleton) instances: an armed timer or chained in-flight save on a
2072
+ // dropped instance fires mid-way through the NEXT test and races its file
2073
+ // locks/spies (cross-file pollution).
2074
+ for (const ref of liveSettingsInstances) {
2075
+ ref.deref()?.cancelPendingSaves();
2076
+ }
2077
+ liveSettingsInstances.clear();
2000
2078
  globalInstance = null;
2001
2079
  globalInstancePromise = null;
2002
2080
  clearBoundSettingsMethods();
2003
2081
  configureProviderMaxInFlightRequests(undefined);
2082
+ configureCredentialRedaction(false);
2004
2083
  }
2005
2084
 
2006
2085
  /**
package/src/dap/client.ts CHANGED
@@ -260,7 +260,7 @@ export class DapClient {
260
260
  // socket connect fails, we must not leak the detached adapter process.
261
261
  try {
262
262
  await waitForCondition(() => isUnixSocketReady(socketPath), timeoutMs, proc);
263
- const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
263
+ const { readable, writeSink, socket } = await connectSocket({ unix: socketPath }, timeoutMs);
264
264
  const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
265
265
  proc.exited.then(() => client.#handleProcessExit());
266
266
  void client.#startMessageReader();
@@ -924,10 +924,20 @@ function socketToSink(socket: Bun.Socket<undefined>): DapWriteSink {
924
924
  };
925
925
  }
926
926
 
927
- /** Connect to a unix domain socket and return DAP transport streams. */
928
- async function connectSocket(options: { unix: string }): Promise<SocketTransport> {
929
- const { promise, resolve } = Promise.withResolvers<SocketTransport>();
927
+ /**
928
+ * Connect to a unix domain socket and return DAP transport streams.
929
+ *
930
+ * Rejects (rather than hanging) when the connect fails — a stat-ready but dead
931
+ * socket returns ECONNREFUSED, a socket removed between the readiness stat and
932
+ * the connect returns ENOENT, a permission mismatch returns EACCES — and when
933
+ * neither `open` nor an error arrives within `timeoutMs` (e.g. a TOCTOU stall).
934
+ * `#spawnSocketUnix`'s catch then kills the detached adapter instead of leaking
935
+ * it. Exported so tests can drive the reject path deterministically.
936
+ */
937
+ export async function connectSocket(options: { unix: string }, timeoutMs: number): Promise<SocketTransport> {
938
+ const { promise, resolve, reject } = Promise.withResolvers<SocketTransport>();
930
939
  let streamController: ReadableStreamDefaultController<Uint8Array>;
940
+ let opened = false;
931
941
 
932
942
  const readable = new ReadableStream<Uint8Array>({
933
943
  start(controller) {
@@ -935,10 +945,21 @@ async function connectSocket(options: { unix: string }): Promise<SocketTransport
935
945
  },
936
946
  });
937
947
 
948
+ const timer = setTimeout(() => {
949
+ reject(new Error(`Timed out connecting to unix socket ${options.unix} after ${timeoutMs}ms`));
950
+ }, timeoutMs);
951
+ // A late socket callback after settle is a no-op; clearing the timer just
952
+ // stops it from keeping the event loop alive past the connect.
953
+ void promise.then(
954
+ () => clearTimeout(timer),
955
+ () => clearTimeout(timer),
956
+ );
957
+
938
958
  Bun.connect({
939
959
  unix: options.unix,
940
960
  socket: {
941
961
  open(socket) {
962
+ opened = true;
942
963
  resolve({
943
964
  readable,
944
965
  writeSink: socketToSink(socket),
@@ -949,6 +970,9 @@ async function connectSocket(options: { unix: string }): Promise<SocketTransport
949
970
  streamController.enqueue(new Uint8Array(data));
950
971
  },
951
972
  close() {
973
+ if (!opened) {
974
+ reject(new Error(`Unix socket ${options.unix} closed before opening`));
975
+ }
952
976
  try {
953
977
  streamController.close();
954
978
  } catch {
@@ -956,6 +980,9 @@ async function connectSocket(options: { unix: string }): Promise<SocketTransport
956
980
  }
957
981
  },
958
982
  error(_socket, err) {
983
+ if (!opened) {
984
+ reject(err);
985
+ }
959
986
  try {
960
987
  streamController.error(err);
961
988
  } catch {
@@ -963,6 +990,12 @@ async function connectSocket(options: { unix: string }): Promise<SocketTransport
963
990
  }
964
991
  },
965
992
  },
993
+ }).catch(err => {
994
+ // Bun.connect rejects the returned promise on synchronous connect
995
+ // failures (e.g. ENOENT) without always firing the `error` handler.
996
+ if (!opened) {
997
+ reject(err);
998
+ }
966
999
  });
967
1000
 
968
1001
  return promise;
package/src/edit/index.ts CHANGED
@@ -3,7 +3,7 @@ import hashlineGrammar from "@oh-my-pi/hashline/grammar.lark" with { type: "text
3
3
  import hashlineDescription from "@oh-my-pi/hashline/prompt.md" with { type: "text" };
4
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { ToolExample } from "@oh-my-pi/pi-ai";
6
- import { prompt } from "@oh-my-pi/pi-utils";
6
+ import { isEnoent, isEnotdir, prompt } from "@oh-my-pi/pi-utils";
7
7
  import { createLspWritethrough, flushLspWritethroughBatch, type WritethroughCallback, writethroughNoop } from "../lsp";
8
8
  import { DeferredDiagnostics } from "../lsp/deferred-diagnostics";
9
9
  import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
@@ -12,7 +12,8 @@ import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
12
12
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
13
13
  import type { ToolSession } from "../tools";
14
14
  import { truncateForPrompt } from "../tools/approval";
15
- import { isInternalUrlPath } from "../tools/path-utils";
15
+ import { findUniqueWorkspaceSuffix, isInternalUrlPath } from "../tools/path-utils";
16
+ import { resolvePlanPath } from "../tools/plan-mode-guard";
16
17
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
17
18
  import { executeHashlineSingle, hashlineEditParamsSchema } from "./hashline";
18
19
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
@@ -73,6 +74,24 @@ function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
73
74
  return editMode;
74
75
  }
75
76
 
77
+ async function resolveEditPath(
78
+ session: ToolSession,
79
+ authoredPath: string,
80
+ options: { mustExist: boolean; signal?: AbortSignal },
81
+ ): Promise<string> {
82
+ if (!options.mustExist || isInternalUrlPath(authoredPath)) return authoredPath;
83
+
84
+ try {
85
+ await Bun.file(resolvePlanPath(session, authoredPath)).stat();
86
+ return authoredPath;
87
+ } catch (error) {
88
+ if (!isEnoent(error) && !isEnotdir(error)) throw error;
89
+ }
90
+
91
+ const match = await findUniqueWorkspaceSuffix(authoredPath, session.cwd, options.signal);
92
+ return match?.displayPath ?? authoredPath;
93
+ }
94
+
76
95
  function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
77
96
  switch (rawValue) {
78
97
  case "true":
@@ -517,7 +536,7 @@ export class EditTool implements AgentTool<TInput> {
517
536
  note: "All entries in one call apply to the top-level `path`; use separate calls for different files.",
518
537
  },
519
538
  ] satisfies readonly ToolExample<PatchParams>[],
520
- execute: (
539
+ execute: async (
521
540
  tool: EditTool,
522
541
  params: EditParams,
523
542
  signal: AbortSignal | undefined,
@@ -525,11 +544,15 @@ export class EditTool implements AgentTool<TInput> {
525
544
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
526
545
  ) => {
527
546
  const { edits, path } = params as PatchParams;
547
+ const targetPath = await resolveEditPath(tool.session, path, {
548
+ mustExist: (edits[0]?.op ?? "update") !== "create",
549
+ signal,
550
+ });
528
551
  const runs = (edits as PatchEditEntry[]).map(
529
552
  entry => (br: LspBatchRequest | undefined) =>
530
553
  executePatchSingle({
531
554
  session: tool.session,
532
- path,
555
+ path: targetPath,
533
556
  params: entry,
534
557
  signal,
535
558
  batchRequest: br,
@@ -542,7 +565,7 @@ export class EditTool implements AgentTool<TInput> {
542
565
  beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
543
566
  }),
544
567
  );
545
- return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
568
+ return executeSinglePathEntries(targetPath, runs, batchRequest, onUpdate, tool.session.cwd, signal);
546
569
  },
547
570
  },
548
571
  apply_patch: {
@@ -564,14 +587,26 @@ export class EditTool implements AgentTool<TInput> {
564
587
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
565
588
  ) => {
566
589
  const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
590
+ // Resolve each authored path once per patch so paired hunks (e.g. delete
591
+ // then re-add of the same file) share the same workspace target.
592
+ const resolvedTargets = new Map<string, Promise<string>>();
593
+ const resolveOnce = (path: string, mustExist: boolean): Promise<string> => {
594
+ let pending = resolvedTargets.get(path);
595
+ if (!pending) {
596
+ pending = resolveEditPath(tool.session, path, { mustExist, signal });
597
+ resolvedTargets.set(path, pending);
598
+ }
599
+ return pending;
600
+ };
567
601
  const perFile = entries.map(entry => {
568
602
  const { path, ...patchParams } = entry;
569
603
  return {
570
604
  path,
571
- run: (br: LspBatchRequest | undefined) =>
572
- executePatchSingle({
605
+ run: async (br: LspBatchRequest | undefined) => {
606
+ const targetPath = await resolveOnce(path, patchParams.op !== "create");
607
+ return executePatchSingle({
573
608
  session: tool.session,
574
- path,
609
+ path: targetPath,
575
610
  params: patchParams,
576
611
  signal,
577
612
  batchRequest: br,
@@ -579,7 +614,8 @@ export class EditTool implements AgentTool<TInput> {
579
614
  fuzzyThreshold: tool.#fuzzyThreshold,
580
615
  writethrough: tool.#writethrough,
581
616
  beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
582
- }),
617
+ });
618
+ },
583
619
  };
584
620
  });
585
621
  return executeApplyPatchPerFile(perFile, batchRequest, tool.session.cwd, signal, onUpdate);
@@ -609,7 +645,7 @@ export class EditTool implements AgentTool<TInput> {
609
645
  replace: {
610
646
  description: () => prompt.render(replaceDescription),
611
647
  parameters: replaceEditSchema,
612
- execute: (
648
+ execute: async (
613
649
  tool: EditTool,
614
650
  params: EditParams,
615
651
  signal: AbortSignal | undefined,
@@ -617,11 +653,12 @@ export class EditTool implements AgentTool<TInput> {
617
653
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
618
654
  ) => {
619
655
  const { edits, path } = params as ReplaceParams;
656
+ const targetPath = await resolveEditPath(tool.session, path, { mustExist: true, signal });
620
657
  const runs = (edits as ReplaceEditEntry[]).map(
621
658
  entry => (br: LspBatchRequest | undefined) =>
622
659
  executeReplaceSingle({
623
660
  session: tool.session,
624
- path,
661
+ path: targetPath,
625
662
  params: entry,
626
663
  signal,
627
664
  batchRequest: br,
@@ -631,7 +668,7 @@ export class EditTool implements AgentTool<TInput> {
631
668
  beginDeferredDiagnosticsForPath: p => tool.#deferredDiagnostics.begin(p),
632
669
  }),
633
670
  );
634
- return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
671
+ return executeSinglePathEntries(targetPath, runs, batchRequest, onUpdate, tool.session.cwd, signal);
635
672
  },
636
673
  },
637
674
  }[this.mode];
@@ -21,6 +21,7 @@ import {
21
21
  type KernelDisplayOutput,
22
22
  type KernelExecuteOptions,
23
23
  type KernelExecuteResult,
24
+ type KernelShutdownResult,
24
25
  PythonKernel,
25
26
  } from "./kernel";
26
27
  import { resolveExplicitPythonRuntime } from "./runtime";
@@ -136,11 +137,19 @@ export interface PythonResult {
136
137
  // the same tuple; the kernel stays alive until the last owner detaches.
137
138
  // ---------------------------------------------------------------------------
138
139
 
140
+ interface SessionKernelReplacement {
141
+ generation: number;
142
+ deadlineMs?: number;
143
+ promise: Promise<PythonKernel>;
144
+ }
145
+
139
146
  interface PythonSession {
140
147
  sessionKey: string;
141
148
  sessionId: string;
142
149
  cwd: string;
143
150
  kernel: PythonKernel;
151
+ generation: number;
152
+ replacement?: SessionKernelReplacement;
144
153
  ownerIds: Set<string>;
145
154
  hasFallbackOwner: boolean;
146
155
  }
@@ -254,6 +263,7 @@ async function acquireSession(
254
263
  sessionId,
255
264
  cwd,
256
265
  kernel,
266
+ generation: 0,
257
267
  ownerIds: new Set(),
258
268
  hasFallbackOwner: false,
259
269
  };
@@ -272,31 +282,99 @@ async function acquireSession(
272
282
 
273
283
  async function replaceSessionKernel(
274
284
  session: PythonSession,
285
+ kernel: PythonKernel,
286
+ generation: number,
275
287
  cwd: string,
276
288
  options: PythonExecutorOptions,
277
- ): Promise<void> {
278
- const old = session.kernel;
279
- const remaining = getRemainingTimeoutMs(options.deadlineMs);
280
- await old
281
- .shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
282
- .catch(() => undefined);
283
- if (sessions.get(session.sessionKey) !== session) {
284
- throw new PythonExecutionCancelledError(false);
289
+ ): Promise<PythonKernel> {
290
+ const inFlight = session.replacement;
291
+ if (inFlight?.generation === generation) {
292
+ if (
293
+ inFlight.deadlineMs !== undefined &&
294
+ (options.deadlineMs === undefined || options.deadlineMs > inFlight.deadlineMs)
295
+ ) {
296
+ inFlight.deadlineMs = options.deadlineMs;
297
+ }
298
+ return await waitForPromiseWithCancellation(inFlight.promise, options, PythonExecutionCancelledError);
285
299
  }
286
- requireRemainingTimeoutMs(options.deadlineMs);
287
- const next = await startKernel(cwd, options);
288
- if (sessions.get(session.sessionKey) !== session) {
289
- await next.shutdown().catch(() => undefined);
300
+ if (sessions.get(session.sessionKey) !== session || session.generation !== generation || session.kernel !== kernel) {
290
301
  throw new PythonExecutionCancelledError(false);
291
302
  }
292
- session.kernel = next;
303
+
304
+ const deferred = Promise.withResolvers<PythonKernel>();
305
+ const replacement: SessionKernelReplacement = {
306
+ generation,
307
+ deadlineMs: options.deadlineMs,
308
+ promise: deferred.promise,
309
+ };
310
+ session.replacement = replacement;
311
+ void (async () => {
312
+ try {
313
+ const remaining = getRemainingTimeoutMs(options.deadlineMs);
314
+ await kernel
315
+ .shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
316
+ .catch(() => undefined);
317
+ if (replacement.deadlineMs !== undefined && replacement.deadlineMs <= Date.now()) {
318
+ throw new PythonExecutionCancelledError(true);
319
+ }
320
+ if (
321
+ sessions.get(session.sessionKey) !== session ||
322
+ session.generation !== generation ||
323
+ session.kernel !== kernel
324
+ ) {
325
+ throw new PythonExecutionCancelledError(false);
326
+ }
327
+ const next = await startKernel(cwd, {
328
+ ...options,
329
+ signal: undefined,
330
+ deadlineMs: undefined,
331
+ });
332
+ if (
333
+ sessions.get(session.sessionKey) !== session ||
334
+ session.generation !== generation ||
335
+ session.kernel !== kernel
336
+ ) {
337
+ await next.shutdown().catch(() => undefined);
338
+ throw new PythonExecutionCancelledError(false);
339
+ }
340
+ session.kernel = next;
341
+ session.generation += 1;
342
+ deferred.resolve(next);
343
+ } catch (err) {
344
+ deferred.reject(err);
345
+ } finally {
346
+ if (session.replacement === replacement) session.replacement = undefined;
347
+ }
348
+ })();
349
+ return await waitForPromiseWithCancellation(deferred.promise, options, PythonExecutionCancelledError);
350
+ }
351
+
352
+ async function shutdownInvalidatedSession(session: PythonSession): Promise<KernelShutdownResult> {
353
+ const replacement = session.replacement;
354
+ if (replacement) await replacement.promise.catch(() => undefined);
355
+ return await session.kernel.shutdown();
356
+ }
357
+
358
+ async function acquireLiveSessionKernel(
359
+ session: PythonSession,
360
+ cwd: string,
361
+ options: PythonExecutorOptions,
362
+ ): Promise<PythonKernel> {
363
+ while (sessions.get(session.sessionKey) === session) {
364
+ const kernel = session.kernel;
365
+ const generation = session.generation;
366
+ if (kernel.isAlive()) return kernel;
367
+ await replaceSessionKernel(session, kernel, generation, cwd, options);
368
+ }
369
+ throw new PythonExecutionCancelledError(false);
293
370
  }
294
371
 
295
372
  async function resetSession(sessionKey: string): Promise<void> {
296
373
  const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
297
374
  if (!existing) return;
375
+ existing.generation += 1;
298
376
  sessions.delete(sessionKey);
299
- await existing.kernel.shutdown().catch(() => undefined);
377
+ await shutdownInvalidatedSession(existing).catch(() => undefined);
300
378
  }
301
379
 
302
380
  // ---------------------------------------------------------------------------
@@ -315,9 +393,10 @@ export async function disposeAllKernelSessions(): Promise<void> {
315
393
  }
316
394
  }
317
395
  for (const [id, session] of all) {
396
+ session.generation += 1;
318
397
  if (sessions.get(id) === session) sessions.delete(id);
319
398
  }
320
- const results = await Promise.allSettled(all.map(([, session]) => session.kernel.shutdown()));
399
+ const results = await Promise.allSettled(all.map(([, session]) => shutdownInvalidatedSession(session)));
321
400
  for (let i = 0; i < all.length; i += 1) {
322
401
  const [id, session] = all[i];
323
402
  const result = results[i];
@@ -344,9 +423,10 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
344
423
  session.ownerIds.delete(ownerId);
345
424
  }
346
425
  for (const session of toShutdown) {
426
+ session.generation += 1;
347
427
  if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
348
428
  }
349
- const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown()));
429
+ const results = await Promise.allSettled(toShutdown.map(session => shutdownInvalidatedSession(session)));
350
430
  for (let i = 0; i < toShutdown.length; i += 1) {
351
431
  const session = toShutdown[i];
352
432
  const result = results[i];
@@ -458,31 +538,21 @@ async function executeOnSession(code: string, cwd: string, options: PythonExecut
458
538
  isTimedOutCancellation(options.signal.reason, PythonExecutionCancelledError, options.signal),
459
539
  );
460
540
  }
461
- if (sessions.get(session.sessionKey) !== session) {
541
+ const kernel = await acquireLiveSessionKernel(session, cwd, options);
542
+ if (sessions.get(session.sessionKey) !== session || session.kernel !== kernel) {
462
543
  throw new PythonExecutionCancelledError(false);
463
544
  }
464
- if (!session.kernel.isAlive()) {
465
- await replaceSessionKernel(session, cwd, options);
466
- if (sessions.get(session.sessionKey) !== session) {
467
- throw new PythonExecutionCancelledError(false);
468
- }
469
- }
470
545
  const runOptions = { ...options, cwd };
471
546
  try {
472
- return await executeWithKernel(session.kernel, code, runOptions);
547
+ return await executeWithKernel(kernel, code, runOptions);
473
548
  } catch (err) {
474
549
  if (isCancellationError(err, PythonExecutionCancelledError) || options.signal?.aborted) throw err;
475
- if (session.kernel.isAlive()) throw err;
476
- if (sessions.get(session.sessionKey) !== session) {
477
- throw new PythonExecutionCancelledError(false);
478
- }
479
- // Shared kernels are keyed by cwd, so a dead kernel can be recreated in place
480
- // without risking cross-directory state bleed.
481
- await replaceSessionKernel(session, cwd, options);
482
- if (sessions.get(session.sessionKey) !== session) {
550
+ if (kernel.isAlive()) throw err;
551
+ const retryKernel = await acquireLiveSessionKernel(session, cwd, options);
552
+ if (sessions.get(session.sessionKey) !== session || session.kernel !== retryKernel) {
483
553
  throw new PythonExecutionCancelledError(false);
484
554
  }
485
- return await executeWithKernel(session.kernel, code, runOptions);
555
+ return await executeWithKernel(retryKernel, code, runOptions);
486
556
  }
487
557
  }
488
558