oh-my-codex 0.17.3 → 0.18.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 (381) hide show
  1. package/Cargo.lock +13 -5
  2. package/Cargo.toml +2 -1
  3. package/README.md +44 -19
  4. package/crates/omx-api/Cargo.toml +19 -0
  5. package/crates/omx-api/src/lib.rs +2997 -0
  6. package/crates/omx-api/src/main.rs +10 -0
  7. package/crates/omx-api/tests/cli.rs +558 -0
  8. package/crates/omx-explore/src/main.rs +4 -0
  9. package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
  10. package/crates/omx-sparkshell/src/exec.rs +127 -1
  11. package/crates/omx-sparkshell/src/main.rs +829 -30
  12. package/crates/omx-sparkshell/src/prompt.rs +25 -3
  13. package/crates/omx-sparkshell/src/redaction.rs +241 -0
  14. package/crates/omx-sparkshell/tests/execution.rs +702 -237
  15. package/dist/cli/__tests__/api.test.d.ts +2 -0
  16. package/dist/cli/__tests__/api.test.d.ts.map +1 -0
  17. package/dist/cli/__tests__/api.test.js +175 -0
  18. package/dist/cli/__tests__/api.test.js.map +1 -0
  19. package/dist/cli/__tests__/ask.test.js +72 -5
  20. package/dist/cli/__tests__/ask.test.js.map +1 -1
  21. package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
  22. package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
  23. package/dist/cli/__tests__/codex-plugin-layout.test.js +15 -7
  24. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  25. package/dist/cli/__tests__/doctor-warning-copy.test.js +76 -3
  26. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  27. package/dist/cli/__tests__/explore.test.js +23 -0
  28. package/dist/cli/__tests__/explore.test.js.map +1 -1
  29. package/dist/cli/__tests__/index.test.js +171 -5
  30. package/dist/cli/__tests__/index.test.js.map +1 -1
  31. package/dist/cli/__tests__/install-docs-contract.test.d.ts +2 -0
  32. package/dist/cli/__tests__/install-docs-contract.test.d.ts.map +1 -0
  33. package/dist/cli/__tests__/install-docs-contract.test.js +55 -0
  34. package/dist/cli/__tests__/install-docs-contract.test.js.map +1 -0
  35. package/dist/cli/__tests__/launch-fallback.test.js +191 -0
  36. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  37. package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
  38. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  39. package/dist/cli/__tests__/question.test.js +27 -41
  40. package/dist/cli/__tests__/question.test.js.map +1 -1
  41. package/dist/cli/__tests__/setup-install-mode.test.js +232 -35
  42. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  43. package/dist/cli/__tests__/sparkshell-cli.test.js +25 -1
  44. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  45. package/dist/cli/__tests__/sparkshell-packaging.test.js +1 -0
  46. package/dist/cli/__tests__/sparkshell-packaging.test.js.map +1 -1
  47. package/dist/cli/__tests__/ultragoal.test.js +227 -4
  48. package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
  49. package/dist/cli/__tests__/update.test.js +72 -1
  50. package/dist/cli/__tests__/update.test.js.map +1 -1
  51. package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
  52. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  53. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
  54. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  55. package/dist/cli/api.d.ts +26 -0
  56. package/dist/cli/api.d.ts.map +1 -0
  57. package/dist/cli/api.js +153 -0
  58. package/dist/cli/api.js.map +1 -0
  59. package/dist/cli/codex-feature-probe.d.ts +5 -0
  60. package/dist/cli/codex-feature-probe.d.ts.map +1 -1
  61. package/dist/cli/codex-feature-probe.js +13 -7
  62. package/dist/cli/codex-feature-probe.js.map +1 -1
  63. package/dist/cli/doctor.d.ts +7 -0
  64. package/dist/cli/doctor.d.ts.map +1 -1
  65. package/dist/cli/doctor.js +119 -10
  66. package/dist/cli/doctor.js.map +1 -1
  67. package/dist/cli/explore.d.ts +2 -0
  68. package/dist/cli/explore.d.ts.map +1 -1
  69. package/dist/cli/explore.js +43 -1
  70. package/dist/cli/explore.js.map +1 -1
  71. package/dist/cli/index.d.ts +12 -4
  72. package/dist/cli/index.d.ts.map +1 -1
  73. package/dist/cli/index.js +460 -87
  74. package/dist/cli/index.js.map +1 -1
  75. package/dist/cli/native-assets.d.ts +2 -1
  76. package/dist/cli/native-assets.d.ts.map +1 -1
  77. package/dist/cli/native-assets.js +1 -0
  78. package/dist/cli/native-assets.js.map +1 -1
  79. package/dist/cli/plugin-marketplace.d.ts +2 -0
  80. package/dist/cli/plugin-marketplace.d.ts.map +1 -1
  81. package/dist/cli/plugin-marketplace.js +15 -1
  82. package/dist/cli/plugin-marketplace.js.map +1 -1
  83. package/dist/cli/setup.d.ts.map +1 -1
  84. package/dist/cli/setup.js +71 -11
  85. package/dist/cli/setup.js.map +1 -1
  86. package/dist/cli/sparkshell.d.ts +7 -1
  87. package/dist/cli/sparkshell.d.ts.map +1 -1
  88. package/dist/cli/sparkshell.js +31 -4
  89. package/dist/cli/sparkshell.js.map +1 -1
  90. package/dist/cli/ultragoal.d.ts +1 -1
  91. package/dist/cli/ultragoal.d.ts.map +1 -1
  92. package/dist/cli/ultragoal.js +184 -10
  93. package/dist/cli/ultragoal.js.map +1 -1
  94. package/dist/cli/update.d.ts +2 -0
  95. package/dist/cli/update.d.ts.map +1 -1
  96. package/dist/cli/update.js +14 -3
  97. package/dist/cli/update.js.map +1 -1
  98. package/dist/compat/__tests__/doctor-contract.test.js +3 -0
  99. package/dist/compat/__tests__/doctor-contract.test.js.map +1 -1
  100. package/dist/config/__tests__/codex-feature-flags.test.js +11 -1
  101. package/dist/config/__tests__/codex-feature-flags.test.js.map +1 -1
  102. package/dist/config/__tests__/codex-hooks.test.js +19 -8
  103. package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
  104. package/dist/config/__tests__/commit-lore-guard.test.d.ts +2 -0
  105. package/dist/config/__tests__/commit-lore-guard.test.d.ts.map +1 -0
  106. package/dist/config/__tests__/commit-lore-guard.test.js +20 -0
  107. package/dist/config/__tests__/commit-lore-guard.test.js.map +1 -0
  108. package/dist/config/codex-feature-flags.d.ts +4 -0
  109. package/dist/config/codex-feature-flags.d.ts.map +1 -1
  110. package/dist/config/codex-feature-flags.js +4 -0
  111. package/dist/config/codex-feature-flags.js.map +1 -1
  112. package/dist/config/codex-hooks.js +6 -6
  113. package/dist/config/codex-hooks.js.map +1 -1
  114. package/dist/config/commit-lore-guard.d.ts +1 -0
  115. package/dist/config/commit-lore-guard.d.ts.map +1 -1
  116. package/dist/config/commit-lore-guard.js +29 -3
  117. package/dist/config/commit-lore-guard.js.map +1 -1
  118. package/dist/config/generator.d.ts +3 -1
  119. package/dist/config/generator.d.ts.map +1 -1
  120. package/dist/config/generator.js +114 -10
  121. package/dist/config/generator.js.map +1 -1
  122. package/dist/goal-workflows/codex-goal-snapshot.d.ts +1 -0
  123. package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
  124. package/dist/goal-workflows/codex-goal-snapshot.js +5 -1
  125. package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
  126. package/dist/hooks/__tests__/autopilot-skill-contract.test.js +10 -6
  127. package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
  128. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
  129. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
  130. package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
  131. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
  132. package/dist/hooks/__tests__/consensus-execution-handoff.test.d.ts +1 -1
  133. package/dist/hooks/__tests__/consensus-execution-handoff.test.js +13 -11
  134. package/dist/hooks/__tests__/consensus-execution-handoff.test.js.map +1 -1
  135. package/dist/hooks/__tests__/deep-interview-contract.test.js +4 -3
  136. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  137. package/dist/hooks/__tests__/keyword-detector.test.js +15 -3
  138. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  139. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
  140. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  141. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +33 -0
  142. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  143. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
  144. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  145. package/dist/hooks/extensibility/__tests__/dispatcher.test.js +26 -3
  146. package/dist/hooks/extensibility/__tests__/dispatcher.test.js.map +1 -1
  147. package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -1
  148. package/dist/hooks/extensibility/dispatcher.js +29 -14
  149. package/dist/hooks/extensibility/dispatcher.js.map +1 -1
  150. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  151. package/dist/hooks/keyword-detector.js +8 -3
  152. package/dist/hooks/keyword-detector.js.map +1 -1
  153. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  154. package/dist/hooks/keyword-registry.js +1 -0
  155. package/dist/hooks/keyword-registry.js.map +1 -1
  156. package/dist/hooks/prompt-guidance-contract.d.ts.map +1 -1
  157. package/dist/hooks/prompt-guidance-contract.js +3 -2
  158. package/dist/hooks/prompt-guidance-contract.js.map +1 -1
  159. package/dist/hud/__tests__/hud-tmux-injection.test.js +14 -8
  160. package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
  161. package/dist/hud/__tests__/reconcile.test.js +4 -4
  162. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  163. package/dist/hud/__tests__/resource-leak-watch.test.d.ts +2 -0
  164. package/dist/hud/__tests__/resource-leak-watch.test.d.ts.map +1 -0
  165. package/dist/hud/__tests__/resource-leak-watch.test.js +28 -0
  166. package/dist/hud/__tests__/resource-leak-watch.test.js.map +1 -0
  167. package/dist/hud/__tests__/tmux.test.js +23 -18
  168. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  169. package/dist/hud/index.d.ts +1 -1
  170. package/dist/hud/index.d.ts.map +1 -1
  171. package/dist/hud/index.js +10 -4
  172. package/dist/hud/index.js.map +1 -1
  173. package/dist/hud/tmux.d.ts.map +1 -1
  174. package/dist/hud/tmux.js +9 -8
  175. package/dist/hud/tmux.js.map +1 -1
  176. package/dist/mcp/__tests__/bootstrap.test.js +75 -1
  177. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  178. package/dist/mcp/bootstrap.d.ts +3 -1
  179. package/dist/mcp/bootstrap.d.ts.map +1 -1
  180. package/dist/mcp/bootstrap.js +71 -2
  181. package/dist/mcp/bootstrap.js.map +1 -1
  182. package/dist/notifications/__tests__/http-client-resource.test.d.ts +2 -0
  183. package/dist/notifications/__tests__/http-client-resource.test.d.ts.map +1 -0
  184. package/dist/notifications/__tests__/http-client-resource.test.js +41 -0
  185. package/dist/notifications/__tests__/http-client-resource.test.js.map +1 -0
  186. package/dist/notifications/__tests__/verbosity.test.js +20 -0
  187. package/dist/notifications/__tests__/verbosity.test.js.map +1 -1
  188. package/dist/notifications/config.d.ts.map +1 -1
  189. package/dist/notifications/config.js +6 -3
  190. package/dist/notifications/config.js.map +1 -1
  191. package/dist/notifications/http-client.d.ts.map +1 -1
  192. package/dist/notifications/http-client.js +78 -27
  193. package/dist/notifications/http-client.js.map +1 -1
  194. package/dist/notifications/types.d.ts +2 -0
  195. package/dist/notifications/types.d.ts.map +1 -1
  196. package/dist/openclaw/__tests__/dispatcher.test.js +49 -1
  197. package/dist/openclaw/__tests__/dispatcher.test.js.map +1 -1
  198. package/dist/openclaw/dispatcher.d.ts +7 -4
  199. package/dist/openclaw/dispatcher.d.ts.map +1 -1
  200. package/dist/openclaw/dispatcher.js +32 -69
  201. package/dist/openclaw/dispatcher.js.map +1 -1
  202. package/dist/pipeline/__tests__/orchestrator.test.js +65 -3
  203. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  204. package/dist/pipeline/__tests__/stages.test.js +50 -5
  205. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  206. package/dist/pipeline/index.d.ts +8 -2
  207. package/dist/pipeline/index.d.ts.map +1 -1
  208. package/dist/pipeline/index.js +5 -2
  209. package/dist/pipeline/index.js.map +1 -1
  210. package/dist/pipeline/orchestrator.d.ts +5 -4
  211. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  212. package/dist/pipeline/orchestrator.js +56 -15
  213. package/dist/pipeline/orchestrator.js.map +1 -1
  214. package/dist/pipeline/stages/code-review.d.ts +2 -2
  215. package/dist/pipeline/stages/code-review.d.ts.map +1 -1
  216. package/dist/pipeline/stages/code-review.js +5 -3
  217. package/dist/pipeline/stages/code-review.js.map +1 -1
  218. package/dist/pipeline/stages/deep-interview.d.ts +15 -0
  219. package/dist/pipeline/stages/deep-interview.d.ts.map +1 -0
  220. package/dist/pipeline/stages/deep-interview.js +32 -0
  221. package/dist/pipeline/stages/deep-interview.js.map +1 -0
  222. package/dist/pipeline/stages/ralph-verify.d.ts +5 -5
  223. package/dist/pipeline/stages/ralph-verify.d.ts.map +1 -1
  224. package/dist/pipeline/stages/ralph-verify.js +2 -2
  225. package/dist/pipeline/stages/ralph-verify.js.map +1 -1
  226. package/dist/pipeline/stages/ultragoal.d.ts +19 -0
  227. package/dist/pipeline/stages/ultragoal.d.ts.map +1 -0
  228. package/dist/pipeline/stages/ultragoal.js +38 -0
  229. package/dist/pipeline/stages/ultragoal.js.map +1 -0
  230. package/dist/pipeline/stages/ultraqa.d.ts +30 -0
  231. package/dist/pipeline/stages/ultraqa.d.ts.map +1 -0
  232. package/dist/pipeline/stages/ultraqa.js +46 -0
  233. package/dist/pipeline/stages/ultraqa.js.map +1 -0
  234. package/dist/pipeline/types.d.ts +8 -6
  235. package/dist/pipeline/types.d.ts.map +1 -1
  236. package/dist/pipeline/types.js +2 -2
  237. package/dist/scripts/__tests__/codex-native-hook.test.js +1488 -117
  238. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  239. package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
  240. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  241. package/dist/scripts/__tests__/smoke-packed-install.test.js +27 -2
  242. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  243. package/dist/scripts/__tests__/verify-native-agents.test.js +16 -1
  244. package/dist/scripts/__tests__/verify-native-agents.test.js.map +1 -1
  245. package/dist/scripts/build-api.d.ts +2 -0
  246. package/dist/scripts/build-api.d.ts.map +1 -0
  247. package/dist/scripts/build-api.js +44 -0
  248. package/dist/scripts/build-api.js.map +1 -0
  249. package/dist/scripts/cleanup-explore-harness.js +1 -0
  250. package/dist/scripts/cleanup-explore-harness.js.map +1 -1
  251. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  252. package/dist/scripts/codex-native-hook.js +364 -16
  253. package/dist/scripts/codex-native-hook.js.map +1 -1
  254. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  255. package/dist/scripts/codex-native-pre-post.js +98 -25
  256. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  257. package/dist/scripts/notify-dispatcher.js +88 -0
  258. package/dist/scripts/notify-dispatcher.js.map +1 -1
  259. package/dist/scripts/notify-hook/process-runner.d.ts.map +1 -1
  260. package/dist/scripts/notify-hook/process-runner.js +39 -17
  261. package/dist/scripts/notify-hook/process-runner.js.map +1 -1
  262. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  263. package/dist/scripts/notify-hook/team-dispatch.js +36 -14
  264. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  265. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  266. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
  267. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  268. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +2 -1
  269. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  270. package/dist/scripts/notify-hook/team-tmux-guard.js +45 -1
  271. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  272. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  273. package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
  274. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  275. package/dist/scripts/run-provider-advisor.js +9 -3
  276. package/dist/scripts/run-provider-advisor.js.map +1 -1
  277. package/dist/scripts/smoke-packed-install.d.ts +4 -1
  278. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  279. package/dist/scripts/smoke-packed-install.js +101 -1
  280. package/dist/scripts/smoke-packed-install.js.map +1 -1
  281. package/dist/scripts/sync-plugin-mirror.js +2 -2
  282. package/dist/scripts/sync-plugin-mirror.js.map +1 -1
  283. package/dist/scripts/verify-native-agents.js +2 -2
  284. package/dist/scripts/verify-native-agents.js.map +1 -1
  285. package/dist/sidecar/__tests__/resource-leak-watch.test.d.ts +2 -0
  286. package/dist/sidecar/__tests__/resource-leak-watch.test.d.ts.map +1 -0
  287. package/dist/sidecar/__tests__/resource-leak-watch.test.js +38 -0
  288. package/dist/sidecar/__tests__/resource-leak-watch.test.js.map +1 -0
  289. package/dist/sidecar/index.d.ts +1 -1
  290. package/dist/sidecar/index.d.ts.map +1 -1
  291. package/dist/sidecar/index.js +29 -12
  292. package/dist/sidecar/index.js.map +1 -1
  293. package/dist/state/__tests__/operations-ralph-phase.test.js +88 -1
  294. package/dist/state/__tests__/operations-ralph-phase.test.js.map +1 -1
  295. package/dist/state/operations.d.ts.map +1 -1
  296. package/dist/state/operations.js +11 -0
  297. package/dist/state/operations.js.map +1 -1
  298. package/dist/team/__tests__/runtime.test.js +2 -2
  299. package/dist/team/__tests__/runtime.test.js.map +1 -1
  300. package/dist/team/__tests__/tmux-session.test.js +207 -22
  301. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  302. package/dist/team/tmux-session.d.ts +1 -0
  303. package/dist/team/tmux-session.d.ts.map +1 -1
  304. package/dist/team/tmux-session.js +73 -28
  305. package/dist/team/tmux-session.js.map +1 -1
  306. package/dist/ultragoal/__tests__/artifacts.test.js +714 -10
  307. package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
  308. package/dist/ultragoal/__tests__/docs-contract.test.js +57 -1
  309. package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
  310. package/dist/ultragoal/__tests__/steering-fixtures.d.ts +68 -0
  311. package/dist/ultragoal/__tests__/steering-fixtures.d.ts.map +1 -0
  312. package/dist/ultragoal/__tests__/steering-fixtures.js +259 -0
  313. package/dist/ultragoal/__tests__/steering-fixtures.js.map +1 -0
  314. package/dist/ultragoal/__tests__/steering-fixtures.test.d.ts +2 -0
  315. package/dist/ultragoal/__tests__/steering-fixtures.test.d.ts.map +1 -0
  316. package/dist/ultragoal/__tests__/steering-fixtures.test.js +65 -0
  317. package/dist/ultragoal/__tests__/steering-fixtures.test.js.map +1 -0
  318. package/dist/ultragoal/artifacts.d.ts +97 -2
  319. package/dist/ultragoal/artifacts.d.ts.map +1 -1
  320. package/dist/ultragoal/artifacts.js +811 -256
  321. package/dist/ultragoal/artifacts.js.map +1 -1
  322. package/dist/utils/__tests__/sleep-resource.test.d.ts +2 -0
  323. package/dist/utils/__tests__/sleep-resource.test.d.ts.map +1 -0
  324. package/dist/utils/__tests__/sleep-resource.test.js +39 -0
  325. package/dist/utils/__tests__/sleep-resource.test.js.map +1 -0
  326. package/dist/utils/sleep.d.ts.map +1 -1
  327. package/dist/utils/sleep.js +17 -6
  328. package/dist/utils/sleep.js.map +1 -1
  329. package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
  330. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  331. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
  332. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  333. package/package.json +5 -3
  334. package/plugins/oh-my-codex/.codex-plugin/plugin.json +4 -3
  335. package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +56 -0
  336. package/plugins/oh-my-codex/hooks/hooks.json +77 -0
  337. package/plugins/oh-my-codex/skills/autopilot/SKILL.md +77 -47
  338. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
  339. package/plugins/oh-my-codex/skills/cancel/SKILL.md +2 -2
  340. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +9 -8
  341. package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +1 -1
  342. package/plugins/oh-my-codex/skills/pipeline/SKILL.md +22 -11
  343. package/plugins/oh-my-codex/skills/plan/SKILL.md +8 -8
  344. package/plugins/oh-my-codex/skills/ralph/SKILL.md +7 -0
  345. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +5 -5
  346. package/plugins/oh-my-codex/skills/team/SKILL.md +1 -1
  347. package/plugins/oh-my-codex/skills/ultragoal/SKILL.md +38 -4
  348. package/plugins/oh-my-codex/skills/ultrawork/SKILL.md +1 -1
  349. package/prompts/planner.md +1 -1
  350. package/prompts/researcher.md +15 -10
  351. package/skills/autopilot/SKILL.md +77 -47
  352. package/skills/best-practice-research/SKILL.md +83 -0
  353. package/skills/cancel/SKILL.md +2 -2
  354. package/skills/deep-interview/SKILL.md +9 -8
  355. package/skills/omx-setup/SKILL.md +1 -1
  356. package/skills/pipeline/SKILL.md +22 -11
  357. package/skills/plan/SKILL.md +8 -8
  358. package/skills/ralph/SKILL.md +7 -0
  359. package/skills/ralplan/SKILL.md +5 -5
  360. package/skills/team/SKILL.md +1 -1
  361. package/skills/ultragoal/SKILL.md +38 -4
  362. package/skills/ultrawork/SKILL.md +1 -1
  363. package/src/scripts/__tests__/codex-native-hook.test.ts +1758 -166
  364. package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
  365. package/src/scripts/__tests__/smoke-packed-install.test.ts +39 -2
  366. package/src/scripts/__tests__/verify-native-agents.test.ts +21 -1
  367. package/src/scripts/build-api.ts +48 -0
  368. package/src/scripts/cleanup-explore-harness.ts +1 -0
  369. package/src/scripts/codex-native-hook.ts +416 -18
  370. package/src/scripts/codex-native-pre-post.ts +119 -25
  371. package/src/scripts/notify-dispatcher.ts +97 -0
  372. package/src/scripts/notify-hook/process-runner.ts +40 -16
  373. package/src/scripts/notify-hook/team-dispatch.ts +36 -13
  374. package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
  375. package/src/scripts/notify-hook/team-tmux-guard.ts +49 -0
  376. package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
  377. package/src/scripts/run-provider-advisor.ts +11 -3
  378. package/src/scripts/smoke-packed-install.ts +107 -0
  379. package/src/scripts/sync-plugin-mirror.ts +3 -3
  380. package/src/scripts/verify-native-agents.ts +2 -2
  381. package/templates/catalog-manifest.json +7 -0
@@ -1,7 +1,11 @@
1
1
  use std::env;
2
2
  use std::fs;
3
+ use std::io::{Read, Write};
4
+ use std::net::{TcpListener, TcpStream};
3
5
  use std::path::{Path, PathBuf};
4
6
  use std::process::Command;
7
+ use std::sync::{Arc, Mutex};
8
+ use std::thread::{self, JoinHandle};
5
9
  use std::time::{SystemTime, UNIX_EPOCH};
6
10
 
7
11
  fn sparkshell_bin() -> &'static str {
@@ -32,6 +36,80 @@ fn write_executable(path: &Path, body: &str) {
32
36
  }
33
37
  }
34
38
 
39
+ fn start_api_server<F>(expected_requests: usize, mut handler: F) -> (String, JoinHandle<()>)
40
+ where
41
+ F: FnMut(String) -> (u16, String) + Send + 'static,
42
+ {
43
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind api server");
44
+ let address = listener.local_addr().expect("api address");
45
+ let handle = thread::spawn(move || {
46
+ for _ in 0..expected_requests {
47
+ let (stream, _) = listener.accept().expect("accept api request");
48
+ let request = read_http_request(stream.try_clone().expect("clone stream"));
49
+ let (status, body) = handler(request);
50
+ write_http_response(stream, status, &body);
51
+ }
52
+ });
53
+ (format!("http://{}", address), handle)
54
+ }
55
+
56
+ fn read_http_request(mut stream: TcpStream) -> String {
57
+ let mut buffer = Vec::new();
58
+ let mut scratch = [0; 1024];
59
+ loop {
60
+ let count = stream.read(&mut scratch).expect("read request");
61
+ assert!(count > 0, "connection closed before request headers");
62
+ buffer.extend_from_slice(&scratch[..count]);
63
+ if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
64
+ break;
65
+ }
66
+ }
67
+ let request = String::from_utf8_lossy(&buffer).into_owned();
68
+ let content_length = request
69
+ .lines()
70
+ .find_map(|line| {
71
+ line.strip_prefix("Content-Length:")
72
+ .or_else(|| line.strip_prefix("content-length:"))
73
+ .and_then(|value| value.trim().parse::<usize>().ok())
74
+ })
75
+ .unwrap_or(0);
76
+ let body_start = buffer
77
+ .windows(4)
78
+ .position(|window| window == b"\r\n\r\n")
79
+ .expect("header boundary")
80
+ + 4;
81
+ while buffer.len() - body_start < content_length {
82
+ let count = stream.read(&mut scratch).expect("read request body");
83
+ assert!(count > 0, "connection closed before request body");
84
+ buffer.extend_from_slice(&scratch[..count]);
85
+ }
86
+ String::from_utf8_lossy(&buffer).into_owned()
87
+ }
88
+
89
+ fn write_http_response(mut stream: TcpStream, status: u16, body: &str) {
90
+ let reason = if (200..300).contains(&status) {
91
+ "OK"
92
+ } else {
93
+ "ERROR"
94
+ };
95
+ let response = format!(
96
+ "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
97
+ body.len()
98
+ );
99
+ stream
100
+ .write_all(response.as_bytes())
101
+ .expect("write response");
102
+ }
103
+
104
+ fn response_json(text: &str) -> String {
105
+ format!(
106
+ "{{\"object\":\"response\",\"output_text\":\"{}\"}}",
107
+ text.replace('\\', "\\\\")
108
+ .replace('"', "\\\"")
109
+ .replace('\n', "\\n")
110
+ )
111
+ }
112
+
35
113
  #[test]
36
114
  fn raw_mode_preserves_stdout_and_stderr() {
37
115
  let output = Command::new(sparkshell_bin())
@@ -48,27 +126,19 @@ fn raw_mode_preserves_stdout_and_stderr() {
48
126
  }
49
127
 
50
128
  #[test]
51
- fn summary_mode_uses_codex_exec_and_model_override() {
52
- let temp = unique_temp_dir("codex-success");
53
- let codex = temp.join("codex");
54
- let args_log = temp.join("args.log");
55
- let prompt_log = temp.join("prompt.log");
56
- write_executable(
57
- &codex,
58
- &format!(
59
- "#!/bin/sh\nprintf '%s\n' \"$@\" > '{}'\ncat > '{}'\nprintf '%s\n' '- summary: command produced long output' '- warnings: stderr was empty'\n",
60
- args_log.display(),
61
- prompt_log.display()
62
- ),
63
- );
129
+ fn summary_mode_uses_local_api_and_model_override() {
130
+ let request_log = Arc::new(Mutex::new(String::new()));
131
+ let request_log_for_server = Arc::clone(&request_log);
132
+ let (base_url, server) = start_api_server(1, move |request| {
133
+ *request_log_for_server.lock().expect("request log") = request;
134
+ (
135
+ 200,
136
+ response_json("- summary: command produced long output\n- warnings: stderr was empty"),
137
+ )
138
+ });
64
139
 
65
- let path = format!(
66
- "{}:{}",
67
- temp.display(),
68
- env::var("PATH").unwrap_or_default()
69
- );
70
140
  let output = Command::new(sparkshell_bin())
71
- .env("PATH", path)
141
+ .env("OMX_API_BASE_URL", base_url)
72
142
  .env("OMX_SPARKSHELL_LINES", "1")
73
143
  .env("OMX_SPARKSHELL_MODEL", "spark-test-model")
74
144
  .arg("sh")
@@ -76,6 +146,7 @@ fn summary_mode_uses_codex_exec_and_model_override() {
76
146
  .arg("printf 'one\ntwo\n'")
77
147
  .output()
78
148
  .expect("run sparkshell");
149
+ server.join().expect("api server");
79
150
 
80
151
  assert!(output.status.success());
81
152
  let stdout = String::from_utf8_lossy(&output.stdout);
@@ -83,42 +154,65 @@ fn summary_mode_uses_codex_exec_and_model_override() {
83
154
  assert!(stdout.contains("- warnings: stderr was empty"));
84
155
  assert!(String::from_utf8_lossy(&output.stderr).is_empty());
85
156
 
86
- let args = fs::read_to_string(args_log).expect("args log");
87
- assert!(args.contains("exec"));
88
- assert!(args.contains("--model"));
89
- assert!(args.contains("spark-test-model"));
90
- assert!(args.contains("model_reasoning_effort=\"low\""));
157
+ let request = request_log.lock().expect("request log");
158
+ assert!(request.starts_with("POST /v1/responses HTTP/1.1"));
159
+ assert!(request.contains("\"model\":\"spark-test-model\""));
160
+ assert!(request.contains("\"reasoning\":{\"effort\":\"low\"}"));
161
+ assert!(request.contains("Command family: generic-shell"));
162
+ assert!(request.contains("<<<STDOUT"));
163
+ assert!(request.contains("one\\ntwo"));
164
+ }
91
165
 
92
- let prompt = fs::read_to_string(prompt_log).expect("prompt log");
93
- assert!(prompt.contains("Command family: generic-shell"));
94
- assert!(prompt.contains("<<<STDOUT"));
95
- assert!(prompt.contains("one\ntwo"));
166
+ #[test]
167
+ fn summary_mode_redacts_secret_like_output_before_prompt_request() {
168
+ let request_log = Arc::new(Mutex::new(String::new()));
169
+ let request_log_for_server = Arc::clone(&request_log);
170
+ let (base_url, server) = start_api_server(1, move |request| {
171
+ *request_log_for_server.lock().expect("request log") = request;
172
+ (200, response_json("- summary: redacted output summarized"))
173
+ });
96
174
 
97
- let _ = fs::remove_dir_all(temp);
175
+ let output = Command::new(sparkshell_bin())
176
+ .env("OMX_API_BASE_URL", base_url)
177
+ .env("OMX_SPARKSHELL_LINES", "1")
178
+ .env("CHILD_API_TOKEN", "super-secret-token")
179
+ .env("CHILD_BEARER", "bearer-secret-token")
180
+ .arg("sh")
181
+ .arg("-c")
182
+ .arg("printf 'API_TOKEN=%s\\nline-2\\n' \"$CHILD_API_TOKEN\"; printf 'Authorization: Bearer %s\\n' \"$CHILD_BEARER\" >&2")
183
+ .output()
184
+ .expect("run sparkshell");
185
+ server.join().expect("api server");
186
+
187
+ assert!(output.status.success());
188
+ assert!(String::from_utf8_lossy(&output.stdout).contains("redacted output summarized"));
189
+
190
+ let request = request_log.lock().expect("request log");
191
+ assert!(request.contains("API_TOKEN=[REDACTED]"));
192
+ assert!(request.contains("Authorization: Bearer [REDACTED]"));
193
+ assert!(request.contains("line-2"));
194
+ assert!(!request.contains("super-secret-token"));
195
+ assert!(!request.contains("bearer-secret-token"));
98
196
  }
99
197
 
100
198
  #[test]
101
199
  fn summary_mode_injects_model_instructions_file_override() {
102
- let temp = unique_temp_dir("codex-instructions-file");
103
- let codex = temp.join("codex");
104
- let args_log = temp.join("args.log");
200
+ let temp = unique_temp_dir("api-instructions-file");
105
201
  let instructions_file = temp.join("sparkshell-lightweight-AGENTS.md");
106
- write_executable(
107
- &codex,
108
- &format!(
109
- "#!/bin/sh\nprintf '%s\n' \"$@\" > '{}'\nprintf '%s\n' '- summary: command produced long output'\n",
110
- args_log.display()
111
- ),
112
- );
113
202
  fs::write(&instructions_file, "# sparkshell instructions\n").expect("write instructions file");
114
203
 
115
- let path = format!(
116
- "{}:{}",
117
- temp.display(),
118
- env::var("PATH").unwrap_or_default()
119
- );
204
+ let request_log = Arc::new(Mutex::new(String::new()));
205
+ let request_log_for_server = Arc::clone(&request_log);
206
+ let (base_url, server) = start_api_server(1, move |request| {
207
+ *request_log_for_server.lock().expect("request log") = request;
208
+ (
209
+ 200,
210
+ response_json("- summary: command produced long output"),
211
+ )
212
+ });
213
+
120
214
  let output = Command::new(sparkshell_bin())
121
- .env("PATH", path)
215
+ .env("OMX_API_BASE_URL", base_url)
122
216
  .env("OMX_SPARKSHELL_LINES", "1")
123
217
  .env(
124
218
  "OMX_SPARKSHELL_MODEL_INSTRUCTIONS_FILE",
@@ -129,87 +223,64 @@ fn summary_mode_injects_model_instructions_file_override() {
129
223
  .arg("printf 'one\ntwo\n'")
130
224
  .output()
131
225
  .expect("run sparkshell");
226
+ server.join().expect("api server");
132
227
 
133
228
  assert!(output.status.success());
134
- let args = fs::read_to_string(args_log).expect("args log");
135
- assert!(args.contains("model_reasoning_effort=\"low\""));
136
- assert!(args.contains(&format!(
137
- "model_instructions_file=\"{}\"",
138
- instructions_file
139
- .display()
140
- .to_string()
141
- .replace('\\', "\\\\")
142
- .replace('"', "\\\"")
143
- )));
229
+ let request = request_log.lock().expect("request log");
230
+ assert!(request.contains("\"reasoning\":{\"effort\":\"low\"}"));
231
+ assert!(request.contains("\"instructions\":\"# sparkshell instructions\\n\""));
144
232
 
145
233
  let _ = fs::remove_dir_all(temp);
146
234
  }
147
235
 
148
236
  #[test]
149
237
  fn summary_failure_falls_back_to_raw_output_with_notice() {
150
- let temp = unique_temp_dir("codex-fail");
151
- let codex = temp.join("codex");
152
- write_executable(
153
- &codex,
154
- "#!/bin/sh\nprintf '%s\n' 'bridge failed' >&2\nexit 9\n",
155
- );
238
+ let (base_url, server) = start_api_server(1, |_request| {
239
+ (503, "{\"error\":\"bridge failed\"}".to_string())
240
+ });
156
241
 
157
- let path = format!(
158
- "{}:{}",
159
- temp.display(),
160
- env::var("PATH").unwrap_or_default()
161
- );
162
242
  let output = Command::new(sparkshell_bin())
163
- .env("PATH", path)
243
+ .env("OMX_API_BASE_URL", base_url)
164
244
  .env("OMX_SPARKSHELL_LINES", "1")
165
245
  .arg("/bin/sh")
166
246
  .arg("-c")
167
247
  .arg("printf 'one\ntwo\n'; printf 'child-err\n' >&2")
168
248
  .output()
169
249
  .expect("run sparkshell");
250
+ server.join().expect("api server");
170
251
 
171
252
  assert!(output.status.success());
172
253
  assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
173
254
  let stderr = String::from_utf8_lossy(&output.stderr);
174
255
  assert!(stderr.contains("child-err"));
175
256
  assert!(stderr.contains("summary unavailable"));
176
-
177
- let _ = fs::remove_dir_all(temp);
257
+ assert!(stderr.contains("showing raw output instead"));
178
258
  }
179
259
 
180
260
  #[test]
181
261
  fn summary_mode_retries_with_fallback_model_when_spark_is_unavailable() {
182
- let temp = unique_temp_dir("codex-fallback-model");
183
- let codex = temp.join("codex");
184
- let args_log = temp.join("args.log");
185
- write_executable(
186
- &codex,
187
- &format!(
188
- "#!/bin/sh
189
- printf '%s\n' \"$@\" >> '{}'
190
- model=''
191
- prev=''
192
- for arg in \"$@\"; do
193
- if [ \"$prev\" = '--model' ]; then model=\"$arg\"; fi
194
- prev=\"$arg\"
195
- done
196
- if [ \"$model\" = 'spark-test-model' ]; then
197
- printf '%s\n' 'rate limit exceeded for spark model' >&2
198
- exit 17
199
- fi
200
- printf '%s\n' '- summary: fallback model recovered summary'
201
- ",
202
- args_log.display()
203
- ),
204
- );
262
+ let request_log = Arc::new(Mutex::new(Vec::new()));
263
+ let request_log_for_server = Arc::clone(&request_log);
264
+ let (base_url, server) = start_api_server(2, move |request| {
265
+ request_log_for_server
266
+ .lock()
267
+ .expect("request log")
268
+ .push(request.clone());
269
+ if request.contains("\"model\":\"spark-test-model\"") {
270
+ (
271
+ 429,
272
+ "{\"error\":\"rate limit exceeded for spark model\"}".to_string(),
273
+ )
274
+ } else {
275
+ (
276
+ 200,
277
+ response_json("- summary: fallback model recovered summary"),
278
+ )
279
+ }
280
+ });
205
281
 
206
- let path = format!(
207
- "{}:{}",
208
- temp.display(),
209
- env::var("PATH").unwrap_or_default()
210
- );
211
282
  let output = Command::new(sparkshell_bin())
212
- .env("PATH", path)
283
+ .env("OMX_API_BASE_URL", base_url)
213
284
  .env("OMX_SPARKSHELL_LINES", "1")
214
285
  .env("OMX_SPARKSHELL_MODEL", "spark-test-model")
215
286
  .env("OMX_SPARKSHELL_FALLBACK_MODEL", "frontier-test-model")
@@ -218,47 +289,36 @@ printf '%s\n' '- summary: fallback model recovered summary'
218
289
  .arg("printf 'one\ntwo\n'")
219
290
  .output()
220
291
  .expect("run sparkshell");
292
+ server.join().expect("api server");
221
293
 
222
294
  assert!(output.status.success());
223
295
  assert!(String::from_utf8_lossy(&output.stdout).contains("fallback model recovered summary"));
224
296
  assert!(String::from_utf8_lossy(&output.stderr).is_empty());
225
297
 
226
- let args = fs::read_to_string(args_log).expect("args log");
227
- assert!(args.contains("spark-test-model"));
228
- assert!(args.contains("frontier-test-model"));
229
-
230
- let _ = fs::remove_dir_all(temp);
298
+ let requests = request_log.lock().expect("request log");
299
+ assert_eq!(requests.len(), 2);
300
+ assert!(requests[0].contains("\"model\":\"spark-test-model\""));
301
+ assert!(requests[1].contains("\"model\":\"frontier-test-model\""));
231
302
  }
232
303
 
233
304
  #[test]
234
305
  fn summary_mode_reports_both_models_when_fallback_also_fails() {
235
- let temp = unique_temp_dir("codex-fallback-model-fail");
236
- let codex = temp.join("codex");
237
- write_executable(
238
- &codex,
239
- "#!/bin/sh
240
- model=''
241
- prev=''
242
- for arg in \"$@\"; do
243
- if [ \"$prev\" = '--model' ]; then model=\"$arg\"; fi
244
- prev=\"$arg\"
245
- done
246
- if [ \"$model\" = 'spark-test-model' ]; then
247
- printf '%s\n' 'quota exhausted for spark model' >&2
248
- exit 17
249
- fi
250
- printf '%s\n' 'fallback backend unavailable' >&2
251
- exit 29
252
- ",
253
- );
306
+ let (base_url, server) = start_api_server(2, |request| {
307
+ if request.contains("\"model\":\"spark-test-model\"") {
308
+ (
309
+ 429,
310
+ "{\"error\":\"quota exhausted for spark model\"}".to_string(),
311
+ )
312
+ } else {
313
+ (
314
+ 503,
315
+ "{\"error\":\"fallback backend unavailable\"}".to_string(),
316
+ )
317
+ }
318
+ });
254
319
 
255
- let path = format!(
256
- "{}:{}",
257
- temp.display(),
258
- env::var("PATH").unwrap_or_default()
259
- );
260
320
  let output = Command::new(sparkshell_bin())
261
- .env("PATH", path)
321
+ .env("OMX_API_BASE_URL", base_url)
262
322
  .env("OMX_SPARKSHELL_LINES", "1")
263
323
  .env("OMX_SPARKSHELL_MODEL", "spark-test-model")
264
324
  .env("OMX_SPARKSHELL_FALLBACK_MODEL", "frontier-test-model")
@@ -267,6 +327,7 @@ exit 29
267
327
  .arg("printf 'one\ntwo\n'; printf 'child-err\n' >&2")
268
328
  .output()
269
329
  .expect("run sparkshell");
330
+ server.join().expect("api server");
270
331
 
271
332
  assert!(output.status.success());
272
333
  assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
@@ -274,48 +335,34 @@ exit 29
274
335
  assert!(stderr.contains("child-err"));
275
336
  assert!(stderr.contains("primary model `spark-test-model`"));
276
337
  assert!(stderr.contains("fallback model `frontier-test-model`"));
277
-
278
- let _ = fs::remove_dir_all(temp);
279
338
  }
280
339
 
281
340
  #[test]
282
341
  fn summary_mode_preserves_child_exit_code() {
283
- let temp = unique_temp_dir("codex-exit");
284
- let codex = temp.join("codex");
285
- write_executable(
286
- &codex,
287
- "#!/bin/sh\nprintf '%s\n' '- failures: command exited non-zero'\n",
288
- );
342
+ let (base_url, server) = start_api_server(1, |_request| {
343
+ (200, response_json("- failures: command exited non-zero"))
344
+ });
289
345
 
290
- let path = format!(
291
- "{}:{}",
292
- temp.display(),
293
- env::var("PATH").unwrap_or_default()
294
- );
295
346
  let output = Command::new(sparkshell_bin())
296
- .env("PATH", path)
347
+ .env("OMX_API_BASE_URL", base_url)
297
348
  .env("OMX_SPARKSHELL_LINES", "1")
298
349
  .arg("sh")
299
350
  .arg("-c")
300
351
  .arg("printf 'one\ntwo\n'; exit 7")
301
352
  .output()
302
353
  .expect("run sparkshell");
354
+ server.join().expect("api server");
303
355
 
304
356
  assert_eq!(output.status.code(), Some(7));
305
357
  assert!(String::from_utf8_lossy(&output.stdout).contains("- failures: command exited non-zero"));
306
358
  assert!(String::from_utf8_lossy(&output.stderr).is_empty());
307
-
308
- let _ = fs::remove_dir_all(temp);
309
359
  }
310
360
 
311
361
  #[test]
312
362
  fn tmux_pane_mode_captures_large_tail_and_summarizes() {
313
363
  let temp = unique_temp_dir("tmux-pane-summary");
314
364
  let tmux = temp.join("tmux");
315
- let codex = temp.join("codex");
316
365
  let args_log = temp.join("tmux-args.log");
317
- let prompt_log = temp.join("pane-prompt.log");
318
-
319
366
  write_executable(
320
367
  &tmux,
321
368
  &format!(
@@ -323,13 +370,16 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
323
370
  args_log.display()
324
371
  ),
325
372
  );
326
- write_executable(
327
- &codex,
328
- &format!(
329
- "#!/bin/sh\ncat > '{}'\nprintf '%s\n' '- summary: tmux pane summarized' '- warnings: tail captured'\n",
330
- prompt_log.display()
331
- ),
332
- );
373
+
374
+ let request_log = Arc::new(Mutex::new(String::new()));
375
+ let request_log_for_server = Arc::clone(&request_log);
376
+ let (base_url, server) = start_api_server(1, move |request| {
377
+ *request_log_for_server.lock().expect("request log") = request;
378
+ (
379
+ 200,
380
+ response_json("- summary: tmux pane summarized\n- warnings: tail captured"),
381
+ )
382
+ });
333
383
 
334
384
  let path = format!(
335
385
  "{}:{}",
@@ -338,6 +388,7 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
338
388
  );
339
389
  let output = Command::new(sparkshell_bin())
340
390
  .env("PATH", path)
391
+ .env("OMX_API_BASE_URL", base_url)
341
392
  .env("OMX_SPARKSHELL_LINES", "1")
342
393
  .arg("--tmux-pane")
343
394
  .arg("%17")
@@ -345,6 +396,7 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
345
396
  .arg("400")
346
397
  .output()
347
398
  .expect("run sparkshell");
399
+ server.join().expect("api server");
348
400
 
349
401
  assert!(output.status.success());
350
402
  let stdout = String::from_utf8_lossy(&output.stdout);
@@ -356,36 +408,16 @@ fn tmux_pane_mode_captures_large_tail_and_summarizes() {
356
408
  assert!(tmux_args.contains("%17"));
357
409
  assert!(tmux_args.contains("-400"));
358
410
 
359
- let prompt = fs::read_to_string(prompt_log).expect("prompt log");
360
- assert!(prompt.contains("Command: tmux capture-pane"));
361
- assert!(prompt.contains("line-1"));
411
+ let request = request_log.lock().expect("request log");
412
+ assert!(request.contains("Command: tmux capture-pane"));
413
+ assert!(request.contains("line-1"));
362
414
 
363
415
  let _ = fs::remove_dir_all(temp);
364
416
  }
365
417
 
366
418
  #[test]
367
419
  fn raw_mode_keeps_boundary_output_without_summary() {
368
- let temp = unique_temp_dir("boundary-raw");
369
- let codex = temp.join("codex");
370
- let codex_log = temp.join("codex.log");
371
- write_executable(
372
- &codex,
373
- &format!(
374
- "#!/bin/sh
375
- printf '%s\n' invoked > '{}'
376
- exit 0
377
- ",
378
- codex_log.display()
379
- ),
380
- );
381
-
382
- let path = format!(
383
- "{}:{}",
384
- temp.display(),
385
- env::var("PATH").unwrap_or_default()
386
- );
387
420
  let output = Command::new(sparkshell_bin())
388
- .env("PATH", path)
389
421
  .env("OMX_SPARKSHELL_LINES", "2")
390
422
  .arg("sh")
391
423
  .arg("-c")
@@ -394,67 +426,47 @@ exit 0
394
426
  .expect("run sparkshell");
395
427
 
396
428
  assert!(output.status.success());
397
- assert_eq!(
398
- String::from_utf8_lossy(&output.stdout),
399
- "one
400
- two
401
- "
402
- );
403
- assert!(
404
- !codex_log.exists(),
405
- "codex should not run at the raw/summary boundary"
406
- );
407
-
408
- let _ = fs::remove_dir_all(temp);
429
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
409
430
  }
410
431
 
411
432
  #[test]
412
433
  fn summary_mode_uses_combined_stdout_and_stderr_threshold() {
413
- let temp = unique_temp_dir("combined-threshold");
414
- let codex = temp.join("codex");
415
- let prompt_log = temp.join("prompt.log");
416
- write_executable(
417
- &codex,
418
- &format!(
419
- "#!/bin/sh
420
- cat > '{}'
421
- printf '%s\n' '- summary: combined output exceeded threshold'
422
- ",
423
- prompt_log.display()
424
- ),
425
- );
434
+ let request_log = Arc::new(Mutex::new(String::new()));
435
+ let request_log_for_server = Arc::clone(&request_log);
436
+ let (base_url, server) = start_api_server(1, move |request| {
437
+ *request_log_for_server.lock().expect("request log") = request;
438
+ (
439
+ 200,
440
+ response_json("- summary: combined output exceeded threshold"),
441
+ )
442
+ });
426
443
 
427
- let path = format!(
428
- "{}:{}",
429
- temp.display(),
430
- env::var("PATH").unwrap_or_default()
431
- );
432
444
  let output = Command::new(sparkshell_bin())
433
- .env("PATH", path)
445
+ .env("OMX_API_BASE_URL", base_url)
434
446
  .env("OMX_SPARKSHELL_LINES", "2")
435
447
  .arg("sh")
436
448
  .arg("-c")
437
449
  .arg("printf 'one\n' && printf 'warn\nextra\n' >&2")
438
450
  .output()
439
451
  .expect("run sparkshell");
452
+ server.join().expect("api server");
440
453
 
441
454
  assert!(output.status.success());
442
455
  assert!(String::from_utf8_lossy(&output.stdout).contains("combined output exceeded threshold"));
443
- let prompt = fs::read_to_string(prompt_log).expect("prompt log");
444
- assert!(prompt.contains("<<<STDERR"));
445
- assert!(prompt.contains(
446
- "warn
447
- extra"
448
- ));
449
-
450
- let _ = fs::remove_dir_all(temp);
456
+ let request = request_log.lock().expect("request log");
457
+ assert!(request.contains("<<<STDERR"));
458
+ assert!(request.contains("warn\\nextra"));
451
459
  }
452
460
 
453
461
  #[test]
454
- fn summary_failure_when_codex_is_missing_falls_back_to_raw_output() {
455
- let empty_path = unique_temp_dir("missing-codex");
462
+ fn summary_failure_when_api_is_missing_falls_back_to_raw_output() {
463
+ let listener = TcpListener::bind("127.0.0.1:0").expect("reserve port");
464
+ let base_url = format!("http://{}", listener.local_addr().expect("address"));
465
+ drop(listener);
466
+
456
467
  let output = Command::new(sparkshell_bin())
457
- .env("PATH", empty_path.display().to_string())
468
+ .env("OMX_API_BASE_URL", base_url)
469
+ .env("OMX_SPARKSHELL_SUMMARY_TIMEOUT_MS", "500")
458
470
  .env("OMX_SPARKSHELL_LINES", "1")
459
471
  .arg("/bin/sh")
460
472
  .arg("-c")
@@ -463,41 +475,53 @@ fn summary_failure_when_codex_is_missing_falls_back_to_raw_output() {
463
475
  .expect("run sparkshell");
464
476
 
465
477
  assert!(output.status.success());
466
- assert_eq!(
467
- String::from_utf8_lossy(&output.stdout),
468
- "one
469
- two
470
- "
471
- );
478
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "one\ntwo\n");
472
479
  let stderr = String::from_utf8_lossy(&output.stderr);
473
480
  assert!(stderr.contains("child-err"));
474
481
  assert!(stderr.contains("summary unavailable"));
482
+ assert!(stderr.contains("showing raw output instead"));
483
+ }
475
484
 
476
- let _ = fs::remove_dir_all(empty_path);
485
+ #[test]
486
+ fn json_summary_failure_reports_raw_output_omitted() {
487
+ let (base_url, server) = start_api_server(1, |_request| {
488
+ (503, "{\"error\":\"bridge failed\"}".to_string())
489
+ });
490
+
491
+ let output = Command::new(sparkshell_bin())
492
+ .env("OMX_API_BASE_URL", base_url)
493
+ .env("OMX_SPARKSHELL_LINES", "1")
494
+ .arg("--json")
495
+ .arg("sh")
496
+ .arg("-c")
497
+ .arg("printf 'one\ntwo\n'")
498
+ .output()
499
+ .expect("run sparkshell");
500
+ server.join().expect("api server");
501
+
502
+ assert!(output.status.success());
503
+ let stdout = String::from_utf8_lossy(&output.stdout);
504
+ assert!(stdout.contains("summary unavailable"));
505
+ assert!(stdout.contains("raw output omitted from JSON report"));
506
+ assert!(!stdout.contains("raw output included"));
507
+ assert!(String::from_utf8_lossy(&output.stderr).is_empty());
477
508
  }
478
509
 
479
510
  #[test]
480
511
  fn tmux_pane_mode_uses_default_tail_lines_when_not_overridden() {
481
512
  let temp = unique_temp_dir("tmux-default-tail");
482
513
  let tmux = temp.join("tmux");
483
- let codex = temp.join("codex");
484
514
  let args_log = temp.join("tmux-args.log");
485
515
  write_executable(
486
516
  &tmux,
487
517
  &format!(
488
- "#!/bin/sh
489
- printf '%s\n' \"$@\" > '{}'
490
- printf 'line-1\nline-2\nline-3\n'
491
- ",
518
+ "#!/bin/sh\nprintf '%s\n' \"$@\" > '{}'\nprintf 'line-1\nline-2\nline-3\n'\n",
492
519
  args_log.display()
493
520
  ),
494
521
  );
495
- write_executable(
496
- &codex,
497
- "#!/bin/sh
498
- printf '%s\n' '- summary: used default tmux tail'
499
- ",
500
- );
522
+ let (base_url, server) = start_api_server(1, |_request| {
523
+ (200, response_json("- summary: used default tmux tail"))
524
+ });
501
525
 
502
526
  let path = format!(
503
527
  "{}:{}",
@@ -506,11 +530,13 @@ printf '%s\n' '- summary: used default tmux tail'
506
530
  );
507
531
  let output = Command::new(sparkshell_bin())
508
532
  .env("PATH", path)
533
+ .env("OMX_API_BASE_URL", base_url)
509
534
  .env("OMX_SPARKSHELL_LINES", "1")
510
535
  .arg("--tmux-pane")
511
536
  .arg("%21")
512
537
  .output()
513
538
  .expect("run sparkshell");
539
+ server.join().expect("api server");
514
540
 
515
541
  assert!(output.status.success());
516
542
  let tmux_args = fs::read_to_string(args_log).expect("tmux args");
@@ -519,3 +545,442 @@ printf '%s\n' '- summary: used default tmux tail'
519
545
 
520
546
  let _ = fs::remove_dir_all(temp);
521
547
  }
548
+
549
+ #[test]
550
+ fn summary_module_does_not_shell_out_to_codex() {
551
+ let source =
552
+ fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/codex_bridge.rs"))
553
+ .expect("source");
554
+ assert!(!source.contains("Command::new(\"codex\")"));
555
+ assert!(!source.contains(".arg(\"exec\")"));
556
+ assert!(!source.contains("codex exec"));
557
+ }
558
+
559
+ #[test]
560
+ fn json_mode_emits_machine_readable_contract() {
561
+ let output = Command::new(sparkshell_bin())
562
+ .arg("--json")
563
+ .arg("sh")
564
+ .arg("-c")
565
+ .arg("printf 'ok\n'")
566
+ .output()
567
+ .expect("run sparkshell");
568
+
569
+ assert!(output.status.success());
570
+ let stdout = String::from_utf8_lossy(&output.stdout);
571
+ assert!(stdout.contains("\"ok\": true"));
572
+ assert!(stdout.contains("\"mode\": \"command\""));
573
+ assert!(stdout.contains("\"status\": \"ok\""));
574
+ assert!(stdout.contains("\"summary\":"));
575
+ assert!(stdout.contains("\"evidence\":"));
576
+ assert!(stdout.contains("\"raw_hash\":"));
577
+ }
578
+
579
+ #[test]
580
+ fn json_mode_reports_failed_command_details() {
581
+ let output = Command::new(sparkshell_bin())
582
+ .arg("--json")
583
+ .arg("sh")
584
+ .arg("-c")
585
+ .arg("printf 'bad\n' >&2; exit 9")
586
+ .output()
587
+ .expect("run sparkshell");
588
+
589
+ assert_eq!(output.status.code(), Some(9));
590
+ let stdout = String::from_utf8_lossy(&output.stdout);
591
+ assert!(stdout.contains("\"ok\": false"));
592
+ assert!(stdout.contains("\"status\": \"failed\""));
593
+ assert!(stdout.contains("\"exit_code\": 9"));
594
+ assert!(stdout.contains("bad"));
595
+ }
596
+
597
+ #[test]
598
+ fn json_mode_classifies_auth_errors() {
599
+ let output = Command::new(sparkshell_bin())
600
+ .arg("--json")
601
+ .arg("sh")
602
+ .arg("-c")
603
+ .arg("printf 'Authorization failed\n' >&2; exit 1")
604
+ .output()
605
+ .expect("run sparkshell");
606
+
607
+ assert_eq!(output.status.code(), Some(1));
608
+ let stdout = String::from_utf8_lossy(&output.stdout);
609
+ assert!(stdout.contains("\"classification\": \"auth_error\""));
610
+ assert!(stdout.contains("authentication-like error"));
611
+ }
612
+
613
+ #[test]
614
+ fn direct_command_preserves_child_json_flag() {
615
+ let temp = unique_temp_dir("child-json-flag");
616
+ let script = temp.join("echo-argv");
617
+ write_executable(
618
+ &script,
619
+ r#"#!/usr/bin/env bash
620
+ printf '%s\n' "$@"
621
+ "#,
622
+ );
623
+
624
+ let output = Command::new(sparkshell_bin())
625
+ .arg(script)
626
+ .arg("--json")
627
+ .arg("value")
628
+ .output()
629
+ .expect("run sparkshell");
630
+
631
+ assert!(output.status.success());
632
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "--json\nvalue\n");
633
+ let _ = fs::remove_dir_all(temp);
634
+ }
635
+
636
+ #[test]
637
+ fn team_diagnostics_reads_last_turn_at_heartbeat() {
638
+ let temp = unique_temp_dir("last-turn-heartbeat");
639
+ let worker_dir = temp.join("team/demo/workers/worker-1");
640
+ fs::create_dir_all(&worker_dir).expect("worker dir");
641
+ fs::write(
642
+ worker_dir.join("heartbeat.json"),
643
+ r#"{"last_turn_at":"1970-01-01T00:00:00.000Z"}"#,
644
+ )
645
+ .expect("heartbeat");
646
+ fs::write(
647
+ worker_dir.join("status.json"),
648
+ r#"{"state":"working","current_task_id":"1","updated_at":"2026-05-17T20:00:00.000Z"}"#,
649
+ )
650
+ .expect("status");
651
+
652
+ let output = Command::new(sparkshell_bin())
653
+ .env("OMX_TEAM_STATE_ROOT", temp.display().to_string())
654
+ .arg("--json")
655
+ .arg("--team")
656
+ .arg("demo")
657
+ .arg("--worker")
658
+ .arg("worker-1")
659
+ .arg("printf")
660
+ .arg("ok\n")
661
+ .output()
662
+ .expect("run sparkshell");
663
+
664
+ assert!(output.status.success());
665
+ assert!(
666
+ String::from_utf8_lossy(&output.stdout).contains("\"classification\": \"stale_heartbeat\"")
667
+ );
668
+ let _ = fs::remove_dir_all(temp);
669
+ }
670
+
671
+ fn run_team_status_diagnostics(status_json: &str) -> String {
672
+ let temp = unique_temp_dir("team-status");
673
+ let worker_dir = temp.join("team/demo/workers/worker-1");
674
+ fs::create_dir_all(&worker_dir).expect("worker dir");
675
+ fs::write(worker_dir.join("status.json"), status_json).expect("status");
676
+
677
+ let output = Command::new(sparkshell_bin())
678
+ .env("OMX_TEAM_STATE_ROOT", temp.display().to_string())
679
+ .arg("--json")
680
+ .arg("--team")
681
+ .arg("demo")
682
+ .arg("--worker")
683
+ .arg("worker-1")
684
+ .arg("sh")
685
+ .arg("-c")
686
+ .arg("printf 'quiet\n'")
687
+ .output()
688
+ .expect("run sparkshell");
689
+
690
+ let _ = fs::remove_dir_all(temp);
691
+ assert!(output.status.success());
692
+ String::from_utf8_lossy(&output.stdout).into_owned()
693
+ }
694
+
695
+ #[test]
696
+ fn json_mode_treats_worker_status_working_as_busy() {
697
+ let stdout = run_team_status_diagnostics(
698
+ r#"{"state":"working","current_task_id":"1","updated_at":"2026-05-17T20:00:00.000Z"}"#,
699
+ );
700
+
701
+ assert!(stdout.contains("\"classification\": \"busy_processing\""));
702
+ assert!(stdout.contains("\"next_action\": \"wait\""));
703
+ assert!(stdout.contains("do not shutdown yet"));
704
+ assert!(!stdout.contains("\"classification\": \"unknown\""));
705
+ }
706
+
707
+ #[test]
708
+ fn json_mode_reports_blocked_worker_status() {
709
+ let stdout = run_team_status_diagnostics(
710
+ r#"{"state":"blocked","updated_at":"2026-05-17T20:00:00.000Z"}"#,
711
+ );
712
+
713
+ assert!(stdout.contains("\"classification\": \"waiting_for_input\""));
714
+ assert!(stdout.contains("\"next_action\": \"inspect raw pane\""));
715
+ }
716
+
717
+ #[test]
718
+ fn json_mode_reports_failed_worker_status() {
719
+ let stdout = run_team_status_diagnostics(
720
+ r#"{"state":"failed","updated_at":"2026-05-17T20:00:00.000Z"}"#,
721
+ );
722
+
723
+ assert!(stdout.contains("\"classification\": \"test_failure\""));
724
+ assert!(stdout.contains("worker status is failed"));
725
+ }
726
+
727
+ #[test]
728
+ fn json_mode_leaves_inactive_worker_statuses_to_output_heuristics() {
729
+ for state in ["idle", "done", "draining", "unknown"] {
730
+ let stdout = run_team_status_diagnostics(&format!(
731
+ r#"{{"state":"{state}","updated_at":"2026-05-17T20:00:00.000Z"}}"#
732
+ ));
733
+
734
+ assert!(stdout.contains("\"classification\": \"unknown\""));
735
+ assert!(!stdout.contains("do not shutdown yet"));
736
+ }
737
+ }
738
+
739
+ #[test]
740
+ fn json_mode_reads_team_state_from_env_root() {
741
+ let temp = unique_temp_dir("team-state");
742
+ let worker_dir = temp.join("team/demo/workers/worker-1");
743
+ fs::create_dir_all(&worker_dir).expect("worker dir");
744
+ fs::write(
745
+ worker_dir.join("status.json"),
746
+ r#"{"state":"busy","task":"in_progress"}"#,
747
+ )
748
+ .expect("status");
749
+
750
+ let output = Command::new(sparkshell_bin())
751
+ .env("OMX_TEAM_STATE_ROOT", temp.display().to_string())
752
+ .arg("--json")
753
+ .arg("--team")
754
+ .arg("demo")
755
+ .arg("--worker")
756
+ .arg("worker-1")
757
+ .arg("sh")
758
+ .arg("-c")
759
+ .arg("printf 'quiet\n'")
760
+ .output()
761
+ .expect("run sparkshell");
762
+
763
+ assert!(output.status.success());
764
+ let stdout = String::from_utf8_lossy(&output.stdout);
765
+ assert!(stdout.contains("\"classification\": \"busy_processing\""));
766
+ assert!(stdout.contains("do not shutdown yet"));
767
+
768
+ let _ = fs::remove_dir_all(temp);
769
+ }
770
+
771
+ #[test]
772
+ fn pane_json_cache_reports_hits_and_since_last_changes() {
773
+ let temp = unique_temp_dir("pane-cache");
774
+ let tmux = temp.join("tmux");
775
+ let cache = temp.join("cache");
776
+ let pane = temp.join("pane.txt");
777
+ fs::write(&pane, "line-1\nline-2\n").expect("pane");
778
+ write_executable(&tmux, &format!("#!/bin/sh\ncat {}\n", pane.display()));
779
+ let path = format!(
780
+ "{}:{}",
781
+ temp.display(),
782
+ env::var("PATH").unwrap_or_default()
783
+ );
784
+
785
+ let first = Command::new(sparkshell_bin())
786
+ .env("PATH", &path)
787
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
788
+ .arg("--json")
789
+ .arg("--tmux-pane")
790
+ .arg("%31")
791
+ .output()
792
+ .expect("first");
793
+ assert!(first.status.success());
794
+ assert!(String::from_utf8_lossy(&first.stdout).contains("\"cache_hit\":false"));
795
+
796
+ let second = Command::new(sparkshell_bin())
797
+ .env("PATH", &path)
798
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
799
+ .arg("--json")
800
+ .arg("--tmux-pane")
801
+ .arg("%31")
802
+ .output()
803
+ .expect("second");
804
+ assert!(second.status.success());
805
+ assert!(String::from_utf8_lossy(&second.stdout).contains("\"cache_hit\":true"));
806
+
807
+ fs::write(&pane, "line-1\nline-2\nline-3\n").expect("pane update");
808
+ let third = Command::new(sparkshell_bin())
809
+ .env("PATH", &path)
810
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
811
+ .arg("--json")
812
+ .arg("--since-last")
813
+ .arg("--tmux-pane")
814
+ .arg("%31")
815
+ .output()
816
+ .expect("third");
817
+ assert!(third.status.success());
818
+ let stdout = String::from_utf8_lossy(&third.stdout);
819
+ assert!(stdout.contains("\"changed_line_ranges\":[\"3-3\"]"));
820
+ assert!(stdout.contains("new findings since last observation"));
821
+ assert!(stdout.contains("line-3"));
822
+
823
+ let _ = fs::remove_dir_all(temp);
824
+ }
825
+
826
+ #[test]
827
+ fn pane_cache_key_does_not_escape_cache_dir_for_path_like_pane_ids() {
828
+ let temp = unique_temp_dir("pane-cache-traversal");
829
+ let tmux = temp.join("tmux");
830
+ let cache = temp.join("cache");
831
+ let intermediate = cache.join("pane-..");
832
+ let outside = temp.join("outside-pr2371.txt");
833
+
834
+ fs::create_dir_all(&intermediate).expect("intermediate cache dir");
835
+ write_executable(
836
+ &tmux,
837
+ "#!/bin/sh
838
+ printf 'safe pane output
839
+ '
840
+ ",
841
+ );
842
+ let path = format!(
843
+ "{}:{}",
844
+ temp.display(),
845
+ env::var("PATH").unwrap_or_default()
846
+ );
847
+
848
+ let output = Command::new(sparkshell_bin())
849
+ .env("PATH", &path)
850
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
851
+ .arg("--json")
852
+ .arg("--tmux-pane")
853
+ .arg("../../../outside-pr2371")
854
+ .output()
855
+ .expect("run sparkshell");
856
+
857
+ assert!(
858
+ output.status.success(),
859
+ "sparkshell failed: stdout={} stderr={}",
860
+ String::from_utf8_lossy(&output.stdout),
861
+ String::from_utf8_lossy(&output.stderr)
862
+ );
863
+ assert!(
864
+ !outside.exists(),
865
+ "path-like pane id wrote outside cache dir at {}",
866
+ outside.display()
867
+ );
868
+
869
+ let cache_files = fs::read_dir(&cache)
870
+ .expect("cache dir")
871
+ .map(|entry| entry.expect("cache entry").path())
872
+ .collect::<Vec<_>>();
873
+ assert!(
874
+ cache_files.iter().any(|path| {
875
+ path.is_file()
876
+ && path.parent() == Some(cache.as_path())
877
+ && path
878
+ .file_name()
879
+ .and_then(|name| name.to_str())
880
+ .is_some_and(|name| name.starts_with("pane-h") && name.ends_with(".txt"))
881
+ }),
882
+ "expected sanitized pane cache file directly under cache dir, got {cache_files:?}"
883
+ );
884
+
885
+ let _ = fs::remove_dir_all(temp);
886
+ }
887
+
888
+ #[test]
889
+ fn pane_cache_does_not_persist_raw_secret_like_text() {
890
+ let temp = unique_temp_dir("pane-cache-secret");
891
+ let tmux = temp.join("tmux");
892
+ let cache = temp.join("cache");
893
+ let pane = temp.join("pane.txt");
894
+ let secret_line = "OPENAI_API_KEY=sk-test-secret";
895
+ fs::write(
896
+ &pane,
897
+ format!(
898
+ "starting
899
+ {secret_line}
900
+ finished
901
+ "
902
+ ),
903
+ )
904
+ .expect("pane");
905
+ write_executable(
906
+ &tmux,
907
+ &format!(
908
+ "#!/bin/sh
909
+ cat {}
910
+ ",
911
+ pane.display()
912
+ ),
913
+ );
914
+ let path = format!(
915
+ "{}:{}",
916
+ temp.display(),
917
+ env::var("PATH").unwrap_or_default()
918
+ );
919
+
920
+ let output = Command::new(sparkshell_bin())
921
+ .env("PATH", &path)
922
+ .env("OMX_SPARKSHELL_CACHE_DIR", cache.display().to_string())
923
+ .arg("--json")
924
+ .arg("--tmux-pane")
925
+ .arg("%99")
926
+ .output()
927
+ .expect("run sparkshell");
928
+
929
+ assert!(output.status.success());
930
+ let cache_file = cache.join("pane-pct99.txt");
931
+ let cached = fs::read_to_string(&cache_file).expect("cache file");
932
+ assert!(
933
+ !cached.contains(secret_line),
934
+ "cache file persisted raw secret-like pane text: {cached}"
935
+ );
936
+ assert!(
937
+ !cached.contains("sk-test-secret"),
938
+ "cache file persisted raw token value: {cached}"
939
+ );
940
+ assert!(
941
+ !cached.contains("OPENAI_API_KEY"),
942
+ "cache file persisted raw secret variable name: {cached}"
943
+ );
944
+ assert!(cached.contains("omx-sparkshell-cache-v2"));
945
+ assert!(cached.contains("lines=3"));
946
+
947
+ let _ = fs::remove_dir_all(temp);
948
+ }
949
+
950
+ #[test]
951
+ fn raw_mode_preserves_non_utf8_bytes() {
952
+ let temp = unique_temp_dir("raw-non-utf8");
953
+ let script = temp.join("raw-bytes");
954
+ write_executable(
955
+ &script,
956
+ r#"#!/usr/bin/env bash
957
+ printf '\xff\xfe\n'
958
+ "#,
959
+ );
960
+
961
+ let output = Command::new(sparkshell_bin())
962
+ .arg(script)
963
+ .output()
964
+ .expect("run sparkshell");
965
+
966
+ assert!(output.status.success());
967
+ assert_eq!(output.stdout, vec![0xff, 0xfe, b'\n']);
968
+ let _ = fs::remove_dir_all(temp);
969
+ }
970
+
971
+ #[test]
972
+ fn shell_mode_executes_explicit_shell_and_redacts_json_output() {
973
+ let output = Command::new(sparkshell_bin())
974
+ .arg("--json")
975
+ .arg("--shell")
976
+ .arg("printf 'left && right\n'; printf 'Authorization: Bearer secret-token\n' >&2")
977
+ .output()
978
+ .expect("run sparkshell");
979
+
980
+ assert!(output.status.success());
981
+ let stdout = String::from_utf8_lossy(&output.stdout);
982
+ assert!(stdout.contains("\"mode\": \"shell\""));
983
+ assert!(stdout.contains("left && right"));
984
+ assert!(stdout.contains("Authorization: Bearer [REDACTED]"));
985
+ assert!(stdout.contains(r#""redactions": {"count": 1}"#));
986
+ }