@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
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { createClineIO } from "./cline-io";
12
12
  import { parseClineDocument } from "./cline-document";
13
- import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel, type ManagedContribution } from "../clients/config-export";
13
+ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, droidDefaultsFromOwnedRows, type ExportModel, type ManagedContribution } from "../clients/config-export";
14
14
  import type { ConfigFormat } from "../clients/config-export";
15
15
  import type { OcxConfig } from "../types";
16
16
  import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
@@ -25,6 +25,8 @@ import {
25
25
  } from "./merge";
26
26
  import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
27
27
  import {
28
+ droidNormalizedContributionMatchesRecord,
29
+ droidNormalizedFileMatchesRecord,
28
30
  isHermesAffinityUpgrade,
29
31
  protectedContributionFingerprint,
30
32
  refreshablePathsOf,
@@ -40,7 +42,7 @@ import {
40
42
  unresolvedPathHintFor,
41
43
  type IntegrationClientId,
42
44
  } from "./registry";
43
- import { resolveIntegrationTarget, type IntegrationTarget } from "./target";
45
+ import { resolveIntegrationTarget, type IneffectiveWriteReason, type IntegrationTarget } from "./target";
44
46
  import { inspectKiloCandidates } from "./kilo-candidates";
45
47
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
46
48
 
@@ -84,6 +86,13 @@ export interface IntegrationStatus {
84
86
  * `current` has to be able to report this beside it.
85
87
  */
86
88
  supersededBy?: string;
89
+ /** Why `supersededBy` is not written; present exactly when it is. */
90
+ supersededReason?: IneffectiveWriteReason;
91
+ /**
92
+ * `missing-store` only: the document that recreates the store. A surface that localizes the
93
+ * remedy needs the content to name, not the writer's English sentence.
94
+ */
95
+ missingStoreDocument?: string;
87
96
  /** Snapshot files retained for this client; -1 when they cannot be inspected. */
88
97
  snapshotCount: number;
89
98
  /** Pruning is behind, so older (possibly credential-bearing) snapshots remain. */
@@ -208,6 +217,18 @@ function recordedContribution(
208
217
  };
209
218
  }
210
219
 
220
+ function observedContributionMatchesRecord(
221
+ observed: ManagedContribution,
222
+ record: OwnershipRecord,
223
+ ): boolean {
224
+ if (fingerprint(canonicalContribution(observed)) === record.blockFingerprint) return true;
225
+ if (
226
+ typeof record.semanticBlockFingerprint === "string"
227
+ && fingerprint(semanticContribution(observed)) === record.semanticBlockFingerprint
228
+ ) return true;
229
+ return droidNormalizedContributionMatchesRecord(observed, record);
230
+ }
231
+
211
232
  /**
212
233
  * Prove that every protected field still matches what OpenCodex wrote.
213
234
  *
@@ -223,13 +244,8 @@ function recordedBlockIsOwned(
223
244
  ): boolean {
224
245
  const observed = recordedContribution(doc, record);
225
246
  if (!observed) return false;
226
- if (fingerprint(canonicalContribution(observed)) === record.blockFingerprint) return true;
227
-
247
+ if (observedContributionMatchesRecord(observed, record)) return true;
228
248
  const observedSemanticFingerprint = fingerprint(semanticContribution(observed));
229
- if (
230
- typeof record.semanticBlockFingerprint === "string"
231
- && observedSemanticFingerprint === record.semanticBlockFingerprint
232
- ) return true;
233
249
 
234
250
  const desiredFingerprint = fingerprint(canonicalContribution(desired));
235
251
  if (
@@ -304,6 +320,12 @@ export function classifyIntegration(input: {
304
320
  * the wrong way.
305
321
  */
306
322
  format?: ConfigFormat;
323
+ /**
324
+ * Whether the file being classified is patched in place, which makes a
325
+ * sibling edit harmless. Like `format`, it belongs to the target; omitted, the
326
+ * client's config-file declaration answers.
327
+ */
328
+ sourcePreservingYaml?: boolean;
307
329
  }): { state: IntegrationState; reason?: StateReason } {
308
330
  if (input.fileText !== null && !input.fileIsRegular) {
309
331
  return { state: "unsafe", reason: "not-regular-file" };
@@ -405,12 +427,16 @@ export function classifyIntegration(input: {
405
427
  && !isHermesAffinityUpgrade(input.parsed, input.record, input.contribution)) {
406
428
  return { state: "conflict", reason: "foreign-edit" };
407
429
  }
408
- if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml
409
- && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
430
+ if (!(input.sourcePreservingYaml ?? INTEGRATION_CLIENTS[clientId].sourcePreservingYaml !== undefined)
431
+ && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint
432
+ && !droidNormalizedFileMatchesRecord(input.parsed, input.record)) {
410
433
  /*
411
- * The file changed since we wrote it, but every fragment we own is still
412
- * byte-for-byte what we put there — a sibling edit, not tampering. Apply
413
- * rewrites the WHOLE document, so for comment-capable formats (yaml,
434
+ * The file changed since we wrote it. Every fragment we own is still
435
+ * byte-for-byte what we put there, so this is a sibling edit rather than
436
+ * tampering. Known Droid row normalization is accepted before this branch
437
+ * only when removing its two client fields reproduces the recorded file.
438
+ * Other drift reaches this branch. Apply rewrites the WHOLE document, so
439
+ * for comment-capable formats (yaml,
414
440
  * json5, toml) it would drop comments the user wrote next to us: fail
415
441
  * closed there. Strict JSON cannot carry comments — a commented file
416
442
  * never reaches this branch because parsing already failed — so the only
@@ -449,6 +475,7 @@ export interface IntegrationStateInput {
449
475
  models: readonly ExportModel[];
450
476
  config: OcxConfig;
451
477
  port: number;
478
+ droidReasoningDefaults?: Record<string, string>;
452
479
  env?: NodeJS.ProcessEnv;
453
480
  home?: string;
454
481
  /** The whole integration state store, bound to one root. */
@@ -462,7 +489,8 @@ export function exportContextOf(input: {
462
489
  models: readonly ExportModel[];
463
490
  config: OcxConfig;
464
491
  port: number;
465
- }): { baseUrl: string; models: readonly ExportModel[]; config: OcxConfig } {
492
+ droidReasoningDefaults?: Record<string, string>;
493
+ }): { baseUrl: string; models: readonly ExportModel[]; config: OcxConfig; droidReasoningDefaults?: Record<string, string> } {
466
494
  return {
467
495
  /*
468
496
  * Composed through the SAME helper `ocx export` uses. Interpolating the
@@ -476,9 +504,35 @@ export function exportContextOf(input: {
476
504
  baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config),
477
505
  models: input.models,
478
506
  config: input.config,
507
+ ...(input.droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: input.droidReasoningDefaults }),
479
508
  };
480
509
  }
481
510
 
511
+ export function buildIntegrationContribution(
512
+ input: IntegrationStateInput,
513
+ effective: IntegrationTarget,
514
+ parsed: unknown,
515
+ record: OwnershipRecord | null,
516
+ ): ManagedContribution {
517
+ const context = exportContextOf(input);
518
+ if (input.clientId === "droid" && input.droidReasoningDefaults === undefined && record?.configPath === effective.configPath) {
519
+ const inherited = recordedDroidContributionMatches(parsed, record)
520
+ ? droidDefaultsFromOwnedRows(context, parsed, record.fragmentPaths)
521
+ : {};
522
+ if (Object.keys(inherited).length > 0) context.droidReasoningDefaults = inherited;
523
+ }
524
+ return effective.buildContribution(context);
525
+ }
526
+
527
+ function recordedDroidContributionMatches(document: unknown, record: OwnershipRecord): boolean {
528
+ try {
529
+ const observed = recordedContribution(document, record);
530
+ return observed !== null && observedContributionMatchesRecord(observed, record);
531
+ } catch {
532
+ return false;
533
+ }
534
+ }
535
+
482
536
  let retriedThisProcess = false;
483
537
 
484
538
  /**
@@ -535,9 +589,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
535
589
  try {
536
590
  // One resolution for both, so a client whose paths come from mutable state
537
591
  // cannot report one account's install beside another account's config path.
538
- const context = exportContextOf(input);
539
- const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home, context);
540
- if (input.clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(paths.detectDir, context);
592
+ const paths = resolveStatePaths(input);
541
593
  installed = io.statKind(paths.detectDir) === "dir";
542
594
  if (input.clientId === "cline") io = createClineIO(io, paths.configPath, store);
543
595
  /*
@@ -608,7 +660,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
608
660
  const parsed = input.clientId === "cline"
609
661
  ? parseClineDocument(loaded.before)
610
662
  : parseConfig(loaded.before, effective.format, EXPORT_CLIENTS[input.clientId].jsonc ? { jsonc: true } : undefined);
611
- const contribution = effective.buildContribution(exportContextOf(input));
663
+ const contribution = buildIntegrationContribution(input, effective, parsed, record);
612
664
  if (input.clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0
613
665
  && (!record || record.configPath !== configPath)) {
614
666
  return { clientId: input.clientId, state: "unsafe", installed, configPath,
@@ -623,6 +675,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
623
675
  configPath,
624
676
  clientId: input.clientId,
625
677
  format: effective.format,
678
+ sourcePreservingYaml: effective.sourcePreservingYaml !== null,
626
679
  });
627
680
  if (input.clientId === "droid" && record && (state === "current" || state === "stale")) {
628
681
  try { assertDroidRecordedSettingsUnambiguous(spec.detectDir(input.env, input.home), parsed, record); }
@@ -644,9 +697,43 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
644
697
  * about. A store we are writing needs no notice; the path already names it.
645
698
  */
646
699
  ...(effective.ineffective && effective.ineffective.store !== configPath
647
- ? { supersededBy: effective.ineffective.store }
700
+ ? {
701
+ supersededBy: effective.ineffective.store,
702
+ supersededReason: effective.ineffective.why,
703
+ ...(effective.ineffective.emptyDocument === undefined ? {} : { missingStoreDocument: effective.ineffective.emptyDocument }),
704
+ }
648
705
  : {}),
649
706
  ...(record ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}),
650
707
  ...retention,
651
708
  };
652
709
  }
710
+
711
+ function resolveStatePaths(input: IntegrationStateInput): { configPath: string; detectDir: string } {
712
+ const context = exportContextOf(input);
713
+ const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home, context);
714
+ if (input.clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(paths.detectDir, context);
715
+ return paths;
716
+ }
717
+
718
+ export function readOwnedDroidReasoningDefaults(input: IntegrationStateInput): Record<string, string> {
719
+ if (input.clientId !== "droid") return {};
720
+ const store = input.store ?? createIntegrationStateStore();
721
+ const io = input.io ?? store.io();
722
+ try {
723
+ const paths = resolveStatePaths(input);
724
+ const record = store.readRecords().droid;
725
+ if (!record || record.clientId !== "droid" || record.configPath !== paths.configPath) return {};
726
+ const effective = resolveIntegrationTarget({
727
+ clientId: "droid", configPath: paths.configPath, io, record, env: input.env, home: input.home,
728
+ });
729
+ const loaded = loadTarget(io, effective.configPath);
730
+ if (!loaded.ok) return {};
731
+ const parsed = parseConfig(loaded.before, "json");
732
+ if (parsed === PARSE_FAILED) return {};
733
+ if (!recordedDroidContributionMatches(parsed, record)) return {};
734
+ return droidDefaultsFromOwnedRows(exportContextOf(input), parsed, record.fragmentPaths);
735
+ } catch (error) {
736
+ if (error instanceof ClientPathError) return {};
737
+ throw error;
738
+ }
739
+ }
@@ -36,6 +36,12 @@ export interface IntegrationTarget {
36
36
  readonly format: ConfigFormat;
37
37
  /** The contribution shape that file's reader understands. */
38
38
  readonly buildContribution: BuildContribution;
39
+ /**
40
+ * The one YAML leaf patched in place in that file, or null when the writer
41
+ * re-renders the document. A client's config file and its store have
42
+ * different shapes, so this belongs to the target rather than the client.
43
+ */
44
+ readonly sourcePreservingYaml: { readonly path: readonly string[] } | null;
39
45
  /**
40
46
  * Set when a write to the file above would not reach the client.
41
47
  *
@@ -60,10 +66,20 @@ export interface IneffectiveWrite {
60
66
  * with the store itself as the target: a client that bumps its schema after
61
67
  * we wrote the store leaves our block there, removable, and the file no
62
68
  * longer one we may merge into.
69
+ *
70
+ * `missing-store` — the store does not exist, but the client already manages it
71
+ * (DSH: a Desktop profile manifest is present). DSH renames `settings.yaml` to
72
+ * `settings.yaml.imported` and imports it on startup, so a config-file write would
73
+ * orphan our ownership record. `remedy` says how to bring the store back, and
74
+ * `emptyDocument` is what to create it with.
63
75
  */
64
- readonly why: "owned-config-file" | "unestablished-schema";
76
+ readonly why: IneffectiveWriteReason;
77
+ readonly remedy?: string;
78
+ readonly emptyDocument?: string;
65
79
  }
66
80
 
81
+ export type IneffectiveWriteReason = "owned-config-file" | "unestablished-schema" | "missing-store";
82
+
67
83
  function configFileTarget(
68
84
  clientId: IntegrationClientId,
69
85
  configPath: string,
@@ -74,6 +90,7 @@ function configFileTarget(
74
90
  configPath,
75
91
  format: exportSpec.format,
76
92
  buildContribution: exportSpec.buildContribution,
93
+ sourcePreservingYaml: INTEGRATION_CLIENTS[clientId].sourcePreservingYaml ?? null,
77
94
  ineffective,
78
95
  };
79
96
  }
@@ -87,6 +104,7 @@ function storeTarget(
87
104
  configPath,
88
105
  format: declared.format,
89
106
  buildContribution: declared.buildContribution,
107
+ sourcePreservingYaml: declared.sourcePreservingYaml ?? null,
90
108
  ineffective,
91
109
  };
92
110
  }
@@ -141,7 +159,12 @@ export function resolveIntegrationTarget(args: {
141
159
  if (!declared) return configFileTarget(clientId, configPath, null);
142
160
  const storePath = declared.path(args.env, args.home);
143
161
  const kind = io.statKind(storePath);
144
- if (kind === "missing") return configFileTarget(clientId, configPath, null);
162
+ if (kind === "missing") {
163
+ const missing = declared.missingStore;
164
+ return missing?.readsStore(storePath, path => io.statKind(path)) === true
165
+ ? configFileTarget(clientId, configPath, { store: storePath, why: "missing-store", remedy: missing.remedy, emptyDocument: missing.emptyDocument })
166
+ : configFileTarget(clientId, configPath, null);
167
+ }
145
168
  // Only proven absence permits a legacy write. Unreadable or non-file stores
146
169
  // cannot establish what the client reads; preserve the recorded removal target.
147
170
  if (kind !== "file") {
@@ -18,7 +18,7 @@ import { shouldInjectApiAuthHeader } from "../codex/inject";
18
18
  import { detachedConfigSnapshot } from "../config/admitted-identity";
19
19
  import { copyPlainData } from "../lib/plain-data";
20
20
  import type { OcxConfig } from "../types";
21
- import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io";
21
+ import { defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
22
22
  import { inspectKiloCandidates } from "./kilo-candidates";
23
23
  import {
24
24
  fingerprint,
@@ -33,7 +33,7 @@ import {
33
33
  refreshablePathsOf,
34
34
  semanticProtectedContributionFingerprint,
35
35
  } from "./ownership-policy";
36
- import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
36
+ import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, parseSegment, removeFragments } from "./merge";
37
37
  import {
38
38
  INTEGRATION_CLIENTS,
39
39
  assertDroidPathsUnambiguous,
@@ -43,12 +43,12 @@ import {
43
43
  type IntegrationClientId,
44
44
  } from "./registry";
45
45
  import { declaredIntegrationTarget } from "./target";
46
- import { exportContextOf } from "./state";
46
+ import { buildIntegrationContribution, exportContextOf } from "./state";
47
47
  import type { IntegrationState } from "./state";
48
48
  import { serializeDocument, UnserializableValueError } from "./serialize";
49
49
  import { ClientPathError } from "../clients/config-export";
50
50
  import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
51
- import { observeIntegration, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan";
51
+ import { observeIntegration, restoreStoreDirectoryRefusal, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan";
52
52
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
53
53
  import {
54
54
  patchYamlFragmentSource,
@@ -300,6 +300,8 @@ function applyOrRefreshIntegration(
300
300
  return refuse(clientId, "superseded_store", classified.state,
301
301
  target.ineffective.why === "owned-config-file"
302
302
  ? `${clientId} now reads its providers from ${readsFrom}, and opencodex still has a block in ${configPath}, which it no longer reads. Disable the ${clientId} integration to remove that block, then enable it again to write ${readsFrom}.`
303
+ : target.ineffective.why === "missing-store"
304
+ ? `${clientId} reads its providers from ${readsFrom}, which does not exist. ${clientId} renames ${configPath} away and imports it on its next start, so opencodex will not write there. ${target.ineffective.remedy ?? fallback}`
303
305
  : readsFrom === configPath
304
306
  ? `opencodex does not recognise the schema of ${readsFrom}, the file ${clientId} reads its providers from, so it will not merge into it. ${fallback}`
305
307
  : `${clientId} now reads its providers from ${readsFrom}, whose schema opencodex does not recognise, so writing ${configPath} would change nothing it loads. ${fallback}`);
@@ -383,8 +385,15 @@ function applyOrRefreshIntegration(
383
385
  let created: string[];
384
386
  let text: string;
385
387
  try {
388
+ // The source patcher updates this selected row in place. Keep it in the
389
+ // semantic merge too, even when we originally created it, and retain that
390
+ // provenance so disable can still remove the row when it becomes empty.
391
+ const sourceRoot = target.sourcePreservingYaml?.path[0];
392
+ const retainedRow = sourceRoot && parseSegment(sourceRoot).kind === "select" ? sourceRoot : null;
393
+ const priorCreated = record?.createdContainers ?? [];
394
+ const prunable = new Set(priorCreated.filter(path => path !== retainedRow));
386
395
  const base = classified.state === "stale" && record
387
- ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
396
+ ? removeFragments(parsed, record.fragmentPaths, prunable).doc
388
397
  : classified.state === "conflict" && record
389
398
  /*
390
399
  * A forced overwrite of a `foreign-edit` conflict drops what the previous
@@ -399,26 +408,27 @@ function applyOrRefreshIntegration(
399
408
  * them, so a later disable removes our leaves and leaves their structure
400
409
  * standing.
401
410
  */
402
- ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
411
+ ? removeFragments(parsed, record.fragmentPaths, prunable).doc
403
412
  : parsed;
404
413
  // Computed against the document as it stands BEFORE the merge: afterwards
405
414
  // every container exists and "did we create this?" is unanswerable.
406
415
  created = createdContainerPaths(base, contribution);
416
+ if (retainedRow && priorCreated.includes(retainedRow)) created = [...new Set([retainedRow, ...created])];
407
417
  const nextDocument = mergeContribution(base, contribution);
408
418
  if (clientId === "cline") preserveClineSelection(parsed, nextDocument);
409
- if (spec.sourcePreservingYaml && before !== null) {
410
- const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
419
+ if (target.sourcePreservingYaml && before !== null) {
420
+ const value = sourcePreservingFragmentValue(contribution, target.sourcePreservingYaml.path);
411
421
  const patched = value === undefined
412
422
  ? null
413
423
  : patchYamlFragmentSource(
414
424
  before,
415
- spec.sourcePreservingYaml.path,
425
+ target.sourcePreservingYaml.path,
416
426
  { kind: "upsert", value },
417
427
  nextDocument,
418
428
  );
419
429
  if (patched === null) {
420
430
  return refuse(clientId, "unsafe", "unsafe",
421
- yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "it was left alone"));
431
+ yamlRefusalReason(before, target.sourcePreservingYaml.path, configPath, "it was left alone"));
422
432
  }
423
433
  text = patched;
424
434
  } else {
@@ -565,12 +575,12 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
565
575
  * comment makes its ancestor user-owned without protecting our leaf.
566
576
  */
567
577
  const recordedCreated = record!.createdContainers ?? [];
568
- const prunableCreated = spec.sourcePreservingYaml && before !== null
569
- ? sourcePrunableYamlContainers(before, spec.sourcePreservingYaml.path, recordedCreated)
578
+ const prunableCreated = target.sourcePreservingYaml && before !== null
579
+ ? sourcePrunableYamlContainers(before, target.sourcePreservingYaml.path, recordedCreated)
570
580
  : recordedCreated;
571
581
  if (prunableCreated === null) {
572
582
  return refuse(clientId, "unsafe", "unsafe",
573
- yamlRefusalReason(before ?? "", spec.sourcePreservingYaml!.path, configPath, "nothing was removed"));
583
+ yamlRefusalReason(before ?? "", target.sourcePreservingYaml!.path, configPath, "nothing was removed"));
574
584
  }
575
585
  let doc: unknown;
576
586
  let removed: boolean;
@@ -586,14 +596,14 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
586
596
  }
587
597
  let text: string;
588
598
  try {
589
- if (spec.sourcePreservingYaml && before !== null) {
590
- const patched = patchYamlFragmentSource(before, spec.sourcePreservingYaml.path, {
599
+ if (target.sourcePreservingYaml && before !== null) {
600
+ const patched = patchYamlFragmentSource(before, target.sourcePreservingYaml.path, {
591
601
  kind: "remove",
592
602
  createdContainers: prunableCreated,
593
603
  }, doc);
594
604
  if (patched === null) {
595
605
  return refuse(clientId, "unsafe", "unsafe",
596
- yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "nothing was removed"));
606
+ yamlRefusalReason(before, target.sourcePreservingYaml.path, configPath, "nothing was removed"));
597
607
  }
598
608
  text = patched;
599
609
  } else {
@@ -651,6 +661,10 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
651
661
  return refuse(clientId, "conflict", "conflict",
652
662
  `that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`);
653
663
  }
664
+ const directoryRefusal = restoreStoreDirectoryRefusal(input, configPath, io);
665
+ if (directoryRefusal !== null) {
666
+ return refuse(clientId, "unsafe", "unsafe", directoryRefusal);
667
+ }
654
668
  /*
655
669
  * Preview refuses this in observeRestore. Refusing here too is what keeps an
656
670
  * undo of an older candidate from replacing the record a newer candidate owns.
@@ -713,7 +727,11 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
713
727
  // exact bytes when the snapshot was taken. Re-deriving it from the file would
714
728
  // mean guessing which entries are ours, and a wrong guess deletes a user's.
715
729
  const restoredRecord = entry.priorRecord;
716
- const fresh = rowTarget.buildContribution(exportContextOf(input));
730
+ const fresh = buildIntegrationContribution(
731
+ { ...input, droidReasoningDefaults: undefined }, rowTarget,
732
+ clientId === "droid" && restoredText !== null ? parseConfig(restoredText, "json") : undefined,
733
+ restoredRecord,
734
+ );
717
735
  /*
718
736
  * Does the restored record actually describe the restored bytes?
719
737
  *
@@ -812,6 +830,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
812
830
  if (!models.ok) {
813
831
  throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
814
832
  }
833
+ const droidReasoningDefaults = input.droidReasoningDefaults === undefined
834
+ ? undefined
835
+ : copyPlainData(input.droidReasoningDefaults);
836
+ if (droidReasoningDefaults !== undefined && !droidReasoningDefaults.ok) {
837
+ throw new UncopyableIntegrationInputError("Droid reasoning defaults could not be captured for this write");
838
+ }
815
839
  /*
816
840
  * One resolution for both paths. Aside derives them from the account id in
817
841
  * its manifest, so two independent calls could verify one account's install
@@ -828,7 +852,11 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
828
852
  * checked in one state and written from another, which is the substitution the fingerprint
829
853
  * exists to prevent. Copying both here gives the plan and the document one input.
830
854
  */
831
- return { ...input, config, models: models.value, env, home, store, io, resolvedPaths };
855
+ return {
856
+ ...input, config, models: models.value,
857
+ ...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: droidReasoningDefaults.value }),
858
+ env, home, store, io, resolvedPaths,
859
+ };
832
860
  }
833
861
 
834
862
  function tryFreezeIntegrationInput(input: IntegrationWriteInput):
@@ -848,6 +876,57 @@ function tryFreezeIntegrationInput(input: IntegrationWriteInput):
848
876
  }
849
877
  }
850
878
 
879
+ /**
880
+ * The store lock to hold beside the config file's, or null.
881
+ *
882
+ * Only for a store whose directory exists: a lock is never what creates a
883
+ * client's profile. A store path that cannot be resolved names nothing to lock;
884
+ * target resolution reports it on its own.
885
+ */
886
+ function storeLockFile(frozen: FrozenIntegrationInput): string | null {
887
+ const declared = INTEGRATION_CLIENTS[frozen.clientId].currentStore;
888
+ if (!declared?.lockFile) return null;
889
+ let storePath: string;
890
+ try {
891
+ storePath = declared.path(frozen.env, frozen.home);
892
+ } catch (error) {
893
+ if (error instanceof ClientPathError) return null;
894
+ throw error;
895
+ }
896
+ return frozen.io.statKind(dirname(storePath)) === "dir" ? declared.lockFile(storePath) : null;
897
+ }
898
+
899
+ /**
900
+ * Hold the config file's sibling lock, then the store's. Every operation takes
901
+ * them in that order, so two of ours cannot deadlock, and the client's own
902
+ * writer only ever holds one of them.
903
+ */
904
+ function withClientLocks(
905
+ frozen: FrozenIntegrationInput,
906
+ suffix: ".lock",
907
+ operation: () => WriteOutcome,
908
+ options?: CoordinatedIntegrationOptions,
909
+ ): Promise<WriteOutcome> {
910
+ const run = async (heldStoreLock: string | null): Promise<WriteOutcome> => {
911
+ const refused = await options?.revalidate?.(frozen);
912
+ if (refused) return refused;
913
+ // A profile can appear while revalidation awaits. Acquire its lock and
914
+ // repeat the validation before observing or mutating its document.
915
+ const required = storeLockFile(frozen);
916
+ if (required !== null && required !== heldStoreLock) {
917
+ return withIntegrationWriterLock(required, () => run(required), options?.lockSeams, ".lock");
918
+ }
919
+ // No await between the last store probe and the synchronous transaction.
920
+ return operation();
921
+ };
922
+ return withIntegrationWriterLock(frozen.resolvedPaths.configPath, () => {
923
+ // Decide only after the outer lock has actually been acquired.
924
+ const storeLock = storeLockFile(frozen);
925
+ return storeLock === null ? run(null)
926
+ : withIntegrationWriterLock(storeLock, () => run(storeLock), options?.lockSeams, ".lock");
927
+ }, options?.lockSeams, suffix);
928
+ }
929
+
851
930
  async function coordinatedWrite(
852
931
  input: IntegrationWriteInput,
853
932
  operation: (frozen: IntegrationWriteInput) => WriteOutcome,
@@ -865,16 +944,14 @@ async function coordinatedWrite(
865
944
  // An absent client home is not created merely to acquire a sibling lock.
866
945
  if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
867
946
  const refused = await options?.revalidate?.(frozen);
868
- return refused ?? operation(frozen);
947
+ if (refused) return refused;
948
+ if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") return operation(frozen);
869
949
  }
870
- return withIntegrationWriterLock(
871
- frozen.resolvedPaths.configPath,
872
- async () => {
873
- const refused = await options?.revalidate?.(frozen);
874
- return refused ?? operation(frozen);
875
- },
876
- options?.lockSeams,
950
+ return withClientLocks(
951
+ frozen,
877
952
  spec.writerLock.suffix,
953
+ () => operation(frozen),
954
+ options,
878
955
  );
879
956
  }
880
957
 
@@ -927,15 +1004,10 @@ export async function restoreIntegrationCoordinated(
927
1004
  `${frozen.resolvedPaths.detectDir} is missing; restore will not create the client home`,
928
1005
  );
929
1006
  }
930
- return withIntegrationWriterLock(
931
- frozen.resolvedPaths.configPath,
932
- async () => {
933
- // An undo is bound like any other confirmation, and this is the only place where that check
934
- // happens with the lock held and before the snapshot, the write and the journal row.
935
- const refused = await options?.revalidate?.(frozen);
936
- return refused ?? run();
937
- },
938
- options?.lockSeams,
1007
+ return withClientLocks(
1008
+ frozen,
939
1009
  spec.writerLock.suffix,
1010
+ run,
1011
+ options,
940
1012
  );
941
1013
  }
@@ -65,6 +65,23 @@ export function canonicalHttpOrigin(value: unknown): string | null {
65
65
  }
66
66
  }
67
67
 
68
+ /**
69
+ * Standalone pairing is only for this process's configured literal loopback origin.
70
+ * No CORS entry, public hub URL, wildcard bind, alias or client role can widen it.
71
+ * The CLI separately checks the attested runtime's actual port before requesting a grant.
72
+ */
73
+ export function standaloneGuiPairingOrigin(config: {
74
+ runtimeRole?: string;
75
+ hostname?: string;
76
+ port: number;
77
+ }): string | null {
78
+ if ((config.runtimeRole ?? "standalone") !== "standalone") return null;
79
+ const hostname = config.hostname ?? "127.0.0.1";
80
+ if (hostname !== "127.0.0.1" && hostname !== "::1") return null;
81
+ if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) return null;
82
+ return new URL(`http://${hostname === "::1" ? "[::1]" : hostname}:${config.port}`).origin;
83
+ }
84
+
68
85
  function capabilityPayload(
69
86
  nonce: string,
70
87
  method: string,