@bitkyc08/opencodex 2.77.0 → 2.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  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/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -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,7 +35,7 @@ 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
40
  import { buildIntegrationContribution, classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
40
41
  import { inspectKiloCandidates } from "./kilo-candidates";
@@ -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,
@@ -954,7 +997,7 @@ export function observeIntegration(
954
997
  // ownership here and disable would delete fragments it never wrote.
955
998
  const classified = classifyIntegration({
956
999
  fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
957
- format: effective.format,
1000
+ format: effective.format, sourcePreservingYaml: effective.sourcePreservingYaml !== null,
958
1001
  });
959
1002
  if (clientId === "droid" && record && (classified.state === "current" || classified.state === "stale")) {
960
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",
@@ -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
@@ -500,14 +526,8 @@ export function buildIntegrationContribution(
500
526
 
501
527
  function recordedDroidContributionMatches(document: unknown, record: OwnershipRecord): boolean {
502
528
  try {
503
- const fragments = record.fragmentPaths.flatMap(path => {
504
- const value = readPath(document, path);
505
- return value === undefined ? [] : [{ path, value }];
506
- });
507
- if (fragments.length !== record.fragmentPaths.length) return false;
508
- const observed: ManagedContribution = { clientId: "droid", fragments };
509
- return record.semanticBlockFingerprint === fingerprint(semanticContribution(observed))
510
- || record.blockFingerprint === fingerprint(canonicalContribution(observed));
529
+ const observed = recordedContribution(document, record);
530
+ return observed !== null && observedContributionMatchesRecord(observed, record);
511
531
  } catch {
512
532
  return false;
513
533
  }
@@ -655,6 +675,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
655
675
  configPath,
656
676
  clientId: input.clientId,
657
677
  format: effective.format,
678
+ sourcePreservingYaml: effective.sourcePreservingYaml !== null,
658
679
  });
659
680
  if (input.clientId === "droid" && record && (state === "current" || state === "stale")) {
660
681
  try { assertDroidRecordedSettingsUnambiguous(spec.detectDir(input.env, input.home), parsed, record); }
@@ -676,7 +697,11 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
676
697
  * about. A store we are writing needs no notice; the path already names it.
677
698
  */
678
699
  ...(effective.ineffective && effective.ineffective.store !== configPath
679
- ? { 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
+ }
680
705
  : {}),
681
706
  ...(record ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}),
682
707
  ...retention,