@cortexkit/opencode-magic-context 0.26.0 → 0.27.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 (285) hide show
  1. package/README.md +14 -12
  2. package/dist/agents/dreamer.d.ts +19 -0
  3. package/dist/agents/dreamer.d.ts.map +1 -1
  4. package/dist/agents/hidden-agent-registrations.d.ts +67 -0
  5. package/dist/agents/hidden-agent-registrations.d.ts.map +1 -0
  6. package/dist/agents/historian.d.ts +1 -0
  7. package/dist/agents/historian.d.ts.map +1 -1
  8. package/dist/agents/permissions.d.ts +15 -44
  9. package/dist/agents/permissions.d.ts.map +1 -1
  10. package/dist/agents/smart-note-compiler.d.ts +2 -0
  11. package/dist/agents/smart-note-compiler.d.ts.map +1 -0
  12. package/dist/config/index.d.ts +1 -1
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/migrate-config-location.d.ts +89 -0
  15. package/dist/config/migrate-config-location.d.ts.map +1 -0
  16. package/dist/config/migrate-dreamer-v2.d.ts +37 -0
  17. package/dist/config/migrate-dreamer-v2.d.ts.map +1 -0
  18. package/dist/config/migrate-experimental.d.ts.map +1 -1
  19. package/dist/config/project-security.d.ts +3 -0
  20. package/dist/config/project-security.d.ts.map +1 -1
  21. package/dist/config/prune-config-leaf.d.ts.map +1 -1
  22. package/dist/config/schema/magic-context.d.ts +584 -60
  23. package/dist/config/schema/magic-context.d.ts.map +1 -1
  24. package/dist/features/magic-context/compaction-marker.d.ts +9 -3
  25. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  26. package/dist/features/magic-context/compartment-chunk-embedding.d.ts +1 -1
  27. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  28. package/dist/features/magic-context/dreamer/classify-prompt.d.ts +50 -0
  29. package/dist/features/magic-context/dreamer/classify-prompt.d.ts.map +1 -0
  30. package/dist/features/magic-context/dreamer/classify.d.ts +22 -0
  31. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -0
  32. package/dist/features/magic-context/dreamer/cron.d.ts +72 -0
  33. package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -0
  34. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +30 -0
  35. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -0
  36. package/dist/features/magic-context/dreamer/index.d.ts +1 -3
  37. package/dist/features/magic-context/dreamer/index.d.ts.map +1 -1
  38. package/dist/features/magic-context/dreamer/lease.d.ts +44 -6
  39. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  40. package/dist/features/magic-context/dreamer/maintain-docs-protected-enforcement.d.ts +13 -0
  41. package/dist/features/magic-context/dreamer/maintain-docs-protected-enforcement.d.ts.map +1 -0
  42. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +36 -0
  43. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -0
  44. package/dist/features/magic-context/dreamer/map-memories.d.ts +22 -0
  45. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -0
  46. package/dist/features/magic-context/dreamer/open-opencode-db.d.ts +7 -0
  47. package/dist/features/magic-context/dreamer/open-opencode-db.d.ts.map +1 -0
  48. package/dist/features/magic-context/dreamer/primer-seed.d.ts +25 -0
  49. package/dist/features/magic-context/dreamer/primer-seed.d.ts.map +1 -0
  50. package/dist/features/magic-context/dreamer/promote-primers.d.ts +21 -0
  51. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -0
  52. package/dist/features/magic-context/dreamer/protected-regions.d.ts +19 -0
  53. package/dist/features/magic-context/dreamer/protected-regions.d.ts.map +1 -0
  54. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +30 -0
  55. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -0
  56. package/dist/features/magic-context/dreamer/retrospective-learnings.d.ts +47 -0
  57. package/dist/features/magic-context/dreamer/retrospective-learnings.d.ts.map +1 -0
  58. package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts +48 -0
  59. package/dist/features/magic-context/dreamer/retrospective-orphan-sweep.d.ts.map +1 -0
  60. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +81 -0
  61. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -0
  62. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +8 -0
  63. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  64. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +82 -0
  65. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -0
  66. package/dist/features/magic-context/dreamer/task-config.d.ts +28 -0
  67. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -0
  68. package/dist/features/magic-context/dreamer/task-executor.d.ts +49 -0
  69. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -0
  70. package/dist/features/magic-context/dreamer/task-gates.d.ts +29 -0
  71. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -0
  72. package/dist/features/magic-context/dreamer/task-prompts.d.ts +37 -6
  73. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  74. package/dist/features/magic-context/dreamer/task-registry.d.ts +48 -0
  75. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -0
  76. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +88 -0
  77. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -0
  78. package/dist/features/magic-context/dreamer/verify-gate.d.ts +43 -0
  79. package/dist/features/magic-context/dreamer/verify-gate.d.ts.map +1 -0
  80. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +41 -0
  81. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -0
  82. package/dist/features/magic-context/dreamer/verify.d.ts +43 -0
  83. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -0
  84. package/dist/features/magic-context/git-commits/search-git-commits.d.ts +2 -0
  85. package/dist/features/magic-context/git-commits/search-git-commits.d.ts.map +1 -1
  86. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts +4 -4
  87. package/dist/features/magic-context/git-commits/storage-git-commit-embeddings.d.ts.map +1 -1
  88. package/dist/features/magic-context/index.d.ts +1 -0
  89. package/dist/features/magic-context/index.d.ts.map +1 -1
  90. package/dist/features/magic-context/memory/embedding-cache.d.ts +2 -2
  91. package/dist/features/magic-context/memory/embedding-cache.d.ts.map +1 -1
  92. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  93. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  94. package/dist/features/magic-context/memory/embedding-openai.d.ts +12 -5
  95. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  96. package/dist/features/magic-context/memory/embedding.d.ts +2 -2
  97. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  98. package/dist/features/magic-context/memory/index.d.ts +4 -1
  99. package/dist/features/magic-context/memory/index.d.ts.map +1 -1
  100. package/dist/features/magic-context/memory/memory-migration.d.ts +1 -0
  101. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
  102. package/dist/features/magic-context/memory/promotion.d.ts +16 -4
  103. package/dist/features/magic-context/memory/promotion.d.ts.map +1 -1
  104. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +2 -2
  105. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  106. package/dist/features/magic-context/memory/storage-memory-verifications.d.ts +31 -0
  107. package/dist/features/magic-context/memory/storage-memory-verifications.d.ts.map +1 -0
  108. package/dist/features/magic-context/memory/storage-memory.d.ts +12 -1
  109. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  110. package/dist/features/magic-context/memory/types.d.ts +4 -0
  111. package/dist/features/magic-context/memory/types.d.ts.map +1 -1
  112. package/dist/features/magic-context/memory/verification-paths.d.ts +32 -0
  113. package/dist/features/magic-context/memory/verification-paths.d.ts.map +1 -0
  114. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  115. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  116. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  117. package/dist/features/magic-context/primer-clustering.d.ts +29 -0
  118. package/dist/features/magic-context/primer-clustering.d.ts.map +1 -0
  119. package/dist/features/magic-context/project-embedding-registry.d.ts +25 -1
  120. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  121. package/dist/features/magic-context/search.d.ts +12 -2
  122. package/dist/features/magic-context/search.d.ts.map +1 -1
  123. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  124. package/dist/features/magic-context/smart-notes/capabilities.d.ts +31 -0
  125. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -0
  126. package/dist/features/magic-context/smart-notes/compiler-prompt.d.ts +2 -0
  127. package/dist/features/magic-context/smart-notes/compiler-prompt.d.ts.map +1 -0
  128. package/dist/features/magic-context/smart-notes/compiler.d.ts +52 -0
  129. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -0
  130. package/dist/features/magic-context/smart-notes/index.d.ts +10 -0
  131. package/dist/features/magic-context/smart-notes/index.d.ts.map +1 -0
  132. package/dist/features/magic-context/smart-notes/runner.d.ts +18 -0
  133. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -0
  134. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +22 -0
  135. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -0
  136. package/dist/features/magic-context/smart-notes/schedule.d.ts +9 -0
  137. package/dist/features/magic-context/smart-notes/schedule.d.ts.map +1 -0
  138. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +49 -0
  139. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -0
  140. package/dist/features/magic-context/smart-notes/storage.d.ts +27 -0
  141. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -0
  142. package/dist/features/magic-context/smart-notes/types.d.ts +63 -0
  143. package/dist/features/magic-context/smart-notes/types.d.ts.map +1 -0
  144. package/dist/features/magic-context/storage-db.d.ts +5 -1
  145. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  146. package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -4
  147. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  148. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  149. package/dist/features/magic-context/storage-meta-shared.d.ts +3 -1
  150. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  151. package/dist/features/magic-context/storage-notes.d.ts +15 -0
  152. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  153. package/dist/features/magic-context/storage-primers.d.ts +85 -0
  154. package/dist/features/magic-context/storage-primers.d.ts.map +1 -0
  155. package/dist/features/magic-context/storage-tags.d.ts +20 -0
  156. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  157. package/dist/features/magic-context/storage.d.ts +2 -1
  158. package/dist/features/magic-context/storage.d.ts.map +1 -1
  159. package/dist/features/magic-context/tagger.d.ts +6 -0
  160. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  161. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  162. package/dist/features/magic-context/transform-decision-log.d.ts +10 -0
  163. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  164. package/dist/features/magic-context/types.d.ts +2 -0
  165. package/dist/features/magic-context/types.d.ts.map +1 -1
  166. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +5 -0
  167. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  168. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +18 -0
  169. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  170. package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -1
  171. package/dist/hooks/auto-update-checker/semver.d.ts +9 -0
  172. package/dist/hooks/auto-update-checker/semver.d.ts.map +1 -1
  173. package/dist/hooks/magic-context/auto-search-hint.d.ts.map +1 -1
  174. package/dist/hooks/magic-context/command-handler.d.ts +8 -15
  175. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  176. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  177. package/dist/hooks/magic-context/compartment-parser.d.ts +9 -0
  178. package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
  179. package/dist/hooks/magic-context/compartment-prompt.d.ts +4 -1
  180. package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
  181. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +1 -0
  182. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  183. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  184. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  185. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  186. package/dist/hooks/magic-context/compartment-runner-types.d.ts +8 -0
  187. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  188. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  189. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  190. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  191. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  192. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  193. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
  194. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
  195. package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
  196. package/dist/hooks/magic-context/hook-handlers.d.ts +2 -1
  197. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  198. package/dist/hooks/magic-context/hook.d.ts +1 -0
  199. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  200. package/dist/hooks/magic-context/inject-compartments.d.ts +0 -3
  201. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  202. package/dist/hooks/magic-context/send-session-notification.d.ts +2 -0
  203. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  204. package/dist/hooks/magic-context/system-prompt-hash.d.ts +17 -0
  205. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  206. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -5
  207. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  208. package/dist/hooks/magic-context/transform.d.ts +0 -2
  209. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  210. package/dist/index.d.ts +2 -2
  211. package/dist/index.d.ts.map +1 -1
  212. package/dist/index.js +17028 -4059
  213. package/dist/plugin/dream-timer.d.ts +17 -9
  214. package/dist/plugin/dream-timer.d.ts.map +1 -1
  215. package/dist/plugin/embedding-bootstrap-helpers.d.ts +1 -1
  216. package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
  217. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  218. package/dist/plugin/hooks/create-session-hooks.d.ts +211 -0
  219. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  220. package/dist/plugin/instance-disposal.d.ts +2 -0
  221. package/dist/plugin/instance-disposal.d.ts.map +1 -0
  222. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  223. package/dist/shared/announcement.d.ts +1 -1
  224. package/dist/shared/announcement.d.ts.map +1 -1
  225. package/dist/shared/data-path.d.ts +26 -7
  226. package/dist/shared/data-path.d.ts.map +1 -1
  227. package/dist/shared/model-suggestion-retry.d.ts +48 -2
  228. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  229. package/dist/shared/redaction.d.ts +7 -0
  230. package/dist/shared/redaction.d.ts.map +1 -0
  231. package/dist/shared/resolve-fallbacks.d.ts +12 -0
  232. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  233. package/dist/shared/rpc-server.d.ts.map +1 -1
  234. package/dist/shared/rpc-types.d.ts +2 -0
  235. package/dist/shared/rpc-types.d.ts.map +1 -1
  236. package/dist/shared/subagent-runner.d.ts +12 -3
  237. package/dist/shared/subagent-runner.d.ts.map +1 -1
  238. package/dist/shared/tui-config.d.ts.map +1 -1
  239. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  240. package/dist/tools/ctx-memory/types.d.ts.map +1 -1
  241. package/dist/tools/ctx-memory/verification-recording.d.ts +8 -0
  242. package/dist/tools/ctx-memory/verification-recording.d.ts.map +1 -0
  243. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  244. package/dist/tools/ctx-search/types.d.ts +1 -1
  245. package/dist/tools/ctx-search/types.d.ts.map +1 -1
  246. package/dist/tui/data/context-db.d.ts +2 -0
  247. package/dist/tui/data/context-db.d.ts.map +1 -1
  248. package/package.json +3 -1
  249. package/src/shared/announcement.test.ts +20 -0
  250. package/src/shared/announcement.ts +19 -7
  251. package/src/shared/data-path.test.ts +70 -6
  252. package/src/shared/data-path.ts +50 -8
  253. package/src/shared/model-suggestion-retry.test.ts +79 -2
  254. package/src/shared/model-suggestion-retry.ts +181 -3
  255. package/src/shared/redaction.test.ts +48 -0
  256. package/src/shared/redaction.ts +240 -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 +63 -0
  262. package/src/shared/tui-config.ts +67 -39
  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/hooks/magic-context/key-files-block.d.ts +0 -27
  285. package/dist/hooks/magic-context/key-files-block.d.ts.map +0 -1
@@ -6,18 +6,36 @@ import { parseProviderModel } from "./resolve-fallbacks";
6
6
 
7
7
  type Client = ReturnType<typeof createOpencodeClient>;
8
8
 
9
- type PromptBody = {
9
+ /** Max time to wait for the best-effort child-session abort HTTP call before
10
+ * giving up on its response (the abort still proceeds server-side). Keeps a
11
+ * wedged abort endpoint from masking the original timeout/abort error. */
12
+ const ABORT_CALL_TIMEOUT_MS = 3000;
13
+
14
+ export type PromptBody = {
10
15
  model?: { providerID: string; modelID: string };
11
16
  [key: string]: unknown;
12
17
  };
13
18
 
14
- type PromptArgs = {
19
+ export type PromptArgs = {
15
20
  path: { id: string };
16
21
  body: PromptBody;
17
22
  signal?: AbortSignal;
18
23
  [key: string]: unknown;
19
24
  };
20
25
 
26
+ export interface PromptAttemptInfo {
27
+ /** Human-readable model label used in logs ("primary" or "provider/model"). */
28
+ label: string;
29
+ /** Zero-based attempt index: 0 is primary, 1+ are fallback models. */
30
+ attemptIndex: number;
31
+ /** True for configured fallback models, false for the primary attempt. */
32
+ isFallback: boolean;
33
+ /** Total attempted models including the primary and all configured fallbacks. */
34
+ totalAttempts: number;
35
+ /** Explicit model override for this attempt, when one was supplied. */
36
+ model?: { providerID: string; modelID: string };
37
+ }
38
+
21
39
  export interface PromptRetryOptions {
22
40
  timeoutMs?: number;
23
41
  /** External abort signal — cancels the in-flight LLM prompt immediately when aborted */
@@ -47,6 +65,29 @@ export interface PromptRetryOptions {
47
65
  callContext?: string;
48
66
  }
49
67
 
68
+ export interface ValidatedPromptRetryOptions<TOutput, TValidated> extends PromptRetryOptions {
69
+ /**
70
+ * Fetch the output produced by the just-completed prompt attempt. This is
71
+ * intentionally caller-owned because OpenCode exposes results via session
72
+ * messages and each caller validates a different shape.
73
+ */
74
+ fetchOutput: (args: PromptArgs, attempt: PromptAttemptInfo) => Promise<TOutput>;
75
+ /**
76
+ * Validate and optionally transform the fetched output. Throw to reject this
77
+ * model's output and advance to the next configured fallback model.
78
+ */
79
+ validateOutput: (
80
+ output: TOutput,
81
+ attempt: PromptAttemptInfo,
82
+ ) => TValidated | Promise<TValidated>;
83
+ }
84
+
85
+ export interface ValidatedPromptRetryResult<TOutput, TValidated> {
86
+ output: TOutput;
87
+ validated: TValidated;
88
+ attempt: PromptAttemptInfo;
89
+ }
90
+
50
91
  export interface ModelSuggestionInfo {
51
92
  providerID: string;
52
93
  modelID: string;
@@ -171,7 +212,15 @@ async function promptWithTimeout(
171
212
  */
172
213
  async function abortChildRun(client: Client, sessionId: string): Promise<void> {
173
214
  try {
174
- await client.session.abort({ path: { id: sessionId } });
215
+ // Bound the abort call: it's best-effort cleanup, and if the abort
216
+ // endpoint itself stalls (the runner is wedged) an unbounded await here
217
+ // would hang the caller and MASK the original timeout/abort error that we
218
+ // still need to surface. Race against a short timer; the abort keeps
219
+ // running server-side regardless of whether we wait for its response.
220
+ await Promise.race([
221
+ client.session.abort({ path: { id: sessionId } }),
222
+ new Promise<void>((resolve) => setTimeout(resolve, ABORT_CALL_TIMEOUT_MS)),
223
+ ]);
175
224
  } catch (error) {
176
225
  log(`[model-retry] child session abort failed for ${sessionId}: ${String(error)}`);
177
226
  }
@@ -368,3 +417,132 @@ export async function promptSyncWithModelSuggestionRetry(
368
417
  );
369
418
  throw lastError ?? new Error("All fallback models failed");
370
419
  }
420
+
421
+ async function attemptAndValidate<TOutput, TValidated>(
422
+ client: Client,
423
+ args: PromptArgs,
424
+ timeoutMs: number,
425
+ signal: AbortSignal | undefined,
426
+ callContext: string,
427
+ attempt: PromptAttemptInfo,
428
+ options: ValidatedPromptRetryOptions<TOutput, TValidated>,
429
+ ): Promise<ValidatedPromptRetryResult<TOutput, TValidated>> {
430
+ await attemptOnce(client, args, timeoutMs, signal, callContext, attempt.label);
431
+ const output = await options.fetchOutput(args, attempt);
432
+ const validated = await options.validateOutput(output, attempt);
433
+ return { output, validated, attempt };
434
+ }
435
+
436
+ /**
437
+ * Run a prompt with model fallback support, but accept an attempt only after the
438
+ * caller validates the model's actual output. This covers "empty success" cases
439
+ * where the provider/OpenCode prompt call completes successfully but the subagent
440
+ * produced no usable assistant text / JSON.
441
+ *
442
+ * The happy path is still one prompt + one caller-owned output fetch: callers
443
+ * should use the returned output instead of fetching messages a second time.
444
+ * Validation failures are retryable across configured fallback models. If every
445
+ * attempt produces invalid output (or otherwise fails retryably), the first
446
+ * failure is re-thrown so callers surface the original failure semantics.
447
+ */
448
+ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = TOutput>(
449
+ client: Client,
450
+ args: PromptArgs,
451
+ options: ValidatedPromptRetryOptions<TOutput, TValidated>,
452
+ ): Promise<ValidatedPromptRetryResult<TOutput, TValidated>> {
453
+ const timeoutMs = options.timeoutMs ?? 300_000;
454
+ const callContext = options.callContext ?? "subagent";
455
+ const fallbacks = options.fallbackModels ?? [];
456
+
457
+ const explicitPrimaryLabel =
458
+ args.body.model?.providerID && args.body.model.modelID
459
+ ? `${args.body.model.providerID}/${args.body.model.modelID}`
460
+ : "primary";
461
+ const totalAttempts = fallbacks.length + 1;
462
+
463
+ let firstError: unknown = null;
464
+ let lastError: unknown = null;
465
+
466
+ try {
467
+ return await attemptAndValidate(
468
+ client,
469
+ args,
470
+ timeoutMs,
471
+ options.signal,
472
+ callContext,
473
+ {
474
+ label: explicitPrimaryLabel,
475
+ attemptIndex: 0,
476
+ isFallback: false,
477
+ totalAttempts,
478
+ model: args.body.model,
479
+ },
480
+ options,
481
+ );
482
+ } catch (error) {
483
+ firstError = error;
484
+ lastError = error;
485
+ if (isNonRetryable(error, options.signal)) throw error;
486
+
487
+ if (fallbacks.length === 0) {
488
+ throw error;
489
+ }
490
+
491
+ log(
492
+ `[${callContext}] primary (${explicitPrimaryLabel}) failed validation/prompt: ${shortErr(error)}; trying ${fallbacks.length} fallback(s)`,
493
+ );
494
+ }
495
+
496
+ for (let i = 0; i < fallbacks.length; i += 1) {
497
+ const parsed = parseProviderModel(fallbacks[i]);
498
+ if (!parsed) {
499
+ log(`[${callContext}] skipping invalid fallback spec: ${fallbacks[i]}`);
500
+ continue;
501
+ }
502
+
503
+ const label = `${parsed.providerID}/${parsed.modelID}`;
504
+ const attemptArgs: PromptArgs = {
505
+ ...args,
506
+ body: { ...args.body, model: parsed },
507
+ };
508
+ const attempt: PromptAttemptInfo = {
509
+ label,
510
+ attemptIndex: i + 1,
511
+ isFallback: true,
512
+ totalAttempts,
513
+ model: parsed,
514
+ };
515
+
516
+ try {
517
+ const result = await attemptAndValidate(
518
+ client,
519
+ attemptArgs,
520
+ timeoutMs,
521
+ options.signal,
522
+ callContext,
523
+ attempt,
524
+ options,
525
+ );
526
+ log(
527
+ `[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
528
+ );
529
+ return result;
530
+ } catch (error) {
531
+ if (firstError === null) firstError = error;
532
+ lastError = error;
533
+ if (isNonRetryable(error, options.signal)) throw error;
534
+
535
+ const remaining = fallbacks.length - i - 1;
536
+ if (remaining > 0) {
537
+ log(
538
+ `[${callContext}] ${label} failed validation/prompt: ${shortErr(error)}; ${remaining} fallback(s) left`,
539
+ );
540
+ }
541
+ }
542
+ }
543
+
544
+ log(
545
+ `[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks].join(", ")}; original error: ${shortErr(firstError)}; last error: ${shortErr(lastError)}`,
546
+ );
547
+ throw firstError ?? lastError ?? new Error("All fallback models failed validation");
548
+ }
@@ -0,0 +1,48 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ import { hasShareabilitySensitiveText } from "./redaction";
6
+
7
+ describe("hasShareabilitySensitiveText", () => {
8
+ test("safe project facts are shareable", () => {
9
+ expect(
10
+ hasShareabilitySensitiveText(
11
+ "The historian runs as a hidden subagent and never busts the prompt cache.",
12
+ ),
13
+ ).toBe(false);
14
+ expect(
15
+ hasShareabilitySensitiveText("Migration v45 adds the retrospective watermark column."),
16
+ ).toBe(false);
17
+ });
18
+
19
+ test("flags inline key:value / key=value secrets the keyed redactor misses in prose", () => {
20
+ expect(hasShareabilitySensitiveText("Set api_key: sk-live-abc123 in the env.")).toBe(true);
21
+ expect(hasShareabilitySensitiveText("password=hunter2 for the staging box")).toBe(true);
22
+ expect(hasShareabilitySensitiveText("client_secret = abcdef in the OAuth app")).toBe(true);
23
+ });
24
+
25
+ test("flags Windows forward-slash home (sanitizePathString only rewrites backslash form)", () => {
26
+ expect(hasShareabilitySensitiveText("logs are under C:/Users/ufuk/AppData/mc")).toBe(true);
27
+ });
28
+
29
+ test("flags ~/ rooted personal paths", () => {
30
+ expect(hasShareabilitySensitiveText("config lives at ~/.config/opencode/x.jsonc")).toBe(
31
+ true,
32
+ );
33
+ });
34
+
35
+ test("flags local / private endpoints", () => {
36
+ expect(hasShareabilitySensitiveText("embed endpoint is http://localhost:1234/v1")).toBe(
37
+ true,
38
+ );
39
+ expect(hasShareabilitySensitiveText("the box answers on 127.0.0.1:8080")).toBe(true);
40
+ expect(hasShareabilitySensitiveText("LAN host 192.168.1.42 runs the model")).toBe(true);
41
+ expect(hasShareabilitySensitiveText("internal 10.0.0.5 endpoint")).toBe(true);
42
+ });
43
+
44
+ test("a public IP / port alone is not flagged by the private-range rules", () => {
45
+ // 8.8.8.8 is public; no private-range or localhost pattern should match.
46
+ expect(hasShareabilitySensitiveText("DNS resolver at 8.8.8.8")).toBe(false);
47
+ });
48
+ });
@@ -0,0 +1,240 @@
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
+ const SECRET_QUALIFIERS = new Set([
37
+ "api",
38
+ "access",
39
+ "private",
40
+ "client",
41
+ "auth",
42
+ "authorization",
43
+ "secret",
44
+ "bearer",
45
+ "session",
46
+ "refresh",
47
+ "service",
48
+ "x",
49
+ "openai",
50
+ "anthropic",
51
+ "google",
52
+ "github",
53
+ "huggingface",
54
+ "aws",
55
+ "azure",
56
+ ]);
57
+
58
+ export function isSecretKey(key: string): boolean {
59
+ const segments = key
60
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
61
+ .toLowerCase()
62
+ .split(/[._-]+/)
63
+ .filter(Boolean);
64
+ if (segments.length === 0) return false;
65
+
66
+ if (segments.length === 1) {
67
+ const first = segments[0];
68
+ return Boolean(first && SECRET_SEGMENT_PATTERN.test(first));
69
+ }
70
+
71
+ for (let i = 0; i < segments.length; i++) {
72
+ const seg = segments[i];
73
+ if (!seg || !SECRET_SEGMENT_PATTERN.test(seg)) continue;
74
+
75
+ let trailingOk = true;
76
+ for (let j = i + 1; j < segments.length; j++) {
77
+ const tail = segments[j];
78
+ if (!tail) continue;
79
+ if (TRAILING_DESCRIPTORS.has(tail)) continue;
80
+ if (SECRET_SEGMENT_PATTERN.test(tail)) continue;
81
+ trailingOk = false;
82
+ break;
83
+ }
84
+ if (!trailingOk) continue;
85
+
86
+ for (let k = i - 1; k >= 0; k--) {
87
+ const lead = segments[k];
88
+ if (lead && SECRET_QUALIFIERS.has(lead)) return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ export function sanitizePathString(value: string): string {
95
+ const home = homedir();
96
+ const username = userInfo().username;
97
+ let sanitized = value;
98
+ if (home) {
99
+ sanitized = sanitized.replace(new RegExp(escapeRegex(home), "g"), "~");
100
+ }
101
+ sanitized = sanitized.replace(/\/Users\/[^/]+\//g, "/Users/<USER>/");
102
+ sanitized = sanitized.replace(/\/home\/[^/]+\//g, "/home/<USER>/");
103
+ sanitized = sanitized.replace(/C:\\Users\\[^\\]+\\/g, "C:\\Users\\<USER>\\");
104
+ if (username) {
105
+ sanitized = sanitized.replace(new RegExp(escapeRegex(username), "g"), "<USER>");
106
+ }
107
+ return sanitized;
108
+ }
109
+
110
+ const SECRET_TEXT_PATTERNS: Array<{
111
+ pattern: RegExp;
112
+ replacement: string | ((match: string, ...groups: string[]) => string);
113
+ }> = [
114
+ {
115
+ pattern: /\bsk-ant-(?:api03-)?[A-Za-z0-9_-]{32,}/g,
116
+ replacement: "<ANTHROPIC_API_KEY_REDACTED>",
117
+ },
118
+ {
119
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g,
120
+ replacement: "<OPENAI_API_KEY_REDACTED>",
121
+ },
122
+ {
123
+ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
124
+ replacement: "<GITHUB_PAT_REDACTED>",
125
+ },
126
+ {
127
+ pattern: /\b(?:gh[opsu]|ghr)_[A-Za-z0-9]{30,}/g,
128
+ replacement: "<GITHUB_TOKEN_REDACTED>",
129
+ },
130
+ {
131
+ pattern: /\bhf_[A-Za-z0-9]{30,}/g,
132
+ replacement: "<HUGGINGFACE_TOKEN_REDACTED>",
133
+ },
134
+ {
135
+ pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
136
+ replacement: "<AWS_ACCESS_KEY_ID_REDACTED>",
137
+ },
138
+ {
139
+ pattern: /\bxox[abprsuvc]-[A-Za-z0-9-]{10,}/g,
140
+ replacement: "<SLACK_TOKEN_REDACTED>",
141
+ },
142
+ {
143
+ pattern: /\bAIza[A-Za-z0-9_-]{35}\b/g,
144
+ replacement: "<GOOGLE_API_KEY_REDACTED>",
145
+ },
146
+ {
147
+ pattern: /\b(Authorization\s*:\s*Bearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi,
148
+ replacement: (_full: string, prefix: string) => `${prefix}<REDACTED:bearer>`,
149
+ },
150
+ {
151
+ pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
152
+ replacement: "<JWT_REDACTED>",
153
+ },
154
+ {
155
+ pattern:
156
+ /(["'])([^"']*(?:key|token|secret|password|auth|bearer|credential)[^"']*)\1(\s*:\s*)(["'])([^"']*)\4/gi,
157
+ replacement: (
158
+ _full: string,
159
+ quote: string,
160
+ key: string,
161
+ separator: string,
162
+ valueQuote: string,
163
+ ) =>
164
+ `${quote}${key}${quote}${separator}${valueQuote}<REDACTED:${redactionTypeForKey(key)}>${valueQuote}`,
165
+ },
166
+ {
167
+ pattern:
168
+ /\b([A-Za-z0-9_.-]*(?:key|token|secret|password|auth|bearer|credential)[A-Za-z0-9_.-]*)\s*=\s*([^\s'"`]+)/gi,
169
+ replacement: (_full: string, key: string) =>
170
+ `${key}=<REDACTED:${redactionTypeForKey(key)}>`,
171
+ },
172
+ ];
173
+
174
+ export function redactSecretText(value: string): string {
175
+ let redacted = value;
176
+ for (const { pattern, replacement } of SECRET_TEXT_PATTERNS) {
177
+ if (typeof replacement === "string") {
178
+ redacted = redacted.replace(pattern, replacement);
179
+ } else {
180
+ redacted = redacted.replace(
181
+ pattern,
182
+ replacement as (match: string, ...groups: string[]) => string,
183
+ );
184
+ }
185
+ }
186
+ return redacted;
187
+ }
188
+
189
+ export function sanitizeDiagnosticText(value: string): string {
190
+ return redactSecretText(sanitizePathString(value));
191
+ }
192
+
193
+ // Extra shareability-only signals — patterns that mark text as unsafe to share
194
+ // with teammates but that the diagnostic sanitizer (tuned for secret/path
195
+ // REDACTION, not share-gating) does not rewrite. Kept here, NOT in
196
+ // sanitizeDiagnosticText, so diagnostic redaction output is unchanged.
197
+ const SHAREABILITY_SENSITIVE_PATTERNS: RegExp[] = [
198
+ // Windows user home, forward- OR back-slash (sanitizePathString only rewrites
199
+ // the backslash form).
200
+ /\bC:\/Users\/[^/\s]+/i,
201
+ // A `~`-rooted home path (personal/local).
202
+ /(?:^|\s)~\/[^\s]+/,
203
+ // Inline `key: value` / `key=value` secrets the keyed redactor misses in free
204
+ // text (it keys on config OBJECT keys, not prose).
205
+ /\b(?:api[_-]?key|secret|token|password|passwd|pwd|client[_-]?secret|access[_-]?key)\b\s*[:=]\s*\S+/i,
206
+ // Local / private endpoints — environment-specific, not a shared truth.
207
+ /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\b/i,
208
+ /\b(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
209
+ /\b192\.168\.\d{1,3}\.\d{1,3}\b/,
210
+ /\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/,
211
+ ];
212
+
213
+ export function hasShareabilitySensitiveText(text: string): boolean {
214
+ try {
215
+ if (sanitizeDiagnosticText(text) !== text) return true;
216
+ return SHAREABILITY_SENSITIVE_PATTERNS.some((pattern) => pattern.test(text));
217
+ } catch {
218
+ return true;
219
+ }
220
+ }
221
+
222
+ export function sanitizeConfigValue(value: unknown, keyPath: string[] = []): unknown {
223
+ const key = keyPath.at(-1) ?? "";
224
+ if (key && isSecretKey(key)) {
225
+ return `<REDACTED:${redactionTypeForKey(key)}>`;
226
+ }
227
+ if (typeof value === "string") return sanitizeDiagnosticText(value);
228
+ if (Array.isArray(value)) {
229
+ return value.map((entry, index) => sanitizeConfigValue(entry, [...keyPath, String(index)]));
230
+ }
231
+ if (value && typeof value === "object") {
232
+ return Object.fromEntries(
233
+ Object.entries(value).map(([entryKey, entry]) => [
234
+ entryKey,
235
+ sanitizeConfigValue(entry, [...keyPath, entryKey]),
236
+ ]),
237
+ );
238
+ }
239
+ return value;
240
+ }
@@ -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,63 @@
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: [["@cortexkit/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("@cortexkit/opencode-magic-context@latest");
61
+ expect(entry[1]).toEqual({ enabled: true });
62
+ });
63
+ });