@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Grok Build sends its conversation identity as `x-grok-conv-id` (and the same value as the body
3
+ * `prompt_cache_key`), but never the `session_id` header Codex clients send. The ChatGPT Codex
4
+ * backend only reuses a warmed prompt prefix for requests that carry `session_id`, and opencodex
5
+ * reads the same header for thread affinity and conversation logging. Without it, every Grok turn
6
+ * routed to an OpenAI model was a cold `new_bind` with near-zero cached input.
7
+ *
8
+ * Only the managed Grok surface (`x-opencodex-grok: 1`, written by `ocx` into Grok's config) is
9
+ * promoted, and an explicit caller session header always wins.
10
+ */
11
+ const GROK_SURFACE_HEADER = "x-opencodex-grok";
12
+ const GROK_CONVERSATION_HEADER = "x-grok-conv-id";
13
+ const SESSION_HEADERS = ["session_id", "session-id", "thread-id"] as const;
14
+ // Grok emits UUIDs and `turn-summary-<uuid>`; anything else is not promoted.
15
+ const SAFE_CONVERSATION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
16
+
17
+ export function grokConversationSessionId(headers: Headers): string | undefined {
18
+ if (headers.get(GROK_SURFACE_HEADER) !== "1") return undefined;
19
+ if (SESSION_HEADERS.some(name => headers.has(name))) return undefined;
20
+ const conversationId = headers.get(GROK_CONVERSATION_HEADER)?.trim();
21
+ if (!conversationId || !SAFE_CONVERSATION_ID.test(conversationId)) return undefined;
22
+ return conversationId;
23
+ }
24
+
25
+ /** Returns `req` unchanged unless a Grok conversation id can stand in for the missing `session_id`. */
26
+ export function withGrokSessionIdentity(req: Request): Request {
27
+ const sessionId = grokConversationSessionId(req.headers);
28
+ if (!sessionId) return req;
29
+ const headers = new Headers(req.headers);
30
+ headers.set("session_id", sessionId);
31
+ // The clone keeps the unread body stream and the caller's abort signal.
32
+ return new Request(req, { headers });
33
+ }
@@ -11,7 +11,7 @@ import {
11
11
  /** Refresh only previously connected clients; a refused file never blocks its peers. */
12
12
  export async function refreshOwnedCatalogIntegrations(
13
13
  input: Omit<OwnedIntegrationRefreshInput, "clientId">,
14
- clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid"],
14
+ clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid", "opencode", "kilo"],
15
15
  ): Promise<OwnedIntegrationRefreshOutcome[]> {
16
16
  // Client files are shared with the live proxy a sibling instance runs beside; their entries
17
17
  // point at the owner's port, and refreshing them here would re-point them at this one.
@@ -184,9 +184,11 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown):
184
184
  /*
185
185
  * `parent[slot]` is the position the segment just consumed addresses. The
186
186
  * root sits in a one-key holder so the first segment needs no special case:
187
- * a non-record document is replaced by `{}` exactly as before.
187
+ * a document that is neither a record nor a sequence is replaced by `{}`
188
+ * exactly as before. A sequence root survives so a leading selector can
189
+ * address one of its entries (a DSH profile patch is a top-level list).
188
190
  */
189
- const holder: Record<string, unknown> = { root: isPlainRecord(doc) ? clone(doc) : {} };
191
+ const holder: Record<string, unknown> = { root: isPlainRecord(doc) || Array.isArray(doc) ? clone(doc) : {} };
190
192
  let parent: Record<string, unknown> | unknown[] = holder;
191
193
  let slot: string | number = "root";
192
194
  const read = (): unknown => (Array.isArray(parent) ? parent[slot as number] : parent[slot as string]);
@@ -244,8 +246,8 @@ export function deletePath(
244
246
  path: readonly string[],
245
247
  createdContainers: ReadonlySet<string> = new Set(),
246
248
  ): { doc: unknown; removed: boolean } {
247
- if (!isPlainRecord(doc) || path.length === 0) return { doc, removed: false };
248
- const root = clone(doc) as Record<string, unknown>;
249
+ if ((!isPlainRecord(doc) && !Array.isArray(doc)) || path.length === 0) return { doc, removed: false };
250
+ const root = clone(doc) as Record<string, unknown> | unknown[];
249
251
  // `chain[i]` is the container segment `i` is resolved against; `slots[i]` is
250
252
  // the key or index it resolved to, so the prune walk can delete by position.
251
253
  const chain: (Record<string, unknown> | unknown[])[] = [root];
@@ -288,7 +290,13 @@ export function deletePath(
288
290
  */
289
291
  for (let index = path.length - 1; index >= 1; index -= 1) {
290
292
  const container = chain[index]!;
291
- const empty = Array.isArray(container) ? container.length === 0 : Object.keys(container).length === 0;
293
+ // An element we pushed for a selector is empty once only the fields that seeded it remain.
294
+ const segment = parseSegment(path[index - 1]!);
295
+ const empty = Array.isArray(container)
296
+ ? container.length === 0
297
+ : segment.kind === "select"
298
+ ? Object.keys(container).length === segment.criteria.length && selectIndex([container], segment.criteria) === 0
299
+ : Object.keys(container).length === 0;
292
300
  if (!empty) break;
293
301
  const containerPath = path.slice(0, index).join("\u0000");
294
302
  if (!createdContainers.has(containerPath)) break;
@@ -14,6 +14,7 @@
14
14
  * preview route.
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
+ import { dirname } from "node:path";
17
18
  import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
18
19
  import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
19
20
  import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
@@ -34,9 +35,9 @@ import {
34
35
  restoreOwnershipCollision,
35
36
  type IntegrationClientId,
36
37
  } from "./registry";
37
- import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
38
+ import { declaredIntegrationTarget, resolveIntegrationTarget, type IneffectiveWrite, type IneffectiveWriteReason, type IntegrationTarget } from "./target";
38
39
  import { shouldInjectApiAuthHeader } from "../codex/inject";
39
- import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
40
+ import { buildIntegrationContribution, classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
40
41
  import { inspectKiloCandidates } from "./kilo-candidates";
41
42
  import { InvalidSelectorError } from "./merge";
42
43
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
@@ -99,6 +100,13 @@ export interface IntegrationMutationPlan {
99
100
  */
100
101
  readonly willChange: boolean;
101
102
  readonly refusalReason?: RefusalReason;
103
+ /**
104
+ * `superseded_store` only: why the store the client reads is not written. The store's location
105
+ * stays on the status row (`supersededBy`); the plan names no file.
106
+ */
107
+ readonly supersededReason?: IneffectiveWriteReason;
108
+ /** `missing-store` only: the document that recreates the store, so the remedy can name it. */
109
+ readonly missingStoreDocument?: string;
102
110
  readonly profileId?: number;
103
111
  }
104
112
 
@@ -129,7 +137,7 @@ const CLIENT_MANAGED_PATHS = {
129
137
  openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]],
130
138
  kimi: [["providers", OPENCODE_PROVIDER_ID], ["models", DYNAMIC_SEGMENT]],
131
139
  gajae: [["providers", OPENCODE_PROVIDER_ID]],
132
- dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]],
140
+ dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], ["[id=llm-pi-ai]", "config", "providers", OPENCODE_PROVIDER_ID]],
133
141
  mcode: [["custom_provider", OPENCODE_PROVIDER_ID]],
134
142
  zcode: [
135
143
  ["provider", OPENCODE_PROVIDER_ID],
@@ -245,6 +253,11 @@ export interface PlanFingerprintInput {
245
253
  * flip as a store appearing.
246
254
  */
247
255
  readonly ineffectiveWrite: string | null;
256
+ /**
257
+ * The same finding as `ineffectiveWrite`, unflattened, for the refusal's description. It is
258
+ * descriptive only: the bound token above is what decides and what the fingerprint covers.
259
+ */
260
+ readonly ineffective?: IneffectiveWrite | null;
248
261
  /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */
249
262
  readonly before: string | null;
250
263
  readonly contribution: ManagedContribution | null;
@@ -497,6 +510,12 @@ export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan {
497
510
  canApply: outcome.kind !== "refuse",
498
511
  willChange: outcome.kind === "change",
499
512
  ...(outcome.kind === "refuse" ? { refusalReason: outcome.reason } : {}),
513
+ ...(outcome.kind === "refuse" && outcome.reason === "superseded_store" && input.ineffective
514
+ ? {
515
+ supersededReason: input.ineffective.why,
516
+ ...(input.ineffective.emptyDocument === undefined ? {} : { missingStoreDocument: input.ineffective.emptyDocument }),
517
+ }
518
+ : {}),
500
519
  ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
501
520
  });
502
521
  }
@@ -531,6 +550,25 @@ function unboundPlan(
531
550
  });
532
551
  }
533
552
 
553
+ /** A restore must not recreate a declared store without its client's lock. */
554
+ export function restoreStoreDirectoryRefusal(
555
+ input: IntegrationWriteInput,
556
+ configPath: string,
557
+ io: IntegrationIO,
558
+ ): string | null {
559
+ const declared = INTEGRATION_CLIENTS[input.clientId].currentStore;
560
+ if (!declared?.lockFile) return null;
561
+ let storePath: string;
562
+ try {
563
+ storePath = declared.path(input.env, input.home);
564
+ } catch (error) {
565
+ if (error instanceof ClientPathError) return null;
566
+ throw error;
567
+ }
568
+ return configPath === storePath && io.statKind(dirname(storePath)) !== "dir"
569
+ ? "the client store directory is missing; restore will not create it" : null;
570
+ }
571
+
534
572
  /**
535
573
  * Restore reads a different specification, so it gets its own observation.
536
574
  *
@@ -592,6 +630,10 @@ export function observeRestore(
592
630
  failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
593
631
  } as const;
594
632
  }
633
+ const directoryRefusal = restoreStoreDirectoryRefusal(input, configPath, io);
634
+ if (directoryRefusal !== null) {
635
+ return { failed: observationFailure("unsafe", "unsafe", directoryRefusal) } as const;
636
+ }
595
637
  /*
596
638
  * A legal historical path is not enough. Another candidate can already own
597
639
  * the single record, and committing this row's prior record would orphan the
@@ -678,6 +720,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe
678
720
  // Loopback-only clients cannot carry the admission header a non-loopback bind requires.
679
721
  admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config),
680
722
  ineffectiveWrite: observed.ineffectiveWrite,
723
+ ineffective: observed.target.ineffective,
681
724
  before: observed.before,
682
725
  contribution: observed.contribution,
683
726
  record: observed.record,
@@ -791,6 +834,8 @@ export interface IntegrationWriteInput {
791
834
  models: readonly ExportModel[];
792
835
  config: OcxConfig;
793
836
  port: number;
837
+ /** Explicit Droid-only defaults; omitted means retain matching owned rows. */
838
+ droidReasoningDefaults?: Record<string, string>;
794
839
  env?: NodeJS.ProcessEnv;
795
840
  home?: string;
796
841
  store?: IntegrationStateStore;
@@ -936,13 +981,13 @@ export function observeIntegration(
936
981
  * reads a different document there, and a write in the config file's shape
937
982
  * would be as unread as a write to the config file itself.
938
983
  */
939
- const contribution = effective.buildContribution(exportContextOf(input));
940
984
  // A record proves ownership of the file it was written FOR. Matching only by
941
985
  // client id let a record for one home authorize a write to another whose
942
986
  // bytes happened to hash the same — which deleted a config we never touched.
943
987
  const record = stored && stored.clientId === clientId && stored.configPath === configPath
944
988
  ? stored
945
989
  : null;
990
+ const contribution = buildIntegrationContribution(input, effective, parsed, record);
946
991
  if (clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && !record) {
947
992
  return { failed: observationFailure("unsafe", "unsafe", "Factory Droid has no addressable models in the selected catalog") } as const;
948
993
  }
@@ -952,7 +997,7 @@ export function observeIntegration(
952
997
  // ownership here and disable would delete fragments it never wrote.
953
998
  const classified = classifyIntegration({
954
999
  fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
955
- format: effective.format,
1000
+ format: effective.format, sourcePreservingYaml: effective.sourcePreservingYaml !== null,
956
1001
  });
957
1002
  if (clientId === "droid" && record && (classified.state === "current" || classified.state === "stale")) {
958
1003
  try { assertDroidRecordedSettingsUnambiguous(detectDir, parsed, record); }
@@ -7,6 +7,7 @@
7
7
  * expected document; unsupported YAML fails closed and never falls back to a
8
8
  * whole-document serializer.
9
9
  */
10
+ import { parseSegment, selectIndex, type SelectorCriterion } from "./merge";
10
11
  import { renderYaml } from "./serialize";
11
12
 
12
13
  interface SourceLine {
@@ -442,6 +443,7 @@ function upsertSource(
442
443
  path: readonly string[],
443
444
  value: unknown,
444
445
  ): string | null {
446
+ if (leadingSelector(path) !== null) return upsertSequenceEntry(text, parsed, path, value);
445
447
  const located = locatePath(text, parsed, path);
446
448
  if (located === null || located.kind === "unsupported-style") return null;
447
449
  const eol = lineEnding(text);
@@ -467,6 +469,178 @@ function upsertSource(
467
469
  return preserveFinalNewline(candidate, text, eol);
468
470
  }
469
471
 
472
+ /**
473
+ * The criteria of a path whose first segment selects one entry of a top-level
474
+ * sequence, or null for an ordinary block-map path.
475
+ *
476
+ * DSH 0.1.7+ keeps settings in a profile patch: a YAML list of loader rows,
477
+ * `- id: llm-pi-ai` among them. The entry is patched as the block map it is
478
+ * once its `- ` indicator and two-space continuation indent are set aside, and
479
+ * both are put back byte for byte. Every other list shape fails closed.
480
+ */
481
+ function leadingSelector(path: readonly string[]): readonly SelectorCriterion[] | null {
482
+ if (path.length === 0) return null;
483
+ const segment = parseSegment(path[0]!);
484
+ return segment.kind === "select" ? segment.criteria : null;
485
+ }
486
+
487
+ /** Line ranges of a block sequence's top-level entries, or null for any other document shape. */
488
+ function topLevelEntries(lines: readonly SourceLine[]): Array<{ start: number; end: number }> | null {
489
+ const entries: Array<{ start: number; end: number }> = [];
490
+ let open = false;
491
+ for (let index = 0; index < lines.length; index += 1) {
492
+ const body = lines[index]!.body;
493
+ if (isBlank(body)) continue;
494
+ const spaces = leadingSpaces(body);
495
+ if (spaces === null) return null;
496
+ if (spaces > 0) {
497
+ // Continuation of the open entry; deeper text anywhere else is not a list we can place.
498
+ if (!open) return null;
499
+ continue;
500
+ }
501
+ if (open) entries[entries.length - 1]!.end = index;
502
+ open = false;
503
+ if (isComment(body)) continue;
504
+ if (!/^- \S/u.test(body)) return null;
505
+ entries.push({ start: index, end: lines.length });
506
+ open = true;
507
+ }
508
+ return entries;
509
+ }
510
+
511
+ /** The entry's text as the block map it holds, or null when it is not in the two-space form. */
512
+ function entrySource(text: string, lines: readonly SourceLine[], start: number, end: number): string | null {
513
+ let source = "";
514
+ for (let index = start; index < end; index += 1) {
515
+ const line = lines[index]!;
516
+ const raw = text.slice(line.start, line.end);
517
+ if (index === start) source += raw.slice(2);
518
+ else if (isBlank(line.body)) source += raw;
519
+ else if ((leadingSpaces(line.body) ?? 0) >= 2) source += raw.slice(2);
520
+ else return null;
521
+ }
522
+ return source;
523
+ }
524
+
525
+ /** Put an entry back: `- ` on its first line, two spaces on every other non-blank line. */
526
+ function reindentEntry(source: string): string {
527
+ return sourceLines(source).map((line, index) => {
528
+ const raw = source.slice(line.start, line.end);
529
+ if (index === 0) return `- ${raw}`;
530
+ return isBlank(line.body) ? raw : ` ${raw}`;
531
+ }).join("");
532
+ }
533
+
534
+ function firstLine(text: string): string {
535
+ return text.split(/\r?\n/u, 1)[0] ?? "";
536
+ }
537
+
538
+ type SequenceEntry =
539
+ | {
540
+ kind: "found";
541
+ criteria: readonly SelectorCriterion[];
542
+ rest: readonly string[];
543
+ /** Byte range of the entry, trailing blank lines included. */
544
+ startOffset: number;
545
+ endOffset: number;
546
+ source: string;
547
+ parsed: Record<string, unknown>;
548
+ /** Entries the list keeps if this one is removed. */
549
+ siblings: number;
550
+ }
551
+ | {
552
+ kind: "missing";
553
+ criteria: readonly SelectorCriterion[];
554
+ rest: readonly string[];
555
+ /** Where a new entry goes, and the end of the `[]` line it replaces, if any. */
556
+ insertAt: number;
557
+ replaceEnd: number | null;
558
+ };
559
+
560
+ function sequenceEntry(text: string, parsed: unknown, path: readonly string[]): SequenceEntry | null {
561
+ const criteria = leadingSelector(path);
562
+ if (criteria === null || !Array.isArray(parsed)) return null;
563
+ const lines = sourceLines(text);
564
+ const rest = path.slice(1);
565
+ const found = selectIndex(parsed, criteria);
566
+ const entries = topLevelEntries(lines);
567
+ if (entries === null) {
568
+ // `[]` is how DSH writes a profile patch with no rows yet: the one flow form adopted.
569
+ const content = lines.filter(line => !isBlank(line.body) && !isComment(line.body));
570
+ if (found >= 0 || parsed.length !== 0 || content.length !== 1 || content[0]!.body.trimEnd() !== "[]") return null;
571
+ return { kind: "missing", criteria, rest, insertAt: content[0]!.start, replaceEnd: content[0]!.end };
572
+ }
573
+ if (entries.length !== parsed.length) return null;
574
+ if (found < 0) return { kind: "missing", criteria, rest, insertAt: text.length, replaceEnd: null };
575
+ const element = parsed[found];
576
+ const { start, end } = entries[found]!;
577
+ const source = entrySource(text, lines, start, end);
578
+ if (!isPlainRecord(element) || source === null) return null;
579
+ return {
580
+ kind: "found",
581
+ criteria,
582
+ rest,
583
+ startOffset: lines[start]!.start,
584
+ endOffset: end < lines.length ? lines[end]!.start : text.length,
585
+ source,
586
+ parsed: element,
587
+ siblings: entries.length - 1,
588
+ };
589
+ }
590
+
591
+ function spliceEntry(text: string, entry: Extract<SequenceEntry, { kind: "found" }>, replacement: string): string {
592
+ return `${text.slice(0, entry.startOffset)}${replacement}${text.slice(entry.endOffset)}`;
593
+ }
594
+
595
+ function upsertSequenceEntry(text: string, parsed: unknown, path: readonly string[], value: unknown): string | null {
596
+ const entry = sequenceEntry(text, parsed, path);
597
+ if (entry === null) return null;
598
+ const eol = lineEnding(text);
599
+ if (entry.kind === "found") {
600
+ if (entry.rest.length === 0) return null;
601
+ const patched = upsertSource(entry.source, entry.parsed, entry.rest, value);
602
+ // The `- ` belongs to the entry's first line; a patch that rewrote that line cannot take it back.
603
+ if (patched === null || firstLine(patched) !== firstLine(entry.source)) return null;
604
+ return preserveFinalNewline(spliceEntry(text, entry, reindentEntry(patched)), text, eol);
605
+ }
606
+ // The same element `setPath` seeds: the selector's own fields, then the fragment beneath them.
607
+ const seeded = Object.fromEntries(entry.criteria.map(criterion => [criterion.field, criterion.value]));
608
+ const element = entry.rest.length === 0 ? value : { ...seeded, ...nestedValue(entry.rest, value) };
609
+ if (!isPlainRecord(element)) return null;
610
+ const item = reindentEntry(rendered(element, 0, eol));
611
+ if (entry.replaceEnd !== null) {
612
+ return preserveFinalNewline(`${text.slice(0, entry.insertAt)}${item}${text.slice(entry.replaceEnd)}`, text, eol);
613
+ }
614
+ const prefix = text.length > 0 && !text.endsWith("\n") ? eol : "";
615
+ return preserveFinalNewline(`${text}${prefix}${item}`, text, eol);
616
+ }
617
+
618
+ function removeSequenceEntryPath(
619
+ text: string,
620
+ parsed: unknown,
621
+ path: readonly string[],
622
+ requireEmpty: boolean,
623
+ ): string | null {
624
+ const entry = sequenceEntry(text, parsed, path);
625
+ if (entry === null || entry.kind !== "found") return null;
626
+ if (entry.rest.length > 0) {
627
+ const patched = removeExactPath(entry.source, entry.rest, requireEmpty);
628
+ if (patched === null || firstLine(patched) !== firstLine(entry.source)) return null;
629
+ return spliceEntry(text, entry, reindentEntry(patched));
630
+ }
631
+ if (requireEmpty) {
632
+ // A pruned entry may still hold what its selector seeded, and nothing else.
633
+ const seededOnly = Object.keys(entry.parsed).length === entry.criteria.length
634
+ && selectIndex([entry.parsed], entry.criteria) === 0;
635
+ if (!seededOnly || sourceLines(entry.source).some(line => hasInlineComment(line.body) || isComment(line.body))) {
636
+ return null;
637
+ }
638
+ }
639
+ // A list left with no entries goes back to the `[]` DSH writes for an empty one.
640
+ const eol = lineEnding(text);
641
+ return preserveFinalNewline(spliceEntry(text, entry, entry.siblings === 0 ? `[]${eol}` : ""), text, eol);
642
+ }
643
+
470
644
  function removeExactPath(text: string, path: readonly string[], requireEmpty: boolean): string | null {
471
645
  let parsed: unknown;
472
646
  try {
@@ -474,6 +648,7 @@ function removeExactPath(text: string, path: readonly string[], requireEmpty: bo
474
648
  } catch {
475
649
  return null;
476
650
  }
651
+ if (leadingSelector(path) !== null) return removeSequenceEntryPath(text, parsed, path, requireEmpty);
477
652
  const located = locatePath(text, parsed, path);
478
653
  if (located === null || located.kind !== "existing") return null;
479
654
  const { lines, index, endIndex } = located.entry;
@@ -506,7 +681,7 @@ function planSourceRemoval(
506
681
  next = pruned;
507
682
  prunedContainers.push(encoded);
508
683
  }
509
- return { text: next, prunedContainers };
684
+ return { text: preserveFinalNewline(next, text, lineEnding(text)), prunedContainers };
510
685
  }
511
686
 
512
687
  /** Containers whose source ranges are still empty and safe to prune. */
@@ -560,6 +735,11 @@ export function yamlFragmentUnsupportedStyle(text: string, path: readonly string
560
735
  } catch {
561
736
  return false;
562
737
  }
738
+ if (leadingSelector(path) !== null) {
739
+ const entry = sequenceEntry(text, parsed, path);
740
+ return entry?.kind === "found" && entry.rest.length > 0
741
+ && locatePath(entry.source, entry.parsed, entry.rest)?.kind === "unsupported-style";
742
+ }
563
743
  return locatePath(text, parsed, path)?.kind === "unsupported-style";
564
744
  }
565
745
 
@@ -14,6 +14,7 @@ import {
14
14
  } from "../clients/config-export";
15
15
  import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
16
16
  import { readPath } from "./merge";
17
+ import { serializeDocument } from "./serialize";
17
18
 
18
19
  type JsonObject = Record<string, unknown>;
19
20
 
@@ -51,6 +52,71 @@ export function isHermesAffinityUpgrade(
51
52
  && fingerprint(semanticContribution(predecessor)) === record.semanticBlockFingerprint);
52
53
  }
53
54
 
55
+ function removeDroidNormalizationFields(row: Record<string, unknown>): boolean {
56
+ let projected = false;
57
+ if (Object.hasOwn(row, "id")) {
58
+ delete row.id;
59
+ projected = true;
60
+ }
61
+ if (Object.hasOwn(row, "index")) {
62
+ delete row.index;
63
+ projected = true;
64
+ }
65
+ return projected;
66
+ }
67
+
68
+ function projectObservedDroidContribution(
69
+ contribution: ManagedContribution,
70
+ ): ManagedContribution {
71
+ if (contribution.clientId !== "droid") return contribution;
72
+ let projected = false;
73
+ const fragments = contribution.fragments.map(fragment => {
74
+ if (
75
+ fragment.path.length !== 2
76
+ || fragment.path[0] !== "customModels"
77
+ || !isObject(fragment.value)
78
+ ) return fragment;
79
+ const value = { ...fragment.value };
80
+ if (!removeDroidNormalizationFields(value)) return fragment;
81
+ projected = true;
82
+ return { ...fragment, value };
83
+ });
84
+ return projected ? { ...contribution, fragments } : contribution;
85
+ }
86
+
87
+ export function droidNormalizedContributionMatchesRecord(
88
+ observed: ManagedContribution,
89
+ record: OwnershipRecord,
90
+ ): boolean {
91
+ const projectedObserved = projectObservedDroidContribution(observed);
92
+ return projectedObserved !== observed
93
+ && (
94
+ fingerprint(canonicalContribution(projectedObserved)) === record.blockFingerprint
95
+ || (typeof record.semanticBlockFingerprint === "string"
96
+ && fingerprint(semanticContribution(projectedObserved)) === record.semanticBlockFingerprint)
97
+ );
98
+ }
99
+
100
+ export function droidNormalizedFileMatchesRecord(
101
+ document: unknown,
102
+ record: OwnershipRecord,
103
+ ): boolean {
104
+ if (record.clientId !== "droid") return false;
105
+ try {
106
+ const normalized = structuredClone(document);
107
+ let projected = false;
108
+ for (const path of record.fragmentPaths) {
109
+ if (path.length !== 2 || path[0] !== "customModels") return false;
110
+ const row = readPath(normalized, path);
111
+ if (!isObject(row)) return false;
112
+ if (removeDroidNormalizationFields(row)) projected = true;
113
+ }
114
+ return projected && fingerprint(serializeDocument(normalized, "json")) === record.fileFingerprint;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
54
120
  function pathStartsWith(path: readonly string[], prefix: readonly string[]): boolean {
55
121
  return prefix.length <= path.length && prefix.every((part, index) => path[index] === part);
56
122
  }
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
12
12
  import { assertDroidSettingsUnambiguous } from "./droid-settings";
13
13
  import { readPath } from "./merge";
14
14
  import type { OwnershipRecord } from "./ownership";
15
- import { join } from "node:path";
15
+ import { dirname, join } from "node:path";
16
16
  import {
17
17
  ClientPathError,
18
18
  buildDroidContribution,
@@ -52,6 +52,10 @@ import {
52
52
  zcodeProviderStorePath,
53
53
  buildZcodeStoreContribution,
54
54
  zcodeStoreSchemaEstablished,
55
+ dshProfilePatchPath,
56
+ dshProfilePatchEstablished,
57
+ buildDshProfilePatchContribution,
58
+ DSH_PROFILE_PROVIDER_PATH,
55
59
  type BuildContribution,
56
60
  type ConfigFormat,
57
61
  kiloConfigPath,
@@ -96,6 +100,29 @@ export interface IntegrationClientSpec {
96
100
  format: ConfigFormat;
97
101
  establishes: (parsed: unknown) => boolean;
98
102
  buildContribution: BuildContribution;
103
+ /** Patch only this leaf of the store, as `sourcePreservingYaml` does for the config file. */
104
+ sourcePreservingYaml?: { path: readonly string[] };
105
+ /**
106
+ * The file whose `<file>.lock` sibling the client's own writer holds while
107
+ * it rewrites the store. Taken after the config file's lock, whenever the
108
+ * store's directory exists.
109
+ */
110
+ lockFile?: (storePath: string) => string;
111
+ /**
112
+ * A missing store the client already manages. Writing the config file instead would not stay
113
+ * where opencodex records it, so the target is reported as an ineffective write with this
114
+ * remedy rather than silently writing the config file.
115
+ */
116
+ missingStore?: {
117
+ readsStore: (storePath: string, statKind: (path: string) => string) => boolean;
118
+ /**
119
+ * The document the client writes into a new store. Creating the store with exactly this is
120
+ * the remedy, and it is published on its own so a surface that localizes the remedy text
121
+ * can still name what to write.
122
+ */
123
+ emptyDocument: string;
124
+ remedy: string;
125
+ };
99
126
  };
100
127
  /** Patch only this block-map YAML leaf; never re-render the shared file. */
101
128
  sourcePreservingYaml?: { path: readonly string[] };
@@ -297,6 +324,28 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
297
324
  detectDir: (env = process.env, home = homedir()) => dshHomeDir(env, home),
298
325
  sourcePreservingYaml: { path: ["llm-pi-ai", "providers", "opencodex"] },
299
326
  writerLock: { suffix: ".lock" },
327
+ /*
328
+ * DSH 0.1.7+ imports `settings.yaml` once into the first profile that boots
329
+ * and renames it; what it reads afterwards is the Desktop profile's patch.
330
+ */
331
+ currentStore: {
332
+ path: (env = process.env, home = homedir()) => dshProfilePatchPath(env, home),
333
+ format: "yaml",
334
+ establishes: dshProfilePatchEstablished,
335
+ buildContribution: buildDshProfilePatchContribution,
336
+ sourcePreservingYaml: { path: DSH_PROFILE_PROVIDER_PATH },
337
+ // DSH's config editor serializes profile edits on the profile manifest's lock.
338
+ lockFile: store => join(dirname(store), "package.json"),
339
+ // A Desktop profile manifest without its patch is a profile DSH manages. On each startup
340
+ // DSH's importLegacyDocument renames `settings.yaml` to `settings.yaml.imported` and imports
341
+ // it into the active profile, so a block written there would leave opencodex's ownership
342
+ // record pointing at a file that no longer exists. Refuse it and name the remedy instead.
343
+ missingStore: {
344
+ readsStore: (store, statKind) => statKind(join(dirname(store), "package.json")) === "file",
345
+ emptyDocument: "[]",
346
+ remedy: "Create it containing `[]` (the empty patch DSH writes for a new profile), then enable the integration again.",
347
+ },
348
+ },
300
349
  },
301
350
  mcode: {
302
351
  id: "mcode",