@wolfx/opencode-magic-context 0.26.0 → 0.27.2-patch.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 (287) hide show
  1. package/dist/agents/dreamer.d.ts +19 -0
  2. package/dist/agents/dreamer.d.ts.map +1 -1
  3. package/dist/agents/hidden-agent-registrations.d.ts +67 -0
  4. package/dist/agents/hidden-agent-registrations.d.ts.map +1 -0
  5. package/dist/agents/historian.d.ts +1 -0
  6. package/dist/agents/historian.d.ts.map +1 -1
  7. package/dist/agents/permissions.d.ts +15 -44
  8. package/dist/agents/permissions.d.ts.map +1 -1
  9. package/dist/agents/smart-note-compiler.d.ts +2 -0
  10. package/dist/agents/smart-note-compiler.d.ts.map +1 -0
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.d.ts.map +1 -1
  13. package/dist/config/migrate-config-location.d.ts +97 -0
  14. package/dist/config/migrate-config-location.d.ts.map +1 -0
  15. package/dist/config/migrate-dreamer-v2.d.ts +37 -0
  16. package/dist/config/migrate-dreamer-v2.d.ts.map +1 -0
  17. package/dist/config/migrate-experimental.d.ts.map +1 -1
  18. package/dist/config/project-security.d.ts +3 -0
  19. package/dist/config/project-security.d.ts.map +1 -1
  20. package/dist/config/prune-config-leaf.d.ts.map +1 -1
  21. package/dist/config/schema/magic-context.d.ts +586 -77
  22. package/dist/config/schema/magic-context.d.ts.map +1 -1
  23. package/dist/features/magic-context/compaction-marker.d.ts +9 -3
  24. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  25. package/dist/features/magic-context/compartment-chunk-embedding.d.ts +1 -1
  26. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/classify-prompt.d.ts +50 -0
  28. package/dist/features/magic-context/dreamer/classify-prompt.d.ts.map +1 -0
  29. package/dist/features/magic-context/dreamer/classify.d.ts +22 -0
  30. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -0
  31. package/dist/features/magic-context/dreamer/cron.d.ts +72 -0
  32. package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -0
  33. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +30 -0
  34. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -0
  35. package/dist/features/magic-context/dreamer/index.d.ts +1 -3
  36. package/dist/features/magic-context/dreamer/index.d.ts.map +1 -1
  37. package/dist/features/magic-context/dreamer/lease.d.ts +44 -6
  38. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  39. package/dist/features/magic-context/dreamer/maintain-docs-protected-enforcement.d.ts +13 -0
  40. package/dist/features/magic-context/dreamer/maintain-docs-protected-enforcement.d.ts.map +1 -0
  41. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +36 -0
  42. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -0
  43. package/dist/features/magic-context/dreamer/map-memories.d.ts +22 -0
  44. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -0
  45. package/dist/features/magic-context/dreamer/open-opencode-db.d.ts +7 -0
  46. package/dist/features/magic-context/dreamer/open-opencode-db.d.ts.map +1 -0
  47. package/dist/features/magic-context/dreamer/primer-seed.d.ts +25 -0
  48. package/dist/features/magic-context/dreamer/primer-seed.d.ts.map +1 -0
  49. package/dist/features/magic-context/dreamer/promote-primers.d.ts +21 -0
  50. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -0
  51. package/dist/features/magic-context/dreamer/protected-regions.d.ts +19 -0
  52. package/dist/features/magic-context/dreamer/protected-regions.d.ts.map +1 -0
  53. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +30 -0
  54. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -0
  55. package/dist/features/magic-context/dreamer/retrospective-learnings.d.ts +47 -0
  56. package/dist/features/magic-context/dreamer/retrospective-learnings.d.ts.map +1 -0
  57. package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts +48 -0
  58. package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts.map +1 -0
  59. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +81 -0
  60. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -0
  61. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +8 -0
  62. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  63. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +82 -0
  64. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -0
  65. package/dist/features/magic-context/dreamer/task-config.d.ts +28 -0
  66. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -0
  67. package/dist/features/magic-context/dreamer/task-executor.d.ts +49 -0
  68. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -0
  69. package/dist/features/magic-context/dreamer/task-gates.d.ts +29 -0
  70. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -0
  71. package/dist/features/magic-context/dreamer/task-prompts.d.ts +37 -6
  72. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  73. package/dist/features/magic-context/dreamer/task-registry.d.ts +48 -0
  74. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -0
  75. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +88 -0
  76. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -0
  77. package/dist/features/magic-context/dreamer/verify-gate.d.ts +43 -0
  78. package/dist/features/magic-context/dreamer/verify-gate.d.ts.map +1 -0
  79. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +41 -0
  80. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -0
  81. package/dist/features/magic-context/dreamer/verify.d.ts +43 -0
  82. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -0
  83. package/dist/features/magic-context/git-commits/search-git-commits.d.ts +2 -0
  84. package/dist/features/magic-context/git-commits/search-git-commits.d.ts.map +1 -1
  85. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts +4 -4
  86. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts.map +1 -1
  87. package/dist/features/magic-context/index.d.ts +1 -0
  88. package/dist/features/magic-context/index.d.ts.map +1 -1
  89. package/dist/features/magic-context/memory/embedding-cache.d.ts +2 -2
  90. package/dist/features/magic-context/memory/embedding-cache.d.ts.map +1 -1
  91. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  92. package/dist/features/magic-context/memory/embedding-openai.d.ts +12 -5
  93. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  94. package/dist/features/magic-context/memory/embedding.d.ts +2 -2
  95. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  96. package/dist/features/magic-context/memory/index.d.ts +4 -1
  97. package/dist/features/magic-context/memory/index.d.ts.map +1 -1
  98. package/dist/features/magic-context/memory/memory-migration.d.ts +1 -0
  99. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
  100. package/dist/features/magic-context/memory/promotion.d.ts +16 -4
  101. package/dist/features/magic-context/memory/promotion.d.ts.map +1 -1
  102. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +2 -2
  103. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  104. package/dist/features/magic-context/memory/storage-memory-verifications.d.ts +31 -0
  105. package/dist/features/magic-context/memory/storage-memory-verifications.d.ts.map +1 -0
  106. package/dist/features/magic-context/memory/storage-memory.d.ts +12 -1
  107. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  108. package/dist/features/magic-context/memory/types.d.ts +4 -0
  109. package/dist/features/magic-context/memory/types.d.ts.map +1 -1
  110. package/dist/features/magic-context/memory/verification-paths.d.ts +32 -0
  111. package/dist/features/magic-context/memory/verification-paths.d.ts.map +1 -0
  112. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  113. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  114. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  115. package/dist/features/magic-context/primer-clustering.d.ts +29 -0
  116. package/dist/features/magic-context/primer-clustering.d.ts.map +1 -0
  117. package/dist/features/magic-context/project-embedding-registry.d.ts +25 -1
  118. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  119. package/dist/features/magic-context/search.d.ts +12 -2
  120. package/dist/features/magic-context/search.d.ts.map +1 -1
  121. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  122. package/dist/features/magic-context/smart-notes/capabilities.d.ts +31 -0
  123. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -0
  124. package/dist/features/magic-context/smart-notes/compiler-prompt.d.ts +2 -0
  125. package/dist/features/magic-context/smart-notes/compiler-prompt.d.ts.map +1 -0
  126. package/dist/features/magic-context/smart-notes/compiler.d.ts +52 -0
  127. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -0
  128. package/dist/features/magic-context/smart-notes/index.d.ts +10 -0
  129. package/dist/features/magic-context/smart-notes/index.d.ts.map +1 -0
  130. package/dist/features/magic-context/smart-notes/runner.d.ts +18 -0
  131. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -0
  132. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +22 -0
  133. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -0
  134. package/dist/features/magic-context/smart-notes/schedule.d.ts +9 -0
  135. package/dist/features/magic-context/smart-notes/schedule.d.ts.map +1 -0
  136. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +49 -0
  137. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -0
  138. package/dist/features/magic-context/smart-notes/storage.d.ts +27 -0
  139. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -0
  140. package/dist/features/magic-context/smart-notes/types.d.ts +63 -0
  141. package/dist/features/magic-context/smart-notes/types.d.ts.map +1 -0
  142. package/dist/features/magic-context/storage-db.d.ts +5 -1
  143. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  144. package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -4
  145. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  146. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  147. package/dist/features/magic-context/storage-meta-shared.d.ts +3 -1
  148. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  149. package/dist/features/magic-context/storage-notes.d.ts +15 -0
  150. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  151. package/dist/features/magic-context/storage-primers.d.ts +85 -0
  152. package/dist/features/magic-context/storage-primers.d.ts.map +1 -0
  153. package/dist/features/magic-context/storage-tags.d.ts +20 -0
  154. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  155. package/dist/features/magic-context/storage.d.ts +2 -1
  156. package/dist/features/magic-context/storage.d.ts.map +1 -1
  157. package/dist/features/magic-context/tagger.d.ts +6 -0
  158. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  159. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  160. package/dist/features/magic-context/transform-decision-log.d.ts +10 -0
  161. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  162. package/dist/features/magic-context/types.d.ts +2 -0
  163. package/dist/features/magic-context/types.d.ts.map +1 -1
  164. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +5 -0
  165. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  166. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +18 -0
  167. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  168. package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -1
  169. package/dist/hooks/magic-context/auto-search-hint.d.ts.map +1 -1
  170. package/dist/hooks/magic-context/command-handler.d.ts +8 -15
  171. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  172. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  173. package/dist/hooks/magic-context/compartment-parser.d.ts +9 -0
  174. package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
  175. package/dist/hooks/magic-context/compartment-prompt.d.ts +4 -1
  176. package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
  177. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +1 -0
  178. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  179. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  180. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  181. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  182. package/dist/hooks/magic-context/compartment-runner-types.d.ts +8 -0
  183. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  184. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  185. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  186. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  187. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  188. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  189. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
  190. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
  191. package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
  192. package/dist/hooks/magic-context/hook-handlers.d.ts +2 -1
  193. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  194. package/dist/hooks/magic-context/hook.d.ts +1 -0
  195. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  196. package/dist/hooks/magic-context/inject-compartments.d.ts +0 -3
  197. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  198. package/dist/hooks/magic-context/send-session-notification.d.ts +2 -0
  199. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  200. package/dist/hooks/magic-context/system-prompt-hash.d.ts +17 -0
  201. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  202. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -5
  203. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  204. package/dist/hooks/magic-context/transform.d.ts +0 -2
  205. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  206. package/dist/index.d.ts +2 -2
  207. package/dist/index.d.ts.map +1 -1
  208. package/dist/index.js +18059 -13232
  209. package/dist/plugin/dream-timer.d.ts +17 -9
  210. package/dist/plugin/dream-timer.d.ts.map +1 -1
  211. package/dist/plugin/embedding-bootstrap-helpers.d.ts +1 -1
  212. package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
  213. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  214. package/dist/plugin/hooks/create-session-hooks.d.ts +211 -0
  215. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  216. package/dist/plugin/instance-disposal.d.ts +2 -0
  217. package/dist/plugin/instance-disposal.d.ts.map +1 -0
  218. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  219. package/dist/shared/announcement.d.ts +1 -1
  220. package/dist/shared/announcement.d.ts.map +1 -1
  221. package/dist/shared/data-path.d.ts +30 -9
  222. package/dist/shared/data-path.d.ts.map +1 -1
  223. package/dist/shared/model-suggestion-retry.d.ts +48 -2
  224. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  225. package/dist/shared/models-dev-cache.d.ts +23 -0
  226. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  227. package/dist/shared/redaction.d.ts +7 -0
  228. package/dist/shared/redaction.d.ts.map +1 -0
  229. package/dist/shared/resolve-fallbacks.d.ts +12 -0
  230. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  231. package/dist/shared/rpc-server.d.ts.map +1 -1
  232. package/dist/shared/rpc-types.d.ts +2 -0
  233. package/dist/shared/rpc-types.d.ts.map +1 -1
  234. package/dist/shared/subagent-runner.d.ts +12 -3
  235. package/dist/shared/subagent-runner.d.ts.map +1 -1
  236. package/dist/shared/tui-config.d.ts.map +1 -1
  237. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  238. package/dist/tools/ctx-memory/types.d.ts.map +1 -1
  239. package/dist/tools/ctx-memory/verification-recording.d.ts +8 -0
  240. package/dist/tools/ctx-memory/verification-recording.d.ts.map +1 -0
  241. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  242. package/dist/tools/ctx-search/types.d.ts +1 -1
  243. package/dist/tools/ctx-search/types.d.ts.map +1 -1
  244. package/dist/tui/data/context-db.d.ts +2 -0
  245. package/dist/tui/data/context-db.d.ts.map +1 -1
  246. package/package.json +7 -3
  247. package/src/shared/announcement.test.ts +20 -0
  248. package/src/shared/announcement.ts +32 -7
  249. package/src/shared/data-path.test.ts +74 -9
  250. package/src/shared/data-path.ts +54 -10
  251. package/src/shared/model-suggestion-retry.test.ts +79 -2
  252. package/src/shared/model-suggestion-retry.ts +181 -3
  253. package/src/shared/models-dev-cache.test.ts +82 -0
  254. package/src/shared/models-dev-cache.ts +35 -0
  255. package/src/shared/redaction.test.ts +84 -0
  256. package/src/shared/redaction.ts +264 -0
  257. package/src/shared/resolve-fallbacks.ts +14 -0
  258. package/src/shared/rpc-server.ts +24 -0
  259. package/src/shared/rpc-types.ts +2 -0
  260. package/src/shared/subagent-runner.ts +12 -3
  261. package/src/shared/tui-config.test.ts +106 -0
  262. package/src/shared/tui-config.ts +75 -40
  263. package/src/tui/data/context-db.ts +12 -0
  264. package/src/tui/index.tsx +87 -17
  265. package/src/tui/slots/sidebar-content.tsx +4 -0
  266. package/dist/features/magic-context/dreamer/queue.d.ts +0 -55
  267. package/dist/features/magic-context/dreamer/queue.d.ts.map +0 -1
  268. package/dist/features/magic-context/dreamer/runner.d.ts +0 -92
  269. package/dist/features/magic-context/dreamer/runner.d.ts.map +0 -1
  270. package/dist/features/magic-context/dreamer/scheduler.d.ts +0 -29
  271. package/dist/features/magic-context/dreamer/scheduler.d.ts.map +0 -1
  272. package/dist/features/magic-context/key-files/aft-availability.d.ts +0 -11
  273. package/dist/features/magic-context/key-files/aft-availability.d.ts.map +0 -1
  274. package/dist/features/magic-context/key-files/identify-key-files.d.ts +0 -84
  275. package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +0 -1
  276. package/dist/features/magic-context/key-files/project-key-files.d.ts +0 -42
  277. package/dist/features/magic-context/key-files/project-key-files.d.ts.map +0 -1
  278. package/dist/features/magic-context/key-files/read-history.d.ts +0 -26
  279. package/dist/features/magic-context/key-files/read-history.d.ts.map +0 -1
  280. package/dist/features/magic-context/key-files/read-stats.d.ts +0 -18
  281. package/dist/features/magic-context/key-files/read-stats.d.ts.map +0 -1
  282. package/dist/features/magic-context/key-files/storage-key-files.d.ts +0 -20
  283. package/dist/features/magic-context/key-files/storage-key-files.d.ts.map +0 -1
  284. package/dist/features/magic-context/memory/embedding-local.d.ts +0 -25
  285. package/dist/features/magic-context/memory/embedding-local.d.ts.map +0 -1
  286. package/dist/hooks/magic-context/key-files-block.d.ts +0 -27
  287. package/dist/hooks/magic-context/key-files-block.d.ts.map +0 -1
@@ -0,0 +1,264 @@
1
+ import { homedir, userInfo } from "node:os";
2
+
3
+ function escapeRegex(value: string): string {
4
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+
7
+ // Whole-segment match: the key (or its components when split on common
8
+ // separators) must BE one of these words, not merely contain them as a
9
+ // substring. Bare substring matching wrongly redacts benign fields like
10
+ // `pin_key_files`, `token_budget`, and `injection_budget_tokens`.
11
+ const SECRET_WORDS = [
12
+ "key",
13
+ "token",
14
+ "secret",
15
+ "password",
16
+ "auth",
17
+ "authorization",
18
+ "bearer",
19
+ "credential",
20
+ ];
21
+ const SECRET_SEGMENT_PATTERN = new RegExp(
22
+ `^(?:${SECRET_WORDS.map((w) => `${w}s?`).join("|")})$`,
23
+ "i",
24
+ );
25
+ const TRAILING_DESCRIPTORS = new Set(["id", "ids", "value", "values", "header", "headers"]);
26
+
27
+ function redactionTypeForKey(key: string): string {
28
+ const normalized = key
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9_.-]+/g, "_");
32
+ const suffix = normalized.split(".").filter(Boolean).at(-1) ?? normalized;
33
+ return suffix || "secret";
34
+ }
35
+
36
+ // A bare number / boolean / null is never a secret — an API key, bearer token,
37
+ // password, or credential is always a high-entropy string. So when a key-based
38
+ // pattern (the `name=value` / `"name":"value"` forms below) matches purely on
39
+ // the KEY containing a word like "token", but the VALUE is numeric/boolean, it's
40
+ // a count or flag, not a secret. These must stay readable in logs:
41
+ // `tokens.input=45000`, `hasUsageTokens=true`, `max_tokens=4096` are diagnostics,
42
+ // not credentials. (High-entropy secret VALUES are still caught by the
43
+ // value-shaped patterns above — bearer, JWT, AKIA, gh*_, etc. — independent of
44
+ // the key name, so relaxing the key-based match for scalars loses no coverage.)
45
+ function isNonSecretScalarValue(value: string): boolean {
46
+ const v = value.trim();
47
+ if (v === "true" || v === "false" || v === "null" || v === "undefined") return true;
48
+ // Integer or decimal, optional sign/exponent — token counts, ports, sizes.
49
+ return /^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(v);
50
+ }
51
+
52
+ const SECRET_QUALIFIERS = new Set([
53
+ "api",
54
+ "access",
55
+ "private",
56
+ "client",
57
+ "auth",
58
+ "authorization",
59
+ "secret",
60
+ "bearer",
61
+ "session",
62
+ "refresh",
63
+ "service",
64
+ "x",
65
+ "openai",
66
+ "anthropic",
67
+ "google",
68
+ "github",
69
+ "huggingface",
70
+ "aws",
71
+ "azure",
72
+ ]);
73
+
74
+ export function isSecretKey(key: string): boolean {
75
+ const segments = key
76
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
77
+ .toLowerCase()
78
+ .split(/[._-]+/)
79
+ .filter(Boolean);
80
+ if (segments.length === 0) return false;
81
+
82
+ if (segments.length === 1) {
83
+ const first = segments[0];
84
+ return Boolean(first && SECRET_SEGMENT_PATTERN.test(first));
85
+ }
86
+
87
+ for (let i = 0; i < segments.length; i++) {
88
+ const seg = segments[i];
89
+ if (!seg || !SECRET_SEGMENT_PATTERN.test(seg)) continue;
90
+
91
+ let trailingOk = true;
92
+ for (let j = i + 1; j < segments.length; j++) {
93
+ const tail = segments[j];
94
+ if (!tail) continue;
95
+ if (TRAILING_DESCRIPTORS.has(tail)) continue;
96
+ if (SECRET_SEGMENT_PATTERN.test(tail)) continue;
97
+ trailingOk = false;
98
+ break;
99
+ }
100
+ if (!trailingOk) continue;
101
+
102
+ for (let k = i - 1; k >= 0; k--) {
103
+ const lead = segments[k];
104
+ if (lead && SECRET_QUALIFIERS.has(lead)) return true;
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+
110
+ export function sanitizePathString(value: string): string {
111
+ const home = homedir();
112
+ const username = userInfo().username;
113
+ let sanitized = value;
114
+ if (home) {
115
+ sanitized = sanitized.replace(new RegExp(escapeRegex(home), "g"), "~");
116
+ }
117
+ sanitized = sanitized.replace(/\/Users\/[^/]+\//g, "/Users/<USER>/");
118
+ sanitized = sanitized.replace(/\/home\/[^/]+\//g, "/home/<USER>/");
119
+ sanitized = sanitized.replace(/C:\\Users\\[^\\]+\\/g, "C:\\Users\\<USER>\\");
120
+ if (username) {
121
+ sanitized = sanitized.replace(new RegExp(escapeRegex(username), "g"), "<USER>");
122
+ }
123
+ return sanitized;
124
+ }
125
+
126
+ const SECRET_TEXT_PATTERNS: Array<{
127
+ pattern: RegExp;
128
+ replacement: string | ((match: string, ...groups: string[]) => string);
129
+ }> = [
130
+ {
131
+ pattern: /\bsk-ant-(?:api03-)?[A-Za-z0-9_-]{32,}/g,
132
+ replacement: "<ANTHROPIC_API_KEY_REDACTED>",
133
+ },
134
+ {
135
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g,
136
+ replacement: "<OPENAI_API_KEY_REDACTED>",
137
+ },
138
+ {
139
+ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
140
+ replacement: "<GITHUB_PAT_REDACTED>",
141
+ },
142
+ {
143
+ pattern: /\b(?:gh[opsu]|ghr)_[A-Za-z0-9]{30,}/g,
144
+ replacement: "<GITHUB_TOKEN_REDACTED>",
145
+ },
146
+ {
147
+ pattern: /\bhf_[A-Za-z0-9]{30,}/g,
148
+ replacement: "<HUGGINGFACE_TOKEN_REDACTED>",
149
+ },
150
+ {
151
+ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
152
+ replacement: "<AWS_ACCESS_KEY_ID_REDACTED>",
153
+ },
154
+ {
155
+ pattern: /\bxox[abprsuvc]-[A-Za-z0-9-]{10,}/g,
156
+ replacement: "<SLACK_TOKEN_REDACTED>",
157
+ },
158
+ {
159
+ pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g,
160
+ replacement: "<GOOGLE_API_KEY_REDACTED>",
161
+ },
162
+ {
163
+ pattern: /\b(Authorization\s*:\s*Bearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi,
164
+ replacement: (_full: string, prefix: string) => `${prefix}<REDACTED:bearer>`,
165
+ },
166
+ {
167
+ pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
168
+ replacement: "<JWT_REDACTED>",
169
+ },
170
+ {
171
+ pattern:
172
+ /(["'])([^"']*(?:key|token|secret|password|auth|bearer|credential)[^"']*)\1(\s*:\s*)(["'])([^"']*)\4/gi,
173
+ replacement: (
174
+ full: string,
175
+ quote: string,
176
+ key: string,
177
+ separator: string,
178
+ valueQuote: string,
179
+ value: string,
180
+ ) =>
181
+ // A numeric/boolean value matched only because the KEY contains a
182
+ // secret word (e.g. "max_tokens": "4096") is a count, not a secret.
183
+ isNonSecretScalarValue(value)
184
+ ? full
185
+ : `${quote}${key}${quote}${separator}${valueQuote}<REDACTED:${redactionTypeForKey(key)}>${valueQuote}`,
186
+ },
187
+ {
188
+ pattern:
189
+ /\b([A-Za-z0-9_.-]*(?:key|token|secret|password|auth|bearer|credential)[A-Za-z0-9_.-]*)\s*=\s*([^\s'"`]+)/gi,
190
+ replacement: (full: string, key: string, value: string) =>
191
+ // tokens.input=45000 / hasUsageTokens=true are diagnostics, not
192
+ // secrets — keep them readable. Real secret values are still caught
193
+ // by the value-shaped patterns above.
194
+ isNonSecretScalarValue(value) ? full : `${key}=<REDACTED:${redactionTypeForKey(key)}>`,
195
+ },
196
+ ];
197
+
198
+ export function redactSecretText(value: string): string {
199
+ let redacted = value;
200
+ for (const { pattern, replacement } of SECRET_TEXT_PATTERNS) {
201
+ if (typeof replacement === "string") {
202
+ redacted = redacted.replace(pattern, replacement);
203
+ } else {
204
+ redacted = redacted.replace(
205
+ pattern,
206
+ replacement as (match: string, ...groups: string[]) => string,
207
+ );
208
+ }
209
+ }
210
+ return redacted;
211
+ }
212
+
213
+ export function sanitizeDiagnosticText(value: string): string {
214
+ return redactSecretText(sanitizePathString(value));
215
+ }
216
+
217
+ // Extra shareability-only signals — patterns that mark text as unsafe to share
218
+ // with teammates but that the diagnostic sanitizer (tuned for secret/path
219
+ // REDACTION, not share-gating) does not rewrite. Kept here, NOT in
220
+ // sanitizeDiagnosticText, so diagnostic redaction output is unchanged.
221
+ const SHAREABILITY_SENSITIVE_PATTERNS: RegExp[] = [
222
+ // Windows user home, forward- OR back-slash (sanitizePathString only rewrites
223
+ // the backslash form).
224
+ /\bC:\/Users\/[^/\s]+/i,
225
+ // A `~`-rooted home path (personal/local).
226
+ /(?:^|\s)~\/[^\s]+/,
227
+ // Inline `key: value` / `key=value` secrets the keyed redactor misses in free
228
+ // text (it keys on config OBJECT keys, not prose).
229
+ /\b(?:api[_-]?key|secret|token|password|passwd|pwd|client[_-]?secret|access[_-]?key)\b\s*[:=]\s*\S+/i,
230
+ // Local / private endpoints — environment-specific, not a shared truth.
231
+ /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\b/i,
232
+ /\b(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
233
+ /\b192\.168\.\d{1,3}\.\d{1,3}\b/,
234
+ /\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/,
235
+ ];
236
+
237
+ export function hasShareabilitySensitiveText(text: string): boolean {
238
+ try {
239
+ if (sanitizeDiagnosticText(text) !== text) return true;
240
+ return SHAREABILITY_SENSITIVE_PATTERNS.some((pattern) => pattern.test(text));
241
+ } catch {
242
+ return true;
243
+ }
244
+ }
245
+
246
+ export function sanitizeConfigValue(value: unknown, keyPath: string[] = []): unknown {
247
+ const key = keyPath.at(-1) ?? "";
248
+ if (key && isSecretKey(key)) {
249
+ return `<REDACTED:${redactionTypeForKey(key)}>`;
250
+ }
251
+ if (typeof value === "string") return sanitizeDiagnosticText(value);
252
+ if (Array.isArray(value)) {
253
+ return value.map((entry, index) => sanitizeConfigValue(entry, [...keyPath, String(index)]));
254
+ }
255
+ if (value && typeof value === "object") {
256
+ return Object.fromEntries(
257
+ Object.entries(value).map(([entryKey, entry]) => [
258
+ entryKey,
259
+ sanitizeConfigValue(entry, [...keyPath, entryKey]),
260
+ ]),
261
+ );
262
+ }
263
+ return value;
264
+ }
@@ -64,3 +64,17 @@ export function parseProviderModel(spec: string): { providerID: string; modelID:
64
64
  modelID: spec.slice(slash + 1).trim(),
65
65
  };
66
66
  }
67
+
68
+ /**
69
+ * Build the `{ model: { providerID, modelID } }` fragment for an OpenCode prompt
70
+ * body from a `provider/model` spec string, or `{}` when the spec is absent or
71
+ * unparseable (the session falls back to its default model). Spread into a
72
+ * `client.session.prompt` body.
73
+ */
74
+ export function modelBodyField(spec: string | undefined): {
75
+ model?: { providerID: string; modelID: string };
76
+ } {
77
+ if (!spec) return {};
78
+ const parsed = parseProviderModel(spec);
79
+ return parsed ? { model: parsed } : {};
80
+ }
@@ -1,9 +1,11 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import {
3
+ chmodSync,
3
4
  mkdirSync,
4
5
  readdirSync,
5
6
  readFileSync,
6
7
  renameSync,
8
+ rmSync,
7
9
  unlinkSync,
8
10
  writeFileSync,
9
11
  } from "node:fs";
@@ -85,7 +87,22 @@ export class MagicContextRpcServer {
85
87
  // file 0o600. renameSync preserves the tmp file's mode, so
86
88
  // the 0o600 on the write covers the final file.
87
89
  mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ // mkdirSync's mode only applies on CREATION — a dir left by an
91
+ // older build (or default 0o755 umask) keeps its loose perms, so
92
+ // chmod it defensively so the bearer token isn't world-readable.
93
+ try {
94
+ chmodSync(dir, 0o700);
95
+ } catch {
96
+ // best-effort
97
+ }
88
98
  const tmpPath = `${this.portFilePath}.tmp`;
99
+ // A stale tmp from a crashed write could exist with loose perms;
100
+ // writeFileSync's mode only applies on create, so remove it first.
101
+ try {
102
+ rmSync(tmpPath, { force: true });
103
+ } catch {
104
+ // best-effort
105
+ }
89
106
  writeFileSync(
90
107
  tmpPath,
91
108
  JSON.stringify({
@@ -97,6 +114,13 @@ export class MagicContextRpcServer {
97
114
  { encoding: "utf-8", mode: 0o600 },
98
115
  );
99
116
  renameSync(tmpPath, this.portFilePath);
117
+ // renameSync preserves the tmp's mode, but chmod the final path
118
+ // defensively in case the token file pre-existed with loose perms.
119
+ try {
120
+ chmodSync(this.portFilePath, 0o600);
121
+ } catch {
122
+ // best-effort
123
+ }
100
124
  log(`[rpc] server listening on 127.0.0.1:${this.port}`);
101
125
  } catch (err) {
102
126
  log(`[rpc] failed to write port file: ${err}`);
@@ -121,6 +121,8 @@ export interface StatusDetail extends SidebarSnapshot {
121
121
  historyBlockTokens: number;
122
122
  compressionBudget: number | null;
123
123
  compressionUsage: string | null;
124
+ /** Effective configured toast duration in ms after config resolution. */
125
+ toastDurationMs: number;
124
126
  }
125
127
 
126
128
  /** Embedding coverage for `/ctx-embed` status (mirrors getEmbeddingCoverageStatus). */
@@ -155,9 +155,9 @@ export type SubagentProgressEvent =
155
155
  * Fields:
156
156
  * - `ok`: true iff the child produced a final assistant message.
157
157
  * - `assistantText`: concatenated text content from the final assistant
158
- * message, with leading/trailing whitespace trimmed. Empty string if the
159
- * child finished but produced no text (rare usually means the model
160
- * only emitted tool calls and we didn't follow up).
158
+ * message, with leading/trailing whitespace trimmed. Empty assistant text is
159
+ * reported as `ok: false, reason: "no_assistant"` so callers can try fallback
160
+ * models instead of accepting an unusable success.
161
161
  * - `reason`: failure category, one of:
162
162
  * - `"timeout"`: hit `timeoutMs` before the child finished
163
163
  * - `"abort"`: caller's `signal` was triggered
@@ -180,6 +180,15 @@ export type SubagentRunResult =
180
180
  ok: true;
181
181
  assistantText: string;
182
182
  durationMs: number;
183
+ /**
184
+ * Number of tool invocations the agent made during the run. Pi reports
185
+ * this so callers that gate on "did the agent actually investigate vs
186
+ * just paraphrase" (refresh-primers' grounding gate) work on Pi, whose
187
+ * facade otherwise surfaces only the final assistant text. OpenCode
188
+ * leaves it undefined — its callers read tool-call parts straight off
189
+ * the real session messages.
190
+ */
191
+ toolCallCount?: number;
183
192
  meta?: Record<string, unknown>;
184
193
  }
185
194
  | {
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const roots: string[] = [];
7
+ const prevConfigDir = process.env.OPENCODE_CONFIG_DIR;
8
+
9
+ afterEach(() => {
10
+ if (prevConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR;
11
+ else process.env.OPENCODE_CONFIG_DIR = prevConfigDir;
12
+ for (const root of roots.splice(0)) {
13
+ rmSync(root, { recursive: true, force: true });
14
+ }
15
+ });
16
+
17
+ describe("ensureTuiPluginEntry", () => {
18
+ it("preserves tuple dev-path plugin entry and does not add @latest", async () => {
19
+ const root = mkdtempSync(join(tmpdir(), "mc-tui-"));
20
+ roots.push(root);
21
+ process.env.OPENCODE_CONFIG_DIR = root;
22
+ const devPath = "/Work/magic-context/packages/plugin";
23
+ const tuiPath = join(root, "tui.json");
24
+ writeFileSync(
25
+ tuiPath,
26
+ `${JSON.stringify({ plugin: [[devPath, { sidebar: true }], "other-plugin"] }, null, 2)}\n`,
27
+ );
28
+
29
+ const { ensureTuiPluginEntry } = await import("./tui-config");
30
+ const changed = ensureTuiPluginEntry();
31
+ expect(changed).toBe(false);
32
+ const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
33
+ expect(parsed.plugin).toHaveLength(2);
34
+ expect(Array.isArray(parsed.plugin[0])).toBe(true);
35
+ expect((parsed.plugin[0] as unknown[])[0]).toBe(devPath);
36
+ expect(parsed.plugin[1]).toBe("other-plugin");
37
+ expect(existsSync(`${tuiPath}.tmp`)).toBe(false);
38
+ });
39
+
40
+ it("upgrades bare npm name to @latest while preserving tuple options", async () => {
41
+ const root = mkdtempSync(join(tmpdir(), "mc-tui-npm-"));
42
+ roots.push(root);
43
+ process.env.OPENCODE_CONFIG_DIR = root;
44
+ const tuiPath = join(root, "tui.json");
45
+ writeFileSync(
46
+ tuiPath,
47
+ `${JSON.stringify(
48
+ {
49
+ plugin: [["@wolfx/opencode-magic-context", { enabled: true }]],
50
+ },
51
+ null,
52
+ 2,
53
+ )}\n`,
54
+ );
55
+
56
+ const { ensureTuiPluginEntry } = await import("./tui-config");
57
+ expect(ensureTuiPluginEntry()).toBe(true);
58
+ const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
59
+ const entry = parsed.plugin[0] as unknown[];
60
+ expect(entry[0]).toBe("@wolfx/opencode-magic-context@latest");
61
+ expect(entry[1]).toEqual({ enabled: true });
62
+ });
63
+
64
+ it("creates tui.jsonc (not tui.json) on a fresh install", async () => {
65
+ const root = mkdtempSync(join(tmpdir(), "mc-tui-fresh-"));
66
+ roots.push(root);
67
+ process.env.OPENCODE_CONFIG_DIR = root;
68
+
69
+ const { ensureTuiPluginEntry } = await import("./tui-config");
70
+ expect(ensureTuiPluginEntry()).toBe(true);
71
+
72
+ // The new file must be tui.jsonc so a tui.json stub never ends up
73
+ // sitting next to a tui.jsonc the user writes later (#176).
74
+ expect(existsSync(join(root, "tui.jsonc"))).toBe(true);
75
+ expect(existsSync(join(root, "tui.json"))).toBe(false);
76
+ const parsed = JSON.parse(readFileSync(join(root, "tui.jsonc"), "utf-8")) as {
77
+ plugin: unknown[];
78
+ };
79
+ expect(parsed.plugin).toContain("@wolfx/opencode-magic-context@latest");
80
+ });
81
+
82
+ it("writes into the existing tui.jsonc when both files exist", async () => {
83
+ const root = mkdtempSync(join(tmpdir(), "mc-tui-both-"));
84
+ roots.push(root);
85
+ process.env.OPENCODE_CONFIG_DIR = root;
86
+ // A real user config in tui.jsonc plus a leftover empty tui.json.
87
+ writeFileSync(
88
+ join(root, "tui.jsonc"),
89
+ `${JSON.stringify({ keybinds: { x: "y" } }, null, 2)}\n`,
90
+ );
91
+ writeFileSync(join(root, "tui.json"), "{}\n");
92
+
93
+ const { ensureTuiPluginEntry } = await import("./tui-config");
94
+ expect(ensureTuiPluginEntry()).toBe(true);
95
+
96
+ // The plugin entry must land in tui.jsonc (higher precedence), and the
97
+ // user's keybinds must survive; tui.json must be left untouched.
98
+ const jsonc = JSON.parse(readFileSync(join(root, "tui.jsonc"), "utf-8")) as {
99
+ plugin: unknown[];
100
+ keybinds: Record<string, string>;
101
+ };
102
+ expect(jsonc.plugin).toContain("@wolfx/opencode-magic-context@latest");
103
+ expect(jsonc.keybinds).toEqual({ x: "y" });
104
+ expect(readFileSync(join(root, "tui.json"), "utf-8")).toBe("{}\n");
105
+ });
106
+ });
@@ -3,7 +3,15 @@
3
3
  * Called from the server plugin at startup so the TUI sidebar loads on next restart.
4
4
  */
5
5
 
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import {
7
+ chmodSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ renameSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from "node:fs";
7
15
  import { dirname, join } from "node:path";
8
16
  import { parse, stringify } from "comment-json";
9
17
  import { log } from "./logger";
@@ -12,27 +20,41 @@ import { getOpenCodeConfigPaths } from "./opencode-config-dir";
12
20
  const PLUGIN_NAME = "@wolfx/opencode-magic-context";
13
21
  const PLUGIN_ENTRY = `${PLUGIN_NAME}@latest`;
14
22
 
15
- /**
16
- * Detect whether a tui.json plugin entry already references magic-context, in
17
- * any form. Covers:
18
- * - Bare npm name: "@wolfx/opencode-magic-context"
19
- * - Versioned npm: "@wolfx/opencode-magic-context@latest" / "@0.15.7" / etc.
20
- * - Local dev directory path (absolute or relative): ".../magic-context"
21
- * or ".../magic-context/packages/plugin"
22
- * - file:// URLs pointing at the same paths
23
- * - Tarball paths ending in opencode-magic-context-*.tgz
24
- *
25
- * Without the path/URL detection, doctor/setup auto-injection adds the npm
26
- * @latest entry on top of an existing dev path, double-loading the plugin.
27
- */
28
- function isMagicContextEntry(entry: string): boolean {
29
- if (!entry) return false;
30
- if (entry === PLUGIN_NAME) return true;
31
- if (entry.startsWith(`${PLUGIN_NAME}@`)) return true;
32
- // Local directory paths: match anywhere in the string so the setup pattern
33
- // (dir-only, dir + /packages/plugin, file:// + either) all qualify.
34
- if (entry.includes("opencode-magic-context")) return true;
35
- return false;
23
+ function pluginEntryId(entry: unknown): string {
24
+ if (typeof entry === "string") return entry;
25
+ if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
26
+ return "";
27
+ }
28
+
29
+ function isLocalMagicContextDevEntry(entry: unknown): boolean {
30
+ const id = pluginEntryId(entry);
31
+ if (!id) return false;
32
+ if (id === PLUGIN_NAME || id.startsWith(`${PLUGIN_NAME}@`)) return false;
33
+ const isPath =
34
+ id.startsWith("file://") || id.startsWith("/") || id.startsWith("./") || id.includes("\\");
35
+ if (!isPath) return false;
36
+ return id.includes("opencode-magic-context") || id.includes("magic-context");
37
+ }
38
+
39
+ function isMagicContextPluginEntry(entry: unknown): boolean {
40
+ const id = pluginEntryId(entry);
41
+ if (!id) return false;
42
+ if (id === PLUGIN_NAME || id.startsWith(`${PLUGIN_NAME}@`)) return true;
43
+ return isLocalMagicContextDevEntry(entry);
44
+ }
45
+
46
+ function writeTuiConfigAtomic(configPath: string, config: Record<string, unknown>): void {
47
+ const body = `${stringify(config, null, 2)}\n`;
48
+ const tmpPath = `${configPath}.tmp`;
49
+ writeFileSync(tmpPath, body);
50
+ try {
51
+ if (statSync(configPath, { throwIfNoEntry: false })?.isFile()) {
52
+ chmodSync(tmpPath, statSync(configPath).mode & 0o777);
53
+ }
54
+ } catch {
55
+ /* new file */
56
+ }
57
+ renameSync(tmpPath, configPath);
36
58
  }
37
59
 
38
60
  function resolveTuiConfigPath(): string {
@@ -40,9 +62,16 @@ function resolveTuiConfigPath(): string {
40
62
  const jsoncPath = join(configDir, "tui.jsonc");
41
63
  const jsonPath = join(configDir, "tui.json");
42
64
 
65
+ // OpenCode loads BOTH tui.json and tui.jsonc and merges them (tui.json first,
66
+ // tui.jsonc second, so .jsonc wins overlapping keys; plugin origins are
67
+ // deduped). So an existing tui.jsonc is the higher-precedence, user-facing
68
+ // file — write into it when present. Otherwise update an existing tui.json.
69
+ // For a fresh install create tui.jsonc, not tui.json: it lets the user add
70
+ // comments later and avoids leaving a second, lower-precedence config file
71
+ // alongside a tui.jsonc they create afterward (#176).
43
72
  if (existsSync(jsoncPath)) return jsoncPath;
44
73
  if (existsSync(jsonPath)) return jsonPath;
45
- return jsonPath; // default: create tui.json
74
+ return jsoncPath;
46
75
  }
47
76
 
48
77
  /**
@@ -59,35 +88,41 @@ export function ensureTuiPluginEntry(): boolean {
59
88
  config = (parse(raw) as Record<string, unknown>) ?? {};
60
89
  }
61
90
 
62
- const plugins = Array.isArray(config.plugin)
63
- ? config.plugin.filter((p): p is string => typeof p === "string")
64
- : [];
91
+ const plugins: unknown[] = Array.isArray(config.plugin) ? [...config.plugin] : [];
65
92
 
66
- const existingIdx = plugins.findIndex(isMagicContextEntry);
93
+ const existingIdx = plugins.findIndex(isMagicContextPluginEntry);
67
94
  if (existingIdx >= 0) {
68
95
  const existing = plugins[existingIdx];
69
- if (existing === PLUGIN_ENTRY) {
70
- return false; // Already @latest
96
+ if (isLocalMagicContextDevEntry(existing)) {
97
+ return false;
98
+ }
99
+ const id = pluginEntryId(existing);
100
+ if (id === PLUGIN_ENTRY) {
101
+ return false;
71
102
  }
72
- // Only upgrade the bare versionless npm name to @latest.
73
- // Pinned versions (e.g. @0.8.10), local dev paths
74
- // (~/Work/OSS/magic-context/packages/plugin), and
75
- // file:// URLs are all left as-is — the user chose them
76
- // intentionally and overwriting their dev-loop entry would
77
- // either double-load the plugin (npm + dev) or replace
78
- // their working directory pointer.
79
- if (existing === PLUGIN_NAME) {
80
- plugins[existingIdx] = PLUGIN_ENTRY;
103
+ if (id === PLUGIN_NAME) {
104
+ if (Array.isArray(existing) && existing.length >= 1) {
105
+ const replacement = [...existing];
106
+ replacement[0] = PLUGIN_ENTRY;
107
+ plugins[existingIdx] = replacement;
108
+ } else {
109
+ plugins[existingIdx] = PLUGIN_ENTRY;
110
+ }
81
111
  } else {
82
112
  return false;
83
113
  }
84
114
  } else {
85
- plugins.push(PLUGIN_ENTRY);
115
+ const hasDev = plugins.some(isLocalMagicContextDevEntry);
116
+ if (!hasDev) {
117
+ plugins.push(PLUGIN_ENTRY);
118
+ } else {
119
+ return false;
120
+ }
86
121
  }
87
122
  config.plugin = plugins;
88
123
 
89
124
  mkdirSync(dirname(configPath), { recursive: true });
90
- writeFileSync(configPath, `${stringify(config, null, 2)}\n`);
125
+ writeTuiConfigAtomic(configPath, config);
91
126
  log(`[magic-context] updated TUI plugin entry in ${configPath}`);
92
127
  return true;
93
128
  } catch (error) {
@@ -205,6 +205,7 @@ export async function loadStatusDetail(
205
205
  historyBlockTokens: 0,
206
206
  compressionBudget: null,
207
207
  compressionUsage: null,
208
+ toastDurationMs: 5000,
208
209
  };
209
210
 
210
211
  if (!rpcClient) return emptyDetail;
@@ -299,6 +300,17 @@ export async function dismissUpgradeReminder(sessionId: string): Promise<boolean
299
300
  }
300
301
  }
301
302
 
303
+ /** Resolve global toast duration from server config via RPC. */
304
+ export async function loadToastDurationMs(): Promise<number> {
305
+ if (!rpcClient) return 5000;
306
+ try {
307
+ const result = await rpcClient.call<{ toastDurationMs?: number }>("toast-duration", {});
308
+ return typeof result.toastDurationMs === "number" ? result.toastDurationMs : 5000;
309
+ } catch {
310
+ return 5000;
311
+ }
312
+ }
313
+
302
314
  export interface TuiMessage {
303
315
  id: number;
304
316
  type: string;