@gajae-code/coding-agent 0.10.1 → 0.11.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 (474) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/cli/args.d.ts +4 -5
  4. package/dist/types/cli/daemon-cli.d.ts +12 -1
  5. package/dist/types/cli/notify-cli.d.ts +21 -1
  6. package/dist/types/cli.d.ts +3 -0
  7. package/dist/types/commands/daemon.d.ts +39 -0
  8. package/dist/types/commands/launch.d.ts +3 -0
  9. package/dist/types/commands/notify.d.ts +28 -1
  10. package/dist/types/commands/sdk.d.ts +33 -0
  11. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  12. package/dist/types/config/model-profile-activation.d.ts +35 -15
  13. package/dist/types/config/model-profiles.d.ts +11 -5
  14. package/dist/types/config/model-registry.d.ts +3 -3
  15. package/dist/types/config/model-resolver.d.ts +39 -6
  16. package/dist/types/config/model-selector-value.d.ts +17 -0
  17. package/dist/types/config/models-config-schema.d.ts +10 -6
  18. package/dist/types/config/settings-schema.d.ts +133 -6
  19. package/dist/types/config/settings.d.ts +63 -35
  20. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  21. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  22. package/dist/types/daemon/builtin.d.ts +6 -5
  23. package/dist/types/daemon/control-types.d.ts +1 -1
  24. package/dist/types/discovery/mcp-json.d.ts +12 -1
  25. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  27. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  28. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  29. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  30. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  31. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  32. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  33. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  35. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  36. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  37. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  38. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  39. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  40. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  41. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  42. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  43. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  44. package/dist/types/harness-control-plane/types.d.ts +3 -4
  45. package/dist/types/lsp/index.d.ts +1 -1
  46. package/dist/types/main.d.ts +32 -38
  47. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  48. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  49. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  50. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  51. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  52. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  53. package/dist/types/modes/components/gajae-pet-widget.d.ts +1 -0
  54. package/dist/types/modes/components/model-selector.d.ts +1 -0
  55. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  56. package/dist/types/modes/components/pet-capability.d.ts +37 -0
  57. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  58. package/dist/types/modes/components/settings-selector.d.ts +15 -4
  59. package/dist/types/modes/components/welcome.d.ts +1 -1
  60. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  61. package/dist/types/modes/index.d.ts +0 -4
  62. package/dist/types/modes/interactive-mode.d.ts +5 -1
  63. package/dist/types/modes/runtime-init.d.ts +2 -2
  64. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  65. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  66. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  67. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  68. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  69. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
  70. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  71. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  72. package/dist/types/modes/types.d.ts +16 -1
  73. package/dist/types/runtime-mcp/client.d.ts +2 -0
  74. package/dist/types/runtime-mcp/config.d.ts +5 -1
  75. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  76. package/dist/types/runtime-mcp/types.d.ts +4 -0
  77. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  78. package/dist/types/sdk/acp/index.d.ts +2 -0
  79. package/dist/types/sdk/broker/broker.d.ts +53 -0
  80. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  81. package/dist/types/sdk/broker/ensure.d.ts +33 -0
  82. package/dist/types/sdk/broker/identity.d.ts +3 -0
  83. package/dist/types/sdk/broker/index.d.ts +8 -0
  84. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  85. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  86. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  87. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  88. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  89. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  90. package/dist/types/sdk/broker/transport.d.ts +9 -0
  91. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  95. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  96. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  97. package/dist/types/sdk/bus/config.d.ts +274 -0
  98. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  99. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  100. package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
  101. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  102. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  103. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  104. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  105. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  106. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  107. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  108. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  109. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  110. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  111. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  112. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  113. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  114. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  115. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  116. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  117. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  118. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  119. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  120. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
  121. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  122. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  123. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  124. package/dist/types/sdk/cli/index.d.ts +1 -0
  125. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  126. package/dist/types/sdk/client/client.d.ts +1 -0
  127. package/dist/types/sdk/client/discovery.d.ts +27 -0
  128. package/dist/types/sdk/client/index.d.ts +2 -0
  129. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  130. package/dist/types/sdk/host/control/index.d.ts +2 -0
  131. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  132. package/dist/types/sdk/host/events.d.ts +35 -0
  133. package/dist/types/sdk/host/host.d.ts +35 -0
  134. package/dist/types/sdk/host/index.d.ts +4 -0
  135. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  136. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  137. package/dist/types/sdk/host/query/index.d.ts +3 -0
  138. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  139. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  140. package/dist/types/sdk/host/types.d.ts +32 -0
  141. package/dist/types/sdk/index.d.ts +6 -0
  142. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  143. package/dist/types/sdk/mcp/index.d.ts +1 -0
  144. package/dist/types/sdk/mcp/server.d.ts +37 -0
  145. package/dist/types/sdk/models.d.ts +44 -0
  146. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  147. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  148. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  149. package/dist/types/sdk/startup-capability.d.ts +50 -0
  150. package/dist/types/session/agent-session.d.ts +114 -21
  151. package/dist/types/session/default-model-selection.d.ts +25 -0
  152. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  153. package/dist/types/session/session-manager.d.ts +58 -1
  154. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  155. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  156. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  157. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  158. package/dist/types/tools/browser/launch.d.ts +22 -0
  159. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  160. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  161. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  162. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  163. package/dist/types/tools/browser/registry.d.ts +19 -0
  164. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  165. package/dist/types/tools/index.d.ts +8 -8
  166. package/package.json +34 -13
  167. package/scripts/build-sdk-package-smoke.ts +94 -0
  168. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  169. package/scripts/compile-args.ts +4 -2
  170. package/scripts/g005-daemon-smoke.ts +4 -5
  171. package/scripts/g011-daemon-path-smoke.ts +5 -5
  172. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  173. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  174. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  175. package/scripts/manifest-schema.ts +124 -0
  176. package/scripts/run-test-manifest.ts +153 -0
  177. package/scripts/run-threat-model-check.ts +157 -0
  178. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  179. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  180. package/src/append-only-mode.ts +1 -1
  181. package/src/cli/args.ts +43 -12
  182. package/src/cli/auth-gateway-cli.ts +2 -2
  183. package/src/cli/config-cli.ts +2 -1
  184. package/src/cli/daemon-cli.ts +63 -14
  185. package/src/cli/list-models.ts +1 -1
  186. package/src/cli/notify-cli.ts +288 -286
  187. package/src/cli.ts +21 -2
  188. package/src/commands/daemon.ts +50 -6
  189. package/src/commands/harness.ts +93 -36
  190. package/src/commands/launch.ts +5 -2
  191. package/src/commands/mcp-serve.ts +19 -12
  192. package/src/commands/notify.ts +36 -3
  193. package/src/commands/sdk.ts +426 -0
  194. package/src/commit/agentic/agent.ts +1 -0
  195. package/src/config/atomic-yaml-patch.ts +423 -0
  196. package/src/config/model-profile-activation.ts +240 -89
  197. package/src/config/model-profiles.ts +37 -34
  198. package/src/config/model-registry.ts +51 -26
  199. package/src/config/model-resolver.ts +138 -51
  200. package/src/config/model-selector-value.ts +50 -0
  201. package/src/config/models-config-schema.ts +14 -21
  202. package/src/config/settings-schema.ts +128 -10
  203. package/src/config/settings.ts +1120 -158
  204. package/src/coordinator-mcp/model-preset.ts +74 -20
  205. package/src/coordinator-mcp/server.ts +1597 -3052
  206. package/src/daemon/builtin.ts +8 -5
  207. package/src/daemon/control-types.ts +1 -1
  208. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  209. package/src/discovery/mcp-json.ts +327 -20
  210. package/src/extensibility/custom-tools/types.ts +1 -1
  211. package/src/extensibility/extensions/compact-handler.ts +3 -3
  212. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  213. package/src/extensibility/extensions/loader.ts +69 -5
  214. package/src/extensibility/extensions/runner.ts +112 -4
  215. package/src/extensibility/extensions/types.ts +176 -17
  216. package/src/extensibility/hooks/types.ts +1 -1
  217. package/src/extensibility/slash-commands.ts +3 -3
  218. package/src/gjc-runtime/launch-tmux.ts +170 -86
  219. package/src/gjc-runtime/psmux-detect.ts +82 -4
  220. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  221. package/src/gjc-runtime/team-runtime.ts +54 -35
  222. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  223. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -2
  224. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  225. package/src/gjc-runtime/ultragoal-guard.ts +53 -6
  226. package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
  227. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  228. package/src/harness-control-plane/operate.ts +9 -9
  229. package/src/harness-control-plane/owner.ts +283 -39
  230. package/src/harness-control-plane/receipts.ts +0 -1
  231. package/src/harness-control-plane/sdk-transport.ts +395 -0
  232. package/src/harness-control-plane/session-transport.ts +94 -0
  233. package/src/harness-control-plane/types.ts +1 -1
  234. package/src/internal-urls/docs-index.generated.ts +36 -37
  235. package/src/lsp/client.ts +42 -6
  236. package/src/lsp/index.ts +2 -2
  237. package/src/main.ts +299 -204
  238. package/src/modes/DESIGN.md +258 -0
  239. package/src/modes/acp/acp-agent.ts +1228 -2612
  240. package/src/modes/acp/acp-client-bridge.ts +2 -20
  241. package/src/modes/acp/acp-event-mapper.ts +18 -2
  242. package/src/modes/acp/acp-mode.ts +8 -36
  243. package/src/modes/acp/permission-mode.ts +25 -0
  244. package/src/modes/acp/startup-options.ts +6 -0
  245. package/src/modes/components/agent-dashboard.ts +37 -17
  246. package/src/modes/components/custom-editor.ts +1 -0
  247. package/src/modes/components/gajae-pet-widget.ts +169 -20
  248. package/src/modes/components/model-selector.ts +93 -35
  249. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  250. package/src/modes/components/pet-capability.ts +115 -0
  251. package/src/modes/components/pet-selector.ts +4 -6
  252. package/src/modes/components/settings-selector.ts +86 -11
  253. package/src/modes/components/welcome.ts +4 -4
  254. package/src/modes/controllers/command-controller.ts +1 -2
  255. package/src/modes/controllers/event-controller.ts +11 -16
  256. package/src/modes/controllers/extension-ui-controller.ts +357 -12
  257. package/src/modes/controllers/input-controller.ts +43 -13
  258. package/src/modes/controllers/selector-controller.ts +671 -44
  259. package/src/modes/index.ts +0 -22
  260. package/src/modes/interactive-mode.ts +108 -24
  261. package/src/modes/runtime-init.ts +298 -6
  262. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  263. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  264. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  265. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  266. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  267. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
  268. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  269. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  270. package/src/modes/types.ts +16 -1
  271. package/src/runtime-mcp/client.ts +94 -19
  272. package/src/runtime-mcp/config.ts +27 -15
  273. package/src/runtime-mcp/manager.ts +343 -105
  274. package/src/runtime-mcp/oauth-flow.ts +42 -12
  275. package/src/runtime-mcp/transports/http.ts +49 -19
  276. package/src/runtime-mcp/transports/stdio.ts +47 -28
  277. package/src/runtime-mcp/types.ts +10 -0
  278. package/src/sdk/acp/adapter.ts +418 -0
  279. package/src/sdk/acp/index.ts +2 -0
  280. package/src/sdk/broker/broker.ts +642 -0
  281. package/src/sdk/broker/discovery.ts +94 -0
  282. package/src/sdk/broker/ensure.ts +249 -0
  283. package/src/sdk/broker/identity.ts +42 -0
  284. package/src/sdk/broker/index.ts +8 -0
  285. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  286. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  287. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  288. package/src/sdk/broker/lifecycle.ts +2157 -0
  289. package/src/sdk/broker/process-incarnation.ts +159 -0
  290. package/src/sdk/broker/runtime.ts +164 -0
  291. package/src/sdk/broker/session-index.ts +318 -0
  292. package/src/sdk/broker/state-version.ts +23 -0
  293. package/src/sdk/broker/transport.ts +125 -0
  294. package/src/sdk/bus/chat-command-policy.ts +107 -0
  295. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  296. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  297. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  298. package/src/sdk/bus/chat-effect-journal.ts +353 -0
  299. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  300. package/src/sdk/bus/config.ts +433 -0
  301. package/src/sdk/bus/conversation-store.ts +391 -0
  302. package/src/sdk/bus/discord-conversation.ts +85 -0
  303. package/src/sdk/bus/discord-daemon.ts +2275 -0
  304. package/src/sdk/bus/discord-live-provider.ts +521 -0
  305. package/src/sdk/bus/discord-provider.ts +56 -0
  306. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  307. package/src/sdk/bus/index.ts +3943 -0
  308. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  309. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  310. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  311. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  312. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  313. package/src/sdk/bus/natives-augment.d.ts +38 -0
  314. package/src/sdk/bus/notification-orchestration.ts +535 -0
  315. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  316. package/src/sdk/bus/session-control.ts +333 -0
  317. package/src/sdk/bus/slack-conversation.ts +74 -0
  318. package/src/sdk/bus/slack-daemon.ts +2144 -0
  319. package/src/sdk/bus/slack-live-provider.ts +380 -0
  320. package/src/sdk/bus/slack-provider.ts +76 -0
  321. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  322. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  323. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  324. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  325. package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
  326. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  327. package/src/sdk/bus/telegram-setup.ts +663 -0
  328. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  329. package/src/sdk/cli/index.ts +1 -0
  330. package/src/sdk/cli/session-cli.ts +275 -0
  331. package/src/sdk/client/client.ts +1 -0
  332. package/src/sdk/client/discovery.ts +108 -0
  333. package/src/sdk/client/index.ts +2 -0
  334. package/src/sdk/host/control/dispatch.ts +367 -0
  335. package/src/sdk/host/control/index.ts +10 -0
  336. package/src/sdk/host/control/operations.ts +63 -0
  337. package/src/sdk/host/events.ts +91 -0
  338. package/src/sdk/host/host.ts +296 -0
  339. package/src/sdk/host/index.ts +4 -0
  340. package/src/sdk/host/query/cursor.ts +189 -0
  341. package/src/sdk/host/query/handlers.ts +533 -0
  342. package/src/sdk/host/query/index.ts +3 -0
  343. package/src/sdk/host/query/revision-store.ts +1126 -0
  344. package/src/sdk/host/reverse-leases.ts +325 -0
  345. package/src/sdk/host/types.ts +29 -0
  346. package/src/sdk/index.ts +13 -0
  347. package/src/sdk/lifecycle-session.ts +39 -0
  348. package/src/sdk/mcp/index.ts +1 -0
  349. package/src/sdk/mcp/server.ts +332 -0
  350. package/src/sdk/models.ts +168 -0
  351. package/src/sdk/protocol/adapter-validation.ts +48 -0
  352. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  353. package/src/sdk/protocol/operation-registry.ts +312 -0
  354. package/src/{sdk.ts → sdk/session.ts} +339 -212
  355. package/src/sdk/startup-capability.ts +189 -0
  356. package/src/session/agent-session.ts +2470 -866
  357. package/src/session/default-model-selection.ts +93 -0
  358. package/src/session/fallback-chain-controller.ts +136 -0
  359. package/src/session/session-manager.ts +447 -13
  360. package/src/session/session-storage.ts +8 -1
  361. package/src/setup/credential-auto-import.ts +290 -35
  362. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  363. package/src/slash-commands/builtin-registry.ts +33 -30
  364. package/src/task/executor.ts +62 -13
  365. package/src/tools/ask-answer-registry.ts +1 -1
  366. package/src/tools/ask.ts +13 -8
  367. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  368. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  369. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  370. package/src/tools/browser/launch.ts +124 -19
  371. package/src/tools/browser/profile-discovery.ts +63 -0
  372. package/src/tools/browser/profile-posture.ts +59 -0
  373. package/src/tools/browser/profile-reuse.ts +102 -0
  374. package/src/tools/browser/profile-warmup.ts +109 -0
  375. package/src/tools/browser/registry.ts +87 -11
  376. package/src/tools/browser/tab-protocol.ts +1 -0
  377. package/src/tools/browser/tab-supervisor.ts +1 -0
  378. package/src/tools/browser/tab-worker.ts +1 -1
  379. package/src/tools/browser.ts +6 -0
  380. package/src/tools/index.ts +8 -8
  381. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  382. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  383. package/src/tools/telegram-send.ts +2 -2
  384. package/vendor/insane-search/MANIFEST.json +2 -1
  385. package/vendor/insane-search/engine/bias_check.py +4 -1
  386. package/vendor/insane-search/engine/phase0.py +26 -1
  387. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  388. package/dist/types/modes/bridge/auth.d.ts +0 -12
  389. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  390. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  391. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  392. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  393. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  394. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  395. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  396. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  397. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  398. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  399. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  400. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  401. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  402. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  403. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  404. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  405. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  406. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  407. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  408. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  409. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  410. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  411. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  412. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  413. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  414. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  415. package/dist/types/notifications/config.d.ts +0 -132
  416. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  417. package/src/modes/bridge/auth.ts +0 -41
  418. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  419. package/src/modes/bridge/bridge-mode.ts +0 -675
  420. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  421. package/src/modes/bridge/event-stream.ts +0 -70
  422. package/src/modes/rpc/host-tools.ts +0 -1
  423. package/src/modes/rpc/host-uris.ts +0 -1
  424. package/src/modes/rpc/rpc-client.ts +0 -1166
  425. package/src/modes/rpc/rpc-mode.ts +0 -1029
  426. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  427. package/src/modes/rpc/rpc-types.ts +0 -637
  428. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  429. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  430. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  431. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  432. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  433. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  434. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  435. package/src/modes/shared/agent-wire/responses.ts +0 -17
  436. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  437. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  438. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  439. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  440. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  441. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  442. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  443. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  444. package/src/notifications/config.ts +0 -219
  445. package/src/notifications/index.ts +0 -1835
  446. package/src/notifications/telegram-daemon-cli.ts +0 -243
  447. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  448. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  449. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  450. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  453. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  454. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  455. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  456. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  457. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  458. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  459. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  460. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  461. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  462. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  463. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  466. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  467. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  468. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  469. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  470. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  471. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  472. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  473. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  474. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -7,10 +7,9 @@
7
7
  * const enabled = settings.get("compaction.enabled"); // sync read
8
8
  * settings.set("theme.dark", "red-claw"); // sync write, saves in background
9
9
  *
10
- * For tests:
10
+ * For tests, `Settings.isolated()` seeds explicit user/global settings:
11
11
  * const isolated = Settings.isolated({ "compaction.enabled": false });
12
12
  */
13
-
14
13
  import * as fs from "node:fs";
15
14
  import * as os from "node:os";
16
15
  import * as path from "node:path";
@@ -29,9 +28,20 @@ import { type Settings as SettingsCapabilityItem, settingsCapability } from "../
29
28
  import type { ModelRole } from "../config/model-registry";
30
29
  import { loadCapability } from "../discovery";
31
30
  import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
31
+ import type { NotificationSettingsReader, NotificationSettingsSnapshot } from "../sdk/bus/config";
32
32
  import { AgentStorage } from "../session/agent-storage";
33
33
  import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
34
- import { withFileLock } from "./file-lock";
34
+ import {
35
+ type AtomicYamlPatch,
36
+ applyAtomicYamlPatches,
37
+ applyAtomicYamlPatchesWithCurrent,
38
+ type CasReceipt,
39
+ deleteByPath,
40
+ reserveAtomicYamlUpdateSlot,
41
+ setByPath,
42
+ } from "./atomic-yaml-patch";
43
+ import { isModelSelectorValue, type ModelSelectorValue, normalizeModelSelectorValue } from "./model-selector-value";
44
+
35
45
  import {
36
46
  type BashInterceptorRule,
37
47
  type GroupPrefix,
@@ -57,10 +67,38 @@ export interface RawSettings {
57
67
 
58
68
  type SettingsPatch = {
59
69
  readonly path: string;
60
- readonly value: unknown;
70
+ readonly value: unknown | undefined;
61
71
  readonly generation: number;
72
+ readonly revision: number;
73
+ readonly modelRole?: string;
74
+ readonly modelRoleRevision?: number;
75
+ readonly configVersion?: string;
76
+ readonly legacyFallbackMigration?: boolean;
77
+ };
78
+
79
+ type PendingSaveSlot = {
80
+ captured: boolean;
81
+ released: boolean;
82
+ release: () => void;
83
+ wait: Promise<void>;
62
84
  };
63
85
 
86
+ type DurableBatchRevision = {
87
+ patch: AtomicYamlPatch;
88
+ previousRevision: number | undefined;
89
+ revision: number;
90
+ };
91
+
92
+ export type SettingsAtomicPatch = { path: SettingPath; op: "set"; value: unknown } | { path: SettingPath; op: "unset" };
93
+ export type SettingsAtomicReceipt = CasReceipt;
94
+
95
+ export interface GlobalDefaultModelRoleCommit {
96
+ readonly previousDefault: ModelSelectorValue | undefined;
97
+ readonly previousModelRolesExisted: boolean;
98
+ readonly committedDefault: ModelSelectorValue | undefined;
99
+ readonly committedConfigVersion?: string;
100
+ readonly defaultRevision: number;
101
+ }
64
102
  export interface SettingsOptions {
65
103
  /** Current working directory for project settings discovery */
66
104
  cwd?: string;
@@ -72,6 +110,31 @@ export interface SettingsOptions {
72
110
  overrides?: Partial<Record<SettingPath, unknown>>;
73
111
  }
74
112
 
113
+ /** Additional layer setup for {@link Settings.isolated}. */
114
+ export interface IsolatedSettingsOptions {
115
+ /** Initial runtime overrides. Notification paths are rejected. */
116
+ overrides?: Partial<Record<SettingPath, unknown>>;
117
+ }
118
+
119
+ /** Raised when an ephemeral override attempts to change global-only notification settings. */
120
+ export class NotificationSettingsOverrideError extends Error {
121
+ constructor(readonly path: SettingPath) {
122
+ super(`Runtime overrides are not allowed for global notification setting ${path}.`);
123
+ this.name = "NotificationSettingsOverrideError";
124
+ }
125
+ }
126
+
127
+ const LOCAL_NOTIFICATION_SETTING_KEYS = new Set(["terminalBell", "bellOnComplete", "bellOnApproval", "bellOnAsk"]);
128
+ const LOCAL_NOTIFICATION_SETTING_PATHS = new Set(
129
+ [...LOCAL_NOTIFICATION_SETTING_KEYS].map(key => `notifications.${key}`),
130
+ );
131
+
132
+ function isNotificationSettingsPath(path: string): boolean {
133
+ return (
134
+ (path === "notifications" || path.startsWith("notifications.")) && !LOCAL_NOTIFICATION_SETTING_PATHS.has(path)
135
+ );
136
+ }
137
+
75
138
  // ═══════════════════════════════════════════════════════════════════════════
76
139
  // Path Utilities
77
140
  // ═══════════════════════════════════════════════════════════════════════════
@@ -90,22 +153,6 @@ function getByPath(obj: RawSettings, segments: string[]): unknown {
90
153
  return current;
91
154
  }
92
155
 
93
- /**
94
- * Set a nested value in an object by path segments.
95
- * Creates intermediate objects as needed.
96
- */
97
- function setByPath(obj: RawSettings, segments: string[], value: unknown): void {
98
- let current = obj;
99
- for (let i = 0; i < segments.length - 1; i++) {
100
- const segment = segments[i];
101
- if (!(segment in current) || typeof current[segment] !== "object" || current[segment] === null) {
102
- current[segment] = {};
103
- }
104
- current = current[segment] as RawSettings;
105
- }
106
- current[segments[segments.length - 1]] = value;
107
- }
108
-
109
156
  const PATH_SCOPED_ARRAY_SETTINGS = new Set<SettingPath>(["enabledModels", "disabledProviders"]);
110
157
  const LEGACY_THEME_NAME_REPLACEMENTS = {
111
158
  dark: "red-claw",
@@ -144,18 +191,37 @@ function stringArrayFromUnknown(value: unknown): string[] {
144
191
  return [];
145
192
  }
146
193
 
147
- function shallowStringRecord(value: unknown): Record<string, string> {
148
- if (!value || typeof value !== "object" || Array.isArray(value)) return {};
194
+ function rawSettingsRecord(value: unknown): RawSettings | undefined {
195
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
196
+ return value as RawSettings;
197
+ }
198
+
199
+ function shallowModelSelectorRecord(value: unknown): Record<string, ModelSelectorValue> {
200
+ const record = rawSettingsRecord(value);
201
+ if (!record) return {};
149
202
 
150
- const result: Record<string, string> = {};
151
- for (const [key, item] of Object.entries(value)) {
152
- if (typeof item === "string") {
153
- result[key] = item;
154
- }
203
+ const result: Record<string, ModelSelectorValue> = {};
204
+ for (const [key, item] of Object.entries(record)) {
205
+ if (isModelSelectorValue(item)) result[key] = Array.isArray(item) ? [...item] : item;
155
206
  }
156
207
  return result;
157
208
  }
158
209
 
210
+ function legacyFallbackChains(value: unknown): Record<string, unknown> {
211
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
212
+ }
213
+
214
+ function hasOwnModelRole(source: RawSettings, role: string): boolean {
215
+ const roles = getByPath(source, ["modelRoles"]);
216
+ return !!roles && typeof roles === "object" && !Array.isArray(roles) && Object.hasOwn(roles, role);
217
+ }
218
+
219
+ function selectorChain(value: unknown): string[] {
220
+ if (typeof value === "string") return normalizeModelSelectorValue(value);
221
+ if (!Array.isArray(value) || !value.every(item => typeof item === "string")) return [];
222
+ return normalizeModelSelectorValue(value);
223
+ }
224
+
159
225
  function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown, cwd: string): string[] | undefined {
160
226
  if (!PATH_SCOPED_ARRAY_SETTINGS.has(settingPath) || !Array.isArray(value)) return undefined;
161
227
 
@@ -193,12 +259,79 @@ function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown,
193
259
 
194
260
  return resolved;
195
261
  }
262
+ type DefaultModelRoleOwnership = {
263
+ generation: number;
264
+ configVersion?: string;
265
+ defaultConfigVersion?: string;
266
+ defaultLineageKnown: boolean;
267
+ };
268
+
269
+ function readConfigVersion(filePath: string): string | undefined {
270
+ try {
271
+ const stat = fs.statSync(filePath, { bigint: true });
272
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}`;
273
+ } catch (error) {
274
+ if (isEnoent(error)) return undefined;
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ function defaultModelRoleFrom(raw: RawSettings): ModelSelectorValue | undefined {
280
+ const value = rawSettingsRecord(raw.modelRoles)?.default;
281
+ return isModelSelectorValue(value) ? (Array.isArray(value) ? [...value] : value) : undefined;
282
+ }
283
+
284
+ function setRawModelRole(
285
+ raw: RawSettings,
286
+ role: string,
287
+ modelId: ModelSelectorValue | undefined,
288
+ removeContainerWhenEmpty = false,
289
+ ): void {
290
+ const roles = { ...rawSettingsRecord(raw.modelRoles) };
291
+ if (modelId === undefined) {
292
+ delete roles[role];
293
+ if (removeContainerWhenEmpty && Object.keys(roles).length === 0) {
294
+ delete raw.modelRoles;
295
+ } else {
296
+ raw.modelRoles = roles;
297
+ }
298
+ return;
299
+ }
300
+ raw.modelRoles = { ...roles, [role]: modelId };
301
+ }
302
+
303
+ function updateModelRolesPatch(
304
+ patch: SettingsPatch,
305
+ role: string,
306
+ modelId: ModelSelectorValue | undefined,
307
+ removeContainerWhenEmpty = false,
308
+ ): SettingsPatch {
309
+ const raw: RawSettings = { modelRoles: structuredClone(patch.value) };
310
+ setRawModelRole(raw, role, modelId, removeContainerWhenEmpty);
311
+ return { ...patch, value: raw.modelRoles };
312
+ }
313
+
314
+ function settingsPatchKey(patch: SettingsPatch): string {
315
+ return patch.modelRole ? `modelRoles.${patch.modelRole}` : patch.path;
316
+ }
317
+
318
+ function applySettingsPatch(raw: RawSettings, patch: SettingsPatch): void {
319
+ if (patch.modelRole) {
320
+ setRawModelRole(raw, patch.modelRole, patch.value as ModelSelectorValue | undefined);
321
+ return;
322
+ }
323
+ if (patch.value === undefined) {
324
+ deleteByPath(raw, patch.path.split("."));
325
+ return;
326
+ }
327
+ setByPath(raw, patch.path.split("."), patch.value);
328
+ }
196
329
 
197
330
  // ═══════════════════════════════════════════════════════════════════════════
198
331
  // Settings Class
199
332
  // ═══════════════════════════════════════════════════════════════════════════
200
333
 
201
- export class Settings {
334
+ export class Settings implements NotificationSettingsReader {
202
335
  #configPath: string | null;
203
336
  #cwd: string;
204
337
  #agentDir: string;
@@ -216,12 +349,19 @@ export class Settings {
216
349
  /** Latest dirty patch for each path, owned by its generation. */
217
350
  #modified = new Map<string, SettingsPatch>();
218
351
  #nextGeneration = 0;
219
-
220
- /** Pending save (debounced) */
352
+ #pathRevisions = new Map<string, number>();
353
+ #nextRevision = 0;
354
+ #defaultModelRoleOwnership: DefaultModelRoleOwnership = { generation: 0, defaultLineageKnown: true };
355
+ /** Pending debounced ordinary save; its queue slot is reserved immediately. */
221
356
  #saveTimer?: NodeJS.Timeout;
222
- #saveTail: Promise<void> = Promise.resolve();
357
+ #savePromise?: Promise<void>;
358
+ #pendingSaveSlot?: PendingSaveSlot;
223
359
  #globalModelRoleTail: Promise<void> = Promise.resolve();
224
360
 
361
+ /** Legacy fallback migration warnings emitted once per settings instance. */
362
+ #legacyFallbackMigrationWarnings = 0;
363
+ #legacyFallbackMigrationGlobalFingerprint: string | undefined;
364
+
225
365
  /** Whether to persist changes */
226
366
  #persist: boolean;
227
367
 
@@ -233,7 +373,8 @@ export class Settings {
233
373
 
234
374
  if (options.overrides) {
235
375
  for (const [key, value] of Object.entries(options.overrides)) {
236
- setByPath(this.#overrides, key.split("."), value);
376
+ if (isNotificationSettingsPath(key)) throw new NotificationSettingsOverrideError(key as SettingPath);
377
+ setByPath(this.#overrides, key.split("."), structuredClone(value));
237
378
  }
238
379
  }
239
380
  }
@@ -267,11 +408,18 @@ export class Settings {
267
408
  }
268
409
 
269
410
  /**
270
- * Create an isolated instance for testing.
411
+ * Create an isolated instance for testing with explicit user/global settings.
271
412
  * Does not affect the global singleton.
272
413
  */
273
- static isolated(overrides: Partial<Record<SettingPath, unknown>> = {}): Settings {
274
- const instance = new Settings({ inMemory: true, overrides });
414
+ static isolated(
415
+ globalSettings: Partial<Record<SettingPath, unknown>> = {},
416
+ options: IsolatedSettingsOptions = {},
417
+ ): Settings {
418
+ const instance = new Settings({ inMemory: true, overrides: options.overrides });
419
+ for (const [key, value] of Object.entries(globalSettings)) {
420
+ setByPath(instance.#global, key.split("."), structuredClone(value));
421
+ }
422
+
275
423
  instance.#rebuildMerged();
276
424
  return instance;
277
425
  }
@@ -316,6 +464,113 @@ export class Settings {
316
464
  return value === undefined ? undefined : (value as SettingValue<P>);
317
465
  }
318
466
 
467
+ /**
468
+ * Read the remote-notification settings from the user/global layer only.
469
+ * Schema defaults are applied per path; project settings and runtime overrides
470
+ * are deliberately excluded from this trust boundary.
471
+ */
472
+ getNotificationSettingsSnapshot(): NotificationSettingsSnapshot {
473
+ const enabled = this.#getGlobalResolved("notifications.enabled");
474
+ const botToken = this.#getGlobalResolved("notifications.telegram.botToken");
475
+ const chatId = this.#getGlobalResolved("notifications.telegram.chatId");
476
+ const activation = this.#getGlobalResolved("notifications.telegram.activation");
477
+ const activationSnapshot =
478
+ activation && Object.keys(activation).length > 0 ? structuredClone(activation) : undefined;
479
+ const richEnabled = this.#getGlobalResolved("notifications.telegram.rich.enabled");
480
+ const richDraftEnabled = this.#getGlobalResolved("notifications.telegram.richDraft.enabled");
481
+ const nameTemplate = this.#getGlobalResolved("notifications.telegram.topics.nameTemplate");
482
+ const discordBotToken = this.#getGlobalResolved("notifications.discord.botToken");
483
+ const discordApplicationId = this.#getGlobalResolved("notifications.discord.applicationId");
484
+ const discordGuildId = this.#getGlobalResolved("notifications.discord.guildId");
485
+ const discordParentChannelId = this.#getGlobalResolved("notifications.discord.parentChannelId");
486
+ const slackBotToken = this.#getGlobalResolved("notifications.slack.botToken");
487
+ const slackAppToken = this.#getGlobalResolved("notifications.slack.appToken");
488
+ const slackWorkspaceId = this.#getGlobalResolved("notifications.slack.workspaceId");
489
+ const slackChannelId = this.#getGlobalResolved("notifications.slack.channelId");
490
+ const slackAuthorizedUserId = this.#getGlobalResolved("notifications.slack.authorizedUserId");
491
+ const redact = this.#getGlobalResolved("notifications.redact");
492
+ const verbosity = this.#getGlobalResolved("notifications.verbosity");
493
+ const sessionScope = this.#getGlobalResolved("notifications.sessionScope");
494
+ const idleTimeoutMs = this.#getGlobalResolved("notifications.daemon.idleTimeoutMs");
495
+
496
+ return {
497
+ enabled: typeof enabled === "boolean" ? enabled : getDefault("notifications.enabled"),
498
+ telegram: {
499
+ botToken:
500
+ typeof botToken === "string" && botToken.length > 0
501
+ ? botToken
502
+ : getDefault("notifications.telegram.botToken"),
503
+ chatId:
504
+ typeof chatId === "string" && chatId.length > 0 ? chatId : getDefault("notifications.telegram.chatId"),
505
+ ...(activationSnapshot === undefined ? {} : { activation: activationSnapshot }),
506
+ rich: {
507
+ enabled:
508
+ typeof richEnabled === "boolean" ? richEnabled : getDefault("notifications.telegram.rich.enabled"),
509
+ },
510
+ richDraft: {
511
+ enabled:
512
+ typeof richDraftEnabled === "boolean"
513
+ ? richDraftEnabled
514
+ : getDefault("notifications.telegram.richDraft.enabled"),
515
+ },
516
+ topics: {
517
+ nameTemplate:
518
+ typeof nameTemplate === "string" && nameTemplate.length > 0
519
+ ? nameTemplate
520
+ : getDefault("notifications.telegram.topics.nameTemplate"),
521
+ },
522
+ },
523
+ discord: {
524
+ botToken:
525
+ typeof discordBotToken === "string" && discordBotToken.length > 0
526
+ ? discordBotToken
527
+ : getDefault("notifications.discord.botToken"),
528
+ applicationId:
529
+ typeof discordApplicationId === "string" && discordApplicationId.length > 0
530
+ ? discordApplicationId
531
+ : getDefault("notifications.discord.applicationId"),
532
+ guildId:
533
+ typeof discordGuildId === "string" && discordGuildId.length > 0
534
+ ? discordGuildId
535
+ : getDefault("notifications.discord.guildId"),
536
+ parentChannelId:
537
+ typeof discordParentChannelId === "string" && discordParentChannelId.length > 0
538
+ ? discordParentChannelId
539
+ : getDefault("notifications.discord.parentChannelId"),
540
+ },
541
+ slack: {
542
+ botToken:
543
+ typeof slackBotToken === "string" && slackBotToken.length > 0
544
+ ? slackBotToken
545
+ : getDefault("notifications.slack.botToken"),
546
+ appToken:
547
+ typeof slackAppToken === "string" && slackAppToken.length > 0
548
+ ? slackAppToken
549
+ : getDefault("notifications.slack.appToken"),
550
+ workspaceId:
551
+ typeof slackWorkspaceId === "string" && slackWorkspaceId.length > 0
552
+ ? slackWorkspaceId
553
+ : getDefault("notifications.slack.workspaceId"),
554
+ channelId:
555
+ typeof slackChannelId === "string" && slackChannelId.length > 0
556
+ ? slackChannelId
557
+ : getDefault("notifications.slack.channelId"),
558
+ authorizedUserId:
559
+ typeof slackAuthorizedUserId === "string" && slackAuthorizedUserId.length > 0
560
+ ? slackAuthorizedUserId
561
+ : getDefault("notifications.slack.authorizedUserId"),
562
+ },
563
+ redact: typeof redact === "boolean" ? redact : getDefault("notifications.redact"),
564
+ verbosity: verbosity === "verbose" || getDefault("notifications.verbosity") === "verbose" ? "verbose" : "lean",
565
+ sessionScope:
566
+ sessionScope === "primary" || getDefault("notifications.sessionScope") === "primary" ? "primary" : "all",
567
+ idleTimeoutMs:
568
+ typeof idleTimeoutMs === "number" && Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0
569
+ ? idleTimeoutMs
570
+ : getDefault("notifications.daemon.idleTimeoutMs"),
571
+ };
572
+ }
573
+
319
574
  /** Check whether a setting is present in loaded settings/overrides rather than coming from schema defaults. */
320
575
  has(path: SettingPath): boolean {
321
576
  return getByPath(this.#merged, path.split(".")) !== undefined;
@@ -323,25 +578,175 @@ export class Settings {
323
578
 
324
579
  /**
325
580
  * Set a setting value (sync).
326
- * Updates global settings and queues a background save.
327
- * Triggers hooks for settings that have side effects.
581
+ * Updates global settings and reserves its background persistence slot before
582
+ * returning, so later durable batches cannot overtake this mutation.
328
583
  */
329
- set<P extends SettingPath>(path: P, value: SettingValue<P>): void {
584
+ set<P extends SettingPath>(path: P, value: SettingValue<P> | undefined): void {
585
+ if (value === undefined) {
586
+ this.unset(path);
587
+ return;
588
+ }
589
+ this.#set(path, value, true);
590
+ }
591
+
592
+ #set<P extends SettingPath>(path: P, value: SettingValue<P>, defaultModelRoleMayHaveChanged: boolean): void {
330
593
  const prev = this.get(path);
594
+ const clonedValue = structuredClone(value);
595
+ let modelRoleRevision: number | undefined;
596
+ if (path === "modelRoles" && defaultModelRoleMayHaveChanged) {
597
+ this.#defaultModelRoleOwnership.generation += 1;
598
+ modelRoleRevision = this.#defaultModelRoleOwnership.generation;
599
+ }
331
600
  const patch: SettingsPatch = {
332
601
  path,
333
- value: structuredClone(value),
602
+ value: clonedValue,
334
603
  generation: ++this.#nextGeneration,
604
+ revision: ++this.#nextRevision,
605
+ modelRoleRevision,
606
+ configVersion: this.#defaultModelRoleOwnership.configVersion,
335
607
  };
336
- setByPath(this.#global, path.split("."), structuredClone(patch.value));
608
+ setByPath(this.#global, path.split("."), structuredClone(clonedValue));
609
+ this.#pathRevisions.set(path, patch.revision);
337
610
  this.#modified.set(path, patch);
611
+
338
612
  this.#rebuildMerged();
339
613
  this.#queueSave();
340
614
 
341
- // Trigger hook if exists
342
615
  const hook = SETTING_HOOKS[path];
343
- if (hook) {
344
- hook(value, prev);
616
+ if (hook) hook(value, prev);
617
+ }
618
+
619
+ /**
620
+ * Delete a global setting (sync), rather than serializing an ambiguous YAML
621
+ * `undefined` value. Defaults/project settings become visible immediately.
622
+ */
623
+ unset<P extends SettingPath>(path: P): void {
624
+ const prev = this.get(path);
625
+ let modelRoleRevision: number | undefined;
626
+ if (path === "modelRoles") {
627
+ this.#defaultModelRoleOwnership.generation += 1;
628
+ modelRoleRevision = this.#defaultModelRoleOwnership.generation;
629
+ }
630
+ const patch: SettingsPatch = {
631
+ path,
632
+ value: undefined,
633
+ generation: ++this.#nextGeneration,
634
+ revision: ++this.#nextRevision,
635
+ modelRoleRevision,
636
+ configVersion: this.#defaultModelRoleOwnership.configVersion,
637
+ };
638
+ deleteByPath(this.#global, path.split("."));
639
+ this.#pathRevisions.set(path, patch.revision);
640
+ this.#modified.set(path, patch);
641
+ this.#rebuildMerged();
642
+ this.#queueSave();
643
+
644
+ const hook = SETTING_HOOKS[path];
645
+ if (hook) hook(this.get(path), prev);
646
+ }
647
+
648
+ /**
649
+ * Persist a tagged batch as one atomic YAML replacement. Unlike ordinary
650
+ * {@link set}, canonical state and hooks change only after the rename succeeds.
651
+ */
652
+ async commitAtomicBatch(patches: readonly SettingsAtomicPatch[]): Promise<CasReceipt> {
653
+ if (!this.#persist || !this.#configPath) {
654
+ throw new Error("commitAtomicBatch requires persistent Settings with a config.yml path.");
655
+ }
656
+
657
+ const durablePatches: AtomicYamlPatch[] = patches.map(patch => {
658
+ if (!Object.hasOwn(SETTINGS_SCHEMA, patch.path)) {
659
+ throw new Error(`Unknown setting path for atomic batch: ${patch.path}`);
660
+ }
661
+ if (patch.op === "unset") return { path: patch.path, op: "unset" };
662
+ if (patch.value === undefined) {
663
+ throw new TypeError(`Settings set patch for ${patch.path} cannot carry undefined; use unset instead.`);
664
+ }
665
+ return { path: patch.path, op: "set", value: structuredClone(patch.value) };
666
+ });
667
+
668
+ // A durable batch is a causal barrier: close the earlier ordinary debounce
669
+ // inside its already-reserved slot before queueing this batch.
670
+ this.#releasePendingSaveSlot();
671
+
672
+ const revisions = durablePatches.map(patch => ({
673
+ patch,
674
+ revision: ++this.#nextRevision,
675
+ previousRevision: this.#pathRevisions.get(patch.path),
676
+ }));
677
+ for (const entry of revisions) this.#pathRevisions.set(entry.patch.path, entry.revision);
678
+
679
+ try {
680
+ const receipt = await applyAtomicYamlPatches(this.#configPath, durablePatches, {
681
+ onRestored: restoredPatches => this.#applyRestoredDurableBatch(revisions, restoredPatches),
682
+ });
683
+ this.#applyDurableBatch(revisions);
684
+ return receipt;
685
+ } catch (error) {
686
+ for (const entry of revisions) {
687
+ if (this.#pathRevisions.get(entry.patch.path) === entry.revision) {
688
+ if (entry.previousRevision === undefined) this.#pathRevisions.delete(entry.patch.path);
689
+ else this.#pathRevisions.set(entry.patch.path, entry.previousRevision);
690
+ }
691
+ }
692
+ if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
693
+ throw error;
694
+ }
695
+ }
696
+
697
+ /** Build a durable batch from the current on-disk YAML under the shared queue and file lock. */
698
+ async commitAtomicBatchWithCurrent(
699
+ buildPatches: (
700
+ current: Readonly<RawSettings>,
701
+ ) => Promise<readonly SettingsAtomicPatch[]> | readonly SettingsAtomicPatch[],
702
+ ): Promise<CasReceipt> {
703
+ if (!this.#persist || !this.#configPath) {
704
+ const patches = await buildPatches(structuredClone(this.#global));
705
+ return this.commitAtomicBatch(patches);
706
+ }
707
+
708
+ this.#releasePendingSaveSlot();
709
+ let revisions: DurableBatchRevision[] = [];
710
+ try {
711
+ const receipt = await applyAtomicYamlPatchesWithCurrent(
712
+ this.#configPath,
713
+ async current => {
714
+ const patches = await buildPatches(structuredClone(current));
715
+ const durablePatches: AtomicYamlPatch[] = patches.map(patch => {
716
+ if (!Object.hasOwn(SETTINGS_SCHEMA, patch.path)) {
717
+ throw new Error(`Unknown setting path for atomic batch: ${patch.path}`);
718
+ }
719
+ if (patch.op === "unset") return { path: patch.path, op: "unset" };
720
+ if (patch.value === undefined) {
721
+ throw new TypeError(
722
+ `Settings set patch for ${patch.path} cannot carry undefined; use unset instead.`,
723
+ );
724
+ }
725
+ return { path: patch.path, op: "set", value: structuredClone(patch.value) };
726
+ });
727
+ revisions = durablePatches.map(patch => ({
728
+ patch,
729
+ revision: ++this.#nextRevision,
730
+ previousRevision: this.#pathRevisions.get(patch.path),
731
+ }));
732
+ for (const entry of revisions) this.#pathRevisions.set(entry.patch.path, entry.revision);
733
+ return durablePatches;
734
+ },
735
+ {
736
+ onRestored: restoredPatches => this.#applyRestoredDurableBatch(revisions, restoredPatches),
737
+ },
738
+ );
739
+ this.#applyDurableBatch(revisions);
740
+ return receipt;
741
+ } catch (error) {
742
+ for (const entry of revisions) {
743
+ if (this.#pathRevisions.get(entry.patch.path) === entry.revision) {
744
+ if (entry.previousRevision === undefined) this.#pathRevisions.delete(entry.patch.path);
745
+ else this.#pathRevisions.set(entry.patch.path, entry.previousRevision);
746
+ }
747
+ }
748
+ if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
749
+ throw error;
345
750
  }
346
751
  }
347
752
 
@@ -349,8 +754,9 @@ export class Settings {
349
754
  * Apply runtime overrides (not persisted).
350
755
  */
351
756
  override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
352
- const segments = path.split(".");
353
- setByPath(this.#overrides, segments, value);
757
+ if (isNotificationSettingsPath(path)) throw new NotificationSettingsOverrideError(path);
758
+ const clonedValue = structuredClone(value);
759
+ setByPath(this.#overrides, path.split("."), clonedValue);
354
760
  this.#rebuildMerged();
355
761
  }
356
762
 
@@ -369,45 +775,58 @@ export class Settings {
369
775
  this.#rebuildMerged();
370
776
  }
371
777
 
372
- /**
373
- * Flush any pending saves to disk.
374
- * Call before exit to ensure all changes are persisted.
375
- */
778
+ /** Flush a reserved debounced save without allowing it to be overtaken. */
376
779
  async flush(): Promise<void> {
377
- if (this.#saveTimer) {
378
- clearTimeout(this.#saveTimer);
379
- this.#saveTimer = undefined;
780
+ this.#releasePendingSaveSlot();
781
+ if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
782
+ this.#releasePendingSaveSlot();
783
+ const observedSave = this.#savePromise;
784
+ try {
785
+ await observedSave;
786
+ } catch {
787
+ // Historical flush() behavior logs background failures but does not reject.
788
+ }
789
+ // A failed predecessor may settle just before a new mutation observes its
790
+ // still-reserved slot. Explicit flush owns one fresh attempt for remaining
791
+ // dirty patches instead of leaving them stranded or retrying forever.
792
+ if (this.#modified.size > 0 && this.#savePromise === observedSave) {
793
+ if (!this.#pendingSaveSlot) this.#queueSave();
794
+ this.#releasePendingSaveSlot();
795
+ try {
796
+ await this.#savePromise;
797
+ } catch {
798
+ // Keep dirty state for a later explicit flush or mutation.
799
+ }
380
800
  }
381
- const save = this.#modified.size > 0 ? this.#saveNow() : this.#saveTail;
382
- await save;
801
+ await this.#refreshDurableSettings();
383
802
  }
384
803
 
385
- /**
386
- * Like {@link flush}, but rejects if the durable save fails instead of
387
- * swallowing the error. Use where the caller must confirm persistence before
388
- * reporting success (e.g. the Telegram `/rich` toggle). In-memory instances
389
- * ({@link isolated}) short-circuit in {@link #saveNow} and never throw.
390
- */
804
+ /** Like {@link flush}, but reports a durable save failure to the caller. */
391
805
  async flushOrThrow(): Promise<void> {
392
- if (this.#saveTimer) {
393
- clearTimeout(this.#saveTimer);
394
- this.#saveTimer = undefined;
395
- }
396
- const save = this.#modified.size > 0 ? this.#saveNow({ throwOnError: true }) : this.#saveTail;
397
- await save;
806
+ this.#releasePendingSaveSlot();
807
+ if (this.#modified.size > 0 && !this.#pendingSaveSlot) this.#queueSave();
808
+ this.#releasePendingSaveSlot();
809
+ await this.#savePromise;
810
+ await this.#refreshDurableSettings();
398
811
  }
399
812
 
400
813
  async cloneForCwd(cwd: string): Promise<Settings> {
814
+ // A clone shares the same config queue. Settle an already-reserved local
815
+ // debounce before the clone can enqueue a durable selector, preventing it
816
+ // from waiting behind a slot only this instance can open.
817
+ await this.flush();
401
818
  const cloned = new Settings({
402
819
  cwd,
403
820
  agentDir: this.#agentDir,
404
821
  inMemory: !this.#persist,
405
822
  });
406
823
  cloned.#storage = this.#storage;
824
+ cloned.#defaultModelRoleOwnership = this.#defaultModelRoleOwnership;
825
+
407
826
  cloned.#global = structuredClone(this.#global);
408
827
  cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
409
828
  cloned.#overrides = structuredClone(this.#overrides);
410
- cloned.#rebuildMerged();
829
+ await cloned.#normalizeAfterLoad();
411
830
  cloned.#fireAllHooks();
412
831
  return cloned;
413
832
  }
@@ -483,7 +902,7 @@ export class Settings {
483
902
  /**
484
903
  * Set a model role (helper for modelRoles record).
485
904
  */
486
- setModelRole(role: ModelRole | string, modelId: string): void {
905
+ setModelRole(role: ModelRole | string, modelId: ModelSelectorValue): void {
487
906
  const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
488
907
  const updateRuntimeOverride =
489
908
  !!runtimeOverrides &&
@@ -494,52 +913,210 @@ export class Settings {
494
913
  this.setGlobalModelRole(role, modelId);
495
914
 
496
915
  if (updateRuntimeOverride) {
497
- this.override("modelRoles", { ...shallowStringRecord(runtimeOverrides), [role]: modelId });
916
+ this.override("modelRoles", { ...shallowModelSelectorRecord(runtimeOverrides), [role]: modelId });
498
917
  }
499
918
  }
500
919
 
501
- setGlobalModelRole(role: ModelRole | string, modelId: string | undefined): void {
502
- const current = shallowStringRecord(getByPath(this.#global, ["modelRoles"]));
503
- if (modelId === undefined) {
504
- const { [role]: _removed, ...remaining } = current;
505
- this.set("modelRoles", remaining);
506
- return;
920
+ setGlobalModelRole(role: ModelRole | string, modelId: ModelSelectorValue | undefined): void {
921
+ let modelRoleRevision: number | undefined;
922
+ if (role === "default") {
923
+ this.#defaultModelRoleOwnership.generation += 1;
924
+ modelRoleRevision = this.#defaultModelRoleOwnership.generation;
507
925
  }
508
- this.set("modelRoles", { ...current, [role]: modelId });
926
+ const revision = ++this.#nextRevision;
927
+ const patch: SettingsPatch = {
928
+ path: "modelRoles",
929
+ value: modelId,
930
+ generation: ++this.#nextGeneration,
931
+ revision,
932
+ modelRole: role,
933
+ modelRoleRevision,
934
+ configVersion: this.#defaultModelRoleOwnership.configVersion,
935
+ };
936
+ setRawModelRole(this.#global, role, modelId);
937
+ this.#pathRevisions.set("modelRoles", revision);
938
+ this.#modified.set(settingsPatchKey(patch), patch);
939
+ this.#rebuildMerged();
940
+ this.#queueSave();
509
941
  }
510
942
 
511
- setGlobalModelRoleAndFlush(role: ModelRole | string, modelId: string | undefined): Promise<void> {
512
- const transaction = this.#globalModelRoleTail.then(async () => {
513
- const hadModelRoles = Object.hasOwn(this.#global, "modelRoles");
514
- const previousModelRoles = structuredClone(this.#global.modelRoles);
515
- const previousPatch = this.#modified.get("modelRoles");
516
- this.setGlobalModelRole(role, modelId);
517
- const generation = this.#modified.get("modelRoles")?.generation;
518
- if (this.#saveTimer) {
519
- clearTimeout(this.#saveTimer);
520
- this.#saveTimer = undefined;
521
- }
522
- const save = this.#saveNow({ throwOnError: true });
523
- try {
524
- await save;
525
- } catch (error) {
526
- const currentPatch = this.#modified.get("modelRoles");
527
- if (currentPatch?.generation === generation) {
528
- if (hadModelRoles) this.#global.modelRoles = previousModelRoles;
529
- else delete this.#global.modelRoles;
530
- if (previousPatch) this.#modified.set("modelRoles", previousPatch);
531
- else this.#modified.delete("modelRoles");
532
- this.#rebuildMerged();
533
- }
534
- throw error;
535
- }
536
- });
943
+ setGlobalModelRoleAndFlush(
944
+ role: ModelRole | string,
945
+ modelId: ModelSelectorValue | undefined,
946
+ ): Promise<GlobalDefaultModelRoleCommit> {
947
+ const transaction = this.#globalModelRoleTail.then(() => this.#commitGlobalModelRoleAndFlush(role, modelId));
537
948
  this.#globalModelRoleTail = transaction.then(
538
949
  () => undefined,
539
950
  () => undefined,
540
951
  );
541
952
  return transaction;
542
953
  }
954
+
955
+ restoreGlobalDefaultModelRoleIfCurrent(commit: GlobalDefaultModelRoleCommit): Promise<boolean> {
956
+ const transaction = this.#globalModelRoleTail.then(() => this.#restoreGlobalDefaultModelRoleIfCurrent(commit));
957
+ this.#globalModelRoleTail = transaction.then(
958
+ () => undefined,
959
+ () => undefined,
960
+ );
961
+ return transaction;
962
+ }
963
+
964
+ async #commitGlobalModelRoleAndFlush(
965
+ role: ModelRole | string,
966
+ modelId: ModelSelectorValue | undefined,
967
+ ): Promise<GlobalDefaultModelRoleCommit> {
968
+ if (this.#persist) await this.flushOrThrow();
969
+ const previousDefault = defaultModelRoleFrom(this.#global);
970
+ const previousModelRolesExisted = Object.hasOwn(this.#global, "modelRoles");
971
+ const previousDefaultRevision = this.#defaultModelRoleOwnership.generation;
972
+ let defaultRevision = previousDefaultRevision;
973
+ if (role === "default") {
974
+ defaultRevision += 1;
975
+ this.#defaultModelRoleOwnership.generation = defaultRevision;
976
+ }
977
+ this.#setGlobalModelRoleInMemory(role, modelId, false, true);
978
+
979
+ if (!this.#persist || !this.#configPath) {
980
+ return {
981
+ previousDefault,
982
+ previousModelRolesExisted,
983
+ committedDefault: defaultModelRoleFrom(this.#global),
984
+ defaultRevision,
985
+ committedConfigVersion: undefined,
986
+ };
987
+ }
988
+
989
+ let durableBeforeWrite: RawSettings | undefined;
990
+ let durableVersionBeforeWrite: string | undefined;
991
+ try {
992
+ const result = await reserveAtomicYamlUpdateSlot(this.#configPath, () => ({
993
+ apply: current => {
994
+ durableBeforeWrite = structuredClone(current);
995
+ durableVersionBeforeWrite = readConfigVersion(this.#configPath!);
996
+ const durablePreviousDefault = defaultModelRoleFrom(current);
997
+ const durablePreviousModelRolesExisted = Object.hasOwn(current, "modelRoles");
998
+ setRawModelRole(current, role, modelId);
999
+ return {
1000
+ durablePreviousDefault,
1001
+ durablePreviousModelRolesExisted,
1002
+ committedDefault: defaultModelRoleFrom(current),
1003
+ committedConfigVersion: undefined as string | undefined,
1004
+ defaultRevision,
1005
+ };
1006
+ },
1007
+ committed: (current, result) => {
1008
+ const committedConfigVersion = readConfigVersion(this.#configPath!);
1009
+ this.#replaceGlobalWithDurable(
1010
+ current,
1011
+ committedConfigVersion,
1012
+ role === "default",
1013
+ durableVersionBeforeWrite,
1014
+ );
1015
+ result.committedConfigVersion = committedConfigVersion;
1016
+ },
1017
+ }));
1018
+ return {
1019
+ previousDefault: result.durablePreviousDefault,
1020
+ previousModelRolesExisted: result.durablePreviousModelRolesExisted,
1021
+ committedDefault: result.committedDefault,
1022
+ committedConfigVersion: result.committedConfigVersion,
1023
+ defaultRevision: result.defaultRevision,
1024
+ };
1025
+ } catch (error) {
1026
+ if (role === "default" && this.#defaultModelRoleOwnership.generation === defaultRevision) {
1027
+ if (durableBeforeWrite) {
1028
+ this.#replaceGlobalWithDurable(durableBeforeWrite, durableVersionBeforeWrite, false);
1029
+ } else {
1030
+ this.#setGlobalModelRoleInMemory("default", previousDefault, !previousModelRolesExisted, true);
1031
+ }
1032
+ this.#defaultModelRoleOwnership.generation = previousDefaultRevision;
1033
+ }
1034
+ throw error;
1035
+ }
1036
+ }
1037
+
1038
+ async #restoreGlobalDefaultModelRoleIfCurrent(commit: GlobalDefaultModelRoleCommit): Promise<boolean> {
1039
+ if (this.#defaultModelRoleOwnership.generation !== commit.defaultRevision) return false;
1040
+
1041
+ if (!this.#persist || !this.#configPath) {
1042
+ if (defaultModelRoleFrom(this.#global) !== commit.committedDefault) return false;
1043
+ this.#setGlobalModelRoleInMemory("default", commit.previousDefault, !commit.previousModelRolesExisted);
1044
+ this.#defaultModelRoleOwnership.generation += 1;
1045
+ return true;
1046
+ }
1047
+
1048
+ const restored = await reserveAtomicYamlUpdateSlot(this.#configPath, () => ({
1049
+ apply: current => {
1050
+ if (this.#defaultModelRoleOwnership.generation !== commit.defaultRevision) return false;
1051
+ const currentConfigVersion = readConfigVersion(this.#configPath!);
1052
+ if (defaultModelRoleFrom(current) !== commit.committedDefault) return false;
1053
+ if (
1054
+ commit.committedConfigVersion !== undefined &&
1055
+ currentConfigVersion !== commit.committedConfigVersion &&
1056
+ !(
1057
+ this.#defaultModelRoleOwnership.defaultConfigVersion === commit.committedConfigVersion &&
1058
+ this.#defaultModelRoleOwnership.defaultLineageKnown &&
1059
+ this.#defaultModelRoleOwnership.configVersion === currentConfigVersion
1060
+ )
1061
+ ) {
1062
+ return false;
1063
+ }
1064
+ setRawModelRole(current, "default", commit.previousDefault, !commit.previousModelRolesExisted);
1065
+ return { currentConfigVersion };
1066
+ },
1067
+ shouldWrite: result => result !== false,
1068
+ committed: (current, result) => {
1069
+ if (result === false) return;
1070
+ const restoredConfigVersion = readConfigVersion(this.#configPath!);
1071
+ this.#replaceGlobalWithDurable(current, restoredConfigVersion, true, result.currentConfigVersion);
1072
+ this.#defaultModelRoleOwnership.generation += 1;
1073
+ },
1074
+ }));
1075
+ if (!restored) return false;
1076
+ await this.flushOrThrow();
1077
+ return true;
1078
+ }
1079
+
1080
+ #setGlobalModelRoleInMemory(
1081
+ role: string,
1082
+ modelId: ModelSelectorValue | undefined,
1083
+ removeContainerWhenEmpty: boolean,
1084
+ updatePendingPatch = false,
1085
+ ): void {
1086
+ setRawModelRole(this.#global, role, modelId, removeContainerWhenEmpty);
1087
+ if (updatePendingPatch) {
1088
+ const rootPatch = this.#modified.get("modelRoles");
1089
+ if (rootPatch) {
1090
+ this.#modified.set("modelRoles", updateModelRolesPatch(rootPatch, role, modelId, removeContainerWhenEmpty));
1091
+ }
1092
+ const rolePatch = this.#modified.get(`modelRoles.${role}`);
1093
+ if (rolePatch) {
1094
+ this.#modified.set(`modelRoles.${role}`, { ...rolePatch, value: modelId });
1095
+ }
1096
+ }
1097
+ this.#rebuildMerged();
1098
+ }
1099
+
1100
+ #replaceGlobalWithDurable(
1101
+ current: RawSettings,
1102
+ configVersion?: string,
1103
+ defaultChanged = false,
1104
+ predecessorConfigVersion = configVersion,
1105
+ ): void {
1106
+ this.#global = current;
1107
+ for (const patch of this.#pendingPatchesInGenerationOrder()) {
1108
+ this.#applyPatchWithDefaultOwnership(this.#global, { ...patch, value: structuredClone(patch.value) });
1109
+ }
1110
+ const externalLineageBreak = predecessorConfigVersion !== this.#defaultModelRoleOwnership.configVersion;
1111
+ this.#defaultModelRoleOwnership.configVersion = configVersion;
1112
+ if (defaultChanged) {
1113
+ this.#defaultModelRoleOwnership.defaultConfigVersion = configVersion;
1114
+ this.#defaultModelRoleOwnership.defaultLineageKnown = true;
1115
+ } else if (externalLineageBreak) {
1116
+ this.#defaultModelRoleOwnership.defaultLineageKnown = false;
1117
+ }
1118
+ this.#rebuildMerged();
1119
+ }
543
1120
  /**
544
1121
  * Set an agent model override while keeping any live runtime override aligned.
545
1122
  *
@@ -547,8 +1124,8 @@ export class Settings {
547
1124
  * session. A user-selected role assignment must win immediately in that same
548
1125
  * session, but only the explicit agent change should be persisted.
549
1126
  */
550
- setAgentModelOverride(agentName: string, modelId: string): void {
551
- const current = shallowStringRecord(getByPath(this.#global, ["task", "agentModelOverrides"]));
1127
+ setAgentModelOverride(agentName: string, modelId: ModelSelectorValue): void {
1128
+ const current = shallowModelSelectorRecord(getByPath(this.#global, ["task", "agentModelOverrides"]));
552
1129
  const runtimeOverrides = getByPath(this.#overrides, ["task", "agentModelOverrides"]);
553
1130
  const updateRuntimeOverride =
554
1131
  !!runtimeOverrides && typeof runtimeOverrides === "object" && !Array.isArray(runtimeOverrides);
@@ -557,7 +1134,7 @@ export class Settings {
557
1134
 
558
1135
  if (updateRuntimeOverride) {
559
1136
  this.override("task.agentModelOverrides", {
560
- ...shallowStringRecord(runtimeOverrides),
1137
+ ...shallowModelSelectorRecord(runtimeOverrides),
561
1138
  [agentName]: modelId,
562
1139
  });
563
1140
  }
@@ -566,7 +1143,7 @@ export class Settings {
566
1143
  /**
567
1144
  * Get a model role (helper for modelRoles record).
568
1145
  */
569
- getModelRole(role: ModelRole | string): string | undefined {
1146
+ getModelRole(role: ModelRole | string): ModelSelectorValue | undefined {
570
1147
  const roles = this.get("modelRoles");
571
1148
  return roles[role];
572
1149
  }
@@ -574,19 +1151,17 @@ export class Settings {
574
1151
  /**
575
1152
  * Get all model roles (helper for modelRoles record).
576
1153
  */
577
- getModelRoles(): ReadOnlyDict<string> {
1154
+ getModelRoles(): Readonly<Record<string, ModelSelectorValue>> {
578
1155
  return { ...this.get("modelRoles") };
579
1156
  }
580
1157
 
581
1158
  /*
582
1159
  * Override model roles (helper for modelRoles record).
583
1160
  */
584
- overrideModelRoles(roles: ReadOnlyDict<string>): void {
585
- const next = shallowStringRecord(getByPath(this.#overrides, ["modelRoles"]));
1161
+ overrideModelRoles(roles: Readonly<Record<string, ModelSelectorValue>>): void {
1162
+ const next = shallowModelSelectorRecord(getByPath(this.#overrides, ["modelRoles"]));
586
1163
  for (const [role, modelId] of Object.entries(roles)) {
587
- if (modelId) {
588
- next[role] = modelId;
589
- }
1164
+ if (modelId) next[role] = Array.isArray(modelId) ? [...modelId] : modelId;
590
1165
  }
591
1166
  this.override("modelRoles", next);
592
1167
  }
@@ -614,13 +1189,14 @@ export class Settings {
614
1189
  this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
615
1190
  await this.#migrateFromLegacy();
616
1191
  this.#global = await this.#loadYaml(this.#configPath!);
1192
+ const configVersion = readConfigVersion(this.#configPath!);
1193
+ this.#defaultModelRoleOwnership.configVersion = configVersion;
1194
+ this.#defaultModelRoleOwnership.defaultConfigVersion = configVersion;
617
1195
  }
618
1196
 
619
1197
  this.#project = await projectPromise;
620
1198
 
621
- // Build merged view (global → project → overrides; project wins over global)
622
- this.#rebuildMerged();
623
- this.#fireAllHooks();
1199
+ await this.#normalizeAfterLoad();
624
1200
  return this;
625
1201
  }
626
1202
 
@@ -644,9 +1220,14 @@ export class Settings {
644
1220
  const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
645
1221
  let merged: RawSettings = {};
646
1222
  for (const item of result.items as SettingsCapabilityItem[]) {
647
- if (item.level === "project") {
648
- merged = this.#deepMerge(merged, item.data as RawSettings);
1223
+ if (item.level !== "project") continue;
1224
+ const { settings, rejectedNotifications } = this.#stripProjectNotificationSettings(
1225
+ item.data as RawSettings,
1226
+ );
1227
+ if (rejectedNotifications) {
1228
+ logger.warn("Settings: ignoring project notification settings", { path: item.path });
649
1229
  }
1230
+ merged = this.#deepMerge(merged, settings);
650
1231
  }
651
1232
  return this.#migrateRawSettings(merged);
652
1233
  } catch {
@@ -654,6 +1235,149 @@ export class Settings {
654
1235
  }
655
1236
  }
656
1237
 
1238
+ async #normalizeAfterLoad(): Promise<void> {
1239
+ this.#sanitizeModelSelectorRecords();
1240
+ this.#rebuildMerged();
1241
+ this.#legacyFallbackMigrationGlobalFingerprint = YAML.stringify(this.#global, null, 2);
1242
+ this.#migrateRetryFallbackChains();
1243
+ if (
1244
+ !this.#modified.has("modelRoles") &&
1245
+ ![...this.#modified.keys()].some(path => path.startsWith("retry.fallback"))
1246
+ ) {
1247
+ this.#legacyFallbackMigrationGlobalFingerprint = undefined;
1248
+ }
1249
+ await this.flush();
1250
+ this.#sanitizeModelSelectorRecords();
1251
+ this.#rebuildMerged();
1252
+ this.#fireAllHooks();
1253
+ }
1254
+
1255
+ #sanitizeModelSelectorRecords(): void {
1256
+ for (const source of [this.#global, this.#project, this.#overrides]) {
1257
+ for (const pathSegments of [["modelRoles"], ["task", "agentModelOverrides"]]) {
1258
+ const raw = getByPath(source, pathSegments);
1259
+ if (raw === undefined) continue;
1260
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1261
+ logger.warn("Settings: replaced malformed model selector record", { path: pathSegments.join(".") });
1262
+ setByPath(source, pathSegments, {});
1263
+ continue;
1264
+ }
1265
+ const sanitized = shallowModelSelectorRecord(raw);
1266
+ if (Object.keys(sanitized).length !== Object.keys(raw).length) {
1267
+ logger.warn("Settings: dropped invalid model selector values", {
1268
+ path: pathSegments.join("."),
1269
+ dropped: Object.keys(raw).filter(key => !(key in sanitized)),
1270
+ });
1271
+ }
1272
+ setByPath(source, pathSegments, sanitized);
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ #migrateRetryFallbackChains(): void {
1278
+ const globalChains = legacyFallbackChains(getByPath(this.#global, ["retry", "fallbackChains"]));
1279
+ const projectChains = legacyFallbackChains(getByPath(this.#project, ["retry", "fallbackChains"]));
1280
+ const overrideChains = legacyFallbackChains(getByPath(this.#overrides, ["retry", "fallbackChains"]));
1281
+ const roles = new Set([
1282
+ ...Object.keys(globalChains),
1283
+ ...Object.keys(projectChains),
1284
+ ...Object.keys(overrideChains),
1285
+ ]);
1286
+ const retainedGlobalChains: Record<string, unknown> = {};
1287
+ const effectiveRoles = shallowModelSelectorRecord(getByPath(this.#merged, ["modelRoles"]));
1288
+ for (const role of roles) {
1289
+ const source = Object.hasOwn(overrideChains, role)
1290
+ ? "override"
1291
+ : Object.hasOwn(projectChains, role)
1292
+ ? "project"
1293
+ : "global";
1294
+ const tailValue =
1295
+ source === "override"
1296
+ ? overrideChains[role]
1297
+ : source === "project"
1298
+ ? projectChains[role]
1299
+ : globalChains[role];
1300
+ const primary = selectorChain(effectiveRoles[role]);
1301
+ const tail = selectorChain(tailValue);
1302
+ const chain = [...new Set([...primary, ...tail])];
1303
+ if (primary.length === 0 || tail.length === 0) {
1304
+ this.#warnLegacyFallbackMigration(
1305
+ `retry.fallbackChains.${role} could not be migrated because it lacks a valid primary selector or tail.`,
1306
+ );
1307
+ continue;
1308
+ }
1309
+ const target =
1310
+ source === "override" || hasOwnModelRole(this.#overrides, role)
1311
+ ? this.#overrides
1312
+ : source === "project" || hasOwnModelRole(this.#project, role)
1313
+ ? this.#project
1314
+ : this.#global;
1315
+ const targetRoles = shallowModelSelectorRecord(getByPath(target, ["modelRoles"]));
1316
+ setByPath(target, ["modelRoles"], { ...targetRoles, [role]: chain });
1317
+ if (target === this.#global) {
1318
+ this.#recordLegacyFallbackMigrationPatch("modelRoles", getByPath(this.#global, ["modelRoles"]));
1319
+ }
1320
+ if (target !== this.#global && Object.hasOwn(globalChains, role))
1321
+ retainedGlobalChains[role] = globalChains[role];
1322
+ if (source === "project") {
1323
+ this.#warnLegacyFallbackMigration(
1324
+ `retry.fallbackChains.${role} is project-owned and was migrated in memory only.`,
1325
+ );
1326
+ }
1327
+ }
1328
+ for (const source of [this.#project, this.#overrides]) {
1329
+ deleteByPath(source, ["retry", "fallbackChains"]);
1330
+ deleteByPath(source, ["retry", "fallbackRevertPolicy"]);
1331
+ }
1332
+ if (Object.keys(retainedGlobalChains).length > 0) {
1333
+ setByPath(this.#global, ["retry", "fallbackChains"], retainedGlobalChains);
1334
+ this.#recordLegacyFallbackMigrationPatch("retry.fallbackChains", retainedGlobalChains);
1335
+ } else if (getByPath(this.#global, ["retry", "fallbackChains"]) !== undefined) {
1336
+ deleteByPath(this.#global, ["retry", "fallbackChains"]);
1337
+ this.#recordLegacyFallbackMigrationPatch("retry.fallbackChains", undefined);
1338
+ }
1339
+ if (
1340
+ Object.keys(retainedGlobalChains).length === 0 &&
1341
+ getByPath(this.#global, ["retry", "fallbackRevertPolicy"]) !== undefined
1342
+ ) {
1343
+ deleteByPath(this.#global, ["retry", "fallbackRevertPolicy"]);
1344
+ this.#recordLegacyFallbackMigrationPatch("retry.fallbackRevertPolicy", undefined);
1345
+ }
1346
+ if (
1347
+ Object.keys(retainedGlobalChains).length === 0 &&
1348
+ this.#global.retry !== undefined &&
1349
+ Object.keys(rawSettingsRecord(this.#global.retry) ?? {}).length === 0
1350
+ ) {
1351
+ delete this.#global.retry;
1352
+ this.#recordLegacyFallbackMigrationPatch("retry", undefined);
1353
+ }
1354
+ this.#rebuildMerged();
1355
+ }
1356
+
1357
+ #recordLegacyFallbackMigrationPatch(path: string, value: unknown): void {
1358
+ const existing = this.#modified.get(path);
1359
+ if (existing && !existing.legacyFallbackMigration) {
1360
+ this.#modified.set(path, { ...existing, value: structuredClone(value) });
1361
+ return;
1362
+ }
1363
+ const revision = ++this.#nextRevision;
1364
+ this.#pathRevisions.set(path, revision);
1365
+ this.#modified.set(path, {
1366
+ path,
1367
+ value: structuredClone(value),
1368
+ generation: ++this.#nextGeneration,
1369
+ revision,
1370
+ configVersion: this.#defaultModelRoleOwnership.configVersion,
1371
+ legacyFallbackMigration: true,
1372
+ });
1373
+ }
1374
+
1375
+ #warnLegacyFallbackMigration(message: string): void {
1376
+ if (this.#legacyFallbackMigrationWarnings >= 10) return;
1377
+ this.#legacyFallbackMigrationWarnings++;
1378
+ logger.warn(`Settings: ${message}`);
1379
+ }
1380
+
657
1381
  async #migrateFromLegacy(): Promise<void> {
658
1382
  if (!this.#configPath) return;
659
1383
 
@@ -690,10 +1414,17 @@ export class Settings {
690
1414
  }
691
1415
  } catch {}
692
1416
 
693
- // 3. Write merged settings
1417
+ // 3. Write merged settings through the shared atomic YAML pipeline.
694
1418
  if (migrated && Object.keys(settings).length > 0) {
695
1419
  try {
696
- await Bun.write(this.#configPath, YAML.stringify(settings, null, 2));
1420
+ await applyAtomicYamlPatches(
1421
+ this.#configPath,
1422
+ Object.entries(settings).map(([settingPath, value]) => ({
1423
+ path: settingPath,
1424
+ op: "set" as const,
1425
+ value,
1426
+ })),
1427
+ );
697
1428
  logger.debug("Settings: migrated to config.yml", { path: this.#configPath });
698
1429
  } catch {}
699
1430
  }
@@ -870,59 +1601,257 @@ export class Settings {
870
1601
  #queueSave(): void {
871
1602
  if (!this.#persist || !this.#configPath) return;
872
1603
 
1604
+ const currentSlot = this.#pendingSaveSlot;
1605
+ if (currentSlot && !currentSlot.captured && !currentSlot.released) {
1606
+ this.#armSaveTimer(currentSlot);
1607
+ return;
1608
+ }
1609
+
1610
+ let release!: () => void;
1611
+ const slot: PendingSaveSlot = {
1612
+ captured: false,
1613
+ released: false,
1614
+ release: () => release(),
1615
+ wait: new Promise<void>(resolve => {
1616
+ release = resolve;
1617
+ }),
1618
+ };
1619
+ this.#pendingSaveSlot = slot;
1620
+
1621
+ let captured: SettingsPatch[] = [];
1622
+ let durableBeforeWrite: RawSettings | undefined;
1623
+ let durableVersionBeforeWrite: string | undefined;
1624
+ const save = reserveAtomicYamlUpdateSlot(this.#configPath, async () => {
1625
+ await slot.wait;
1626
+ slot.captured = true;
1627
+ if (this.#pendingSaveSlot === slot) this.#pendingSaveSlot = undefined;
1628
+ captured = this.#pendingPatchesInGenerationOrder();
1629
+ return {
1630
+ apply: current => {
1631
+ this.#migrateRawSettings(current);
1632
+ const migrationFingerprint = this.#legacyFallbackMigrationGlobalFingerprint;
1633
+ this.#legacyFallbackMigrationGlobalFingerprint = undefined;
1634
+ if (migrationFingerprint !== undefined && YAML.stringify(current, null, 2) !== migrationFingerprint) {
1635
+ this.#global = structuredClone(current);
1636
+ this.#rebuildMerged();
1637
+ if (getByPath(current, ["retry", "fallbackChains"]) !== undefined) {
1638
+ this.#defaultModelRoleOwnership.configVersion = readConfigVersion(this.#configPath!);
1639
+ this.#defaultModelRoleOwnership.defaultLineageKnown = false;
1640
+ this.#migrateRetryFallbackChains();
1641
+ captured = this.#pendingPatchesInGenerationOrder();
1642
+ } else {
1643
+ for (const patch of captured) {
1644
+ if (!patch.legacyFallbackMigration) continue;
1645
+ const key = settingsPatchKey(patch);
1646
+ if (this.#modified.get(key)?.generation === patch.generation) this.#modified.delete(key);
1647
+ }
1648
+ captured = captured.filter(patch => !patch.legacyFallbackMigration);
1649
+ }
1650
+ }
1651
+ const currentConfigVersion = readConfigVersion(this.#configPath!);
1652
+ durableBeforeWrite = structuredClone(current);
1653
+ durableVersionBeforeWrite = currentConfigVersion;
1654
+ const externalLineageBreak = currentConfigVersion !== this.#defaultModelRoleOwnership.configVersion;
1655
+ const applicablePatches = captured.filter(
1656
+ patch =>
1657
+ !this.#isStaleDefaultModelRolePatch(patch, currentConfigVersion) || patch.modelRole !== "default",
1658
+ );
1659
+ const appliesDefault = applicablePatches.some(
1660
+ patch =>
1661
+ !this.#isStaleDefaultModelRolePatch(patch, currentConfigVersion) &&
1662
+ (patch.modelRole === "default" || (patch.path === "modelRoles" && !patch.modelRole)),
1663
+ );
1664
+ for (const patch of applicablePatches) {
1665
+ this.#applyPatchWithDefaultOwnership(current, patch, currentConfigVersion);
1666
+ }
1667
+ return { appliesDefault, externalLineageBreak, shouldWrite: applicablePatches.length > 0 };
1668
+ },
1669
+ shouldWrite: result => result.shouldWrite,
1670
+ committed: (current, result) => {
1671
+ const savedConfigVersion = readConfigVersion(this.#configPath!);
1672
+ this.#defaultModelRoleOwnership.configVersion = savedConfigVersion;
1673
+ if (result.appliesDefault) {
1674
+ this.#defaultModelRoleOwnership.defaultConfigVersion = savedConfigVersion;
1675
+ this.#defaultModelRoleOwnership.defaultLineageKnown = true;
1676
+ } else if (result.externalLineageBreak) {
1677
+ this.#defaultModelRoleOwnership.defaultLineageKnown = false;
1678
+ }
1679
+ for (const patch of captured) {
1680
+ const key = settingsPatchKey(patch);
1681
+ if (this.#modified.get(key)?.generation === patch.generation) {
1682
+ this.#modified.delete(key);
1683
+ }
1684
+ }
1685
+ this.#global = current;
1686
+ for (const [key, patch] of [...this.#modified].sort(
1687
+ (left, right) => left[1].generation - right[1].generation,
1688
+ )) {
1689
+ if (this.#isStaleDefaultModelRolePatch(patch, savedConfigVersion) && patch.modelRole === "default") {
1690
+ this.#modified.delete(key);
1691
+ continue;
1692
+ }
1693
+ this.#applyPatchWithDefaultOwnership(
1694
+ this.#global,
1695
+ { ...patch, value: structuredClone(patch.value) },
1696
+ savedConfigVersion,
1697
+ );
1698
+ }
1699
+ this.#rebuildMerged();
1700
+ },
1701
+ };
1702
+ }).then(() => undefined);
1703
+ this.#savePromise = save;
1704
+ void save.catch(error => {
1705
+ logger.warn("Settings: background save failed", { error: String(error) });
1706
+ let droppedStaleDefault = false;
1707
+ for (const patch of captured) {
1708
+ const key = settingsPatchKey(patch);
1709
+ const currentPatch = this.#modified.get(key);
1710
+ if (currentPatch?.generation !== patch.generation) continue;
1711
+ if (
1712
+ this.#isStaleDefaultModelRolePatch(patch, readConfigVersion(this.#configPath!)) &&
1713
+ patch.modelRole === "default"
1714
+ ) {
1715
+ this.#modified.delete(key);
1716
+ droppedStaleDefault = true;
1717
+ } else {
1718
+ this.#modified.set(key, patch);
1719
+ }
1720
+ }
1721
+ if (droppedStaleDefault && durableBeforeWrite) {
1722
+ setRawModelRole(
1723
+ this.#global,
1724
+ "default",
1725
+ defaultModelRoleFrom(durableBeforeWrite),
1726
+ !Object.hasOwn(durableBeforeWrite, "modelRoles"),
1727
+ );
1728
+ this.#defaultModelRoleOwnership.configVersion = durableVersionBeforeWrite;
1729
+ this.#defaultModelRoleOwnership.defaultLineageKnown = false;
1730
+ this.#rebuildMerged();
1731
+ }
1732
+ });
1733
+ this.#armSaveTimer(slot);
1734
+ }
1735
+
1736
+ #armSaveTimer(slot: PendingSaveSlot): void {
873
1737
  if (this.#saveTimer) clearTimeout(this.#saveTimer);
874
1738
  this.#saveTimer = setTimeout(() => {
875
1739
  this.#saveTimer = undefined;
876
- void this.#saveNow();
1740
+ if (slot.released) return;
1741
+ slot.released = true;
1742
+ slot.release();
877
1743
  }, 100);
878
1744
  }
879
1745
 
880
- async #saveNow(options: { throwOnError?: boolean } = {}): Promise<void> {
881
- if (!this.#persist || !this.#configPath || this.#modified.size === 0) return;
1746
+ #isStaleDefaultModelRolePatch(
1747
+ patch: SettingsPatch,
1748
+ currentConfigVersion = this.#defaultModelRoleOwnership.configVersion,
1749
+ ): boolean {
1750
+ const changesDefault = patch.modelRole === "default" || (patch.path === "modelRoles" && !patch.modelRole);
1751
+ const generationChanged =
1752
+ patch.modelRoleRevision !== undefined &&
1753
+ patch.modelRoleRevision !== this.#defaultModelRoleOwnership.generation;
1754
+ const externalConfigChanged =
1755
+ patch.configVersion !== currentConfigVersion &&
1756
+ (this.#defaultModelRoleOwnership.configVersion !== currentConfigVersion ||
1757
+ !this.#defaultModelRoleOwnership.defaultLineageKnown);
1758
+ return changesDefault && (generationChanged || externalConfigChanged);
1759
+ }
882
1760
 
883
- const configPath = this.#configPath;
884
- const patches = [...this.#modified.values()];
1761
+ #pendingPatchesInGenerationOrder(): SettingsPatch[] {
1762
+ return [...this.#modified.values()].sort((left, right) => left.generation - right.generation);
1763
+ }
885
1764
 
886
- const save = this.#saveTail.then(() =>
887
- withFileLock(configPath, async () => {
888
- const current = await this.#loadYaml(configPath);
889
- for (const patch of patches) {
890
- setByPath(current, patch.path.split("."), patch.value);
891
- }
892
- await Bun.write(configPath, YAML.stringify(current, null, 2));
893
- for (const patch of patches) {
894
- if (this.#modified.get(patch.path)?.generation === patch.generation) {
895
- this.#modified.delete(patch.path);
896
- }
897
- }
898
- this.#global = current;
899
- for (const patch of this.#modified.values()) {
900
- setByPath(this.#global, patch.path.split("."), structuredClone(patch.value));
901
- }
902
- }),
903
- );
904
- this.#saveTail = save.then(
905
- () => undefined,
906
- () => undefined,
1765
+ #applyPatchWithDefaultOwnership(
1766
+ raw: RawSettings,
1767
+ patch: SettingsPatch,
1768
+ currentConfigVersion = this.#defaultModelRoleOwnership.configVersion,
1769
+ ): boolean {
1770
+ if (!this.#isStaleDefaultModelRolePatch(patch, currentConfigVersion)) {
1771
+ applySettingsPatch(raw, patch);
1772
+ return true;
1773
+ }
1774
+ if (patch.modelRole === "default") return false;
1775
+
1776
+ const durableDefault = defaultModelRoleFrom(raw);
1777
+ const roles = shallowModelSelectorRecord(patch.value);
1778
+ if (durableDefault === undefined) delete roles.default;
1779
+ else roles.default = durableDefault;
1780
+ setByPath(raw, ["modelRoles"], roles);
1781
+ return true;
1782
+ }
1783
+
1784
+ #releasePendingSaveSlot(): void {
1785
+ if (this.#saveTimer) {
1786
+ clearTimeout(this.#saveTimer);
1787
+ this.#saveTimer = undefined;
1788
+ }
1789
+ const slot = this.#pendingSaveSlot;
1790
+ if (!slot || slot.released) return;
1791
+ slot.released = true;
1792
+ slot.release();
1793
+ }
1794
+
1795
+ #applyDurableBatch(revisions: readonly DurableBatchRevision[]): void {
1796
+ this.#applyDurablePatches(
1797
+ revisions,
1798
+ revisions.map(entry => entry.patch),
1799
+ true,
907
1800
  );
1801
+ }
908
1802
 
909
- try {
910
- await save;
911
- } catch (error) {
912
- logger.warn("Settings: save failed", { error: String(error) });
913
- for (const patch of patches) {
914
- const currentPatch = this.#modified.get(patch.path);
915
- if (currentPatch?.generation === patch.generation) {
916
- this.#modified.set(patch.path, patch);
917
- }
1803
+ #applyRestoredDurableBatch(
1804
+ revisions: readonly DurableBatchRevision[],
1805
+ restoredPatches: readonly AtomicYamlPatch[],
1806
+ ): void {
1807
+ this.#applyDurablePatches(revisions, restoredPatches, false);
1808
+ }
1809
+
1810
+ #applyDurablePatches(
1811
+ revisions: readonly DurableBatchRevision[],
1812
+ patches: readonly AtomicYamlPatch[],
1813
+ clearStagedMutations: boolean,
1814
+ ): void {
1815
+ const revisionsByPath = new Map<string, DurableBatchRevision>();
1816
+ for (const entry of revisions) revisionsByPath.set(entry.patch.path, entry);
1817
+ const finalPatches = new Map<string, AtomicYamlPatch>();
1818
+ for (const patch of patches) finalPatches.set(patch.path, patch);
1819
+ const applicable = [...finalPatches.values()].filter(patch => {
1820
+ const revision = revisionsByPath.get(patch.path);
1821
+ return revision !== undefined && this.#pathRevisions.get(patch.path) === revision.revision;
1822
+ });
1823
+ if (applicable.length === 0) return;
1824
+
1825
+ const previous = new Map<SettingPath, SettingValue<SettingPath>>();
1826
+ for (const patch of applicable) {
1827
+ const settingPath = patch.path as SettingPath;
1828
+ const revision = revisionsByPath.get(patch.path)!;
1829
+ previous.set(settingPath, this.get(settingPath));
1830
+ if (patch.op === "set") {
1831
+ setByPath(this.#global, settingPath.split("."), structuredClone(patch.value));
1832
+ } else {
1833
+ deleteByPath(this.#global, settingPath.split("."));
918
1834
  }
919
- if (options.throwOnError) {
920
- this.#rebuildMerged();
921
- throw error;
1835
+ if (clearStagedMutations) {
1836
+ for (const [key, staged] of this.#modified) {
1837
+ if (staged.path === settingPath && staged.revision <= revision.revision) {
1838
+ this.#modified.delete(key);
1839
+ }
1840
+ }
922
1841
  }
923
1842
  }
924
-
925
1843
  this.#rebuildMerged();
1844
+ for (const patch of applicable) {
1845
+ const settingPath = patch.path as SettingPath;
1846
+ const hook = SETTING_HOOKS[settingPath];
1847
+ if (hook) hook(this.get(settingPath), previous.get(settingPath)!);
1848
+ }
1849
+ }
1850
+
1851
+ async #refreshDurableSettings(): Promise<void> {
1852
+ if (!this.#persist || !this.#configPath) return;
1853
+ const current = await this.#loadYaml(this.#configPath);
1854
+ this.#replaceGlobalWithDurable(current, readConfigVersion(this.#configPath), false);
926
1855
  }
927
1856
 
928
1857
  // ─────────────────────────────────────────────────────────────────────────
@@ -944,6 +1873,39 @@ export class Settings {
944
1873
  }
945
1874
  }
946
1875
 
1876
+ #getGlobalResolved<P extends SettingPath>(path: P): SettingValue<P> {
1877
+ const value = getByPath(this.#global, path.split("."));
1878
+ return value === undefined ? getDefault(path) : (value as SettingValue<P>);
1879
+ }
1880
+
1881
+ #stripProjectNotificationSettings(settings: RawSettings): {
1882
+ settings: RawSettings;
1883
+ rejectedNotifications: boolean;
1884
+ } {
1885
+ let rejectedNotifications = false;
1886
+ const sanitized: RawSettings = {};
1887
+ for (const [key, value] of Object.entries(settings)) {
1888
+ if (key === "notifications" && value && typeof value === "object" && !Array.isArray(value)) {
1889
+ const localNotifications: Record<string, unknown> = {};
1890
+ for (const [notificationKey, notificationValue] of Object.entries(value)) {
1891
+ if (LOCAL_NOTIFICATION_SETTING_KEYS.has(notificationKey)) {
1892
+ localNotifications[notificationKey] = notificationValue;
1893
+ } else {
1894
+ rejectedNotifications = true;
1895
+ }
1896
+ }
1897
+ if (Object.keys(localNotifications).length > 0) sanitized[key] = localNotifications;
1898
+ continue;
1899
+ }
1900
+ if (isNotificationSettingsPath(key)) {
1901
+ rejectedNotifications = true;
1902
+ continue;
1903
+ }
1904
+ sanitized[key] = value;
1905
+ }
1906
+ return { settings: sanitized, rejectedNotifications };
1907
+ }
1908
+
947
1909
  #deepMerge(base: RawSettings, overrides: RawSettings): RawSettings {
948
1910
  const result = { ...base };
949
1911
  for (const key of Object.keys(overrides)) {