@cortexkit/opencode-magic-context 0.21.8 → 0.22.1

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 (223) hide show
  1. package/README.md +124 -323
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/agents/permissions.d.ts +29 -14
  4. package/dist/agents/permissions.d.ts.map +1 -1
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/migrate-experimental.d.ts +29 -0
  7. package/dist/config/migrate-experimental.d.ts.map +1 -0
  8. package/dist/config/schema/agent-overrides.d.ts.map +1 -1
  9. package/dist/config/schema/magic-context.d.ts +95 -104
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  12. package/dist/features/magic-context/compartment-embedding.d.ts +34 -0
  13. package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -0
  14. package/dist/features/magic-context/compartment-events.d.ts +50 -0
  15. package/dist/features/magic-context/compartment-events.d.ts.map +1 -0
  16. package/dist/features/magic-context/compartment-storage.d.ts +22 -0
  17. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/queue.d.ts +13 -2
  20. package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/runner.d.ts +11 -0
  22. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
  24. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  25. package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
  26. package/dist/features/magic-context/key-files/identify-key-files.d.ts +1 -1
  27. package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
  28. package/dist/features/magic-context/key-files/project-key-files.d.ts.map +1 -1
  29. package/dist/features/magic-context/key-files/read-stats.d.ts +1 -1
  30. package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
  31. package/dist/features/magic-context/memory/constants.d.ts +4 -0
  32. package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
  33. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  34. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  35. package/dist/features/magic-context/memory/embedding-openai.d.ts +6 -0
  36. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  37. package/dist/features/magic-context/memory/embedding-probe.d.ts +5 -0
  38. package/dist/features/magic-context/memory/embedding-probe.d.ts.map +1 -1
  39. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  40. package/dist/features/magic-context/memory/index.d.ts +1 -1
  41. package/dist/features/magic-context/memory/index.d.ts.map +1 -1
  42. package/dist/features/magic-context/memory/memory-migration.d.ts +133 -0
  43. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -0
  44. package/dist/features/magic-context/memory/project-identity.d.ts +38 -7
  45. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  46. package/dist/features/magic-context/memory/storage-memory-fts.d.ts.map +1 -1
  47. package/dist/features/magic-context/memory/storage-memory.d.ts +15 -1
  48. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  49. package/dist/features/magic-context/memory/types.d.ts +3 -1
  50. package/dist/features/magic-context/memory/types.d.ts.map +1 -1
  51. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  52. package/dist/features/magic-context/migrations.d.ts +7 -0
  53. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  54. package/dist/features/magic-context/project-docs-hash.d.ts +6 -0
  55. package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -0
  56. package/dist/features/magic-context/project-identity.d.ts +2 -0
  57. package/dist/features/magic-context/project-identity.d.ts.map +1 -0
  58. package/dist/features/magic-context/storage-db.d.ts +51 -7
  59. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage-historian-runs.d.ts +73 -0
  61. package/dist/features/magic-context/storage-historian-runs.d.ts.map +1 -0
  62. package/dist/features/magic-context/storage-identity-rekey-map.d.ts +11 -0
  63. package/dist/features/magic-context/storage-identity-rekey-map.d.ts.map +1 -0
  64. package/dist/features/magic-context/storage-m0-mutation-log.d.ts +22 -0
  65. package/dist/features/magic-context/storage-m0-mutation-log.d.ts.map +1 -0
  66. package/dist/features/magic-context/storage-memory-mutation-log.d.ts +25 -0
  67. package/dist/features/magic-context/storage-memory-mutation-log.d.ts.map +1 -0
  68. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  69. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  70. package/dist/features/magic-context/storage-meta-shared.d.ts +44 -0
  71. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  72. package/dist/features/magic-context/storage-meta.d.ts +1 -0
  73. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  74. package/dist/features/magic-context/storage-project-state.d.ts +19 -0
  75. package/dist/features/magic-context/storage-project-state.d.ts.map +1 -0
  76. package/dist/features/magic-context/storage-subagent-invocations.d.ts +9 -0
  77. package/dist/features/magic-context/storage-subagent-invocations.d.ts.map +1 -1
  78. package/dist/features/magic-context/storage-tags.d.ts +21 -1
  79. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  80. package/dist/features/magic-context/storage-v22-backfill-failures.d.ts +24 -0
  81. package/dist/features/magic-context/storage-v22-backfill-failures.d.ts.map +1 -0
  82. package/dist/features/magic-context/storage.d.ts +12 -3
  83. package/dist/features/magic-context/storage.d.ts.map +1 -1
  84. package/dist/features/magic-context/subagent-token-capture.d.ts +1 -1
  85. package/dist/features/magic-context/subagent-token-capture.d.ts.map +1 -1
  86. package/dist/features/magic-context/tagger.d.ts +15 -1
  87. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  88. package/dist/features/magic-context/types.d.ts +21 -0
  89. package/dist/features/magic-context/types.d.ts.map +1 -1
  90. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  91. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  92. package/dist/features/magic-context/v22-deferred-backfill.d.ts +46 -0
  93. package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -0
  94. package/dist/features/magic-context/work-metrics.d.ts +66 -0
  95. package/dist/features/magic-context/work-metrics.d.ts.map +1 -1
  96. package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
  97. package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/cache-busting-signals.d.ts +9 -0
  99. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/command-handler.d.ts +13 -1
  101. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/compartment-parser.d.ts +25 -0
  103. package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
  104. package/dist/hooks/magic-context/compartment-prompt.d.ts +27 -16
  105. package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts +6 -2
  108. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  110. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts +9 -1
  111. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/compartment-runner-types.d.ts +67 -4
  113. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  114. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  115. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  116. package/dist/hooks/magic-context/decay-curve.d.ts +78 -0
  117. package/dist/hooks/magic-context/decay-curve.d.ts.map +1 -0
  118. package/dist/hooks/magic-context/decay-render.d.ts +67 -0
  119. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -0
  120. package/dist/hooks/magic-context/event-handler.d.ts +1 -1
  121. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/event-resolvers.d.ts +17 -0
  123. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  124. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  125. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +2 -0
  126. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -0
  127. package/dist/hooks/magic-context/hook-handlers.d.ts +3 -0
  128. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  129. package/dist/hooks/magic-context/hook.d.ts +9 -21
  130. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  131. package/dist/hooks/magic-context/inject-compartments.d.ts +126 -0
  132. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  133. package/dist/hooks/magic-context/key-files-block.d.ts.map +1 -1
  134. package/dist/hooks/magic-context/live-session-state.d.ts +9 -0
  135. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  136. package/dist/hooks/magic-context/m0-token-breakdown.d.ts +35 -0
  137. package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -0
  138. package/dist/hooks/magic-context/read-session-chunk.d.ts +9 -0
  139. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  140. package/dist/hooks/magic-context/read-session-db.d.ts +7 -0
  141. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  142. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +104 -0
  143. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -0
  144. package/dist/hooks/magic-context/reference-retrieval.d.ts +61 -0
  145. package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -0
  146. package/dist/hooks/magic-context/reference-seeds.generated.d.ts +8 -0
  147. package/dist/hooks/magic-context/reference-seeds.generated.d.ts.map +1 -0
  148. package/dist/hooks/magic-context/send-session-notification.d.ts +1 -1
  149. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  150. package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -6
  151. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  152. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  153. package/dist/hooks/magic-context/tokenizer-calibration.d.ts +6 -0
  154. package/dist/hooks/magic-context/tokenizer-calibration.d.ts.map +1 -1
  155. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +0 -7
  156. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  157. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +18 -0
  158. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  159. package/dist/hooks/magic-context/transform.d.ts +9 -7
  160. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  161. package/dist/hooks/magic-context/upgrade-reminder.d.ts +73 -0
  162. package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -0
  163. package/dist/index.d.ts.map +1 -1
  164. package/dist/index.js +9435 -4001
  165. package/dist/plugin/conflict-warning-hook.d.ts +13 -0
  166. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
  167. package/dist/plugin/dream-timer.d.ts.map +1 -1
  168. package/dist/plugin/event.d.ts +10 -0
  169. package/dist/plugin/event.d.ts.map +1 -1
  170. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  171. package/dist/plugin/messages-transform.d.ts.map +1 -1
  172. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  173. package/dist/plugin/tool-registry.d.ts.map +1 -1
  174. package/dist/shared/announcement.d.ts +17 -1
  175. package/dist/shared/announcement.d.ts.map +1 -1
  176. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  177. package/dist/shared/rpc-client.d.ts +1 -0
  178. package/dist/shared/rpc-client.d.ts.map +1 -1
  179. package/dist/shared/rpc-notifications.d.ts +27 -5
  180. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  181. package/dist/shared/rpc-server.d.ts +1 -0
  182. package/dist/shared/rpc-server.d.ts.map +1 -1
  183. package/dist/shared/rpc-types.d.ts +30 -2
  184. package/dist/shared/rpc-types.d.ts.map +1 -1
  185. package/dist/shared/rpc-utils.d.ts +9 -0
  186. package/dist/shared/rpc-utils.d.ts.map +1 -1
  187. package/dist/shared/sqlite-helpers.d.ts +7 -7
  188. package/dist/shared/sqlite.d.ts +23 -14
  189. package/dist/shared/sqlite.d.ts.map +1 -1
  190. package/dist/shared/tag-transcript.d.ts +10 -1
  191. package/dist/shared/tag-transcript.d.ts.map +1 -1
  192. package/dist/tools/ctx-expand/tools.d.ts +5 -1
  193. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  194. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  195. package/dist/tui/data/context-db.d.ts +16 -1
  196. package/dist/tui/data/context-db.d.ts.map +1 -1
  197. package/package.json +5 -7
  198. package/src/shared/announcement.test.ts +23 -7
  199. package/src/shared/announcement.ts +30 -8
  200. package/src/shared/conflict-detector.test.ts +15 -2
  201. package/src/shared/conflict-fixer.test.ts +5 -1
  202. package/src/shared/models-dev-cache.test.ts +72 -4
  203. package/src/shared/models-dev-cache.ts +47 -8
  204. package/src/shared/opencode-compaction-detector.test.ts +10 -2
  205. package/src/shared/rpc-client.test.ts +54 -3
  206. package/src/shared/rpc-client.ts +19 -9
  207. package/src/shared/rpc-notifications.test.ts +54 -1
  208. package/src/shared/rpc-notifications.ts +82 -13
  209. package/src/shared/rpc-server.ts +33 -4
  210. package/src/shared/rpc-types.ts +30 -2
  211. package/src/shared/rpc-utils.ts +10 -0
  212. package/src/shared/sqlite-helpers.ts +9 -9
  213. package/src/shared/sqlite.ts +99 -80
  214. package/src/shared/tag-transcript.test.ts +280 -0
  215. package/src/shared/tag-transcript.ts +162 -33
  216. package/src/tui/data/context-db.ts +75 -11
  217. package/src/tui/index.tsx +223 -32
  218. package/src/tui/slots/sidebar-content.tsx +366 -34
  219. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +0 -87
  220. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +0 -1
  221. package/dist/shared/native-binding.d.ts +0 -87
  222. package/dist/shared/native-binding.d.ts.map +0 -1
  223. package/src/shared/native-binding.ts +0 -311
@@ -23,19 +23,18 @@ import { getMagicContextStorageDir } from "./data-path";
23
23
  * Bump only when there are user-visible changes worth a startup dialog.
24
24
  * Does NOT need to match the published package version.
25
25
  */
26
- export const ANNOUNCEMENT_VERSION = "0.21.7";
26
+ export const ANNOUNCEMENT_VERSION = "0.22.0";
27
27
 
28
28
  /**
29
29
  * Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
30
30
  * TUI dialog renders cleanly without horizontal scroll on a typical terminal.
31
31
  */
32
32
  export const ANNOUNCEMENT_FEATURES: ReadonlyArray<string> = [
33
- "Pi parity sweep: 44 audit findings fixed, including a critical SHIP-BLOCKER where /ctx-flush did not drain the pending Pi compaction queue.",
34
- "Pi historian recovery fix: empty/no-op historian returns now clear emergency recovery so sessions cannot loop forever at 95%.",
35
- "trimPiMessagesToBoundary now sweeps non-contiguous tool-result orphans, fixing provider 400s after compaction in long Pi sessions.",
36
- "Hidden subagent tool isolation: historian, dreamer, and sidekick can no longer spawn subagents or run unsafe tools.",
37
- "TUI sidebar and /ctx-status header now show execute threshold inline: '47.5% / 65%' on the left, '475K / 1.0M' on the right.",
38
- "doctor --issue now caps GitHub issue bodies at ~60KB with a dedicated 'Recent errors' section so reports stay submittable.",
33
+ "NOW ON BY DEFAULT Temporal awareness: the agent sees elapsed-time markers (e.g. +2h 15m) between messages and dated compartments, so it knows how long ago things happened. Opt out with temporal_awareness: false.",
34
+ "NOW ON BY DEFAULT — Auto-search hints: each turn a background ctx_search whispers a compact 'vague recall' when something relevant exists in your memories, past conversation, or git history. No full content injected. Opt out with memory.auto_search.enabled: false.",
35
+ "Experimental features graduated to stable config: temporal_awareness and caveman_text_compression are now top-level keys; auto_search and git_commit_indexing moved under memory.* . Run `doctor` to migrate old experimental.* settings (your opt-ins/opt-outs are preserved).",
36
+ "git_commit_indexing (make project history semantically searchable) stays opt-in enable with memory.git_commit_indexing.enabled: true.",
37
+ "Audit hardening across both harnesses: memory config-bypass fix, supersede-delta cache-stability fixes, and dashboard correctness fixes.",
39
38
  ];
40
39
 
41
40
  /**
@@ -90,8 +89,31 @@ export function markAnnouncementSeen(version: string): void {
90
89
  * True when the configured `ANNOUNCEMENT_VERSION` has not yet been dismissed
91
90
  * AND there is at least one feature to show. Used by both the TUI dialog path
92
91
  * and the Desktop ignored-message fallback.
92
+ *
93
+ * First-run / sandbox handling: when NO state file exists yet, we seed it to the
94
+ * current `ANNOUNCEMENT_VERSION` and return false instead of announcing. This
95
+ * covers two cases that previously spammed the dialog (issue #99):
96
+ * - Fresh installs: a brand-new user shouldn't be shown a changelog of release
97
+ * bullets they have no context for — they need onboarding, not patch notes.
98
+ * - Ephemeral/sandbox environments (Docker, CI, disposable dev containers)
99
+ * where the storage dir is wiped between launches: without the seed, the
100
+ * missing file made the announcement re-show on every single startup.
101
+ * Real upgrades still announce exactly once: an existing user already has a
102
+ * state file at the prior version, so the version mismatch shows the dialog and
103
+ * dismissing it advances the file to the current version.
104
+ *
105
+ * The seed is a deliberate write side-effect on the "no file" branch — folding
106
+ * it here (rather than a separate startup call) makes every caller path (plugin
107
+ * startup, Pi startup, TUI rpc pull) consistent with no ordering dependency.
93
108
  */
94
109
  export function shouldShowAnnouncement(): boolean {
95
110
  if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) return false;
96
- return readLastAnnouncedVersion() !== ANNOUNCEMENT_VERSION;
111
+ const lastVersion = readLastAnnouncedVersion();
112
+ if (!lastVersion) {
113
+ // No prior state: fresh install or wiped sandbox. Seed to current and
114
+ // skip the announcement so we never pester first-run / ephemeral envs.
115
+ markAnnouncementSeen(ANNOUNCEMENT_VERSION);
116
+ return false;
117
+ }
118
+ return lastVersion !== ANNOUNCEMENT_VERSION;
97
119
  }
@@ -46,8 +46,21 @@ describe("detectConflicts", () => {
46
46
  else process.env[k] = v;
47
47
  }
48
48
  // Test directories live under tmpdir(); cleanup is best-effort.
49
- rmSync(projectDir, { recursive: true, force: true });
50
- rmSync(userConfigDir, { recursive: true, force: true });
49
+ try {
50
+ rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
51
+ } catch {
52
+ /* Ignore EBUSY on Windows */
53
+ }
54
+ try {
55
+ rmSync(userConfigDir, {
56
+ recursive: true,
57
+ force: true,
58
+ maxRetries: 10,
59
+ retryDelay: 100,
60
+ });
61
+ } catch {
62
+ /* Ignore EBUSY on Windows */
63
+ }
51
64
  });
52
65
 
53
66
  function writeProjectConfig(plugins: Array<string | [string, unknown]>): void {
@@ -38,7 +38,11 @@ describe("fixConflicts", () => {
38
38
  if (value === undefined) delete process.env[key];
39
39
  else process.env[key] = value;
40
40
  }
41
- rmSync(root, { recursive: true, force: true });
41
+ try {
42
+ rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
43
+ } catch {
44
+ /* Ignore EBUSY on Windows */
45
+ }
42
46
  });
43
47
 
44
48
  it("preserves JSONC comments and tuple plugin entries while removing canonical DCP", () => {
@@ -39,7 +39,11 @@ describe("models-dev-cache", () => {
39
39
  if (v === undefined) delete process.env[k];
40
40
  else process.env[k] = v;
41
41
  }
42
- rmSync(tempDir, { recursive: true, force: true });
42
+ try {
43
+ rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
44
+ } catch {
45
+ /* Ignore EBUSY on Windows */
46
+ }
43
47
  clearModelsDevCache();
44
48
  });
45
49
 
@@ -234,7 +238,7 @@ describe("models-dev-cache", () => {
234
238
  expect(getModelsDevContextLimit("anthropic", "claude-4")).toBeUndefined();
235
239
  });
236
240
 
237
- test("API cache takes priority over file cache", async () => {
241
+ test("takes the larger limit when both layers know the model (API larger)", async () => {
238
242
  // Seed file layer with one value.
239
243
  const opencodeDir = join(tempDir, "opencode");
240
244
  mkdirSync(opencodeDir, { recursive: true });
@@ -248,7 +252,7 @@ describe("models-dev-cache", () => {
248
252
  // Sanity: file layer returns 100000 before API refresh.
249
253
  expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(100000);
250
254
 
251
- // Mock client providing DIFFERENT value via API.
255
+ // Mock client providing a LARGER value via API.
252
256
  const mockClient = {
253
257
  config: {
254
258
  providers: async () => ({
@@ -267,7 +271,7 @@ describe("models-dev-cache", () => {
267
271
  };
268
272
  await refreshModelLimitsFromApi(mockClient);
269
273
 
270
- // API value wins.
274
+ // Larger (API) value wins.
271
275
  expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
272
276
 
273
277
  const state = getModelsDevCacheState();
@@ -275,6 +279,70 @@ describe("models-dev-cache", () => {
275
279
  expect(state.apiCount).toBe(1);
276
280
  });
277
281
 
282
+ test("file value wins when the live API reports a smaller (wrong) limit (issue #117)", async () => {
283
+ // The ollama-cloud scenario: models.dev has the correct large window, but
284
+ // ollama reports its tiny default num_ctx via the live /config/providers
285
+ // API. The larger, correct file value must win so pressure isn't bogus.
286
+ const opencodeDir = join(tempDir, "opencode");
287
+ mkdirSync(opencodeDir, { recursive: true });
288
+ writeFileSync(
289
+ join(opencodeDir, "models.json"),
290
+ JSON.stringify({
291
+ "ollama-cloud": {
292
+ models: { "deepseek-v4-pro": { limit: { context: 1048576 } } },
293
+ },
294
+ }),
295
+ );
296
+
297
+ const mockClient = {
298
+ config: {
299
+ providers: async () => ({
300
+ data: {
301
+ providers: [
302
+ {
303
+ id: "ollama-cloud",
304
+ models: {
305
+ // Bogus tiny default num_ctx from ollama.
306
+ "deepseek-v4-pro": { limit: { context: 8192 } },
307
+ },
308
+ },
309
+ ],
310
+ },
311
+ }),
312
+ },
313
+ };
314
+ await refreshModelLimitsFromApi(mockClient);
315
+
316
+ // Larger (file/models.dev) value wins, not the tiny live-API value.
317
+ expect(getModelsDevContextLimit("ollama-cloud", "deepseek-v4-pro")).toBe(1048576);
318
+ });
319
+
320
+ test("matches a tagged ollama model against its tag-less models.dev entry (issue #117)", () => {
321
+ // ollama invokes cloud models with a tag (deepseek-v4-pro:cloud) while
322
+ // models.dev stores them tag-less (deepseek-v4-pro).
323
+ const opencodeDir = join(tempDir, "opencode");
324
+ mkdirSync(opencodeDir, { recursive: true });
325
+ writeFileSync(
326
+ join(opencodeDir, "models.json"),
327
+ JSON.stringify({
328
+ "ollama-cloud": {
329
+ models: {
330
+ "deepseek-v4-pro": { limit: { context: 1048576 } },
331
+ // A legitimately-tagged model must still match exactly.
332
+ "gemma3:27b": { limit: { context: 131072 } },
333
+ },
334
+ },
335
+ }),
336
+ );
337
+
338
+ // Tagged invocation falls back to the tag-less entry.
339
+ expect(getModelsDevContextLimit("ollama-cloud", "deepseek-v4-pro:cloud")).toBe(1048576);
340
+ // Exact tagged match still wins (no wrongful collapse).
341
+ expect(getModelsDevContextLimit("ollama-cloud", "gemma3:27b")).toBe(131072);
342
+ // Unknown tagged model with no tag-less base stays undefined.
343
+ expect(getModelsDevContextLimit("ollama-cloud", "nonexistent:cloud")).toBeUndefined();
344
+ });
345
+
278
346
  test("refreshModelLimitsFromApi tolerates empty/malformed responses", async () => {
279
347
  // Undefined data.
280
348
  await refreshModelLimitsFromApi({
@@ -298,19 +298,58 @@ export async function refreshModelLimitsFromApi(client: OpencodeClientLike): Pro
298
298
  * Returns `undefined` if neither layer knows the model.
299
299
  */
300
300
  export function getModelsDevContextLimit(providerID: string, modelID: string): number | undefined {
301
- const key = `${providerID}/${modelID}`;
302
-
303
- if (apiCache) {
304
- const fromApi = apiCache.get(key)?.limit;
305
- if (typeof fromApi === "number") return fromApi;
306
- }
307
-
308
301
  const now = Date.now();
309
302
  if (!fileCache || now - fileLastAttempt > RELOAD_INTERVAL_MS) {
310
303
  fileLastAttempt = now;
311
304
  fileCache = loadModelsDevMetadataFromFile();
312
305
  }
313
- return fileCache.get(key)?.limit;
306
+
307
+ const fromApi = lookupLimitWithTagFallback(apiCache, providerID, modelID);
308
+ const fromFile = lookupLimitWithTagFallback(fileCache, providerID, modelID);
309
+
310
+ // When BOTH layers know the model, take the LARGER limit. Providers never
311
+ // under-report their real window, so a suspiciously small value — e.g.
312
+ // ollama reporting its default `num_ctx` (4k/8k) for a cloud model via the
313
+ // live `/config/providers` API — must not override the correct, larger
314
+ // models.dev value. A genuinely smaller real limit (provider actually
315
+ // rejects at N) is captured separately via the overflow-detection path
316
+ // (detectedContextLimit), not here. (issue #117)
317
+ if (typeof fromApi === "number" && typeof fromFile === "number") {
318
+ return Math.max(fromApi, fromFile);
319
+ }
320
+ return fromApi ?? fromFile;
321
+ }
322
+
323
+ /**
324
+ * Look up a model's limit in one cache layer, with an ollama-style tag-suffix
325
+ * fallback.
326
+ *
327
+ * models.dev stores some models WITH a colon tag (e.g. `gemma3:27b`,
328
+ * `deepseek-v3.1:671b`) and ollama-cloud base models WITHOUT one
329
+ * (`deepseek-v4-pro`). But ollama invokes cloud models with a tag at runtime
330
+ * (`deepseek-v4-pro:cloud`), so OpenCode reports the tagged id. An exact-only
331
+ * match therefore misses → falls back to the 128k default → wrong pressure
332
+ * denominator (issue #117).
333
+ *
334
+ * Strategy: exact match first (never collapses a legitimately-tagged model),
335
+ * then retry once with the last `:tag` segment stripped.
336
+ */
337
+ function lookupLimitWithTagFallback(
338
+ cache: Map<string, CachedModelMetadata> | null,
339
+ providerID: string,
340
+ modelID: string,
341
+ ): number | undefined {
342
+ if (!cache) return undefined;
343
+ const exact = cache.get(`${providerID}/${modelID}`)?.limit;
344
+ if (typeof exact === "number") return exact;
345
+
346
+ const colonIdx = modelID.lastIndexOf(":");
347
+ if (colonIdx > 0) {
348
+ const baseModel = modelID.slice(0, colonIdx);
349
+ const fallback = cache.get(`${providerID}/${baseModel}`)?.limit;
350
+ if (typeof fallback === "number") return fallback;
351
+ }
352
+ return undefined;
314
353
  }
315
354
 
316
355
  /** Clear in-memory caches (for testing). */
@@ -18,7 +18,11 @@ describe("opencode-compaction-detector", () => {
18
18
  });
19
19
 
20
20
  afterEach(() => {
21
- rmSync(tmpDir, { recursive: true, force: true });
21
+ try {
22
+ rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
23
+ } catch {
24
+ /* Ignore EBUSY on Windows */
25
+ }
22
26
  delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
23
27
  });
24
28
 
@@ -30,7 +34,11 @@ describe("opencode-compaction-detector", () => {
30
34
  const result = isOpenCodeAutoCompactionEnabled(emptyDir);
31
35
 
32
36
  expect(result).toBe(true);
33
- rmSync(emptyDir, { recursive: true, force: true });
37
+ try {
38
+ rmSync(emptyDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
39
+ } catch {
40
+ /* Ignore EBUSY on Windows */
41
+ }
34
42
  });
35
43
  });
36
44
 
@@ -1,10 +1,11 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { MagicContextRpcClient } from "./rpc-client";
7
- import { rpcPortFilePath } from "./rpc-utils";
7
+ import { MagicContextRpcServer } from "./rpc-server";
8
+ import { parseRpcPortFile, rpcPortFilePath } from "./rpc-utils";
8
9
 
9
10
  interface TestServer {
10
11
  port: number;
@@ -19,7 +20,11 @@ afterEach(async () => {
19
20
  await server.close();
20
21
  }
21
22
  for (const dir of tempDirs.splice(0)) {
22
- rmSync(dir, { recursive: true, force: true });
23
+ try {
24
+ rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
25
+ } catch {
26
+ /* Ignore EBUSY on Windows */
27
+ }
23
28
  }
24
29
  });
25
30
 
@@ -116,6 +121,52 @@ describe("MagicContextRpcClient", () => {
116
121
  expect(await client.call<{ value: string }>("value")).toEqual({ value: "second" });
117
122
  });
118
123
 
124
+ test("authenticates against a real server with the published token", async () => {
125
+ const storageDir = makeTempDir();
126
+ const directory = "/repo-auth";
127
+ const server = new MagicContextRpcServer(storageDir, directory);
128
+ server.handle("ping", async () => ({ pong: true }));
129
+ await server.start();
130
+ try {
131
+ const client = new MagicContextRpcClient(storageDir, directory);
132
+ // Real round-trip: client must read the token from the port file and
133
+ // send it as Bearer auth, or the server returns 401.
134
+ expect(await client.call<{ pong: boolean }>("ping")).toEqual({ pong: true });
135
+ } finally {
136
+ server.stop();
137
+ }
138
+ });
139
+
140
+ test("a request without the token is rejected 401 by the server", async () => {
141
+ const storageDir = makeTempDir();
142
+ const directory = "/repo-noauth";
143
+ const server = new MagicContextRpcServer(storageDir, directory);
144
+ server.handle("ping", async () => ({ pong: true }));
145
+ const port = await server.start();
146
+ try {
147
+ // Sanity: the port file carries a non-empty token.
148
+ const record = parseRpcPortFile(
149
+ readFileSync(rpcPortFilePath(storageDir, directory), "utf-8"),
150
+ );
151
+ expect(typeof record?.token).toBe("string");
152
+ expect((record?.token ?? "").length).toBeGreaterThan(0);
153
+
154
+ // A raw fetch with no Authorization header must be rejected.
155
+ const res = await fetch(`http://127.0.0.1:${port}/rpc/ping`, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: "{}",
159
+ });
160
+ expect(res.status).toBe(401);
161
+
162
+ // Health stays open (no token required) for discovery.
163
+ const health = await fetch(`http://127.0.0.1:${port}/health`);
164
+ expect(health.status).toBe(200);
165
+ } finally {
166
+ server.stop();
167
+ }
168
+ });
169
+
119
170
  test("gives up when the port file points at a dead server", async () => {
120
171
  const storageDir = makeTempDir();
121
172
  const directory = "/repo";
@@ -17,6 +17,7 @@ type NonRetryableRpcError = Error & { [NON_RETRYABLE_RPC_ERROR]: true };
17
17
 
18
18
  export class MagicContextRpcClient {
19
19
  private port: number | null = null;
20
+ private token: string | null = null;
20
21
  private portDir: string;
21
22
  private legacyPortFilePath: string;
22
23
  private healthChecked = false;
@@ -46,7 +47,14 @@ export class MagicContextRpcClient {
46
47
  `http://127.0.0.1:${port}/rpc/${method}`,
47
48
  {
48
49
  method: "POST",
49
- headers: { "Content-Type": "application/json" },
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ // The server requires this per-process token on all
53
+ // non-health calls; read from the same port file used
54
+ // for discovery. Older servers wrote no token — send
55
+ // nothing then (they also require nothing).
56
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
57
+ },
50
58
  body: JSON.stringify(params),
51
59
  },
52
60
  );
@@ -105,13 +113,14 @@ export class MagicContextRpcClient {
105
113
  }
106
114
 
107
115
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
108
- const port = this.readPortFile();
109
- if (port) {
110
- const alive = await this.healthCheck(port);
116
+ const record = this.readPortFile();
117
+ if (record) {
118
+ const alive = await this.healthCheck(record.port);
111
119
  if (alive) {
112
- this.port = port;
120
+ this.port = record.port;
121
+ this.token = record.token ?? null;
113
122
  this.healthChecked = true;
114
- return port;
123
+ return record.port;
115
124
  }
116
125
  }
117
126
 
@@ -123,7 +132,7 @@ export class MagicContextRpcClient {
123
132
  return null;
124
133
  }
125
134
 
126
- private readPortFile(): number | null {
135
+ private readPortFile(): RpcPortFileRecord | null {
127
136
  const records: RpcPortFileRecord[] = [];
128
137
 
129
138
  try {
@@ -139,13 +148,13 @@ export class MagicContextRpcClient {
139
148
 
140
149
  if (records.length > 0) {
141
150
  records.sort((a, b) => b.started_at - a.started_at);
142
- return records[0].port;
151
+ return records[0];
143
152
  }
144
153
 
145
154
  try {
146
155
  const record = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
147
156
  if (record?.pid && !isPidAlive(record.pid)) return null;
148
- return record?.port ?? null;
157
+ return record;
149
158
  } catch {
150
159
  return null;
151
160
  }
@@ -174,6 +183,7 @@ export class MagicContextRpcClient {
174
183
 
175
184
  reset(): void {
176
185
  this.port = null;
186
+ this.token = null;
177
187
  this.healthChecked = false;
178
188
  }
179
189
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { drainNotifications, pushNotification } from "./rpc-notifications";
2
+ import { drainNotifications, isTuiConnected, pushNotification } from "./rpc-notifications";
3
3
 
4
4
  describe("rpc notifications", () => {
5
5
  test("keeps messages queued until the client acks their id", () => {
@@ -17,4 +17,57 @@ describe("rpc notifications", () => {
17
17
  const lastReceivedId = Math.max(...firstPoll.map((m) => m.id));
18
18
  expect(drainNotifications(lastReceivedId)).toEqual([]);
19
19
  });
20
+
21
+ test("scopes drain to the requesting session; other sessions' items survive", () => {
22
+ // drain everything left from prior tests
23
+ drainNotifications(Number.MAX_SAFE_INTEGER);
24
+
25
+ pushNotification("for-a", { action: "show-upgrade-dialog" }, "ses_A");
26
+ pushNotification("for-b", { action: "show-upgrade-dialog" }, "ses_B");
27
+ pushNotification("global", { action: "show-status-dialog" });
28
+
29
+ // Session A sees only its own item + the global one, never ses_B's.
30
+ const aPoll = drainNotifications(0, "ses_A");
31
+ expect(aPoll.map((m) => m.type).sort()).toEqual(["for-a", "global"]);
32
+
33
+ // Acking session A must NOT prune session B's still-unseen notification.
34
+ const ackId = Math.max(...aPoll.map((m) => m.id));
35
+ drainNotifications(ackId, "ses_A");
36
+ const bPoll = drainNotifications(0, "ses_B");
37
+ expect(bPoll.map((m) => m.type)).toContain("for-b");
38
+ });
39
+
40
+ test("session-less drain (legacy client) still receives all items", () => {
41
+ drainNotifications(Number.MAX_SAFE_INTEGER);
42
+ pushNotification("x", { ok: true }, "ses_1");
43
+ pushNotification("y", { ok: true }, "ses_2");
44
+ const poll = drainNotifications(0);
45
+ expect(poll.map((m) => m.type).sort()).toEqual(["x", "y"]);
46
+ });
47
+
48
+ test("isTuiConnected is per-session: a TUI on session A does not mark session B connected", () => {
49
+ // A TUI draining for tuiA must not make tuiB's producers think a TUI is
50
+ // polling for tuiB (which would route tuiB's /ctx-status, upgrade
51
+ // reminder, etc. to the dialog path and lose them in the unrelated TUI).
52
+ // Use ids no other test drains so the per-session window is unambiguous.
53
+ drainNotifications(0, "ses_tuiA_only");
54
+ expect(isTuiConnected("ses_tuiA_only")).toBe(true);
55
+ expect(isTuiConnected("ses_tuiB_never_drained")).toBe(false);
56
+ // The session-less (global) query still reports recent activity for the
57
+ // legacy callers that have no session context.
58
+ expect(isTuiConnected()).toBe(true);
59
+ });
60
+
61
+ test("queue-cap eviction is session-fair: a noisy session cannot evict another session's newest unseen item", () => {
62
+ drainNotifications(Number.MAX_SAFE_INTEGER);
63
+ // One quiet session with a single pending dialog.
64
+ pushNotification("quiet-dialog", { action: "show-upgrade-dialog" }, "ses_quiet");
65
+ // A noisy session floods well past the 100 cap.
66
+ for (let i = 0; i < 200; i += 1) {
67
+ pushNotification("noise", { i }, "ses_noisy");
68
+ }
69
+ // The quiet session's newest item must survive the eviction.
70
+ const quietPoll = drainNotifications(0, "ses_quiet");
71
+ expect(quietPoll.some((m) => m.type === "quiet-dialog")).toBe(true);
72
+ });
20
73
  });
@@ -16,10 +16,22 @@ export interface RpcNotification {
16
16
 
17
17
  let queue: RpcNotification[] = [];
18
18
  let nextNotificationId = 1;
19
- // Timestamp of last drain — used to detect if TUI is actively polling.
19
+ // Timestamp of last drain — used to detect if a TUI is actively polling.
20
20
  // The TUI polls every 500ms; we consider it connected if it polled within
21
21
  // the last 3 seconds (6× the poll interval, tolerates transient delays).
22
- let lastDrainAt = 0;
22
+ //
23
+ // PER-SESSION: a single server process can serve MANY sessions (e.g. a TUI on
24
+ // session A plus an OpenCode Desktop opened on session B for the same project,
25
+ // whose newer RPC server this TUI's port discovery then selects). The TUI
26
+ // poller drains with ITS active session id, so a session is "TUI-connected"
27
+ // only if a TUI recently drained FOR THAT session. A process-global timestamp
28
+ // would make session B's producers (`/ctx-status`, upgrade reminder) take the
29
+ // TUI-dialog path because session A's TUI is polling — queuing a B-scoped
30
+ // dialog action that A's poller correctly refuses to show, so B's notice is
31
+ // lost (it also suppressed B's non-TUI fallback). Tracking drains per session
32
+ // routes each producer to the right delivery path.
33
+ const lastDrainAtBySession = new Map<string, number>();
34
+ let lastDrainAtAny = 0;
23
35
  const TUI_CONNECTED_WINDOW_MS = 3_000;
24
36
 
25
37
  /** Push a notification for TUI to pick up via polling. */
@@ -29,25 +41,82 @@ export function pushNotification(
29
41
  sessionId?: string,
30
42
  ): void {
31
43
  queue.push({ id: nextNotificationId++, type, payload, sessionId });
32
- // Cap queue size to prevent unbounded growth if TUI is not polling
44
+ // Cap queue size to prevent unbounded growth if a TUI is not draining.
45
+ // Session-FAIR eviction: a naive `slice(-50)` drops the globally-oldest
46
+ // items, so a noisy session could evict ANOTHER session's single unseen
47
+ // notification. Instead, always retain each session's newest item, then
48
+ // fill the rest of the budget with the newest overall — no session can
49
+ // starve another's pending dialog out of the window.
33
50
  if (queue.length > 100) {
34
- queue = queue.slice(-50);
51
+ const newestPerSession = new Map<string | undefined, number>();
52
+ for (const n of queue) {
53
+ const prev = newestPerSession.get(n.sessionId);
54
+ if (prev === undefined || n.id > prev) {
55
+ newestPerSession.set(n.sessionId, n.id);
56
+ }
57
+ }
58
+ const mustKeep = new Set(newestPerSession.values());
59
+ const byNewest = [...queue].sort((a, b) => b.id - a.id);
60
+ const kept: RpcNotification[] = [];
61
+ for (const n of byNewest) {
62
+ if (kept.length < 50 || mustKeep.has(n.id)) kept.push(n);
63
+ }
64
+ queue = kept.sort((a, b) => a.id - b.id);
35
65
  }
36
66
  }
37
67
 
38
68
  /** Return pending notifications after acking the client's last received id.
39
- * Updates lastDrainAt so isTuiConnected() reflects recent activity. */
40
- export function drainNotifications(lastReceivedId = 0): RpcNotification[] {
41
- lastDrainAt = Date.now();
69
+ * Updates lastDrainAt so isTuiConnected() reflects recent activity.
70
+ *
71
+ * Session scoping: when `sessionId` is provided, only notifications tagged for
72
+ * that session (or session-less/global ones) are returned and pruned — a
73
+ * notification tagged for a DIFFERENT session is never handed to this client
74
+ * and is never pruned by this client's ack. This matters because the in-memory
75
+ * queue is per-process but a TUI can end up draining a process that also serves
76
+ * OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
77
+ * newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
78
+ * so a Desktop-session upgrade-dialog action would otherwise surface in an
79
+ * unrelated TUI session. Each client also tracks its own `lastReceivedId`, so a
80
+ * global watermark prune would let session A's ack drop session B's still-unseen
81
+ * notification — scoping the prune to the acking session prevents that too.
82
+ *
83
+ * Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
84
+ * notification stays queued until a later call acks it via a higher
85
+ * `lastReceivedId`, so a lost poll response re-delivers on the next poll. */
86
+ export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] {
87
+ const now = Date.now();
88
+ lastDrainAtAny = now;
89
+ if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now);
90
+ const matchesClient = (notification: RpcNotification): boolean =>
91
+ sessionId === undefined ||
92
+ notification.sessionId === undefined ||
93
+ notification.sessionId === sessionId;
42
94
  if (lastReceivedId > 0) {
43
- queue = queue.filter((notification) => notification.id > lastReceivedId);
95
+ // Prune only notifications THIS client both owns (session-matched) and has
96
+ // acked (id <= lastReceivedId). Other sessions' notifications survive.
97
+ queue = queue.filter(
98
+ (notification) => !(notification.id <= lastReceivedId && matchesClient(notification)),
99
+ );
44
100
  }
45
- return [...queue];
101
+ return queue.filter(
102
+ (notification) => notification.id > lastReceivedId && matchesClient(notification),
103
+ );
46
104
  }
47
105
 
48
106
  /** Whether a TUI client is actively polling for notifications.
49
- * Returns true only if the TUI has drained within the last 3 seconds.
50
- * This prevents stale-connected state after TUI closes or disconnects. */
51
- export function isTuiConnected(): boolean {
52
- return lastDrainAt > 0 && Date.now() - lastDrainAt < TUI_CONNECTED_WINDOW_MS;
107
+ * Returns true only if a TUI has drained within the last 3 seconds.
108
+ *
109
+ * Pass `sessionId` (preferred) to ask whether a TUI is polling FOR THAT
110
+ * SESSION this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
111
+ * reminder) must use to decide dialog-vs-message, so a TUI on a different
112
+ * session in the same process does not misroute their delivery. Omit it only
113
+ * for legacy/global callers that genuinely have no session context; they fall
114
+ * back to "any session recently drained" (the pre-per-session behavior). */
115
+ export function isTuiConnected(sessionId?: string): boolean {
116
+ const now = Date.now();
117
+ if (sessionId !== undefined) {
118
+ const at = lastDrainAtBySession.get(sessionId) ?? 0;
119
+ return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
120
+ }
121
+ return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS;
53
122
  }