@cortexkit/opencode-magic-context 0.36.1 → 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 (125) 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-mt0ttc4x.js → conflict-warning-hook-an4fxhwf.js} +3 -3
  7. package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-0f8xpyw2.js} +3 -2
  8. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  10. package/dist/features/magic-context/memory/embedding-openai.d.ts +3 -1
  11. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  12. package/dist/features/magic-context/memory/project-identity.d.ts +6 -0
  13. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  14. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  15. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  16. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  17. package/dist/features/magic-context/smart-notes/wake-plane.d.ts +21 -0
  18. package/dist/features/magic-context/smart-notes/wake-plane.d.ts.map +1 -0
  19. package/dist/features/magic-context/storage-db.d.ts +13 -2
  20. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  21. package/dist/features/magic-context/storage-meta-persisted.d.ts +22 -0
  22. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  24. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  25. package/dist/features/magic-context/storage-primers.d.ts +11 -0
  26. package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
  27. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  28. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +7 -0
  29. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  30. package/dist/features/magic-context/window-report-ledger.d.ts +70 -0
  31. package/dist/features/magic-context/window-report-ledger.d.ts.map +1 -0
  32. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  34. package/dist/hooks/magic-context/event-resolvers.d.ts +4 -0
  35. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/execute-status.d.ts +2 -1
  37. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/lkg-slot.d.ts +20 -0
  42. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/module-state-sync.d.ts +8 -0
  44. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/module-wire.d.ts +6 -1
  47. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/rust-mode-transform.d.ts +17 -0
  49. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  50. package/dist/hooks/magic-context/strip-content.d.ts +25 -0
  51. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +11 -3
  54. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/transform.d.ts +10 -0
  56. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  58. package/dist/{index-1skzp4dc.js → index-0ze9p983.js} +572 -92
  59. package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
  60. package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
  61. package/dist/{index-zp6x6s6a.js → index-cae89eas.js} +25 -6
  62. package/dist/index-cgyfn1s2.js +53 -0
  63. package/dist/{index-3zqcdwqr.js → index-crke4z5j.js} +7 -4
  64. package/dist/{index-tsgs7td8.js → index-pck9vadr.js} +1 -1
  65. package/dist/index-q0t189ay.js +741 -0
  66. package/dist/{index-3tnkymva.js → index-sw8gnq34.js} +1 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1172 -287
  69. package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
  70. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  71. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  72. package/dist/{recomp-orchestrator-a0g9b52q.js → recomp-orchestrator-c3ff16k7.js} +7 -6
  73. package/dist/{safe-notification-target-c11fdda2.js → safe-notification-target-b693f9x3.js} +2 -2
  74. package/dist/{send-session-notification-bgtf4853.js → send-session-notification-dxske5zm.js} +3 -3
  75. package/dist/shared/conflict-detector.d.ts +63 -0
  76. package/dist/shared/conflict-detector.d.ts.map +1 -1
  77. package/dist/shared/harness-provider-map.d.ts +8 -0
  78. package/dist/shared/harness-provider-map.d.ts.map +1 -1
  79. package/dist/shared/logger.d.ts +8 -0
  80. package/dist/shared/logger.d.ts.map +1 -1
  81. package/dist/shared/models-dev-cache.d.ts +5 -0
  82. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  83. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  84. package/dist/shared/prompt-surface.d.ts +2 -0
  85. package/dist/shared/prompt-surface.d.ts.map +1 -1
  86. package/dist/shared/rpc-client.d.ts.map +1 -1
  87. package/dist/shared/rpc-types.d.ts +14 -0
  88. package/dist/shared/rpc-types.d.ts.map +1 -1
  89. package/dist/shared/rpc-utils.d.ts +19 -9
  90. package/dist/shared/rpc-utils.d.ts.map +1 -1
  91. package/dist/shared/window-geometry.d.ts +89 -0
  92. package/dist/shared/window-geometry.d.ts.map +1 -0
  93. package/dist/{storage-meta-session-df9tstzr.js → storage-meta-session-mk4gsnke.js} +4 -3
  94. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  95. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  96. package/dist/tui/data/context-db.d.ts.map +1 -1
  97. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  98. package/package.json +1 -1
  99. package/src/config/index.ts +2 -0
  100. package/src/config/latch-permanence-guard.test.ts +218 -0
  101. package/src/config/project-security.ts +10 -1
  102. package/src/config/schema/magic-context.ts +10 -0
  103. package/src/shared/__fixtures__/window-overlay.v1.json +337 -0
  104. package/src/shared/conflict-detector.test.ts +176 -1
  105. package/src/shared/conflict-detector.ts +126 -3
  106. package/src/shared/harness-provider-map.test.ts +20 -0
  107. package/src/shared/harness-provider-map.ts +19 -0
  108. package/src/shared/logger.test.ts +119 -0
  109. package/src/shared/logger.ts +33 -15
  110. package/src/shared/models-dev-cache.test.ts +17 -0
  111. package/src/shared/models-dev-cache.ts +58 -6
  112. package/src/shared/prompt-surface-runtime.ts +5 -3
  113. package/src/shared/prompt-surface.test.ts +38 -0
  114. package/src/shared/prompt-surface.ts +15 -2
  115. package/src/shared/rpc-client.ts +4 -2
  116. package/src/shared/rpc-types.ts +14 -0
  117. package/src/shared/rpc-utils.test.ts +137 -13
  118. package/src/shared/rpc-utils.ts +144 -31
  119. package/src/shared/window-geometry.test.ts +309 -0
  120. package/src/shared/window-geometry.ts +629 -0
  121. package/src/tui/data/context-db.ts +5 -0
  122. package/src/tui/index.tsx +22 -0
  123. package/src/tui-compiled/data/context-db.ts +5 -0
  124. package/src/tui-compiled/index.tsx +319 -255
  125. package/dist/index-wgvxkqpx.js +0 -342
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
4
4
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { detectConflicts } from "./conflict-detector";
7
+ import { detectConflicts, resolveCompactionForBoot } from "./conflict-detector";
8
8
 
9
9
  /**
10
10
  * Regression tests for plugin-conflict detection. The previous substring-
@@ -492,4 +492,179 @@ describe("detectConflicts", () => {
492
492
  }
493
493
  });
494
494
  });
495
+
496
+ // --- Resolved-config arm (issue #309) ---
497
+ // The plugin boot now consumes the host's RESOLVED config
498
+ // (ctx.client.config.get()) instead of re-deriving compaction from files.
499
+ // These tests exercise the resolved arm directly via the
500
+ // `resolvedCompaction` option and the `resolveCompactionForBoot` helper.
501
+ describe("resolved-config arm (issue #309)", () => {
502
+ // The suite beforeEach sets OPENCODE_DISABLE_AUTOCOMPACT=1 to isolate
503
+ // plugin detection from compaction detection. These tests exercise the
504
+ // resolved arm, so they clear it and restore it per-case.
505
+ function withoutAutoCompactEnv<T>(fn: () => T): T {
506
+ const prev = process.env.OPENCODE_DISABLE_AUTOCOMPACT;
507
+ delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
508
+ try {
509
+ return fn();
510
+ } finally {
511
+ if (prev !== undefined) process.env.OPENCODE_DISABLE_AUTOCOMPACT = prev;
512
+ }
513
+ }
514
+
515
+ it("resolved auto=false + file layer that would default true → NO conflict (#309)", () => {
516
+ // No compaction block in any file + no env override → the file-based
517
+ // arm would default to auto=true and wrongly disable the plugin.
518
+ // The resolved arm says auto=false, which must win.
519
+ withoutAutoCompactEnv(() => {
520
+ const result = detectConflicts(projectDir, {
521
+ compactionEnabled: true,
522
+ resolvedCompaction: { auto: false, prune: false },
523
+ });
524
+ expect(result.conflicts.compactionAuto).toBe(false);
525
+ expect(result.hasConflict).toBe(false);
526
+ expect(result.nativeCompaction.auto).toBe(false);
527
+ });
528
+ });
529
+
530
+ it("resolved auto=true → conflict, message carries '(resolved config)'", () => {
531
+ withoutAutoCompactEnv(() => {
532
+ const result = detectConflicts(projectDir, {
533
+ compactionEnabled: true,
534
+ resolvedCompaction: { auto: true, prune: false },
535
+ });
536
+ expect(result.conflicts.compactionAuto).toBe(true);
537
+ expect(result.hasConflict).toBe(true);
538
+ expect(result.reasons.join("; ")).toContain("(resolved config)");
539
+ });
540
+ });
541
+
542
+ it("resolved prune=true → conflict, message carries '(resolved config)'", () => {
543
+ withoutAutoCompactEnv(() => {
544
+ const result = detectConflicts(projectDir, {
545
+ compactionEnabled: true,
546
+ resolvedCompaction: { auto: false, prune: true },
547
+ });
548
+ expect(result.conflicts.compactionPrune).toBe(true);
549
+ expect(result.hasConflict).toBe(true);
550
+ expect(result.reasons.join("; ")).toContain("(resolved config)");
551
+ });
552
+ });
553
+
554
+ it("resolved arm is skipped when resolvedCompaction is absent (file-based fallback unchanged)", () => {
555
+ // No resolvedCompaction → the file-based check runs. With no
556
+ // compaction block and no env override, it defaults to auto=true.
557
+ withoutAutoCompactEnv(() => {
558
+ const result = detectConflicts(projectDir, { compactionEnabled: true });
559
+ expect(result.conflicts.compactionAuto).toBe(true);
560
+ expect(result.hasConflict).toBe(true);
561
+ // File-based arm does NOT carry the resolved-config label.
562
+ expect(result.reasons.join("; ")).not.toContain("(resolved config)");
563
+ });
564
+ });
565
+
566
+ it("OPENCODE_DISABLE_AUTOCOMPACT short-circuits the resolved arm", () => {
567
+ // Resolved says auto=true, but the env override must win.
568
+ process.env.OPENCODE_DISABLE_AUTOCOMPACT = "1";
569
+ try {
570
+ const result = detectConflicts(projectDir, {
571
+ compactionEnabled: true,
572
+ resolvedCompaction: { auto: true, prune: true },
573
+ });
574
+ expect(result.conflicts.compactionAuto).toBe(false);
575
+ expect(result.conflicts.compactionPrune).toBe(false);
576
+ expect(result.hasConflict).toBe(false);
577
+ expect(result.nativeCompaction.auto).toBe(false);
578
+ expect(result.nativeCompaction.prune).toBe(false);
579
+ } finally {
580
+ delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
581
+ }
582
+ });
583
+
584
+ it("compaction-off mode: resolved auto=true is NOT a conflict (native compaction active)", () => {
585
+ withoutAutoCompactEnv(() => {
586
+ const result = detectConflicts(projectDir, {
587
+ compactionEnabled: false,
588
+ resolvedCompaction: { auto: true, prune: false },
589
+ });
590
+ expect(result.conflicts.compactionAuto).toBe(false);
591
+ expect(result.hasConflict).toBe(false);
592
+ // Native compaction state is still reported honestly.
593
+ expect(result.nativeCompaction.auto).toBe(true);
594
+ });
595
+ });
596
+ });
597
+
598
+ // --- resolveCompactionForBoot (the resolved-config fetch helper) ---
599
+ describe("resolveCompactionForBoot", () => {
600
+ it("returns the resolved compaction block from the client", async () => {
601
+ const client = {
602
+ config: {
603
+ get: async () => ({
604
+ data: { compaction: { auto: false, prune: true } },
605
+ }),
606
+ },
607
+ };
608
+ const result = await resolveCompactionForBoot(client);
609
+ expect(result).toEqual({ auto: false, prune: true });
610
+ });
611
+
612
+ it("returns null when the compaction block is absent (file-based fallback, not host defaults)", async () => {
613
+ // An absent block means the response shape did not carry the resolved
614
+ // state (server version drift, a fetch racing boot) — NOT that the
615
+ // host resolved its defaults. Reading absence as auto=true disabled
616
+ // the plugin for real users whose auto=false lived in the file layer
617
+ // (issue #309, second arm: the 2026-08-14 desktop incident where
618
+ // every session overflowed with nothing managing the window).
619
+ const client = {
620
+ config: {
621
+ get: async () => ({ data: {} }),
622
+ },
623
+ };
624
+ const result = await resolveCompactionForBoot(client);
625
+ expect(result).toBeNull();
626
+ });
627
+
628
+ it("returns null when compaction values are not explicit booleans", async () => {
629
+ const client = {
630
+ config: {
631
+ get: async () => ({ data: { compaction: { auto: "true", prune: null } } }),
632
+ },
633
+ };
634
+ const result = await resolveCompactionForBoot(client);
635
+ expect(result).toBeNull();
636
+ });
637
+
638
+ it("returns null when the response data is missing entirely", async () => {
639
+ const client = {
640
+ config: {
641
+ get: async () => ({}) as { data?: Record<string, unknown> },
642
+ },
643
+ };
644
+ const result = await resolveCompactionForBoot(client);
645
+ expect(result).toBeNull();
646
+ });
647
+
648
+ it("returns null when the client throws (file-based fallback used)", async () => {
649
+ const client = {
650
+ config: {
651
+ get: async () => {
652
+ throw new Error("boom");
653
+ },
654
+ },
655
+ };
656
+ const result = await resolveCompactionForBoot(client);
657
+ expect(result).toBeNull();
658
+ });
659
+
660
+ it("returns null when the client times out (boot never hangs)", async () => {
661
+ const client = {
662
+ config: {
663
+ get: () => new Promise<never>(() => {}), // never resolves
664
+ },
665
+ };
666
+ const result = await resolveCompactionForBoot(client, 20);
667
+ expect(result).toBeNull();
668
+ });
669
+ });
495
670
  });
@@ -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 };
@@ -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);
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ type LoggerScenarioResult = {
7
+ exists: boolean;
8
+ content: string;
9
+ healthyDiagnostics: {
10
+ swallowedWriteCount: number;
11
+ lastErrorMessage: string | null;
12
+ lastErrorTime: string | null;
13
+ };
14
+ failedDiagnostics: {
15
+ swallowedWriteCount: number;
16
+ lastErrorMessage: string | null;
17
+ lastErrorTime: string | null;
18
+ };
19
+ };
20
+
21
+ const loggerScenario = `
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import * as path from "node:path";
24
+
25
+ const root = process.env.LOGGER_SCENARIO_ROOT;
26
+ const loggerModuleUrl = process.env.LOGGER_MODULE_URL;
27
+ if (!root || !loggerModuleUrl) throw new Error("logger scenario environment is incomplete");
28
+
29
+ const logger = await import(loggerModuleUrl);
30
+ const logPath = path.join(root, "nested", "magic-context.log");
31
+ process.env.MAGIC_CONTEXT_LOG_PATH = logPath;
32
+ logger.log("first");
33
+ logger.flushLogger();
34
+ const healthyDiagnostics = logger.getLoggerDiagnostics();
35
+
36
+ const logDirectory = path.dirname(logPath);
37
+ if (process.env.LOGGER_SCENARIO === "recovery") {
38
+ const { rmSync } = await import("node:fs");
39
+ rmSync(logDirectory, { recursive: true, force: true });
40
+ logger.log("second");
41
+ logger.flushLogger();
42
+ }
43
+
44
+ // A directory used as the file target makes append fail deterministically on every platform.
45
+ const failedPath = path.join(root, "unwritable-log-target");
46
+ const { mkdirSync } = await import("node:fs");
47
+ mkdirSync(failedPath, { recursive: true });
48
+ process.env.MAGIC_CONTEXT_LOG_PATH = failedPath;
49
+ logger.log("failed write");
50
+ logger.flushLogger();
51
+ const failedDiagnostics = logger.getLoggerDiagnostics();
52
+
53
+ const content = existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
54
+ console.log(JSON.stringify({
55
+ exists: existsSync(logPath),
56
+ content,
57
+ healthyDiagnostics,
58
+ failedDiagnostics,
59
+ }));
60
+ `;
61
+
62
+ const scenarioRoots: string[] = [];
63
+
64
+ afterEach(() => {
65
+ for (const root of scenarioRoots.splice(0)) {
66
+ rmSync(root, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ async function runLoggerScenario(
71
+ scenario: "recovery" | "diagnostics",
72
+ ): Promise<LoggerScenarioResult> {
73
+ const root = mkdtempSync(path.join(os.tmpdir(), "magic-context-logger-test-"));
74
+ scenarioRoots.push(root);
75
+ const child = Bun.spawn({
76
+ cmd: ["bun", "--eval", loggerScenario],
77
+ cwd: import.meta.dir,
78
+ env: {
79
+ ...process.env,
80
+ NODE_ENV: "production",
81
+ LOGGER_MODULE_URL: new URL("./logger.ts", import.meta.url).href,
82
+ LOGGER_SCENARIO: scenario,
83
+ LOGGER_SCENARIO_ROOT: root,
84
+ },
85
+ stdout: "pipe",
86
+ stderr: "pipe",
87
+ });
88
+ const [exitCode, stdout, stderr] = await Promise.all([
89
+ child.exited,
90
+ new Response(child.stdout).text(),
91
+ new Response(child.stderr).text(),
92
+ ]);
93
+ expect(exitCode, stderr).toBe(0);
94
+ return JSON.parse(stdout.trim()) as LoggerScenarioResult;
95
+ }
96
+
97
+ describe("logger", () => {
98
+ test("recreates a log directory removed while the process is running", async () => {
99
+ const result = await runLoggerScenario("recovery");
100
+
101
+ expect(result.exists).toBe(true);
102
+ expect(result.content).toContain("second");
103
+ });
104
+
105
+ test("reports swallowed writes while healthy writes leave the counter at zero", async () => {
106
+ const result = await runLoggerScenario("diagnostics");
107
+
108
+ expect(result.healthyDiagnostics).toEqual({
109
+ swallowedWriteCount: 0,
110
+ lastErrorMessage: null,
111
+ lastErrorTime: null,
112
+ });
113
+ expect(result.failedDiagnostics.swallowedWriteCount).toBe(1);
114
+ expect(result.failedDiagnostics.lastErrorMessage).toBeTruthy();
115
+ expect(result.failedDiagnostics.lastErrorTime).toMatch(
116
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
117
+ );
118
+ });
119
+ });
@@ -9,25 +9,30 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
9
9
  const FLUSH_INTERVAL_MS = 500;
10
10
  const BUFFER_SIZE_LIMIT = 50;
11
11
 
12
- // Cache the last log directory we mkdir'd successfully so we only retry the
13
- // filesystem call when the resolved path actually changes. The path is
14
- // re-evaluated on every flush because `setHarness("pi")` runs after module
15
- // load on Pi; we MUST NOT freeze it at import time, or Pi's first flush
16
- // could land in the OpenCode subtree.
17
- let lastEnsuredDir: string | null = null;
12
+ export interface LoggerDiagnostics {
13
+ swallowedWriteCount: number;
14
+ lastErrorMessage: string | null;
15
+ lastErrorTime: string | null;
16
+ }
18
17
 
19
- function ensureDir(filePath: string): void {
20
- const dir = path.dirname(filePath);
21
- if (dir === lastEnsuredDir) return;
18
+ let swallowedWriteCount = 0;
19
+ let lastErrorMessage: string | null = null;
20
+ let lastErrorTime: string | null = null;
21
+
22
+ function recordSwallowedWrite(error: unknown): void {
22
23
  try {
23
- fs.mkdirSync(dir, { recursive: true });
24
- lastEnsuredDir = dir;
24
+ swallowedWriteCount++;
25
+ lastErrorMessage = error instanceof Error ? error.message : String(error);
26
+ lastErrorTime = new Date().toISOString();
25
27
  } catch {
26
- // Intentional: logging must never throw. If mkdir fails we still
27
- // try the append; failure there is also swallowed.
28
+ // Diagnostics must not make the logger throw either.
28
29
  }
29
30
  }
30
31
 
32
+ function ensureDir(filePath: string): void {
33
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
34
+ }
35
+
31
36
  function flush(): void {
32
37
  if (flushTimer) {
33
38
  clearTimeout(flushTimer);
@@ -40,8 +45,8 @@ function flush(): void {
40
45
  const logFile = getMagicContextLogPath();
41
46
  ensureDir(logFile);
42
47
  fs.appendFileSync(logFile, data);
43
- } catch {
44
- // Intentional: logging must never throw
48
+ } catch (error) {
49
+ recordSwallowedWrite(error);
45
50
  }
46
51
  }
47
52
 
@@ -78,6 +83,19 @@ export function sessionLog(sessionId: string, message: string, data?: unknown):
78
83
  log(`[magic-context][${sessionId}] ${message}`, data);
79
84
  }
80
85
 
86
+ export function getLoggerDiagnostics(): LoggerDiagnostics {
87
+ return {
88
+ swallowedWriteCount,
89
+ lastErrorMessage,
90
+ lastErrorTime,
91
+ };
92
+ }
93
+
94
+ /** Flush buffered log entries immediately. Primarily useful to diagnostic readers and tests. */
95
+ export function flushLogger(): void {
96
+ flush();
97
+ }
98
+
81
99
  /**
82
100
  * Resolve the current log file path. The path is harness-aware (see
83
101
  * {@link getMagicContextLogPath}) and re-evaluated on every call, so callers
@@ -78,6 +78,23 @@ describe("output-token reservation", () => {
78
78
  ).toBe(1_048_576);
79
79
  });
80
80
 
81
+ test("output_reserve accepts both harness provider spellings with canonical precedence", () => {
82
+ const limit = { context: 100_000, output: 20_000 };
83
+ expect(
84
+ resolveLimit(limit, "openai-codex", "gpt-5.6-sol", {
85
+ default: 0,
86
+ "openai-codex/gpt-5.6-sol": 8_000,
87
+ }),
88
+ ).toBe(92_000);
89
+ expect(
90
+ resolveLimit(limit, "openai-codex", "gpt-5.6-sol", {
91
+ default: 0,
92
+ "openai-codex/gpt-5.6-sol": 8_000,
93
+ "openai/gpt-5.6-sol": 4_000,
94
+ }),
95
+ ).toBe(96_000);
96
+ });
97
+
81
98
  test("output_reserve overrides shared and separate quota defaults", () => {
82
99
  expect(resolveLimit({ context: 100_000, output: 20_000 }, "anthropic", "claude", 0)).toBe(
83
100
  100_000,