@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3

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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. package/src/web/search/types.ts +6 -1
@@ -27,6 +27,7 @@ import {
27
27
  setWorktreesDir,
28
28
  } from "@oh-my-pi/pi-utils";
29
29
  import { JSONC, YAML } from "bun";
30
+ import { invalidate as invalidateCapabilityFsCache } from "../capability/fs";
30
31
  import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
31
32
  import type { ModelRole } from "../config/model-roles";
32
33
  import { loadCapability } from "../discovery";
@@ -172,6 +173,83 @@ function isRecord(value: unknown): value is Record<string, unknown> {
172
173
  return !!value && typeof value === "object" && !Array.isArray(value);
173
174
  }
174
175
 
176
+ /**
177
+ * Migrate a v17 leaf rename that used to nest under a boolean parent path
178
+ * (`dev.autoqa.consent` → `dev.autoqaConsent`, `todo.reminders.max` →
179
+ * `todo.remindersMax`). Pre-rename configs left the leaf beneath the parent,
180
+ * so the parent path resolved to an object and truthy checks like
181
+ * `isAutoQaEnabled` treated a consent-only container as "enabled".
182
+ *
183
+ * Handles nested (`{ parent: { leaf } }`) and quoted-dotted (`"parent.leaf"`)
184
+ * legacy sources. An explicit new key always wins; a separately configured
185
+ * boolean parent is preserved; an irrecoverable object-valued parent (only ever
186
+ * a container for the old leaf) is dropped so the schema default applies.
187
+ */
188
+ function migrateNestedLeafRename(
189
+ raw: RawSettings,
190
+ root: string,
191
+ parent: string,
192
+ oldLeaf: string,
193
+ newLeaf: string,
194
+ isLeafValue: (value: unknown) => boolean,
195
+ ): void {
196
+ const rootObj = isRecord(raw[root]) ? (raw[root] as Record<string, unknown>) : undefined;
197
+ const nestedParent = rootObj?.[parent];
198
+ const flatParent = raw[`${root}.${parent}`];
199
+ const oldParentPath = `${root}.${parent}`;
200
+
201
+ const candidates = [
202
+ rootObj?.[newLeaf],
203
+ raw[`${root}.${newLeaf}`],
204
+ isRecord(nestedParent) ? nestedParent[oldLeaf] : undefined,
205
+ raw[`${oldParentPath}.${oldLeaf}`],
206
+ ];
207
+ const resolvedLeaf = candidates.find(isLeafValue);
208
+
209
+ const recoveredParent =
210
+ typeof nestedParent === "boolean" ? nestedParent : typeof flatParent === "boolean" ? flatParent : undefined;
211
+
212
+ const ensureRoot = (): Record<string, unknown> => {
213
+ const current = raw[root];
214
+ if (isRecord(current)) return current;
215
+ const created: Record<string, unknown> = {};
216
+ raw[root] = created;
217
+ return created;
218
+ };
219
+
220
+ if (resolvedLeaf !== undefined) {
221
+ const target = ensureRoot();
222
+ if (!isLeafValue(target[newLeaf])) {
223
+ target[newLeaf] = resolvedLeaf;
224
+ }
225
+ }
226
+
227
+ // Strip legacy leaf sources (nested + flat dotted).
228
+ delete raw[`${oldParentPath}.${oldLeaf}`];
229
+ delete raw[`${root}.${newLeaf}`];
230
+ if (isRecord(raw[root]) && isRecord((raw[root] as Record<string, unknown>)[parent])) {
231
+ const parentObj = (raw[root] as Record<string, unknown>)[parent] as Record<string, unknown>;
232
+ delete parentObj[oldLeaf];
233
+ if (Object.keys(parentObj).length === 0) {
234
+ delete (raw[root] as Record<string, unknown>)[parent];
235
+ }
236
+ }
237
+
238
+ // The parent path must be a boolean or absent — never a leftover object.
239
+ if (recoveredParent !== undefined) {
240
+ const target = ensureRoot();
241
+ if (typeof target[parent] !== "boolean") {
242
+ target[parent] = recoveredParent;
243
+ }
244
+ } else if (isRecord(raw[root]) && isRecord((raw[root] as Record<string, unknown>)[parent])) {
245
+ delete (raw[root] as Record<string, unknown>)[parent];
246
+ }
247
+ delete raw[oldParentPath];
248
+ if (isRecord(raw[root]) && Object.keys(raw[root] as Record<string, unknown>).length === 0) {
249
+ delete raw[root];
250
+ }
251
+ }
252
+
175
253
  function modelRoleValueFromUnknown(value: unknown): string | undefined {
176
254
  if (typeof value === "string") return value;
177
255
  if (!Array.isArray(value)) return undefined;
@@ -250,6 +328,16 @@ export class Settings {
250
328
 
251
329
  /** Paths modified during this session (for partial save) */
252
330
  #modified = new Set<string>();
331
+ /** Individual project model roles modified during this session */
332
+ #modifiedProjectModelRoles = new Set<string>();
333
+ /**
334
+ * Original process-wide model-role overrides captured before a project edit
335
+ * temporarily replaced them via `#updateRuntimeModelRoleOverride`. Restored
336
+ * on `reloadForCwd` / `cloneForCwd` so destination projects never inherit the
337
+ * source-project value. Maps role → original override value (`undefined`
338
+ * when the role had no runtime override).
339
+ */
340
+ #savedRuntimeModelRoleOverrides = new Map<string, string | undefined>();
253
341
 
254
342
  /** Legacy `lastChangelogVersion` captured from config.yml during migration (now a marker file). */
255
343
  #legacyLastChangelogVersion?: string;
@@ -257,6 +345,8 @@ export class Settings {
257
345
  /** Pending save (debounced) */
258
346
  #saveTimer?: NodeJS.Timeout;
259
347
  #savePromise?: Promise<void>;
348
+ #projectSaveTimer?: NodeJS.Timeout;
349
+ #projectSavePromise?: Promise<void>;
260
350
 
261
351
  /** Whether to persist changes */
262
352
  #persist: boolean;
@@ -400,6 +490,9 @@ export class Settings {
400
490
  * Apply runtime overrides (not persisted).
401
491
  */
402
492
  override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
493
+ if (path === "modelRoles") {
494
+ this.#savedRuntimeModelRoleOverrides.clear();
495
+ }
403
496
  const prev = this.get(path);
404
497
  const segments = path.split(".");
405
498
  setByPath(this.#overrides, segments, value);
@@ -411,6 +504,9 @@ export class Settings {
411
504
  * Clear a runtime override.
412
505
  */
413
506
  clearOverride(path: SettingPath): void {
507
+ if (path === "modelRoles") {
508
+ this.#savedRuntimeModelRoleOverrides.clear();
509
+ }
414
510
  const prev = this.get(path);
415
511
  const segments = path.split(".");
416
512
  let current = this.#overrides;
@@ -443,12 +539,22 @@ export class Settings {
443
539
  clearTimeout(this.#saveTimer);
444
540
  this.#saveTimer = undefined;
445
541
  }
542
+ if (this.#projectSaveTimer) {
543
+ clearTimeout(this.#projectSaveTimer);
544
+ this.#projectSaveTimer = undefined;
545
+ }
446
546
  if (this.#savePromise) {
447
547
  await this.#savePromise;
448
548
  }
549
+ if (this.#projectSavePromise) {
550
+ await this.#projectSavePromise;
551
+ }
449
552
  if (this.#modified.size > 0) {
450
553
  await this.#saveNow();
451
554
  }
555
+ if (this.#modifiedProjectModelRoles.size > 0) {
556
+ await this.#saveProjectNow();
557
+ }
452
558
  }
453
559
 
454
560
  async cloneForCwd(cwd: string): Promise<Settings> {
@@ -463,7 +569,7 @@ export class Settings {
463
569
  cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
464
570
  cloned.#configFiles = [...this.#configFiles];
465
571
  cloned.#configOverlay = structuredClone(this.#configOverlay);
466
- cloned.#overrides = structuredClone(this.#overrides);
572
+ cloned.#overrides = this.#buildOriginalOverrides();
467
573
  cloned.#rebuildMerged();
468
574
  cloned.#fireAllHooks();
469
575
  return cloned;
@@ -484,6 +590,8 @@ export class Settings {
484
590
  async reloadForCwd(cwd: string): Promise<void> {
485
591
  const normalized = path.normalize(cwd);
486
592
  if (normalized === this.#cwd) return;
593
+ await this.flush();
594
+ this.#restoreRuntimeModelRoleOverrides();
487
595
  const prevModelRoles = this.get("modelRoles");
488
596
  this.#cwd = normalized;
489
597
  if (this.#persist) {
@@ -602,35 +710,168 @@ export class Settings {
602
710
  return roles;
603
711
  }
604
712
 
713
+ #modelRoleLayerOwns(layer: RawSettings, role: ModelRole | string): boolean {
714
+ const value = getByPath(layer, ["modelRoles"]);
715
+ if (!isRecord(value)) return false;
716
+ return Object.hasOwn(value, role);
717
+ }
718
+
719
+ /**
720
+ * Set the full `modelRoles` map on the runtime override layer without
721
+ * routing through the public {@link override} method. Internal callers
722
+ * (project edits, global fallback updates) use this so they can control
723
+ * capture invalidation independently of the whole-map replacement
724
+ * semantics that `override("modelRoles", …)` carries.
725
+ */
726
+ #setRuntimeModelRoleOverrides(next: Record<string, string>): void {
727
+ const prev = this.get("modelRoles");
728
+ setByPath(this.#overrides, ["modelRoles"], next);
729
+ this.#rebuildMerged();
730
+ this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
731
+ }
732
+
733
+ #updateRuntimeModelRoleOverride(role: ModelRole | string, modelId: string | undefined): void {
734
+ const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
735
+ if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return;
736
+
737
+ const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides);
738
+ if (modelId === undefined) {
739
+ delete nextRuntimeOverride[role];
740
+ } else {
741
+ nextRuntimeOverride[role] = modelId;
742
+ }
743
+ this.#setRuntimeModelRoleOverrides(nextRuntimeOverride);
744
+ }
745
+
746
+ /**
747
+ * Capture the original process-wide override for `role` the first time a
748
+ * project edit temporarily replaces it, so the original can be restored on
749
+ * cwd changes. Subsequent edits in the same cwd must not overwrite the
750
+ * first captured value.
751
+ */
752
+ #captureRuntimeModelRoleOverride(role: ModelRole | string): void {
753
+ if (this.#savedRuntimeModelRoleOverrides.has(role)) return;
754
+ const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
755
+ if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return;
756
+ this.#savedRuntimeModelRoleOverrides.set(role, this.#modelRolesFromLayer(this.#overrides)[role]);
757
+ }
758
+
759
+ /**
760
+ * Restore original process-wide model-role overrides that were temporarily
761
+ * replaced by project edits, mutating `#overrides` in place without
762
+ * rebuilding. All remaining captures are valid because superseding
763
+ * operations (late `overrideModelRoles`, global-mode `setModelRole`,
764
+ * whole-map `override`/`clearOverride`) invalidate the affected captures
765
+ * at the point of supersession. Caller is responsible for `#rebuildMerged()`.
766
+ */
767
+ #restoreRuntimeModelRoleOverrides(): void {
768
+ if (this.#savedRuntimeModelRoleOverrides.size === 0) return;
769
+ const runtimeRoles = getByPath(this.#overrides, ["modelRoles"]);
770
+ if (!isRecord(runtimeRoles)) {
771
+ this.#savedRuntimeModelRoleOverrides.clear();
772
+ return;
773
+ }
774
+ for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) {
775
+ if (originalValue === undefined) {
776
+ delete runtimeRoles[role];
777
+ } else {
778
+ runtimeRoles[role] = originalValue;
779
+ }
780
+ }
781
+ this.#savedRuntimeModelRoleOverrides.clear();
782
+ }
783
+
784
+ /**
785
+ * Produce a deep copy of `#overrides` with original process-wide model-role
786
+ * overrides restored, for use by {@link cloneForCwd}. All remaining
787
+ * captures are valid (see {@link #restoreRuntimeModelRoleOverrides}).
788
+ * Does not mutate the current instance's `#overrides`.
789
+ */
790
+ #buildOriginalOverrides(): RawSettings {
791
+ if (this.#savedRuntimeModelRoleOverrides.size === 0) {
792
+ return structuredClone(this.#overrides);
793
+ }
794
+ const overrides = structuredClone(this.#overrides);
795
+ const runtimeRoles = getByPath(overrides, ["modelRoles"]);
796
+ if (!isRecord(runtimeRoles)) return overrides;
797
+ for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) {
798
+ if (originalValue === undefined) {
799
+ delete runtimeRoles[role];
800
+ } else {
801
+ runtimeRoles[role] = originalValue;
802
+ }
803
+ }
804
+ return overrides;
805
+ }
806
+
807
+ #setProjectModelRoleValue(role: ModelRole | string, modelId: string | null): void {
808
+ const prev = this.get("modelRoles");
809
+ const projectRoles = getByPath(this.#project, ["modelRoles"]);
810
+ const current: Record<string, unknown> = isRecord(projectRoles) ? { ...projectRoles } : {};
811
+ current[role] = modelId;
812
+ setByPath(this.#project, ["modelRoles"], current);
813
+ this.#modifiedProjectModelRoles.add(role);
814
+ this.#rebuildMerged();
815
+ this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
816
+ this.#queueProjectSave();
817
+ }
818
+
605
819
  /**
606
820
  * Set a model role (helper for modelRoles record). Passing `undefined`
607
821
  * clears the role from the persisted record and any runtime override.
822
+ *
823
+ * In project storage mode, when a project edit has temporarily replaced
824
+ * the process-wide runtime override for `role` and that override is still
825
+ * active (the runtime slot currently matches the project value), the
826
+ * global-layer write must not rewrite that runtime slot — otherwise the
827
+ * global fallback would immediately shadow the still-configured project
828
+ * role. The global layer is still persisted; only the runtime override is
829
+ * left untouched. The guard is precise so that a later clear, a late
830
+ * `overrideModelRoles`, or a storage-mode transition does not leave a
831
+ * stale skip in place.
608
832
  */
609
833
  setModelRole(role: ModelRole | string, modelId: string | undefined): void {
610
834
  const current = this.#modelRolesFromLayer(this.#global);
611
- const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
612
- const updateRuntimeOverride =
613
- !!runtimeOverrides &&
614
- typeof runtimeOverrides === "object" &&
615
- !Array.isArray(runtimeOverrides) &&
616
- Object.hasOwn(runtimeOverrides, role);
617
-
618
835
  if (modelId === undefined) {
619
836
  delete current[role];
620
837
  } else {
621
838
  current[role] = modelId;
622
839
  }
623
840
  this.set("modelRoles", current);
624
-
625
- if (updateRuntimeOverride) {
626
- const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides);
627
- if (modelId === undefined) {
628
- delete nextRuntimeOverride[role];
629
- } else {
630
- nextRuntimeOverride[role] = modelId;
631
- }
632
- this.override("modelRoles", nextRuntimeOverride);
841
+ if (this.isProjectModelRoleRuntimeOverrideActive(role)) {
842
+ return;
633
843
  }
844
+ this.#savedRuntimeModelRoleOverrides.delete(role);
845
+ this.#updateRuntimeModelRoleOverride(role, modelId);
846
+ }
847
+
848
+ /**
849
+ * Whether `role`'s runtime override slot currently holds the temporary
850
+ * project-scoped value installed by a prior `setProjectModelRole`. Returns
851
+ * `false` when storage is not project-mode, no capture exists, or the
852
+ * project role was cleared. With explicit provenance invalidation, a
853
+ * surviving capture implies no external supersession occurred.
854
+ */
855
+ isProjectModelRoleRuntimeOverrideActive(role: ModelRole | string): boolean {
856
+ if (this.get("modelRoleStorage") !== "project") return false;
857
+ if (!this.#savedRuntimeModelRoleOverrides.has(role)) return false;
858
+ return !!this.getProjectModelRole(role);
859
+ }
860
+ /**
861
+ * Set a model role in the current project's settings layer.
862
+ */
863
+ setProjectModelRole(role: ModelRole | string, modelId: string): void {
864
+ this.#setProjectModelRoleValue(role, modelId);
865
+ this.#captureRuntimeModelRoleOverride(role);
866
+ this.#updateRuntimeModelRoleOverride(role, modelId);
867
+ }
868
+ /**
869
+ * Clear a model role from the current project's settings layer.
870
+ */
871
+ clearProjectModelRole(role: ModelRole | string): void {
872
+ this.#setProjectModelRoleValue(role, null);
873
+ this.#captureRuntimeModelRoleOverride(role);
874
+ this.#updateRuntimeModelRoleOverride(role, undefined);
634
875
  }
635
876
 
636
877
  /**
@@ -641,6 +882,49 @@ export class Settings {
641
882
  if (!isRecord(roles)) return undefined;
642
883
  return modelRoleValueFromUnknown(roles[role]);
643
884
  }
885
+ /**
886
+ * Get a model role from only the global settings layer.
887
+ */
888
+ getGlobalModelRole(role: ModelRole | string): string | undefined {
889
+ const modelId = this.#modelRolesFromLayer(this.#global)[role];
890
+ return modelId || undefined;
891
+ }
892
+
893
+ /**
894
+ * Get a model role from only the current project settings layer.
895
+ */
896
+ getProjectModelRole(role: ModelRole | string): string | undefined {
897
+ const modelId = this.#modelRolesFromLayer(this.#project)[role];
898
+ return modelId || undefined;
899
+ }
900
+
901
+ /**
902
+ * Report which layer actually supplies the effective model role across
903
+ * full merge precedence (runtime override → config overlay → project →
904
+ * global → default). Unlike {@link getModelRoleSource}, this accounts
905
+ * for runtime and config-overlay layers and detects ownership by key
906
+ * presence rather than normalized value, so a `null` tombstone in the
907
+ * overlay or runtime layer correctly blocks lower layers. The project
908
+ * layer is checked through {@link #projectSettingsForMerge} because a
909
+ * project null is a cleared value (falls back to global), not a
910
+ * tombstone.
911
+ */
912
+ getModelRoleProvenance(role: ModelRole | string): "runtime" | "overlay" | "project" | "global" | "default" {
913
+ if (this.#modelRoleLayerOwns(this.#overrides, role)) return "runtime";
914
+ if (this.#modelRoleLayerOwns(this.#configOverlay, role)) return "overlay";
915
+ if (this.#modelRoleLayerOwns(this.#projectSettingsForMerge(), role)) return "project";
916
+ if (this.#modelRoleLayerOwns(this.#global, role)) return "global";
917
+ return "default";
918
+ }
919
+
920
+ /**
921
+ * Get the persisted layer supplying a model role (project/global/default only).
922
+ */
923
+ getModelRoleSource(role: ModelRole | string): "project" | "global" | "default" {
924
+ if (this.getProjectModelRole(role)) return "project";
925
+ if (this.getGlobalModelRole(role)) return "global";
926
+ return "default";
927
+ }
644
928
 
645
929
  /**
646
930
  * Get all model roles (helper for modelRoles record).
@@ -668,9 +952,10 @@ export class Settings {
668
952
  for (const [role, modelId] of Object.entries(roles)) {
669
953
  if (modelId) {
670
954
  next[role] = modelId;
955
+ this.#savedRuntimeModelRoleOverrides.delete(role);
671
956
  }
672
957
  }
673
- this.override("modelRoles", next);
958
+ this.#setRuntimeModelRoleOverrides(next);
674
959
  }
675
960
 
676
961
  /**
@@ -778,6 +1063,11 @@ export class Settings {
778
1063
  merged = this.#deepMerge(merged, item.data as RawSettings);
779
1064
  }
780
1065
  }
1066
+ const nativeProject = await this.#loadYaml(path.join(this.#cwd, ".omp", "config.yml"));
1067
+ const nativeModelRoles = getByPath(nativeProject, ["modelRoles"]);
1068
+ if (nativeModelRoles !== undefined) {
1069
+ merged = this.#deepMerge(merged, { modelRoles: nativeModelRoles });
1070
+ }
781
1071
  return this.#migrateRawSettings(merged);
782
1072
  } catch {
783
1073
  return {};
@@ -1253,6 +1543,26 @@ export class Settings {
1253
1543
  if (tierTouched) raw.tier = tierObj;
1254
1544
  delete raw.fastModeScope;
1255
1545
 
1546
+ // v17 renames that used to nest under a boolean parent path:
1547
+ // dev.autoqa.consent -> dev.autoqaConsent
1548
+ // todo.reminders.max -> todo.remindersMax
1549
+ migrateNestedLeafRename(
1550
+ raw,
1551
+ "dev",
1552
+ "autoqa",
1553
+ "consent",
1554
+ "autoqaConsent",
1555
+ value => value === "unset" || value === "granted" || value === "denied",
1556
+ );
1557
+ migrateNestedLeafRename(
1558
+ raw,
1559
+ "todo",
1560
+ "reminders",
1561
+ "max",
1562
+ "remindersMax",
1563
+ value => typeof value === "number" && Number.isFinite(value),
1564
+ );
1565
+
1256
1566
  // BM25 tool discovery removal: tools.discoveryMode / tools.essentialOverride /
1257
1567
  // mcp.discoveryMode / mcp.discoveryDefaultServers are gone with no
1258
1568
  // replacement (`tools.xdev` stays at its own default). Dead keys are
@@ -1304,14 +1614,20 @@ export class Settings {
1304
1614
  if (!this.#persist || !this.#configPath) return;
1305
1615
 
1306
1616
  // Debounce: wait 100ms for more changes
1307
- if (this.#saveTimer) {
1308
- clearTimeout(this.#saveTimer);
1309
- }
1617
+ clearTimeout(this.#saveTimer);
1310
1618
  this.#saveTimer = setTimeout(() => {
1311
1619
  this.#saveTimer = undefined;
1312
- this.#saveNow().catch(err => {
1313
- logger.warn("Settings: background save failed", { error: String(err) });
1314
- });
1620
+ const savePromise = this.#saveNow();
1621
+ this.#savePromise = savePromise;
1622
+ savePromise
1623
+ .catch(err => {
1624
+ logger.warn("Settings: background save failed", { error: String(err) });
1625
+ })
1626
+ .finally(() => {
1627
+ if (this.#savePromise === savePromise) {
1628
+ this.#savePromise = undefined;
1629
+ }
1630
+ });
1315
1631
  }, 100);
1316
1632
  }
1317
1633
 
@@ -1348,13 +1664,77 @@ export class Settings {
1348
1664
 
1349
1665
  this.#rebuildMerged();
1350
1666
  }
1667
+ #queueProjectSave(): void {
1668
+ if (!this.#persist) return;
1669
+
1670
+ clearTimeout(this.#projectSaveTimer);
1671
+ this.#projectSaveTimer = setTimeout(() => {
1672
+ this.#projectSaveTimer = undefined;
1673
+ const savePromise = this.#saveProjectNow();
1674
+ this.#projectSavePromise = savePromise;
1675
+ savePromise
1676
+ .catch(err => {
1677
+ logger.warn("Settings: background project save failed", { error: String(err) });
1678
+ })
1679
+ .finally(() => {
1680
+ if (this.#projectSavePromise === savePromise) {
1681
+ this.#projectSavePromise = undefined;
1682
+ }
1683
+ });
1684
+ }, 100);
1685
+ }
1686
+
1687
+ async #saveProjectNow(): Promise<void> {
1688
+ if (!this.#persist || this.#modifiedProjectModelRoles.size === 0) return;
1689
+
1690
+ const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml");
1691
+ const modifiedModelRoles = [...this.#modifiedProjectModelRoles];
1692
+ this.#modifiedProjectModelRoles.clear();
1693
+
1694
+ try {
1695
+ await fs.promises.mkdir(path.dirname(projectConfigPath), { recursive: true });
1696
+ await withFileLock(projectConfigPath, async () => {
1697
+ const projectSettings = await this.#loadYaml(projectConfigPath);
1698
+
1699
+ const projectRoles = getByPath(this.#project, ["modelRoles"]);
1700
+ for (const role of modifiedModelRoles) {
1701
+ const value = isRecord(projectRoles) ? projectRoles[role] : undefined;
1702
+ setByPath(projectSettings, ["modelRoles", role], value);
1703
+ }
1704
+
1705
+ await Bun.write(projectConfigPath, YAML.stringify(projectSettings, null, 2));
1706
+ });
1707
+ invalidateCapabilityFsCache(projectConfigPath);
1708
+ } catch (error) {
1709
+ for (const role of modifiedModelRoles) {
1710
+ this.#modifiedProjectModelRoles.add(role);
1711
+ }
1712
+ throw error;
1713
+ }
1714
+
1715
+ this.#rebuildMerged();
1716
+ }
1351
1717
 
1352
1718
  // ─────────────────────────────────────────────────────────────────────────
1353
1719
  // Utilities
1354
1720
  // ─────────────────────────────────────────────────────────────────────────
1355
1721
 
1722
+ #projectSettingsForMerge(): RawSettings {
1723
+ const projectRoles = getByPath(this.#project, ["modelRoles"]);
1724
+ if (!isRecord(projectRoles)) return this.#project;
1725
+
1726
+ let filteredRoles: Record<string, unknown> | undefined;
1727
+ for (const role in projectRoles) {
1728
+ if (!Object.hasOwn(projectRoles, role) || modelRoleValueFromUnknown(projectRoles[role]) !== undefined)
1729
+ continue;
1730
+ filteredRoles ??= { ...projectRoles };
1731
+ delete filteredRoles[role];
1732
+ }
1733
+ return filteredRoles ? { ...this.#project, modelRoles: filteredRoles } : this.#project;
1734
+ }
1735
+
1356
1736
  #rebuildMerged(): void {
1357
- this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project);
1737
+ this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#projectSettingsForMerge());
1358
1738
  this.#merged = this.#deepMerge(this.#merged, this.#configOverlay);
1359
1739
  this.#merged = this.#deepMerge(this.#merged, this.#overrides);
1360
1740
  this.#resolvedCache.clear();
package/src/cursor.ts CHANGED
@@ -18,9 +18,20 @@ import { resolveToCwd } from "./tools/path-utils";
18
18
 
19
19
  interface CursorExecBridgeOptions {
20
20
  cwd: string;
21
+ getCwd?: () => string;
21
22
  tools: Map<string, AgentTool>;
23
+ getTool?: (name: string) => AgentTool | undefined;
22
24
  getToolContext?: () => AgentToolContext | undefined;
23
25
  emitEvent?: (event: AgentEvent) => void;
26
+ /**
27
+ * Whether the Cursor native `delete` frame may remove files. Unlike every
28
+ * other exec handler, `executeDelete` mutates the filesystem directly instead
29
+ * of consulting {@link tools}, so a background read-only advisor could delete
30
+ * workspace files it was never granted a mutating tool for (issue #5680
31
+ * review). Defaults to allowed to preserve the primary agent's behavior;
32
+ * callers with a restricted tool set (advisors) opt out.
33
+ */
34
+ allowNativeDelete?: boolean;
24
35
  }
25
36
 
26
37
  function createToolResultMessage(
@@ -53,7 +64,7 @@ async function executeTool(
53
64
  toolCallId: string,
54
65
  args: Record<string, unknown>,
55
66
  ): Promise<ToolResultMessage> {
56
- const tool = options.tools.get(toolName);
67
+ const tool = options.tools.get(toolName) ?? options.getTool?.(toolName);
57
68
  if (!tool) {
58
69
  const result = buildToolErrorResult(`Tool "${toolName}" not available`);
59
70
  return createToolResultMessage(toolCallId, toolName, result, true);
@@ -106,9 +117,15 @@ async function executeTool(
106
117
 
107
118
  async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
108
119
  const toolName = "delete";
120
+
121
+ if (options.allowNativeDelete === false) {
122
+ const result = buildToolErrorResult(`Tool "${toolName}" not available`);
123
+ return createToolResultMessage(toolCallId, toolName, result, true);
124
+ }
125
+
109
126
  options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
110
127
 
111
- const absolutePath = resolveToCwd(pathArg, options.cwd);
128
+ const absolutePath = resolveToCwd(pathArg, options.getCwd?.() ?? options.cwd);
112
129
  let isError = false;
113
130
  let result: AgentToolResult<unknown>;
114
131
 
@@ -327,7 +344,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
327
344
  async mcp(call: CursorMcpCall) {
328
345
  const toolName = call.toolName || call.name;
329
346
  const toolCallId = decodeToolCallId(call.toolCallId);
330
- const tool = this.options.tools.get(toolName);
347
+ const tool = this.options.tools.get(toolName) ?? this.options.getTool?.(toolName);
331
348
  if (!tool) {
332
349
  const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
333
350
  const message = formatMcpToolErrorMessage(toolName, availableTools);
@@ -105,9 +105,10 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
105
105
  files.push("config.json");
106
106
  }
107
107
 
108
- // Recent logs (last 1000 lines)
109
- const logPath = getLogPath();
110
- const logs = await readLastLines(logPath, 1000);
108
+ // Recent logs (last 1000 lines) across every same-day process. PID-qualified
109
+ // filenames mean a report generated from a later invocation must still gather
110
+ // the crashed process's log, so read all of today's files, not just our own.
111
+ const logs = await collectSameDayLogs(1000);
111
112
  if (logs) {
112
113
  data["logs.txt"] = logs;
113
114
  files.push("logs.txt");
@@ -241,7 +242,42 @@ export async function getLogText(): Promise<string> {
241
242
  return readLastLines(getLogPath(), MAX_LOG_LINES);
242
243
  }
243
244
 
244
- const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.log$`);
245
+ /**
246
+ * Concatenate the tail of every same-day process log so a report generated
247
+ * after a crash still captures the fatal PID's `omp.<date>.<pid>.log`. Files
248
+ * are ordered oldest-first by mtime and separated by a filename header.
249
+ */
250
+ async function collectSameDayLogs(linesPerFile: number): Promise<string> {
251
+ const logsDir = getLogsDir();
252
+ const today = new Date().toISOString().slice(0, 10);
253
+ const sameDay: Array<{ name: string; mtimeMs: number }> = [];
254
+ try {
255
+ const entries = await fs.readdir(logsDir, { withFileTypes: true });
256
+ for (const entry of entries) {
257
+ if (!entry.isFile()) continue;
258
+ const match = LOG_FILE_PATTERN.exec(entry.name);
259
+ if (!match || match[1] !== today) continue;
260
+ try {
261
+ const stat = await fs.stat(path.join(logsDir, entry.name));
262
+ sameDay.push({ name: entry.name, mtimeMs: stat.mtimeMs });
263
+ } catch {
264
+ // File may have rotated away between readdir and stat.
265
+ }
266
+ }
267
+ } catch {
268
+ return "";
269
+ }
270
+ sameDay.sort((a, b) => a.mtimeMs - b.mtimeMs);
271
+
272
+ const chunks: string[] = [];
273
+ for (const { name } of sameDay) {
274
+ const text = await readLastLines(path.join(logsDir, name), linesPerFile);
275
+ if (text) chunks.push(`===== ${name} =====\n${text}`);
276
+ }
277
+ return chunks.join("\n\n");
278
+ }
279
+
280
+ const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.\\d+\\.log(?:\\.\\d+)?$`);
245
281
 
246
282
  export async function createDebugLogSource(): Promise<DebugLogSource> {
247
283
  const logsDir = getLogsDir();