@gajae-code/coding-agent 0.18.10 → 0.18.12

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 (270) hide show
  1. package/CHANGELOG.md +295 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. package/src/web/search/providers/searxng.ts +62 -10
@@ -24,6 +24,8 @@ import { nativeProcessBindings } from "@gajae-code/utils/native-process";
24
24
 
25
25
  export interface FileLockOptions {
26
26
  staleMs?: number;
27
+ /** Age after which an ownerless removal transition may be adopted; defaults to `staleMs`. */
28
+ orphanTransitionAgeMs?: number;
27
29
  retries?: number;
28
30
  retryDelayMs?: number;
29
31
  signal?: AbortSignal;
@@ -91,7 +93,10 @@ export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcqui
91
93
  }
92
94
 
93
95
  const DEFAULT_OPTIONS: Required<
94
- Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired" | "onContended">
96
+ Omit<
97
+ FileLockOptions,
98
+ "orphanTransitionAgeMs" | "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired" | "onContended"
99
+ >
95
100
  > = {
96
101
  staleMs: 10_000,
97
102
  retries: 50,
@@ -104,11 +109,19 @@ const DEFAULT_OPTIONS: Required<
104
109
  * is explicitly pre-mutation in this case, so retrying the same source name cannot
105
110
  * publish twice or clean up a committed namespace change.
106
111
  */
107
- const PUBLICATION_SHARING_RETRY_ATTEMPTS = 3;
108
- const PUBLICATION_SHARING_RETRY_DELAY_MS = 10;
112
+ const PUBLICATION_CONTENTION_RETRY_ATTEMPTS = 3;
113
+ const PUBLICATION_CONTENTION_RETRY_DELAY_MS = 10;
109
114
 
110
- /** Ownerless empty residue must outlive both this grace and the acquire budget. */
115
+ /** Grace period after which an unpublished removal transition becomes reclaimable as orphan. */
111
116
  const REMOVAL_TRANSITION_GRACE_MS = 10_000;
117
+ /**
118
+ * Additional buffer beyond the grace period to ensure a retained removal transition
119
+ * whose owner died is reclaimed promptly, decoupled from acquisition retry budget.
120
+ * This bounds how long a dead owner's transition can block lock acquisition.
121
+ */
122
+ const REMOVAL_TRANSITION_ADOPTION_BUFFER_MS = 5_000;
123
+ export const FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS =
124
+ REMOVAL_TRANSITION_GRACE_MS + REMOVAL_TRANSITION_ADOPTION_BUFFER_MS;
112
125
 
113
126
  /** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
114
127
  export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
@@ -137,6 +150,10 @@ export const FileLockTestHooks: {
137
150
  renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
138
151
  renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
139
152
  };
153
+ stagingIdentityEvidence?: () => {
154
+ processIncarnation: string | null;
155
+ startTime: string | null;
156
+ };
140
157
  nativeQuarantineBindings?: () => NativeFileLockBindings;
141
158
  nativeExactRemovalProbe?: () => boolean | Promise<boolean>;
142
159
  } = {};
@@ -806,8 +823,18 @@ async function exactRemoveLockTree(
806
823
  const transitionPath = fileLockRemovalTransitionPath(lockPath);
807
824
  const recordPath = `${transitionPath}.owner`;
808
825
  const prior = await readRemovalTransitionOwnerRecord(transitionPath);
809
- if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, []))
826
+ if (prior?.owner.owner_host_id !== undefined && lockRecordIsForeignHost(prior.owner, ownerHostId, [])) {
827
+ // A foreign transition remains untouchable. Once its tree is gone, only
828
+ // the remover's final record clear is pending: wait without probing its
829
+ // PID or deleting its record, even for an unqualified successor.
830
+ try {
831
+ await fs.lstat(transitionPath);
832
+ } catch (error) {
833
+ if (!isEnoent(error)) throw error;
834
+ return { ok: false, code: "removal_record_busy" };
835
+ }
810
836
  return { ok: false, code: "identity_mismatch" };
837
+ }
811
838
  if (prior && !ownerIsAlive(prior.owner) && ownerLiveness(prior.owner.pid) !== "unknown") {
812
839
  try {
813
840
  await fs.lstat(transitionPath);
@@ -818,8 +845,12 @@ async function exactRemoveLockTree(
818
845
  if (!(await removeRemovalRecord(recordPath, JSON.stringify(prior))))
819
846
  return { ok: false, code: "identity_mismatch" };
820
847
  }
848
+ // A live remover still owns the sibling record, usually the previous lock
849
+ // generation whose tree is gone but whose record clear is still in flight.
850
+ // That is contention, not a changed owner: identity_mismatch makes a release
851
+ // final and strands this live owner's lock for every other process.
821
852
  if (!(await publishRemovalRecord(transitionPath, snapshot, ownerHostId)))
822
- return { ok: false, code: "identity_mismatch" };
853
+ return { ok: false, code: "removal_record_busy" };
823
854
  try {
824
855
  return nativeFileLockBindings().exactRemoveDirectoryTree(lockPath, snapshot, parentIdentity, detachOnly);
825
856
  } finally {
@@ -1192,6 +1223,7 @@ function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceR
1192
1223
  "identity_violation",
1193
1224
  "durability_not_provable",
1194
1225
  "sharing_violation",
1226
+ "access_denied",
1195
1227
  "unknown",
1196
1228
  ] as const
1197
1229
  ).includes(value.reason as never) ||
@@ -1302,19 +1334,19 @@ function isPreMutationUnsupportedRenameResult(value: unknown): value is NativeNo
1302
1334
  return true;
1303
1335
  }
1304
1336
 
1305
- function isPreMutationSharingViolation(value: unknown): value is NativeNoReplaceResult {
1337
+ function isPreMutationPublicationContention(value: unknown): boolean {
1306
1338
  return (
1307
1339
  isValidNativeNoReplaceResult(value) &&
1308
1340
  !value.ok &&
1309
1341
  value.code === "sharing_violation" &&
1310
1342
  value.mutationState === "not_committed" &&
1311
1343
  value.durabilityState === "not_attempted" &&
1312
- value.reason === "sharing_violation" &&
1344
+ value.reason === value.code &&
1313
1345
  value.phase === "rename"
1314
1346
  );
1315
1347
  }
1316
1348
 
1317
- async function publishNoReplaceWithSharingRetry(
1349
+ async function publishNoReplaceWithContentionRetry(
1318
1350
  publish: (sourcePath: string, destinationPath: string) => Promise<NativeNoReplaceResult>,
1319
1351
  sourcePath: string,
1320
1352
  destinationPath: string,
@@ -1322,10 +1354,10 @@ async function publishNoReplaceWithSharingRetry(
1322
1354
  let result = await publish(sourcePath, destinationPath);
1323
1355
  for (
1324
1356
  let attempt = 1;
1325
- attempt < PUBLICATION_SHARING_RETRY_ATTEMPTS && isPreMutationSharingViolation(result);
1357
+ attempt < PUBLICATION_CONTENTION_RETRY_ATTEMPTS && isPreMutationPublicationContention(result);
1326
1358
  attempt++
1327
1359
  ) {
1328
- await Bun.sleep(PUBLICATION_SHARING_RETRY_DELAY_MS * attempt);
1360
+ await Bun.sleep(PUBLICATION_CONTENTION_RETRY_DELAY_MS * attempt);
1329
1361
  result = await publish(sourcePath, destinationPath);
1330
1362
  }
1331
1363
  return result;
@@ -2003,7 +2035,10 @@ async function tryAcquireLock(
2003
2035
  await ensureLockParent(path.dirname(lockPath));
2004
2036
  const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
2005
2037
  if (afterParentMkdir) await afterParentMkdir(lockPath);
2006
- const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
2038
+ const stagingIdentity = currentFileLockStagingIdentity();
2039
+ const pendingPath = stagingIdentity
2040
+ ? `${lockPath}.pending.${process.pid}.${stagingIdentity}.${crypto.randomBytes(12).toString("base64url")}`
2041
+ : `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
2007
2042
  const owner = lockInfo(ownerHostId, ownerToken);
2008
2043
  let removePending = false;
2009
2044
  try {
@@ -2083,7 +2118,7 @@ async function tryAcquireLock(
2083
2118
  renameNoReplacePathAsync,
2084
2119
  renameDirectoryNoReplacePathAsync,
2085
2120
  };
2086
- const published = await publishNoReplaceWithSharingRetry(
2121
+ const published = await publishNoReplaceWithContentionRetry(
2087
2122
  publication.renameNoReplacePathAsync,
2088
2123
  canonicalPendingPath,
2089
2124
  destinationPath,
@@ -2092,7 +2127,7 @@ async function tryAcquireLock(
2092
2127
  if (published.ok && !publishedSuccessfully)
2093
2128
  throw new Error("Failed to publish file lock: invalid primary success receipt.");
2094
2129
  if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
2095
- const fallback = await publishNoReplaceWithSharingRetry(
2130
+ const fallback = await publishNoReplaceWithContentionRetry(
2096
2131
  publication.renameDirectoryNoReplacePathAsync,
2097
2132
  canonicalPendingPath,
2098
2133
  destinationPath,
@@ -2117,6 +2152,8 @@ async function tryAcquireLock(
2117
2152
  }
2118
2153
  if (fallbackSuccessfully || verifiedCommittedPublication) {
2119
2154
  publishedSuccessfully = true;
2155
+ } else if (isPreMutationPublicationContention(fallback)) {
2156
+ return null;
2120
2157
  } else if (fallback.reason === "destination_exists") {
2121
2158
  return null;
2122
2159
  } else {
@@ -2128,7 +2165,7 @@ async function tryAcquireLock(
2128
2165
  }
2129
2166
  }
2130
2167
  if (!publishedSuccessfully) {
2131
- if (published.reason === "destination_exists") {
2168
+ if (isPreMutationPublicationContention(published) || published.reason === "destination_exists") {
2132
2169
  return null;
2133
2170
  }
2134
2171
  const failure = new Error(
@@ -2308,14 +2345,23 @@ async function quarantineReleasedLock(
2308
2345
  return false;
2309
2346
  }
2310
2347
 
2311
- async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
2348
+ async function releaseOwnedLock(
2349
+ lockPath: string,
2350
+ owner: FileLockOwnerToken,
2351
+ captureInitialIdentity = false,
2352
+ ): Promise<void> {
2312
2353
  if (await finishDetachedLockCleanup(owner)) return;
2313
- let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
2314
- try {
2315
- preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
2316
- if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
2317
- } catch (error) {
2318
- if (!isTransientReleaseError(error)) throw error;
2354
+ // Only the initial release may establish removal authority. Pending retries
2355
+ // must retain that evidence, even if a replacement carries identical info.
2356
+ // If the initial capture failed, a later pathname cannot supply authority.
2357
+ let preVerdictIdentity = fileLockDirIdentities.get(owner);
2358
+ if (captureInitialIdentity && !preVerdictIdentity) {
2359
+ try {
2360
+ preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
2361
+ if (preVerdictIdentity) fileLockDirIdentities.set(owner, preVerdictIdentity);
2362
+ } catch (error) {
2363
+ if (!isTransientReleaseError(error)) throw error;
2364
+ }
2319
2365
  }
2320
2366
  let lastTransientError: unknown;
2321
2367
  for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
@@ -2392,18 +2438,41 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
2392
2438
  return;
2393
2439
  }
2394
2440
  state.status = "releasing";
2395
- const releasePromise = releaseOwnedLock(lockPath, owner);
2441
+ const releasePromise = releaseOwnedLock(lockPath, owner, true);
2396
2442
  state.releasePromise = releasePromise;
2397
2443
  try {
2398
2444
  await releasePromise;
2399
2445
  if (localLockStates.get(key) === state) localLockStates.delete(key);
2400
2446
  } catch (error) {
2401
2447
  state.status = "release_pending";
2448
+ scheduleReleasePendingRetry(lockPath, key, state);
2402
2449
  throw error;
2403
2450
  } finally {
2404
2451
  if (state.releasePromise === releasePromise) state.releasePromise = undefined;
2405
2452
  }
2406
2453
  }
2454
+
2455
+ const RELEASE_PENDING_RETRY_DELAYS_MS = [250, 1_000, 5_000, 15_000, 60_000];
2456
+
2457
+ /**
2458
+ * A failed release otherwise waits for this process's next acquisition of the
2459
+ * same path. An idle process may never acquire again and other processes never
2460
+ * displace a live owner, so retry in the background. Each retry re-runs the full
2461
+ * guarded release and can only remove this owner generation.
2462
+ */
2463
+ function scheduleReleasePendingRetry(lockPath: string, key: string, state: LocalLockState, attempt = 0): void {
2464
+ const delay = RELEASE_PENDING_RETRY_DELAYS_MS[attempt];
2465
+ if (delay === undefined) {
2466
+ logger.warn("File lock release is still pending after background retries", { lockPath });
2467
+ return;
2468
+ }
2469
+ const timer = setTimeout(async () => {
2470
+ if (localLockStates.get(key) !== state) return;
2471
+ await retryPendingLocalRelease(lockPath, key).catch(() => undefined);
2472
+ if (localLockStates.get(key) === state) scheduleReleasePendingRetry(lockPath, key, state, attempt + 1);
2473
+ }, delay);
2474
+ timer.unref?.();
2475
+ }
2407
2476
  /**
2408
2477
  * Bounded, actionable description of who holds `lockPath` at exhaustion time.
2409
2478
  * Never a stealing authority: purely diagnostic, read once after the last retry.
@@ -2482,6 +2551,16 @@ async function lockHolderDescription(
2482
2551
  } catch (error) {
2483
2552
  if (!isEnoent(error)) throw error;
2484
2553
  }
2554
+ try {
2555
+ if ((await fs.readdir(lockPath)).length === 0) {
2556
+ return (
2557
+ "held by an ownerless empty lock directory; inspect and remove this exact directory manually only after " +
2558
+ `confirming no publisher remains: ${manualLockCleanupCommand(lockPath)}`
2559
+ );
2560
+ }
2561
+ } catch (error) {
2562
+ if (!isEnoent(error)) throw error;
2563
+ }
2485
2564
  return "held by an unrecognized owner record";
2486
2565
  } catch (error) {
2487
2566
  return `held by an unreadable owner (${(error as Error).message})`;
@@ -2497,7 +2576,9 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2497
2576
  if (options.previousOwnerHostIds?.some(hostId => !hostId))
2498
2577
  throw new Error("previousOwnerHostIds must contain only non-empty identities");
2499
2578
  const opts = { ...DEFAULT_OPTIONS, ...options };
2500
- const orphanTransitionAgeMs = Math.max(REMOVAL_TRANSITION_GRACE_MS, opts.retries * opts.retryDelayMs);
2579
+ const orphanTransitionAgeMs = options.orphanTransitionAgeMs ?? options.staleMs ?? DEFAULT_OPTIONS.staleMs;
2580
+ if (!Number.isFinite(orphanTransitionAgeMs) || orphanTransitionAgeMs < 0)
2581
+ throw new RangeError("orphanTransitionAgeMs must be a finite, non-negative number");
2501
2582
 
2502
2583
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
2503
2584
  const lockPath = getLockPath(filePath);
@@ -2527,16 +2608,20 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2527
2608
  ownerToken,
2528
2609
  opts.onAcquired,
2529
2610
  );
2530
- if (isFileLockOrphanTransition(result))
2531
- throw new FileLockAcquireError(
2532
- filePath,
2533
- lockPath,
2534
- attempt + 1,
2535
- `orphan transition retained at ${result.path}`,
2536
- "orphan_transition",
2537
- result.path,
2538
- );
2539
- if (result) {
2611
+ if (isFileLockOrphanTransition(result)) {
2612
+ // Adoption is identity-bound and can be refused by a transient cleanup race.
2613
+ // Keep using the caller's bounded contention budget; the same generation is
2614
+ // revalidated before every later adoption attempt.
2615
+ if (attempt + 1 === opts.retries)
2616
+ throw new FileLockAcquireError(
2617
+ filePath,
2618
+ lockPath,
2619
+ opts.retries,
2620
+ `orphan transition retained at ${result.path}`,
2621
+ "orphan_transition",
2622
+ result.path,
2623
+ );
2624
+ } else if (result) {
2540
2625
  localLockStates.set(localKey, { owner: result, status: "held" });
2541
2626
  return () => releaseLock(lockPath, result, localKey);
2542
2627
  }
@@ -2585,20 +2670,26 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2585
2670
  opts.signal.removeEventListener("abort", onAbort);
2586
2671
  }
2587
2672
  }
2673
+ const holder = await lockHolderDescription(
2674
+ lockPath,
2675
+ orphanTransitionAgeMs,
2676
+ opts.ownerHostId,
2677
+ opts.previousOwnerHostIds ?? [],
2678
+ );
2679
+ const transitionState = await classifyFileLockRemovalTransition(
2680
+ lockPath,
2681
+ orphanTransitionAgeMs,
2682
+ opts.ownerHostId,
2683
+ opts.previousOwnerHostIds ?? [],
2684
+ );
2685
+ const transitionPath = transitionState ? fileLockRemovalTransitionPath(lockPath) : undefined;
2588
2686
  throw new FileLockAcquireError(
2589
2687
  filePath,
2590
2688
  lockPath,
2591
2689
  opts.retries,
2592
- await lockHolderDescription(lockPath, orphanTransitionAgeMs, opts.ownerHostId, opts.previousOwnerHostIds ?? []),
2593
- "acquire_timeout",
2594
- (await classifyFileLockRemovalTransition(
2595
- lockPath,
2596
- orphanTransitionAgeMs,
2597
- opts.ownerHostId,
2598
- opts.previousOwnerHostIds ?? [],
2599
- ))
2600
- ? fileLockRemovalTransitionPath(lockPath)
2601
- : undefined,
2690
+ holder,
2691
+ transitionState === "orphan_transition" ? "orphan_transition" : "acquire_timeout",
2692
+ transitionPath,
2602
2693
  await staleRemovalFailureForCurrentGeneration(lockPath, staleRemovalFailure),
2603
2694
  );
2604
2695
  }
@@ -2630,14 +2721,54 @@ export async function withFileLock<T>(
2630
2721
  return result;
2631
2722
  }
2632
2723
 
2633
- /** Strictly recognize the staging names emitted by tryAcquireLock. */
2634
- export function fileLockStagingOwnerPid(name: string): number | null {
2635
- const match = /^.+\.lock\.pending\.([1-9]\d*)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.exec(
2636
- name,
2637
- );
2724
+ type FileLockStagingIdentity = { kind: "process_incarnation" | "start_time"; value: string };
2725
+ type FileLockStagingOwner = { pid: number; identity?: FileLockStagingIdentity };
2726
+
2727
+ const FILE_LOCK_STAGING_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
2728
+ const FILE_LOCK_STAGING_NAME_WITH_IDENTITY =
2729
+ /^.+\.lock\.pending\.([1-9]\d*)\.([is][A-Za-z0-9_-]{16})\.([A-Za-z0-9_-]{16})$/;
2730
+ const LEGACY_FILE_LOCK_STAGING_NAME = new RegExp(`^.+\\.lock\\.pending\\.([1-9]\\d*)\\.${FILE_LOCK_STAGING_UUID}$`);
2731
+
2732
+ function fileLockStagingOwner(name: string): FileLockStagingOwner | null {
2733
+ const current = FILE_LOCK_STAGING_NAME_WITH_IDENTITY.exec(name);
2734
+ const legacy = current ? null : LEGACY_FILE_LOCK_STAGING_NAME.exec(name);
2735
+ const match = current ?? legacy;
2638
2736
  if (!match) return null;
2639
2737
  const pid = Number(match[1]);
2640
- return Number.isSafeInteger(pid) ? pid : null;
2738
+ if (!Number.isSafeInteger(pid)) return null;
2739
+ if (!current) return { pid };
2740
+ const encodedIdentity = current[2] ?? "";
2741
+ return {
2742
+ pid,
2743
+ identity: {
2744
+ kind: encodedIdentity.startsWith("i") ? "process_incarnation" : "start_time",
2745
+ value: encodedIdentity.slice(1),
2746
+ },
2747
+ };
2748
+ }
2749
+
2750
+ function fileLockStagingIdentityHash(identity: string): string {
2751
+ // Ninety-six bits are sufficient to disambiguate process generations while
2752
+ // keeping the sibling name within the old component-length envelope.
2753
+ return crypto.createHash("sha256").update(identity, "utf8").digest("base64url").slice(0, 16);
2754
+ }
2755
+
2756
+ /**
2757
+ * A missing generation is represented by a legacy UUID name that forces a
2758
+ * liveness probe; never hash a synthetic "unknown" as if it were identity evidence.
2759
+ */
2760
+ function currentFileLockStagingIdentity(): string | null {
2761
+ const evidence = FileLockTestHooks.stagingIdentityEvidence?.();
2762
+ const incarnation = evidence ? evidence.processIncarnation : currentProcessIncarnation();
2763
+ if (incarnation) return `i${fileLockStagingIdentityHash(incarnation)}`;
2764
+ const startTime = evidence ? evidence.startTime : currentProcessStartTime();
2765
+ if (!startTime || startTime === "unknown") return null;
2766
+ return `s${fileLockStagingIdentityHash(startTime)}`;
2767
+ }
2768
+
2769
+ /** Return a name-encoded staging PID for GC discovery. */
2770
+ export function fileLockStagingOwnerPid(name: string): number | null {
2771
+ return fileLockStagingOwner(name)?.pid ?? null;
2641
2772
  }
2642
2773
 
2643
2774
  export interface FileLockStagingResult {
@@ -2660,8 +2791,9 @@ export async function inspectFileLockStagingDir(
2660
2791
  removed: false,
2661
2792
  reason: "unverified_staging_directory",
2662
2793
  };
2663
- const namePid = fileLockStagingOwnerPid(path.basename(stagingPath));
2664
- if (namePid === null) return kept;
2794
+ const nameOwner = fileLockStagingOwner(path.basename(stagingPath));
2795
+ if (!nameOwner) return kept;
2796
+ const namePid = nameOwner.pid;
2665
2797
  const canonical = await canonicalLockPathPreservingFinal(stagingPath);
2666
2798
  // A winning acquirer publishes (renames) its staging directory concurrently, so the
2667
2799
  // candidate can disappear between the caller's readdir and this observation.
@@ -2673,6 +2805,36 @@ export async function inspectFileLockStagingDir(
2673
2805
  return { ...kept, reason: "enoent_already_gone" };
2674
2806
  }
2675
2807
  if (!root.isDirectory() || root.isSymbolicLink()) return kept;
2808
+ let namePidStatus: OwnerLiveness;
2809
+ if (nameOwner.identity) {
2810
+ const currentIdentity =
2811
+ nameOwner.identity.kind === "process_incarnation" ? processIncarnation(namePid) : processStartTime(namePid);
2812
+ if (currentIdentity !== null && fileLockStagingIdentityHash(currentIdentity) === nameOwner.identity.value) {
2813
+ return { ...kept, pid: namePid, status: "alive", reason: "live_staging_name_owner" };
2814
+ }
2815
+ const syntheticStartTimeFallback =
2816
+ nameOwner.identity.kind === "start_time" &&
2817
+ (nameOwner.identity.value === fileLockStagingIdentityHash(`${namePid}:unknown`) ||
2818
+ (currentIdentity !== null &&
2819
+ nameOwner.identity.value === fileLockStagingIdentityHash(`${namePid}:${currentIdentity}`)));
2820
+ // Older publishers hashed `${pid}:${startTime}` or `${pid}:unknown` when
2821
+ // start-time evidence was unavailable. A later raw start-time mismatch is
2822
+ // not proof those already-running staging owners are dead.
2823
+ namePidStatus = currentIdentity === null || syntheticStartTimeFallback ? probe(namePid) : "dead";
2824
+ } else {
2825
+ namePidStatus = probe(namePid);
2826
+ }
2827
+ if (namePidStatus !== "dead") {
2828
+ // Do not open the publication source while its owner may be live. On Windows an
2829
+ // info-file handle can prevent the owner's atomic directory rename. Legacy names
2830
+ // without generation evidence and unknown liveness fail closed.
2831
+ return {
2832
+ ...kept,
2833
+ pid: namePid,
2834
+ status: namePidStatus,
2835
+ reason: namePidStatus === "alive" ? "live_staging_name_owner" : "staging_name_owner_liveness_unknown",
2836
+ };
2837
+ }
2676
2838
  const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
2677
2839
  if (
2678
2840
  !captured.ok ||
@@ -2702,10 +2864,8 @@ export async function inspectFileLockStagingDir(
2702
2864
  if (captured.snapshot.entries.some(entry => entry.relativePath !== "")) return kept;
2703
2865
  pid = namePid;
2704
2866
  }
2705
- if (pid === process.pid || namePid === process.pid) {
2706
- return { ...kept, pid, status: "alive", reason: "in_process_staging_owner" };
2707
- }
2708
- let status = probe(pid);
2867
+ let status = pid === namePid ? namePidStatus : probe(pid);
2868
+ if (pid === process.pid && observation && !ownerIncarnationChanged(observation.info)) status = "alive";
2709
2869
  if (status === "alive" && observation && ownerIncarnationChanged(observation.info)) status = "dead";
2710
2870
  const result: FileLockStagingResult = {
2711
2871
  path: stagingPath,
@@ -10,12 +10,14 @@ export interface ConfiguredModelBindings {
10
10
  export class ModelBindingsApplier {
11
11
  #targetSettings: Settings | undefined;
12
12
  #bindings: ConfiguredModelBindings | undefined;
13
+ #lastAppliedRoles = new Map<string, ModelSelectorValue>();
14
+ #lastAppliedAgentOverrides = new Map<string, ModelSelectorValue>();
15
+ #manualRoles = new Map<string, ModelSelectorValue | undefined>();
16
+ #manualAgentOverrides = new Map<string, ModelSelectorValue | undefined>();
13
17
  #appliedRoles = new Set<string>();
14
18
  #appliedAgentOverrides = new Set<string>();
15
19
  #roleBaselines = new Map<string, ModelSelectorValue | undefined>();
16
20
  #agentBaselines = new Map<string, ModelSelectorValue | undefined>();
17
- #lastAppliedRoles = new Map<string, ModelSelectorValue>();
18
- #lastAppliedAgentOverrides = new Map<string, ModelSelectorValue>();
19
21
 
20
22
  /** The currently configured bindings (as installed at startup), for baseline lookup. */
21
23
  getBindings(): ConfiguredModelBindings | undefined {
@@ -71,6 +73,8 @@ export class ModelBindingsApplier {
71
73
  this.#targetSettings = targetSettings;
72
74
  const bindings = this.#bindings;
73
75
  if (!targetSettings) return;
76
+ for (const role of Object.keys(bindings?.modelRoles ?? {})) this.#manualRoles.delete(role);
77
+ for (const role of Object.keys(bindings?.agentModelOverrides ?? {})) this.#manualAgentOverrides.delete(role);
74
78
  const modelRoles = { ...(targetSettings.get("modelRoles") ?? {}) };
75
79
  this.#forceSync(
76
80
  modelRoles,
@@ -98,6 +102,13 @@ export class ModelBindingsApplier {
98
102
  baselines: Map<string, ModelSelectorValue | undefined>,
99
103
  lastApplied: Map<string, ModelSelectorValue>,
100
104
  ): void {
105
+ const configuredKeys = new Set(Object.keys(configured));
106
+ for (const key of applied) {
107
+ if (configuredKeys.has(key)) continue;
108
+ const baseline = baselines.get(key);
109
+ if (baseline === undefined) delete target[key];
110
+ else target[key] = this.#clone(baseline)!;
111
+ }
101
112
  for (const [key, value] of Object.entries(configured)) {
102
113
  if (!baselines.has(key)) baselines.set(key, this.#clone(target[key]));
103
114
  target[key] = this.#clone(value)!;
@@ -111,82 +122,109 @@ export class ModelBindingsApplier {
111
122
  const targetSettings = this.#targetSettings;
112
123
  if (!targetSettings) return;
113
124
  const bindings = this.#bindings;
114
- const nextModelRoles = { ...(targetSettings.get("modelRoles") ?? {}) };
115
125
  this.#sync(
116
- nextModelRoles,
126
+ targetSettings,
127
+ "modelRoles",
117
128
  bindings?.modelRoles ?? {},
118
- this.#appliedRoles,
119
- this.#roleBaselines,
120
129
  this.#lastAppliedRoles,
130
+ this.#manualRoles,
131
+ this.#roleBaselines,
121
132
  );
122
- targetSettings.override("modelRoles", nextModelRoles);
123
-
124
- const nextAgentOverrides = { ...(targetSettings.get("task.agentModelOverrides") ?? {}) };
125
133
  this.#sync(
126
- nextAgentOverrides,
134
+ targetSettings,
135
+ "task.agentModelOverrides",
127
136
  bindings?.agentModelOverrides ?? {},
128
- this.#appliedAgentOverrides,
129
- this.#agentBaselines,
130
137
  this.#lastAppliedAgentOverrides,
138
+ this.#manualAgentOverrides,
139
+ this.#agentBaselines,
131
140
  );
132
- targetSettings.override("task.agentModelOverrides", nextAgentOverrides);
133
141
  }
134
142
 
135
143
  #restoreTarget(targetSettings: Settings): void {
136
- const modelRoles = { ...(targetSettings.get("modelRoles") ?? {}) };
137
- this.#sync(modelRoles, {}, this.#appliedRoles, this.#roleBaselines, this.#lastAppliedRoles);
138
- targetSettings.override("modelRoles", modelRoles);
139
-
140
- const agentOverrides = { ...(targetSettings.get("task.agentModelOverrides") ?? {}) };
141
144
  this.#sync(
142
- agentOverrides,
145
+ targetSettings,
146
+ "task.agentModelOverrides",
143
147
  {},
144
- this.#appliedAgentOverrides,
145
- this.#agentBaselines,
146
148
  this.#lastAppliedAgentOverrides,
149
+ this.#manualAgentOverrides,
150
+ this.#agentBaselines,
147
151
  );
148
- targetSettings.override("task.agentModelOverrides", agentOverrides);
152
+ this.#sync(targetSettings, "modelRoles", {}, this.#lastAppliedRoles, this.#manualRoles, this.#roleBaselines);
149
153
  }
150
154
 
151
155
  #clearTargetLifecycle(): void {
152
- this.#appliedRoles.clear();
153
- this.#appliedAgentOverrides.clear();
154
- this.#roleBaselines.clear();
155
- this.#agentBaselines.clear();
156
156
  this.#lastAppliedRoles.clear();
157
157
  this.#lastAppliedAgentOverrides.clear();
158
+ this.#manualRoles.clear();
159
+ this.#manualAgentOverrides.clear();
160
+ this.#roleBaselines.clear();
161
+ this.#agentBaselines.clear();
158
162
  }
159
163
 
160
164
  #sync(
161
- target: Record<string, ModelSelectorValue>,
165
+ targetSettings: Settings,
166
+ settingPath: "modelRoles" | "task.agentModelOverrides",
162
167
  configured: Record<string, ModelSelectorValue>,
163
- applied: Set<string>,
164
- baselines: Map<string, ModelSelectorValue | undefined>,
165
168
  lastApplied: Map<string, ModelSelectorValue>,
169
+ manualOverrides: Map<string, ModelSelectorValue | undefined>,
170
+ baselines: Map<string, ModelSelectorValue | undefined>,
166
171
  ): void {
167
172
  const configuredKeys = new Set(Object.keys(configured));
168
- for (const key of applied) {
169
- if (configuredKeys.has(key)) continue;
170
- const previous = lastApplied.get(key);
171
- if (previous !== undefined && this.#equal(target[key], previous)) {
172
- const baseline = baselines.get(key);
173
- if (baseline === undefined) delete target[key];
174
- else target[key] = this.#clone(baseline)!;
173
+ const current = (targetSettings.get(settingPath) ?? {}) as Record<string, ModelSelectorValue>;
174
+ const global = (targetSettings.getGlobal(settingPath) ?? {}) as Record<string, ModelSelectorValue>;
175
+ const runtime = targetSettings.getOverride(settingPath) ?? {};
176
+ for (const key of configuredKeys) {
177
+ if (!lastApplied.has(key) && !baselines.has(key)) baselines.set(key, this.#clone(runtime[key]));
178
+ }
179
+
180
+ // Manual bindings remain authoritative, but their cached values must follow
181
+ // later edits and removals from the runtime override slots.
182
+ for (const key of manualOverrides.keys()) {
183
+ manualOverrides.set(key, this.#clone(runtime[key]));
184
+ }
185
+
186
+ // Keep existing non-global values that belong to another runtime layer.
187
+ for (const [key, value] of Object.entries(current)) {
188
+ if (configuredKeys.has(key) || lastApplied.has(key) || manualOverrides.has(key)) continue;
189
+ if (!this.#equal(value, global[key])) manualOverrides.set(key, this.#clone(value)!);
190
+ }
191
+
192
+ // A value changed while our previous overlay was active is a manual edit.
193
+ for (const [key, previous] of lastApplied) {
194
+ const currentValue = current[key];
195
+ if (!Object.hasOwn(runtime, key) || !Object.hasOwn(current, key)) {
196
+ manualOverrides.set(key, undefined);
197
+ } else if (!this.#equal(runtime[key], previous)) {
198
+ manualOverrides.set(key, this.#clone(runtime[key])!);
199
+ } else if (!this.#equal(currentValue, previous)) {
200
+ manualOverrides.set(key, this.#clone(currentValue)!);
175
201
  }
176
- baselines.delete(key);
177
- lastApplied.delete(key);
178
202
  }
179
- for (const [key, value] of Object.entries(configured)) {
180
- const previous = lastApplied.get(key);
181
- if (!baselines.has(key)) baselines.set(key, this.#clone(target[key]));
182
- if (previous === undefined || this.#equal(target[key], previous)) {
183
- const appliedValue = this.#clone(value)!;
184
- target[key] = appliedValue;
185
- lastApplied.set(key, this.#clone(appliedValue)!);
203
+
204
+ const nextOverrides: Record<string, ModelSelectorValue> = {};
205
+ for (const [key, value] of Object.entries(runtime)) {
206
+ if (!lastApplied.has(key) && !configuredKeys.has(key)) nextOverrides[key] = this.#clone(value)!;
207
+ }
208
+ for (const [key, baseline] of baselines) {
209
+ if (!configuredKeys.has(key)) {
210
+ if (baseline !== undefined) nextOverrides[key] = this.#clone(baseline)!;
211
+ baselines.delete(key);
186
212
  }
187
213
  }
188
- applied.clear();
189
- for (const key of configuredKeys) applied.add(key);
214
+ for (const [key, value] of manualOverrides) {
215
+ if (value === undefined) delete nextOverrides[key];
216
+ else nextOverrides[key] = this.#clone(value)!;
217
+ }
218
+ lastApplied.clear();
219
+ for (const [key, value] of Object.entries(configured)) {
220
+ if (manualOverrides.has(key)) continue;
221
+ const appliedValue = this.#clone(value)!;
222
+ nextOverrides[key] = appliedValue;
223
+ lastApplied.set(key, this.#clone(appliedValue)!);
224
+ }
225
+
226
+ if (Object.keys(nextOverrides).length === 0) targetSettings.clearOverride(settingPath);
227
+ else targetSettings.override(settingPath, nextOverrides);
190
228
  }
191
229
 
192
230
  #cloneBindings(