@cortexkit/opencode-magic-context 0.36.0 → 0.37.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 (192) hide show
  1. package/dist/config/index.d.ts.map +1 -1
  2. package/dist/config/project-security.d.ts +2 -1
  3. package/dist/config/project-security.d.ts.map +1 -1
  4. package/dist/config/schema/magic-context.d.ts +10 -0
  5. package/dist/config/schema/magic-context.d.ts.map +1 -1
  6. package/dist/{conflict-warning-hook-dw9fs934.js → conflict-warning-hook-an4fxhwf.js} +4 -4
  7. package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-0f8xpyw2.js} +3 -2
  8. package/dist/features/magic-context/context-authority.d.ts +12 -0
  9. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/classify.d.ts +2 -0
  11. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +2 -0
  13. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/lease.d.ts +10 -3
  15. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/map-memories.d.ts +2 -0
  17. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/module-apply.d.ts +5 -0
  19. package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/promote-primers.d.ts +2 -0
  21. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +2 -0
  23. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  24. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -2
  26. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
  28. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  29. package/dist/features/magic-context/memory/embedding-openai.d.ts +3 -1
  30. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  31. package/dist/features/magic-context/memory/project-identity.d.ts +6 -0
  32. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  33. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  34. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  35. package/dist/features/magic-context/mural/compress-cues.d.ts +2 -0
  36. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  37. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  38. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  39. package/dist/features/magic-context/schema-fence-probe.d.ts +1 -0
  40. package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -1
  41. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +1 -0
  42. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  43. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/wake-plane.d.ts +21 -0
  45. package/dist/features/magic-context/smart-notes/wake-plane.d.ts.map +1 -0
  46. package/dist/features/magic-context/storage-clone.d.ts +6 -0
  47. package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
  48. package/dist/features/magic-context/storage-db.d.ts +14 -3
  49. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  50. package/dist/features/magic-context/storage-identity-merge.d.ts.map +1 -1
  51. package/dist/features/magic-context/storage-meta-persisted.d.ts +22 -0
  52. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  53. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  54. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  55. package/dist/features/magic-context/storage-primers.d.ts +11 -0
  56. package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  58. package/dist/features/magic-context/storage-tags.d.ts +2 -0
  59. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage.d.ts +1 -1
  61. package/dist/features/magic-context/storage.d.ts.map +1 -1
  62. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
  63. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  64. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +7 -0
  65. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  66. package/dist/features/magic-context/window-report-ledger.d.ts +70 -0
  67. package/dist/features/magic-context/window-report-ledger.d.ts.map +1 -0
  68. package/dist/hooks/magic-context/child-session-spawn.d.ts +1 -0
  69. package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -1
  70. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
  72. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +5 -3
  73. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/embed-session-state.d.ts +1 -1
  75. package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -1
  76. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/event-resolvers.d.ts +4 -0
  78. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/execute-status.d.ts +2 -1
  80. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/inject-compartments.d.ts +8 -0
  83. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/lkg-replay.d.ts +5 -2
  85. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/lkg-slot.d.ts +20 -0
  87. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/module-state-sync.d.ts +9 -0
  89. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/module-transport.d.ts +20 -3
  91. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/module-wire.d.ts +6 -1
  93. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/read-session-formatting.d.ts +1 -0
  95. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/rust-mode-transform.d.ts +20 -0
  97. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/strip-content.d.ts +31 -1
  99. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +12 -3
  102. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/transform.d.ts +10 -0
  104. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  106. package/dist/{index-3nfgbsp4.js → index-0ze9p983.js} +897 -114
  107. package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
  108. package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
  109. package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
  110. package/dist/{index-37rt2bka.js → index-cae89eas.js} +26 -7
  111. package/dist/index-cgyfn1s2.js +53 -0
  112. package/dist/{index-z8v31d85.js → index-crke4z5j.js} +144 -14
  113. package/dist/{index-tsgs7td8.js → index-pck9vadr.js} +1 -1
  114. package/dist/index-q0t189ay.js +741 -0
  115. package/dist/{index-3tnkymva.js → index-sw8gnq34.js} +1 -1
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +1952 -795
  118. package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
  119. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  120. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  121. package/dist/plugin/rust-tool-backends.d.ts +4 -0
  122. package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
  123. package/dist/{recomp-orchestrator-7wbbvf87.js → recomp-orchestrator-c3ff16k7.js} +8 -7
  124. package/dist/{safe-notification-target-c11fdda2.js → safe-notification-target-b693f9x3.js} +2 -2
  125. package/dist/{send-session-notification-bgtf4853.js → send-session-notification-dxske5zm.js} +3 -3
  126. package/dist/shared/conflict-detector.d.ts +63 -0
  127. package/dist/shared/conflict-detector.d.ts.map +1 -1
  128. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  129. package/dist/shared/harness-provider-map.d.ts +8 -0
  130. package/dist/shared/harness-provider-map.d.ts.map +1 -1
  131. package/dist/shared/jsonc-edit.d.ts +18 -0
  132. package/dist/shared/jsonc-edit.d.ts.map +1 -0
  133. package/dist/shared/jsonc-parser.d.ts +11 -1
  134. package/dist/shared/jsonc-parser.d.ts.map +1 -1
  135. package/dist/shared/logger.d.ts +8 -0
  136. package/dist/shared/logger.d.ts.map +1 -1
  137. package/dist/shared/models-dev-cache.d.ts +5 -0
  138. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  139. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  140. package/dist/shared/prompt-surface.d.ts +7 -3
  141. package/dist/shared/prompt-surface.d.ts.map +1 -1
  142. package/dist/shared/rpc-client.d.ts.map +1 -1
  143. package/dist/shared/rpc-types.d.ts +14 -0
  144. package/dist/shared/rpc-types.d.ts.map +1 -1
  145. package/dist/shared/rpc-utils.d.ts +32 -6
  146. package/dist/shared/rpc-utils.d.ts.map +1 -1
  147. package/dist/shared/tui-preferences.d.ts +9 -0
  148. package/dist/shared/tui-preferences.d.ts.map +1 -1
  149. package/dist/shared/window-geometry.d.ts +89 -0
  150. package/dist/shared/window-geometry.d.ts.map +1 -0
  151. package/dist/{storage-meta-session-kh5egr11.js → storage-meta-session-mk4gsnke.js} +4 -3
  152. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  153. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  154. package/dist/tui/data/context-db.d.ts.map +1 -1
  155. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  156. package/package.json +2 -1
  157. package/src/config/index.test.ts +23 -0
  158. package/src/config/index.ts +43 -9
  159. package/src/config/latch-permanence-guard.test.ts +218 -0
  160. package/src/config/project-security.ts +10 -1
  161. package/src/config/schema/magic-context.test.ts +2 -1
  162. package/src/config/schema/magic-context.ts +13 -3
  163. package/src/shared/__fixtures__/window-overlay.v1.json +337 -0
  164. package/src/shared/conflict-detector.test.ts +176 -1
  165. package/src/shared/conflict-detector.ts +126 -3
  166. package/src/shared/conflict-fixer.test.ts +174 -0
  167. package/src/shared/conflict-fixer.ts +66 -70
  168. package/src/shared/harness-provider-map.test.ts +20 -0
  169. package/src/shared/harness-provider-map.ts +19 -0
  170. package/src/shared/jsonc-edit.ts +295 -0
  171. package/src/shared/jsonc-parser.test.ts +33 -0
  172. package/src/shared/jsonc-parser.ts +54 -2
  173. package/src/shared/logger.test.ts +119 -0
  174. package/src/shared/logger.ts +33 -15
  175. package/src/shared/models-dev-cache.test.ts +17 -0
  176. package/src/shared/models-dev-cache.ts +58 -6
  177. package/src/shared/prompt-surface-runtime.ts +5 -3
  178. package/src/shared/prompt-surface.test.ts +91 -2
  179. package/src/shared/prompt-surface.ts +35 -11
  180. package/src/shared/rpc-client.ts +4 -2
  181. package/src/shared/rpc-types.ts +14 -0
  182. package/src/shared/rpc-utils.test.ts +173 -13
  183. package/src/shared/rpc-utils.ts +202 -18
  184. package/src/shared/tui-preferences.test.ts +52 -2
  185. package/src/shared/tui-preferences.ts +42 -17
  186. package/src/shared/window-geometry.test.ts +309 -0
  187. package/src/shared/window-geometry.ts +629 -0
  188. package/src/tui/data/context-db.ts +5 -0
  189. package/src/tui/index.tsx +22 -0
  190. package/src/tui-compiled/data/context-db.ts +5 -0
  191. package/src/tui-compiled/index.tsx +319 -255
  192. package/dist/index-wgvxkqpx.js +0 -342
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { readJsoncFile } from "./jsonc-parser";
4
+ import { log } from "./logger";
4
5
  import { getOpenCodeConfigPaths } from "./opencode-config-dir";
5
6
 
6
7
  interface OpenCodeConfig {
@@ -51,6 +52,18 @@ export interface ConflictResult {
51
52
  };
52
53
  }
53
54
 
55
+ /**
56
+ * Resolved native compaction state, as reported by the host's own config
57
+ * resolution (ctx.client.config.get() — the same object `opencode debug
58
+ * config` prints). `auto`/`prune` are booleans; the OpenCode schema annotates
59
+ * `auto` with default `true` and `prune` with default `false`, so an absent
60
+ * compaction block resolves to `{ auto: true, prune: false }`.
61
+ */
62
+ export interface ResolvedCompaction {
63
+ auto: boolean;
64
+ prune: boolean;
65
+ }
66
+
54
67
  /**
55
68
  * Options for {@link detectConflicts}.
56
69
  *
@@ -63,6 +76,13 @@ export interface ConflictResult {
63
76
  * omit it and accept the default `true` (mode-on) behavior, which preserves
64
77
  * today's conflict semantics; it must never silently skip the check.
65
78
  *
79
+ * `resolvedCompaction` is the host's RESOLVED native compaction state
80
+ * (fetched via {@link resolveCompactionForBoot}). When present, the
81
+ * file-based {@link checkCompaction} is NOT called — the resolved values are
82
+ * used directly. Absent means the file-based fallback is used. The
83
+ * OPENCODE_DISABLE_AUTOCOMPACT env short-circuit is applied on top of
84
+ * whichever arm produced the value.
85
+ *
66
86
  * When `compactionEnabled` is `false` (compaction-off mode), OpenCode
67
87
  * `compaction.auto=true` / `compaction.prune=true` are NOT plugin-disabling
68
88
  * conflicts — native compaction is the user's chosen window manager. DCP
@@ -71,6 +91,7 @@ export interface ConflictResult {
71
91
  */
72
92
  export interface DetectConflictsOptions {
73
93
  compactionEnabled?: boolean;
94
+ resolvedCompaction?: ResolvedCompaction;
74
95
  }
75
96
 
76
97
  /**
@@ -81,6 +102,11 @@ export interface DetectConflictsOptions {
81
102
  * When `false` (compaction-off mode), native `compaction.auto`/`prune` are
82
103
  * reported in {@link ConflictResult.nativeCompaction} but are NOT flagged as
83
104
  * conflicts — native compaction is the intended window manager in that mode.
105
+ *
106
+ * `resolvedCompaction` (optional) is the host's RESOLVED native compaction
107
+ * state from {@link resolveCompactionForBoot}. When present it is used
108
+ * directly and the file-based {@link checkCompaction} is skipped; when absent
109
+ * the file-based fallback runs unchanged.
84
110
  */
85
111
  export function detectConflicts(
86
112
  directory: string,
@@ -98,18 +124,38 @@ export function detectConflicts(
98
124
  const reasons: string[] = [];
99
125
 
100
126
  // --- Check OpenCode compaction config ---
101
- const compactionResult = checkCompaction(directory);
127
+ // The host's resolved config is the authority when available (issue #309:
128
+ // the file-based re-derivation defaults to auto=true when no file resolves,
129
+ // wrongly disabling the plugin for users whose auto=false lives in a layer
130
+ // the file reader cannot see). When the resolved fetch failed, fall back to
131
+ // the file-based check unchanged.
132
+ let compactionResult = options?.resolvedCompaction ?? checkCompaction(directory);
133
+ // OPENCODE_DISABLE_AUTOCOMPACT short-circuits BOTH arms: it is the first,
134
+ // cheapest check and is correct regardless of which arm produced the value.
135
+ // (checkCompaction already applies it internally for the file arm; this
136
+ // covers the resolved arm, which skips checkCompaction entirely.)
137
+ if (process.env.OPENCODE_DISABLE_AUTOCOMPACT) {
138
+ compactionResult = { auto: false, prune: false };
139
+ }
102
140
  // Native compaction is a conflict ONLY when MC compaction is ON. In
103
141
  // compaction-off mode the user has explicitly handed the window to native
104
142
  // compaction (or nothing), so compaction.auto=true / prune=true are the
105
143
  // intended state, not a plugin-disabling conflict.
106
144
  if (compactionEnabled && compactionResult.auto) {
107
145
  conflicts.compactionAuto = true;
108
- reasons.push("OpenCode auto-compaction is enabled (compaction.auto=true)");
146
+ reasons.push(
147
+ options?.resolvedCompaction
148
+ ? "OpenCode auto-compaction is enabled (compaction.auto=true) (resolved config)"
149
+ : "OpenCode auto-compaction is enabled (compaction.auto=true)",
150
+ );
109
151
  }
110
152
  if (compactionEnabled && compactionResult.prune) {
111
153
  conflicts.compactionPrune = true;
112
- reasons.push("OpenCode prune is enabled (compaction.prune=true)");
154
+ reasons.push(
155
+ options?.resolvedCompaction
156
+ ? "OpenCode prune is enabled (compaction.prune=true) (resolved config)"
157
+ : "OpenCode prune is enabled (compaction.prune=true)",
158
+ );
113
159
  }
114
160
 
115
161
  // --- Check for DCP plugin ---
@@ -152,6 +198,83 @@ export function detectConflicts(
152
198
 
153
199
  // --- Compaction detection (extracted from opencode-compaction-detector.ts) ---
154
200
 
201
+ /**
202
+ * Minimal shape of the OpenCode SDK client's `config.get()` response. The
203
+ * SDK's generated `Config` type does not declare a `compaction` field (the
204
+ * schema lives in OpenCode's core config, not the SDK surface), so we read it
205
+ * defensively at runtime. `config.get()` returns a `RequestResult` whose
206
+ * `data` is the resolved config object — the same object `opencode debug
207
+ * config` prints. The `data` is typed as `unknown` here so the real SDK client
208
+ * (whose `Config` has no `compaction` key) is structurally assignable.
209
+ */
210
+ export interface OpencodeConfigClientLike {
211
+ config: {
212
+ get: () => Promise<{ data?: unknown }>;
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Shape of the `compaction` block inside the resolved config, read defensively
218
+ * from the SDK response at runtime.
219
+ */
220
+ interface ResolvedCompactionBlock {
221
+ compaction?: {
222
+ auto?: boolean;
223
+ prune?: boolean;
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Fetch the host's RESOLVED native compaction state from the OpenCode SDK
229
+ * client (`ctx.client.config.get()`). This is the authority for the plugin's
230
+ * conflict decision (issue #309): the file-based re-derivation cannot see
231
+ * every layer OpenCode folds in (env-var config path, managed configs,
232
+ * multi-file merge), so any user whose `auto=false` lives in a layer we don't
233
+ * read would be wrongly flagged. We never re-derive what the host will tell
234
+ * us.
235
+ *
236
+ * A response WITHOUT a compaction block is INCONCLUSIVE, not "host defaults
237
+ * apply": a server whose `/config` shape drifted (OpenCode Desktop bundles
238
+ * its own server version) or a fetch racing boot returns data with no
239
+ * `compaction` key, and reading that absence as `auto=true` disables the
240
+ * plugin — the one wrong direction, because a false disable leaves NOTHING
241
+ * managing the window and every long session overflows (issue #309, second
242
+ * arm). Only an explicit boolean from the host resolves this arm; anything
243
+ * else returns `null` so the caller falls back to the file-based check,
244
+ * which reads the layers the user actually wrote.
245
+ *
246
+ * Returns `null` when the fetch fails, times out (bounded to `timeoutMs` so
247
+ * boot never hangs), or serves no explicit compaction block.
248
+ */
249
+ export async function resolveCompactionForBoot(
250
+ client: OpencodeConfigClientLike,
251
+ timeoutMs = 2_000,
252
+ ): Promise<ResolvedCompaction | null> {
253
+ try {
254
+ const result = await Promise.race([
255
+ client.config.get(),
256
+ new Promise<never>((_, reject) =>
257
+ setTimeout(() => reject(new Error("config.get() timed out")), timeoutMs),
258
+ ),
259
+ ]);
260
+ // The SDK's generated `Config` type has no `compaction` key, so read it
261
+ // defensively from the runtime response.
262
+ const compaction = (result?.data as ResolvedCompactionBlock | undefined)?.compaction;
263
+ // Explicit booleans only. An absent block (or non-boolean values) means
264
+ // the response shape did not carry the resolved state — fall back to the
265
+ // file arm rather than resolving to the plugin-disabling default.
266
+ if (typeof compaction?.auto !== "boolean" || typeof compaction?.prune !== "boolean") {
267
+ log(
268
+ `[magic-context] conflict-detector: resolved config carried no explicit compaction block (${JSON.stringify(compaction) ?? "absent"}); falling back to file-based detection`,
269
+ );
270
+ return null;
271
+ }
272
+ return { auto: compaction.auto, prune: compaction.prune };
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
155
278
  function checkCompaction(directory: string): { auto: boolean; prune: boolean } {
156
279
  if (process.env.OPENCODE_DISABLE_AUTOCOMPACT) {
157
280
  return { auto: false, prune: false };
@@ -434,4 +434,178 @@ describe("fixConflicts", () => {
434
434
  expect(updated.compaction).toEqual({ auto: false, prune: false });
435
435
  });
436
436
  });
437
+
438
+ describe("JSONC byte preservation", () => {
439
+ it("removes DCP and disables compaction without changing comments or formatting elsewhere", () => {
440
+ const configPath = join(projectDir, "opencode.jsonc");
441
+ const original =
442
+ "// leading file comment\r\n" +
443
+ "{\r\n" +
444
+ '\t"plugin": [\r\n' +
445
+ "\t\t// first plugin comment\r\n" +
446
+ '\t\t"@keep/first",\r\n' +
447
+ "\t\t// removed DCP comment\r\n" +
448
+ '\t\t"@tarquinen/opencode-dcp@latest",\r\n' +
449
+ "\t\t// second plugin comment\r\n" +
450
+ '\t\t"@keep/second",\r\n' +
451
+ "\t], // trailing array comment\r\n" +
452
+ '\t"compaction": {\r\n' +
453
+ "\t\t// nested compaction comment\r\n" +
454
+ '\t\t"auto": true,\r\n' +
455
+ '\t\t"prune": true,\r\n' +
456
+ "\t},\r\n" +
457
+ '\t"theme": "dark",\r\n' +
458
+ "}\r\n";
459
+ const expected =
460
+ "// leading file comment\r\n" +
461
+ "{\r\n" +
462
+ '\t"plugin": [\r\n' +
463
+ "\t\t// first plugin comment\r\n" +
464
+ '\t\t"@keep/first",\r\n' +
465
+ "\t\t// second plugin comment\r\n" +
466
+ '\t\t"@keep/second",\r\n' +
467
+ "\t], // trailing array comment\r\n" +
468
+ '\t"compaction": {\r\n' +
469
+ "\t\t// nested compaction comment\r\n" +
470
+ '\t\t"auto": false,\r\n' +
471
+ '\t\t"prune": false,\r\n' +
472
+ "\t},\r\n" +
473
+ '\t"theme": "dark",\r\n' +
474
+ "}\r\n";
475
+ writeFileSync(configPath, original);
476
+
477
+ const actions = fixConflicts(projectDir, {
478
+ compactionAuto: true,
479
+ compactionPrune: true,
480
+ dcpPlugin: true,
481
+ ...noOmoConflicts,
482
+ });
483
+
484
+ expect(actions).toEqual(["Disabled auto-compaction", "Removed opencode-dcp plugin"]);
485
+ expect(readFileSync(configPath, "utf-8")).toBe(expected);
486
+ expect(readFileSync(configPath, "utf-8")).not.toContain("removed DCP comment");
487
+ });
488
+
489
+ it("keeps each survivor's own comment when removing a middle plugin entry", () => {
490
+ const configPath = join(projectDir, "opencode.jsonc");
491
+ const original = `{
492
+ "plugin": [
493
+ // first plugin
494
+ "@keep/first",
495
+ // removed plugin
496
+ "@tarquinen/opencode-dcp@latest",
497
+ // second plugin
498
+ "@keep/second",
499
+ ],
500
+ "theme": "dark",
501
+ }
502
+ `;
503
+ const expected = `{
504
+ "plugin": [
505
+ // first plugin
506
+ "@keep/first",
507
+ // second plugin
508
+ "@keep/second",
509
+ ],
510
+ "theme": "dark",
511
+ }
512
+ `;
513
+ writeFileSync(configPath, original);
514
+
515
+ fixConflicts(projectDir, {
516
+ compactionAuto: false,
517
+ compactionPrune: false,
518
+ dcpPlugin: true,
519
+ ...noOmoConflicts,
520
+ });
521
+
522
+ expect(readFileSync(configPath, "utf-8")).toBe(expected);
523
+ });
524
+
525
+ it("preserves CRLF and the array's trailing comment when DCP is the final entry", () => {
526
+ const configPath = join(projectDir, "opencode.jsonc");
527
+ const original =
528
+ "{\r\n" +
529
+ '\t"plugin": [\r\n' +
530
+ "\t\t// surviving plugin\r\n" +
531
+ '\t\t"@keep/first",\r\n' +
532
+ "\t\t// removed final plugin\r\n" +
533
+ '\t\t"@tarquinen/opencode-dcp@latest",\r\n' +
534
+ "\t], // array trailing comment\r\n" +
535
+ "}\r\n";
536
+ const expected =
537
+ "{\r\n" +
538
+ '\t"plugin": [\r\n' +
539
+ "\t\t// surviving plugin\r\n" +
540
+ '\t\t"@keep/first"\r\n' +
541
+ "\t], // array trailing comment\r\n" +
542
+ "}\r\n";
543
+ writeFileSync(configPath, original);
544
+
545
+ fixConflicts(projectDir, {
546
+ compactionAuto: false,
547
+ compactionPrune: false,
548
+ dcpPlugin: true,
549
+ ...noOmoConflicts,
550
+ });
551
+
552
+ expect(readFileSync(configPath, "utf-8")).toBe(expected);
553
+ });
554
+
555
+ it("leaves an already conflict-free config byte-identical", () => {
556
+ const configPath = join(projectDir, "opencode.jsonc");
557
+ const original = '// preserve every byte\r\n{\r\n\t"plugin": ["@keep/one",],\r\n}\r\n';
558
+ writeFileSync(configPath, original);
559
+
560
+ const actions = fixConflicts(projectDir, {
561
+ compactionAuto: false,
562
+ compactionPrune: false,
563
+ dcpPlugin: true,
564
+ ...noOmoConflicts,
565
+ });
566
+
567
+ expect(actions).toEqual([]);
568
+ expect(readFileSync(configPath, "utf-8")).toBe(original);
569
+ });
570
+
571
+ it("appends OMO hooks without reformatting the existing JSONC", () => {
572
+ const omoDir = join(homeDir, ".omo");
573
+ mkdirSync(omoDir, { recursive: true });
574
+ const configPath = join(omoDir, "omo.jsonc");
575
+ const original = `{
576
+ // preserve this leading comment
577
+ "[opencode]": {
578
+ "disabled_hooks": [
579
+ // custom hook comment
580
+ "custom-hook",
581
+ ], // preserve this array comment
582
+ },
583
+ }
584
+ `;
585
+ const expected = `{
586
+ // preserve this leading comment
587
+ "[opencode]": {
588
+ "disabled_hooks": [
589
+ // custom hook comment
590
+ "custom-hook",
591
+ "context-window-monitor",
592
+ ], // preserve this array comment
593
+ },
594
+ }
595
+ `;
596
+ writeFileSync(configPath, original);
597
+
598
+ const actions = fixConflicts(projectDir, {
599
+ compactionAuto: false,
600
+ compactionPrune: false,
601
+ dcpPlugin: false,
602
+ omoPreemptiveCompaction: false,
603
+ omoContextWindowMonitor: true,
604
+ omoAnthropicRecovery: false,
605
+ });
606
+
607
+ expect(actions).toEqual(["Disabled conflicting oh-my-opencode hooks"]);
608
+ expect(readFileSync(configPath, "utf-8")).toBe(expected);
609
+ });
610
+ });
437
611
  });
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { parse, stringify } from "comment-json";
4
+ import { parse } from "comment-json";
5
5
 
6
6
  import {
7
7
  type ConflictResult,
@@ -9,6 +9,7 @@ import {
9
9
  extractPluginName,
10
10
  matchesPackageName,
11
11
  } from "./conflict-detector";
12
+ import { appendJsoncArrayValues, removeJsoncArrayEntries, setJsoncValue } from "./jsonc-edit";
12
13
  import { getOpenCodeConfigPaths } from "./opencode-config-dir";
13
14
 
14
15
  type JsonObject = Record<string, unknown>;
@@ -39,21 +40,27 @@ function asStringArray(value: unknown): string[] {
39
40
  : [];
40
41
  }
41
42
 
42
- function readConfig(filePath: string): JsonObject | null {
43
+ interface JsonConfigDocument {
44
+ config: JsonObject;
45
+ text: string;
46
+ }
47
+
48
+ function readConfig(filePath: string): JsonConfigDocument | null {
43
49
  if (!existsSync(filePath)) {
44
50
  return null;
45
51
  }
46
52
 
47
53
  try {
48
- const parsed = parse(readFileSync(filePath, "utf-8"));
49
- return isRecord(parsed) ? parsed : null;
54
+ const text = readFileSync(filePath, "utf-8");
55
+ const parsed = parse(text);
56
+ return isRecord(parsed) ? { config: parsed, text } : null;
50
57
  } catch {
51
58
  return null;
52
59
  }
53
60
  }
54
61
 
55
- function writeConfig(filePath: string, config: JsonObject): void {
56
- writeFileSync(filePath, `${stringify(config, null, 2)}\n`);
62
+ function writeConfig(filePath: string, text: string): void {
63
+ writeFileSync(filePath, text);
57
64
  }
58
65
 
59
66
  function resolveUserOpenCodeConfigPath(): string {
@@ -130,26 +137,28 @@ function isUnifiedOmoPath(configPath: string): boolean {
130
137
  return basename === "omo.jsonc" || basename === "omo.json";
131
138
  }
132
139
 
133
- /**
134
- * Get or create the `[opencode]` block inside a unified omo.json(c) config.
135
- * Returns the block itself so callers can read/write `disabled_hooks` on it.
136
- */
137
- function getOrCreateOmoV2Block(config: JsonObject): JsonObject {
138
- const block = config["[opencode]"];
139
- if (isRecord(block)) {
140
- return block;
140
+ function disableCompactionFlags(
141
+ text: string,
142
+ config: JsonObject,
143
+ ): { text: string; changed: boolean } {
144
+ if (!isRecord(config.compaction)) {
145
+ return {
146
+ text: setJsoncValue(text, ["compaction"], { auto: false, prune: false }),
147
+ changed: true,
148
+ };
141
149
  }
142
- const newBlock: JsonObject = {};
143
- config["[opencode]"] = newBlock;
144
- return newBlock;
145
- }
146
150
 
147
- function filterDcpPluginEntries(entries: unknown[]): { plugins: unknown[]; removed: boolean } {
148
- const plugins = entries.filter((entry) => {
149
- const name = extractPluginName(entry);
150
- return name ? !matchesPackageName(name, DCP_PACKAGE_NAMES) : true;
151
- });
152
- return { plugins, removed: plugins.length !== entries.length };
151
+ let updated = text;
152
+ let changed = false;
153
+ if (config.compaction.auto !== false) {
154
+ updated = setJsoncValue(updated, ["compaction", "auto"], false);
155
+ changed = true;
156
+ }
157
+ if (config.compaction.prune !== false) {
158
+ updated = setJsoncValue(updated, ["compaction", "prune"], false);
159
+ changed = true;
160
+ }
161
+ return { text: updated, changed };
153
162
  }
154
163
 
155
164
  /**
@@ -190,44 +199,37 @@ export function fixConflicts(
190
199
 
191
200
  if (repairCompaction || conflicts.dcpPlugin) {
192
201
  for (const configPath of collectOpenCodeConfigPaths(directory)) {
193
- const config = readConfig(configPath);
194
- if (!config) {
202
+ const document = readConfig(configPath);
203
+ if (!document) {
195
204
  continue;
196
205
  }
197
206
 
207
+ let text = document.text;
198
208
  let changed = false;
199
209
 
200
210
  if (repairCompaction) {
201
- const compaction = isRecord(config.compaction) ? config.compaction : {};
202
-
203
- if (compaction.auto !== false) {
204
- compaction.auto = false;
205
- changed = true;
206
- updatedCompaction = true;
207
- }
208
-
209
- if (compaction.prune !== false) {
210
- compaction.prune = false;
211
+ const result = disableCompactionFlags(text, document.config);
212
+ if (result.changed) {
213
+ text = result.text;
211
214
  changed = true;
212
215
  updatedCompaction = true;
213
216
  }
214
-
215
- config.compaction = compaction;
216
217
  }
217
218
 
218
219
  if (conflicts.dcpPlugin) {
219
- const plugins = Array.isArray(config.plugin) ? config.plugin : [];
220
- const filtered = filterDcpPluginEntries(plugins);
221
-
222
- if (filtered.removed) {
223
- config.plugin = filtered.plugins;
220
+ const result = removeJsoncArrayEntries(text, ["plugin"], (entry) => {
221
+ const name = extractPluginName(entry);
222
+ return name ? matchesPackageName(name, DCP_PACKAGE_NAMES) : false;
223
+ });
224
+ if (result.removed) {
225
+ text = result.text;
224
226
  changed = true;
225
227
  removedDcpPlugin = true;
226
228
  }
227
229
  }
228
230
 
229
231
  if (changed) {
230
- writeConfig(configPath, config);
232
+ writeConfig(configPath, text);
231
233
  }
232
234
  }
233
235
  }
@@ -249,36 +251,30 @@ export function fixConflicts(
249
251
  }
250
252
 
251
253
  for (const configPath of collectOmoConfigPaths(directory)) {
252
- const config = readConfig(configPath);
253
- if (!config) {
254
+ const document = readConfig(configPath);
255
+ if (!document) {
254
256
  continue;
255
257
  }
256
258
 
257
- // Unified paths nest hooks under the [opencode] block; legacy paths use top-level
258
- const target = isUnifiedOmoPath(configPath) ? getOrCreateOmoV2Block(config) : config;
259
-
259
+ const unifiedPath = isUnifiedOmoPath(configPath);
260
+ const target =
261
+ unifiedPath && isRecord(document.config["[opencode]"])
262
+ ? document.config["[opencode]"]
263
+ : unifiedPath
264
+ ? {}
265
+ : document.config;
260
266
  const disabledHooks = new Set(asStringArray(target.disabled_hooks));
261
- let changed = false;
262
-
263
- for (const hook of hooksToDisable) {
264
- if (!disabledHooks.has(hook)) {
265
- disabledHooks.add(hook);
266
- changed = true;
267
- disabledOmoHooks = true;
268
- }
269
- }
270
-
271
- if (changed) {
272
- target.disabled_hooks = [
273
- ...CONFLICTING_OMO_HOOKS.filter((hook) => disabledHooks.has(hook)),
274
- ...[...disabledHooks].filter(
275
- (hook) =>
276
- !CONFLICTING_OMO_HOOKS.includes(
277
- hook as (typeof CONFLICTING_OMO_HOOKS)[number],
278
- ),
279
- ),
280
- ];
281
- writeConfig(configPath, config);
267
+ const hooksToAdd = CONFLICTING_OMO_HOOKS.filter(
268
+ (hook) => hooksToDisable.has(hook) && !disabledHooks.has(hook),
269
+ );
270
+
271
+ if (hooksToAdd.length > 0) {
272
+ const path = unifiedPath ? ["[opencode]", "disabled_hooks"] : ["disabled_hooks"];
273
+ const text = Array.isArray(target.disabled_hooks)
274
+ ? appendJsoncArrayValues(document.text, path, hooksToAdd)
275
+ : setJsoncValue(document.text, path, hooksToAdd);
276
+ writeConfig(configPath, text);
277
+ disabledOmoHooks = true;
282
278
  }
283
279
  }
284
280
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
+ modelRefLookupOrder,
3
4
  ompModelRefToCanonical,
4
5
  piModelRefToCanonical,
5
6
  resolveModelRefForOmp,
@@ -64,6 +65,25 @@ describe("harness-provider-map", () => {
64
65
  const piForm = "openai-codex/gpt-5.5";
65
66
  expect(resolveModelRefForPi(piModelRefToCanonical(piForm))).toBe(piForm);
66
67
  });
68
+
69
+ describe("modelRefLookupOrder (config read edge)", () => {
70
+ it("tries canonical before the Pi-native spelling", () => {
71
+ expect(modelRefLookupOrder("openai-codex/gpt-5.6-sol")).toEqual([
72
+ "openai/gpt-5.6-sol",
73
+ "openai-codex/gpt-5.6-sol",
74
+ ]);
75
+ expect(modelRefLookupOrder("openai/gpt-5.6-sol")).toEqual([
76
+ "openai/gpt-5.6-sol",
77
+ "openai-codex/gpt-5.6-sol",
78
+ ]);
79
+ });
80
+
81
+ it("keeps unknown provider prefixes as a single passthrough key", () => {
82
+ expect(modelRefLookupOrder("custom-provider/model")).toEqual([
83
+ "custom-provider/model",
84
+ ]);
85
+ });
86
+ });
67
87
  });
68
88
  });
69
89
 
@@ -77,6 +77,25 @@ export function resolveModelRefForPi(ref: string): string {
77
77
  return remapProviderPrefix(piModelRefToCanonical(ref), CANONICAL_TO_PI_PROVIDER);
78
78
  }
79
79
 
80
+ /**
81
+ * Return every known spelling of a model reference with the canonical shared
82
+ * form first. The raw input remains the first fallback, so a single config file
83
+ * works on every harness: canonical wins when both spellings are present, while
84
+ * Pi/OMP-native provider ids are still accepted at the read edge. Unknown
85
+ * providers pass through unchanged and therefore produce one candidate.
86
+ */
87
+ export function modelRefLookupOrder(ref: string): string[] {
88
+ const canonical = piModelRefToCanonical(ompModelRefToCanonical(ref));
89
+ return [
90
+ ...new Set([
91
+ canonical,
92
+ ref,
93
+ resolveModelRefForPi(canonical),
94
+ resolveModelRefForOmp(canonical),
95
+ ]),
96
+ ];
97
+ }
98
+
80
99
  /** OMP-native selector -> canonical shared-config model reference. */
81
100
  export function ompModelRefToCanonical(ref: string): string {
82
101
  return remapProviderPrefix(ref, OMP_TO_CANONICAL_PROVIDER);