@qwen-code/qwen-code 0.19.3 → 0.19.4-nightly.20260702.46814e4f1

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 (308) hide show
  1. package/bundled/loop/SKILL.md +22 -3
  2. package/bundled/loop/SKILL.test.ts +20 -0
  3. package/bundled/loop/autonomous-loop.test.ts +59 -0
  4. package/bundled/loop/autonomous-loop.ts +102 -0
  5. package/bundled/loop/loop-task-file.test.ts +1064 -0
  6. package/bundled/loop/loop-task-file.ts +404 -0
  7. package/bundled/loop/loop-tick-resolver.test.ts +906 -0
  8. package/bundled/loop/loop-tick-resolver.ts +416 -0
  9. package/bundled/qc-helper/SKILL.md +12 -5
  10. package/bundled/qc-helper/docs/configuration/auth.md +1 -1
  11. package/bundled/qc-helper/docs/configuration/settings.md +62 -32
  12. package/bundled/qc-helper/docs/features/approval-mode.md +3 -0
  13. package/bundled/qc-helper/docs/features/auto-mode.md +40 -0
  14. package/bundled/qc-helper/docs/features/channels/overview.md +81 -18
  15. package/bundled/qc-helper/docs/features/channels/plugins.md +1 -1
  16. package/bundled/qc-helper/docs/features/code-review.md +17 -61
  17. package/bundled/qc-helper/docs/features/commands.md +42 -40
  18. package/bundled/qc-helper/docs/features/headless.md +19 -18
  19. package/bundled/qc-helper/docs/features/hooks.md +6 -1
  20. package/bundled/qc-helper/docs/features/mcp.md +3 -1
  21. package/bundled/qc-helper/docs/features/memory.md +1 -1
  22. package/bundled/qc-helper/docs/features/scheduled-tasks.md +10 -0
  23. package/bundled/qc-helper/docs/features/status-line.md +3 -3
  24. package/bundled/qc-helper/docs/ide-integration/ide-integration.md +4 -4
  25. package/bundled/qc-helper/docs/qwen-serve.md +53 -0
  26. package/bundled/qc-helper/docs/support/troubleshooting.md +11 -0
  27. package/bundled/review/DESIGN.md +21 -26
  28. package/bundled/review/SKILL.md +45 -129
  29. package/chunks/MaxSizedBox-GHGRQ2OS.js +69 -0
  30. package/chunks/{StandaloneSessionPicker-Z52JTYST.js → StandaloneSessionPicker-G7YTTM27.js} +49 -42
  31. package/chunks/{acpAgent-7RZUOWOM.js → acpAgent-SDOILBSU.js} +1325 -365
  32. package/chunks/{agent-4FG7WTHJ.js → agent-NTSXGV4L.js} +31 -25
  33. package/chunks/agent-headless-WYURMSAB.js +62 -0
  34. package/chunks/{anthropicContentGenerator-PFITVL7Y.js → anthropicContentGenerator-WL3LJOUB.js} +110 -31
  35. package/chunks/{artifact-tool-D2X7G3C3.js → artifact-tool-W76WC2JX.js} +3 -3
  36. package/chunks/{askUserQuestion-UGETICJQ.js → askUserQuestion-J3P6TH3V.js} +2 -2
  37. package/chunks/bridge-AMOL4W4P.js +73 -0
  38. package/chunks/{ca-IUFWESZL.js → ca-HCMB2I54.js} +10 -9
  39. package/chunks/channel-worker-supervisor-C3IB63V7.js +659 -0
  40. package/chunks/{chunk-STUWFKGD.js → chunk-26JIREA5.js} +466 -543
  41. package/chunks/{chunk-SLNKLLEO.js → chunk-2MRV62AU.js} +37 -8
  42. package/chunks/{chunk-ODLIHNOY.js → chunk-2U4HUMOL.js} +1 -1
  43. package/chunks/{chunk-CKQA6AWF.js → chunk-4D73HUOP.js} +1 -1
  44. package/chunks/{chunk-M2U5NM24.js → chunk-54YZOP7P.js} +309 -72
  45. package/chunks/{chunk-J7FA27RO.js → chunk-5LKL3PAU.js} +3 -3
  46. package/chunks/chunk-5P5XGNYH.js +93 -0
  47. package/chunks/{chunk-UFLGERMB.js → chunk-6JE32BRB.js} +178 -124
  48. package/chunks/{chunk-FKAVULLM.js → chunk-75AVTQJ2.js} +74 -10
  49. package/chunks/{chunk-I2LYBGNI.js → chunk-7IOMIYOE.js} +1 -1
  50. package/chunks/{chunk-4LUZAKMK.js → chunk-7KXAGQJ2.js} +1 -1
  51. package/chunks/{chunk-BXSHL2SF.js → chunk-7LUOQJ3D.js} +1 -1
  52. package/chunks/{chunk-YAINVQ3Q.js → chunk-A5OPLHFM.js} +18693 -17055
  53. package/chunks/chunk-AHNXYU4O.js +24 -0
  54. package/chunks/{chunk-UX4SGCGN.js → chunk-AVQDCKNF.js} +96 -22
  55. package/chunks/{chunk-HYCC3XM3.js → chunk-B236XPGF.js} +1 -1
  56. package/chunks/chunk-B6SZLQAZ.js +3523 -0
  57. package/chunks/{chunk-5DWXLGOM.js → chunk-BJBWRCSK.js} +1 -1
  58. package/chunks/chunk-BPFJQGY7.js +193 -0
  59. package/chunks/{chunk-SKQZZVAV.js → chunk-BZHLWC6T.js} +2 -2
  60. package/chunks/{chunk-U3KSMM7G.js → chunk-D5KISTLW.js} +339 -383
  61. package/chunks/{chunk-GT2FGICD.js → chunk-DE7YXUR7.js} +1 -1
  62. package/chunks/{chunk-BUUQ5HPX.js → chunk-DG573EOO.js} +1 -1
  63. package/chunks/{chunk-VVLOGDB6.js → chunk-DX3TKXNT.js} +2 -2
  64. package/chunks/chunk-E5A7LHNN.js +51 -0
  65. package/chunks/{chunk-3PJXIDKI.js → chunk-E6US47KI.js} +3 -27
  66. package/chunks/{chunk-QBZ22PQX.js → chunk-EWALXUAD.js} +3 -1
  67. package/chunks/{chunk-PDE7CSDS.js → chunk-FXKI7OW6.js} +2 -2
  68. package/chunks/{chunk-F3ZYSEY6.js → chunk-FY4TT4L4.js} +3 -3
  69. package/chunks/{chunk-QLDPRILK.js → chunk-G7OQ3RFJ.js} +3 -3
  70. package/chunks/{chunk-R2BXK7SX.js → chunk-GHOTR7HL.js} +6 -1
  71. package/chunks/{chunk-KMGMKW5Q.js → chunk-GLPV75ES.js} +9 -3
  72. package/chunks/{chunk-LD2XBG6Z.js → chunk-GO6LNQXT.js} +1 -39
  73. package/chunks/{chunk-CNLEDYE3.js → chunk-H6TNLOLM.js} +5 -5
  74. package/chunks/{chunk-JVHEVCAU.js → chunk-HLW4EGKR.js} +31 -6
  75. package/chunks/{chunk-I4H7PRWQ.js → chunk-HTWHAN44.js} +1 -1
  76. package/chunks/{chunk-QE6KJYLT.js → chunk-IPYE5BIS.js} +123 -25
  77. package/chunks/{chunk-EJYJYJZ2.js → chunk-IRJWHZWU.js} +3 -70
  78. package/chunks/{chunk-EDMZZFDU.js → chunk-J3YPTTNL.js} +930 -798
  79. package/chunks/{chunk-GGBIN5K7.js → chunk-JGAJMIJZ.js} +1 -1
  80. package/chunks/{chunk-N4EJFBN5.js → chunk-JVLP5US5.js} +427 -263
  81. package/chunks/{chunk-R5OP2ATH.js → chunk-JZG7VB6C.js} +11 -11
  82. package/chunks/{chunk-QI7GOGJR.js → chunk-KKQWC6WS.js} +1 -1
  83. package/chunks/{chunk-ADCIHOQZ.js → chunk-KX5QZRW2.js} +142 -30
  84. package/chunks/{chunk-SBBN4BWR.js → chunk-L7OJ3ZO3.js} +1 -1
  85. package/chunks/{chunk-NDOC6M5S.js → chunk-L7QPM2YD.js} +2 -2
  86. package/chunks/{chunk-BVSVIN45.js → chunk-LKWAVAB5.js} +3 -3
  87. package/chunks/{chunk-T4T2FNV4.js → chunk-LOW52WYZ.js} +2 -2
  88. package/chunks/{chunk-4QK3O43S.js → chunk-LU7BIVWC.js} +4 -4
  89. package/chunks/{chunk-ZP6UKOPB.js → chunk-LWGLBVWA.js} +1 -1
  90. package/chunks/{chunk-L7OKCI7T.js → chunk-M7BLWURA.js} +15 -1
  91. package/chunks/{chunk-PR32FRT6.js → chunk-MIM62IXS.js} +3 -3
  92. package/chunks/{chunk-RD6YJJJ6.js → chunk-MOUXUCXQ.js} +1 -1
  93. package/chunks/{chunk-VBZ452JT.js → chunk-MSNGFH6L.js} +494 -114
  94. package/chunks/{chunk-BX5YO7UW.js → chunk-MTNTVOJR.js} +542 -370
  95. package/chunks/chunk-MX2YXRER.js +44 -0
  96. package/chunks/chunk-NIOPRZNN.js +27 -0
  97. package/chunks/{chunk-SMYQZVZ3.js → chunk-NOGSPTLJ.js} +1 -1
  98. package/chunks/{chunk-DDMOU62Q.js → chunk-NPQWQYU6.js} +1 -1
  99. package/chunks/{chunk-MNF6S5PR.js → chunk-P5THQV6A.js} +4 -4
  100. package/chunks/{chunk-6V4NZFUS.js → chunk-PCYOT4PK.js} +5 -5
  101. package/chunks/{chunk-CF6BB3LT.js → chunk-PPFWEV7G.js} +10 -1
  102. package/chunks/{chunk-FXKVLFFY.js → chunk-PSFDB5FZ.js} +3 -3
  103. package/chunks/{chunk-XJYUPHRC.js → chunk-QOMAHFYI.js} +3 -3
  104. package/chunks/{chunk-J37FGIOA.js → chunk-QXPI7FEC.js} +38 -4
  105. package/chunks/{chunk-RRFZXMYP.js → chunk-QZDBHNMV.js} +691 -164
  106. package/chunks/{chunk-S7AKE7HL.js → chunk-R6RI2HIC.js} +63 -9
  107. package/chunks/{chunk-HZT4UCFB.js → chunk-RB7S52NJ.js} +4 -4
  108. package/chunks/{chunk-J33ZR6OJ.js → chunk-RK22ACPP.js} +16 -16
  109. package/chunks/chunk-RM3PGSGL.js +66 -0
  110. package/chunks/chunk-RNBYOUGV.js +42 -0
  111. package/chunks/chunk-S6RLAIUR.js +23 -0
  112. package/chunks/{chunk-3TV3HUWZ.js → chunk-SCYK2YLI.js} +81 -30
  113. package/chunks/{chunk-HPZTOP4I.js → chunk-SEKRT4SD.js} +133 -23
  114. package/chunks/chunk-SEZC2725.js +36 -0
  115. package/chunks/chunk-SIUQ3YYX.js +78 -0
  116. package/chunks/{chunk-O2VBQ3Y2.js → chunk-SJBIFVD2.js} +2 -2
  117. package/chunks/chunk-SYCJMSIJ.js +82 -0
  118. package/chunks/{chunk-JE5FVRPC.js → chunk-T22H4V4R.js} +48 -23
  119. package/chunks/{chunk-3227DZO4.js → chunk-T42ACHFI.js} +1 -1
  120. package/chunks/{chunk-UO5GVXQE.js → chunk-TENOUWY2.js} +3 -3
  121. package/chunks/{chunk-JOLIREYR.js → chunk-TOAUDIS7.js} +1 -1
  122. package/chunks/{chunk-QNKBEJO2.js → chunk-UA6XBZT7.js} +2776 -1289
  123. package/chunks/{chunk-O75BHFQW.js → chunk-UQTYK5SS.js} +2 -2
  124. package/chunks/{chunk-Y6YMRS3U.js → chunk-UY2TCIZS.js} +17 -3
  125. package/chunks/{chunk-FNJ7BDPH.js → chunk-UY7ZAYTY.js} +10 -7
  126. package/chunks/{chunk-UYT3MWEB.js → chunk-UYHWM24C.js} +30 -2
  127. package/chunks/{chunk-SMXULRAN.js → chunk-VXGPYF7U.js} +1 -1
  128. package/chunks/{chunk-BTLJ2MF5.js → chunk-W7CG5HPC.js} +3 -3
  129. package/chunks/{chunk-JJXWTVSK.js → chunk-XHZKDN7M.js} +34 -1
  130. package/chunks/{chunk-K5XU6E4T.js → chunk-XKSCTXPK.js} +2 -2
  131. package/chunks/chunk-Y4LITCRB.js +367 -0
  132. package/chunks/chunk-YHTNWGRR.js +239 -0
  133. package/chunks/{chunk-BKXBARJZ.js → chunk-YPCPZSZB.js} +1 -1
  134. package/chunks/{chunk-QWFNTRH4.js → chunk-YSM24SCE.js} +1 -1
  135. package/chunks/chunk-YTFBURQD.js +313 -0
  136. package/chunks/{chunk-NL4C3H6C.js → chunk-YXK4GPYO.js} +1 -1
  137. package/chunks/{chunk-RYLJU2DY.js → chunk-ZN5T4BHI.js} +1 -1
  138. package/chunks/{computer-use-JGB36FDW.js → computer-use-MTSVPLPY.js} +34 -28
  139. package/chunks/contextCommand-VGJ3BPTK.js +67 -0
  140. package/chunks/{cron-create-SKEY55RG.js → cron-create-6YTCOX6P.js} +4 -4
  141. package/chunks/{cron-delete-I3NAT32Z.js → cron-delete-E3NIFSUK.js} +4 -4
  142. package/chunks/{cron-list-6XRRL6TH.js → cron-list-Q64MNQNE.js} +4 -4
  143. package/chunks/daemon-EEB2RYOL.js +8453 -0
  144. package/chunks/daemon-status-provider-H5SNMK42.js +71 -0
  145. package/chunks/{de-EOW5PVHF.js → de-PLLFDERI.js} +10 -9
  146. package/chunks/{dist-NHEYYB2B.js → dist-ADMIQGHI.js} +2 -2
  147. package/chunks/{dist-J37VXPLD.js → dist-BCQ7INM7.js} +40 -15
  148. package/chunks/{dist-663NZ6MI.js → dist-KNZFCY5F.js} +116 -11
  149. package/chunks/{dist-LB6OGPVM.js → dist-LHUWC3K6.js} +26 -5
  150. package/chunks/{dist-PQPZ5Q4N.js → dist-WBBZ76JZ.js} +20 -9
  151. package/chunks/{earlyInputCapture-44RISVVJ.js → earlyInputCapture-U4ZIQSQU.js} +32 -26
  152. package/chunks/{edit-NN6GM5DT.js → edit-SYIQ2KBI.js} +41 -38
  153. package/chunks/{en-HPWEJBTR.js → en-M4EBUBNU.js} +32 -10
  154. package/chunks/{enter-worktree-PB3O2C7D.js → enter-worktree-HPPOO244.js} +31 -25
  155. package/chunks/{enterPlanMode-YQFNNSUA.js → enterPlanMode-56YYO52U.js} +41 -25
  156. package/chunks/{errors-LS7XU5DZ.js → errors-EO4SXCFP.js} +33 -27
  157. package/chunks/{exit-worktree-E2FTNSBK.js → exit-worktree-UDM7E6XH.js} +31 -25
  158. package/chunks/{exitPlanMode-GLYK2BUB.js → exitPlanMode-M6P3AWLJ.js} +149 -33
  159. package/chunks/{fast-path-WQ6M7LI7.js → fast-path-42P4QYWI.js} +33 -14
  160. package/chunks/{fast-path-settings-OAGU3X5H.js → fast-path-settings-UQLRFDQP.js} +2 -2
  161. package/chunks/{fr-BN5NKSAC.js → fr-MBMNQSQ6.js} +10 -9
  162. package/chunks/{gemini-2YFLVA2M.js → gemini-NRLXS5J7.js} +84 -70
  163. package/chunks/{geminiContentGenerator-XV6SV6CD.js → geminiContentGenerator-DLJPYYPA.js} +41 -5
  164. package/chunks/{glob-2H6CDDMK.js → glob-JPY3F74Y.js} +31 -25
  165. package/chunks/{grep-VXIUXM3C.js → grep-HW23YES5.js} +31 -25
  166. package/chunks/initializer-HWOZTC4A.js +67 -0
  167. package/chunks/{ja-7DEUB2TS.js → ja-HFV5QCHR.js} +10 -9
  168. package/chunks/{keychain-token-storage-YLMDLKXT.js → keychain-token-storage-GPAKRDKQ.js} +2 -2
  169. package/chunks/list-OZGAGRDG.js +71 -0
  170. package/chunks/loadedSettingsAdapter-RUTZVVIA.js +66 -0
  171. package/chunks/{loop-wakeup-CITGGQHA.js → loop-wakeup-G2EHBRGR.js} +5 -5
  172. package/chunks/{ls-R5WP4BGZ.js → ls-IDHUVSAG.js} +4 -4
  173. package/chunks/{lsp-GSICJMMW.js → lsp-FSRYXGFT.js} +2 -2
  174. package/chunks/{monitor-HKLRS2HQ.js → monitor-QVWEIXU3.js} +33 -26
  175. package/chunks/nonInteractiveCli-RV2WYPML.js +109 -0
  176. package/chunks/{notebook-edit-Y6SKSGTT.js → notebook-edit-B26W3FFH.js} +37 -34
  177. package/chunks/{openaiContentGenerator-J7G2FYJT.js → openaiContentGenerator-6TH7AZGQ.js} +15 -13
  178. package/chunks/pidfile-KW2QQJZI.js +73 -0
  179. package/chunks/{pt-TNZRNLQB.js → pt-2THT3YHA.js} +10 -9
  180. package/chunks/{qwenContentGenerator-5XIEM5GV.js → qwenContentGenerator-5IQAC7RN.js} +33 -27
  181. package/chunks/{qwenOAuth2-AG3PFFLZ.js → qwenOAuth2-LWLA6MQG.js} +5 -4
  182. package/chunks/{read-file-J7D552ZJ.js → read-file-CWFAHOI2.js} +11 -10
  183. package/chunks/{read-mcp-resource-DQQW7H7T.js → read-mcp-resource-PIJF24RO.js} +2 -2
  184. package/chunks/ripGrep-FTZGDKY4.js +60 -0
  185. package/chunks/{ru-NIAUBKCN.js → ru-Z67YJHBG.js} +10 -9
  186. package/chunks/{run-qwen-serve-SGEEVMXD.js → run-qwen-serve-FN2SW5ZI.js} +545 -97
  187. package/chunks/{scheduler-7CQI3S4Q.js → scheduler-BMS23O3I.js} +31 -25
  188. package/chunks/{send-message-EE4TWKVU.js → send-message-33PYFMCT.js} +20 -4
  189. package/chunks/serve-PXFYKL65.js +71 -0
  190. package/chunks/{server-4QMUWXPW.js → server-BRKALJ4F.js} +4132 -1175
  191. package/chunks/{session-NRO7UFOY.js → session-AQMISBP4.js} +205 -64
  192. package/chunks/{settings-SGWRDQSC.js → settings-JCPVUZMB.js} +35 -29
  193. package/chunks/{shell-62ANGIHL.js → shell-POHCRVN2.js} +31 -25
  194. package/chunks/{skill-VWAQAWW6.js → skill-QPEVHS6H.js} +13 -12
  195. package/chunks/spawnChannel-V3QT62TE.js +69 -0
  196. package/chunks/{src-KUV5J5Q2.js → src-IZSP7L6Y.js} +127 -32
  197. package/chunks/{startInteractiveUI-6EYJN3JV.js → startInteractiveUI-63KATISO.js} +16762 -16075
  198. package/chunks/{syntheticOutput-T5YHQ6S2.js → syntheticOutput-YOSWIHDJ.js} +3 -3
  199. package/chunks/{task-create-BLY7RHXX.js → task-create-NHS2NGB3.js} +9 -7
  200. package/chunks/{task-list-3QUYNVYR.js → task-list-5UIB564R.js} +7 -6
  201. package/chunks/{task-stop-U4RATK2V.js → task-stop-HGXZ2VZ3.js} +2 -2
  202. package/chunks/{task-update-U54O7KJX.js → task-update-IYYWUILG.js} +40 -7
  203. package/chunks/{team-create-LPOGCPM7.js → team-create-E5O2F3CA.js} +31 -25
  204. package/chunks/{team-delete-LKBYPASD.js → team-delete-FEKJCU4P.js} +7 -6
  205. package/chunks/team-plan-approval-2TU4YROB.js +202 -0
  206. package/chunks/theme-manager-37KOGPJL.js +64 -0
  207. package/chunks/{todoWrite-OYXKTVYN.js → todoWrite-TKMVZ2HT.js} +4 -4
  208. package/chunks/{tool-search-YPUFJ3ZQ.js → tool-search-4ZTGNSFI.js} +38 -11
  209. package/chunks/{trustedFolders-5B24TMEA.js → trustedFolders-RJ5WXJ6L.js} +32 -26
  210. package/chunks/{validateNonInterActiveAuth-BTDTYOVT.js → validateNonInterActiveAuth-ZWMHZAOK.js} +66 -55
  211. package/chunks/{web-fetch-EEB5LZYU.js → web-fetch-J4PIW36Q.js} +5 -4
  212. package/chunks/{workflow-LAWIW5LI.js → workflow-6PG2PC5M.js} +68 -39
  213. package/chunks/workspace-providers-status-4QIZN2W4.js +68 -0
  214. package/chunks/workspace-service-CYMCOEIO.js +78 -0
  215. package/chunks/{write-file-PCV226DJ.js → write-file-R354LGQF.js} +41 -40
  216. package/chunks/{zh-5EUYXASN.js → zh-SLZNSSZB.js} +32 -10
  217. package/chunks/{zh-TW-TR3XYGE5.js → zh-TW-P53C2SL5.js} +32 -10
  218. package/cli.js +6 -6
  219. package/locales/ca.js +15 -13
  220. package/locales/de.js +15 -13
  221. package/locales/en.js +52 -15
  222. package/locales/fr.js +15 -14
  223. package/locales/ja.js +15 -14
  224. package/locales/pt.js +15 -13
  225. package/locales/ru.js +15 -13
  226. package/locales/zh-TW.js +50 -14
  227. package/locales/zh.js +50 -14
  228. package/node_modules/@qwen-code/audio-capture/package.json +1 -1
  229. package/node_modules/@qwen-code/audio-capture/prebuilds/darwin-arm64/@qwen-code+audio-capture.node +0 -0
  230. package/node_modules/@qwen-code/audio-capture/prebuilds/darwin-x64/@qwen-code+audio-capture.node +0 -0
  231. package/node_modules/@qwen-code/audio-capture/prebuilds/win32-x64/@qwen-code+audio-capture.node +0 -0
  232. package/package.json +15 -6
  233. package/patches/chrome-devtools-mcp+1.4.0.patch +31 -0
  234. package/postinstall.js +50 -0
  235. package/web-shell/assets/{arc-DA7r8Ueo.js → arc-Bd3048nX.js} +1 -1
  236. package/web-shell/assets/{architectureDiagram-3BPJPVTR-ZDUxPtZ2.js → architectureDiagram-3BPJPVTR-DyzbzABx.js} +1 -1
  237. package/web-shell/assets/{blockDiagram-GPEHLZMM-vrtOf1kJ.js → blockDiagram-GPEHLZMM-CTjxm9Sy.js} +1 -1
  238. package/web-shell/assets/{c4Diagram-AAUBKEIU-BHFjzIi4.js → c4Diagram-AAUBKEIU-DSOFWNU6.js} +1 -1
  239. package/web-shell/assets/channel-oVoup_Ci.js +1 -0
  240. package/web-shell/assets/{chunk-2J33WTMH-mdeQVz59.js → chunk-2J33WTMH-D_PFdjo2.js} +1 -1
  241. package/web-shell/assets/{chunk-4BX2VUAB-BkKMlwBX.js → chunk-4BX2VUAB-52eG6wOT.js} +1 -1
  242. package/web-shell/assets/{chunk-55IACEB6-B7bnXNfu.js → chunk-55IACEB6-CGTZfKkC.js} +1 -1
  243. package/web-shell/assets/{chunk-727SXJPM-DGz8Mm6i.js → chunk-727SXJPM-CZ-qfRUm.js} +1 -1
  244. package/web-shell/assets/{chunk-AQP2D5EJ-BQAydUNk.js → chunk-AQP2D5EJ-B2dSDcqM.js} +1 -1
  245. package/web-shell/assets/{chunk-FMBD7UC4-GHr2LN_p.js → chunk-FMBD7UC4-Cfy3tyA0.js} +1 -1
  246. package/web-shell/assets/{chunk-ND2GUHAM-7R91lnUx.js → chunk-ND2GUHAM-CApNUpUq.js} +1 -1
  247. package/web-shell/assets/{chunk-QZHKN3VN-aTJD3lSQ.js → chunk-QZHKN3VN-CVBG8kgg.js} +1 -1
  248. package/web-shell/assets/classDiagram-4FO5ZUOK-B8WsaT2G.js +1 -0
  249. package/web-shell/assets/classDiagram-v2-Q7XG4LA2-B8WsaT2G.js +1 -0
  250. package/web-shell/assets/{cose-bilkent-S5V4N54A-BTf8t1RL.js → cose-bilkent-S5V4N54A-URd77-_-.js} +1 -1
  251. package/web-shell/assets/{dagre-BM42HDAG-_sgp3WuA.js → dagre-BM42HDAG-C07H4G9-.js} +1 -1
  252. package/web-shell/assets/{diagram-2AECGRRQ-CROtRy1w.js → diagram-2AECGRRQ-CYbpXrSn.js} +1 -1
  253. package/web-shell/assets/{diagram-5GNKFQAL-BqGRzwSc.js → diagram-5GNKFQAL-SI8s6fjX.js} +1 -1
  254. package/web-shell/assets/{diagram-KO2AKTUF-CMrcEtA7.js → diagram-KO2AKTUF-mBkC4RTW.js} +1 -1
  255. package/web-shell/assets/{diagram-LMA3HP47-BRydGNW5.js → diagram-LMA3HP47-CyBPDzWJ.js} +1 -1
  256. package/web-shell/assets/{diagram-OG6HWLK6-D0ilgNjV.js → diagram-OG6HWLK6-DwK7kBCu.js} +1 -1
  257. package/web-shell/assets/{erDiagram-TEJ5UH35-2lnQeJUM.js → erDiagram-TEJ5UH35-Ds1StEUd.js} +1 -1
  258. package/web-shell/assets/{flowDiagram-I6XJVG4X-C_ZMdQiC.js → flowDiagram-I6XJVG4X-BDC4-jHO.js} +1 -1
  259. package/web-shell/assets/{ganttDiagram-6RSMTGT7-Dw9V_Ny0.js → ganttDiagram-6RSMTGT7-CV2aHQqf.js} +1 -1
  260. package/web-shell/assets/{gitGraphDiagram-PVQCEYII-HDXI-F1B.js → gitGraphDiagram-PVQCEYII-B8RcuV_L.js} +1 -1
  261. package/web-shell/assets/index-B5WZ0qP1.js +717 -0
  262. package/web-shell/assets/index-CYE1IMd1.css +5 -0
  263. package/web-shell/assets/{infoDiagram-5YYISTIA-CuB8Ebua.js → infoDiagram-5YYISTIA-DUPoTf50.js} +1 -1
  264. package/web-shell/assets/{ishikawaDiagram-YF4QCWOH-D_tyhVwh.js → ishikawaDiagram-YF4QCWOH-FDYOx9rf.js} +1 -1
  265. package/web-shell/assets/{journeyDiagram-JHISSGLW-CgCwJQNU.js → journeyDiagram-JHISSGLW-C5_ohMRp.js} +1 -1
  266. package/web-shell/assets/{kanban-definition-UN3LZRKU-PZUtcbPx.js → kanban-definition-UN3LZRKU-Bn4UnOMG.js} +1 -1
  267. package/web-shell/assets/{linear-CZqRdJIU.js → linear-Bl0pKQtY.js} +1 -1
  268. package/web-shell/assets/{mermaid.core-CPW_4UEF.js → mermaid.core-sevQFoji.js} +72 -72
  269. package/web-shell/assets/{mindmap-definition-RKZ34NQL-D_Y75ID7.js → mindmap-definition-RKZ34NQL-DSH4JZF9.js} +1 -1
  270. package/web-shell/assets/{pieDiagram-4H26LBE5-Q4dTyLeF.js → pieDiagram-4H26LBE5-acWvdQTD.js} +1 -1
  271. package/web-shell/assets/{quadrantDiagram-W4KKPZXB-ckw329Ft.js → quadrantDiagram-W4KKPZXB-DlggX4Sm.js} +1 -1
  272. package/web-shell/assets/{requirementDiagram-4Y6WPE33-DbVDm4It.js → requirementDiagram-4Y6WPE33--mMqwmV8.js} +1 -1
  273. package/web-shell/assets/{sankeyDiagram-5OEKKPKP-CHuZ_ecH.js → sankeyDiagram-5OEKKPKP-BtV9tZ77.js} +1 -1
  274. package/web-shell/assets/{sequenceDiagram-3UESZ5HK-DU2YSpq-.js → sequenceDiagram-3UESZ5HK-iDQzPFlj.js} +1 -1
  275. package/web-shell/assets/{stateDiagram-AJRCARHV-CA9PEBUo.js → stateDiagram-AJRCARHV-CsCyQLZ3.js} +1 -1
  276. package/web-shell/assets/stateDiagram-v2-BHNVJYJU-CPapECG9.js +1 -0
  277. package/web-shell/assets/{timeline-definition-PNZ67QCA-Bhlo4WVc.js → timeline-definition-PNZ67QCA-Dl0rnKvr.js} +1 -1
  278. package/web-shell/assets/{vennDiagram-CIIHVFJN-6xtPLsyl.js → vennDiagram-CIIHVFJN-qF7nTZzA.js} +1 -1
  279. package/web-shell/assets/{wardley-L42UT6IY-3eozmXPf.js → wardley-L42UT6IY-fWoK9i7F.js} +1 -1
  280. package/web-shell/assets/{wardleyDiagram-YWT4CUSO-BOn6hKss.js → wardleyDiagram-YWT4CUSO-pHGfkMnc.js} +1 -1
  281. package/web-shell/assets/{xychartDiagram-2RQKCTM6-BsP4-I_y.js → xychartDiagram-2RQKCTM6-3Doc4Trz.js} +1 -1
  282. package/web-shell/index.html +18 -2
  283. package/chunks/MaxSizedBox-QNF3EIGU.js +0 -63
  284. package/chunks/agent-headless-RQ5BTX3G.js +0 -56
  285. package/chunks/bridge-JL2PMCIW.js +0 -66
  286. package/chunks/chunk-5GOVVXEV.js +0 -73
  287. package/chunks/chunk-BC3C4F3Q.js +0 -40
  288. package/chunks/chunk-BW4KCQFD.js +0 -1103
  289. package/chunks/chunk-RKIZXJMB.js +0 -13
  290. package/chunks/contextCommand-WYU6OQ2F.js +0 -61
  291. package/chunks/daemon-status-provider-HH2EC5FI.js +0 -65
  292. package/chunks/headlessSafetyWarnings-TBE35GJ4.js +0 -55
  293. package/chunks/initializer-2Y7SLOKV.js +0 -61
  294. package/chunks/list-ZAFHZ7OS.js +0 -65
  295. package/chunks/loadedSettingsAdapter-6KJXD6M3.js +0 -60
  296. package/chunks/nonInteractiveCli-5W4GIZQM.js +0 -96
  297. package/chunks/ripGrep-IEIFIWP4.js +0 -54
  298. package/chunks/serve-V6GVZR6U.js +0 -65
  299. package/chunks/spawnChannel-3VHW5EPY.js +0 -63
  300. package/chunks/theme-manager-LYLGAERF.js +0 -58
  301. package/chunks/workspace-providers-status-PIAFVYHA.js +0 -62
  302. package/chunks/workspace-service-YAIOD4HG.js +0 -71
  303. package/web-shell/assets/channel-iRo68q4Q.js +0 -1
  304. package/web-shell/assets/classDiagram-4FO5ZUOK-iHF24a8X.js +0 -1
  305. package/web-shell/assets/classDiagram-v2-Q7XG4LA2-iHF24a8X.js +0 -1
  306. package/web-shell/assets/index-CHmzAWys.css +0 -5
  307. package/web-shell/assets/index-w6GTOHh1.js +0 -713
  308. package/web-shell/assets/stateDiagram-v2-BHNVJYJU-t1LRI0oY.js +0 -1
@@ -0,0 +1,1064 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Qwen
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs/promises';
8
+ import * as os from 'node:os';
9
+ import * as path from 'node:path';
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import {
12
+ LOOP_TASK_FILE_MAX_BYTES,
13
+ readLoopTaskFile,
14
+ } from './loop-task-file.js';
15
+
16
+ // Make only open controllable; every other fs call stays real so the temp-dir
17
+ // fixtures keep working. The reader is bounded via fs.open + filehandle.read,
18
+ // so open is the injection point. The default impl calls through to actual.
19
+ vi.mock('node:fs/promises', async (importActual) => {
20
+ const actual = await importActual<typeof import('node:fs/promises')>();
21
+ return { ...actual, open: vi.fn(actual.open) };
22
+ });
23
+
24
+ // Capture the module's debug calls so a test can assert WHY a candidate was
25
+ // skipped (the whitespace-only branch is the load-bearing case). Other tests
26
+ // don't read it; production debug() no-ops without an active session anyway.
27
+ const debugSpy = vi.hoisted(() => vi.fn());
28
+ vi.mock('../../../utils/debugLogger.js', () => ({
29
+ createDebugLogger: () => ({
30
+ isEnabled: () => true,
31
+ debug: debugSpy,
32
+ info: vi.fn(),
33
+ warn: vi.fn(),
34
+ error: vi.fn(),
35
+ }),
36
+ }));
37
+
38
+ describe('readLoopTaskFile', () => {
39
+ let tempDir: string;
40
+ let projectRoot: string;
41
+ let homeDir: string;
42
+
43
+ beforeEach(async () => {
44
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-task-file-'));
45
+ projectRoot = path.join(tempDir, 'project');
46
+ homeDir = path.join(tempDir, 'home');
47
+ await fs.mkdir(projectRoot, { recursive: true });
48
+ await fs.mkdir(homeDir, { recursive: true });
49
+ });
50
+
51
+ afterEach(async () => {
52
+ vi.restoreAllMocks();
53
+ await fs.rm(tempDir, { recursive: true, force: true });
54
+ });
55
+
56
+ const writeProject = (content: string) =>
57
+ fs
58
+ .mkdir(path.join(projectRoot, '.qwen'), { recursive: true })
59
+ .then(() =>
60
+ fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), content),
61
+ );
62
+ const writeHome = (content: string) =>
63
+ fs
64
+ .mkdir(path.join(homeDir, '.qwen'), { recursive: true })
65
+ .then(() =>
66
+ fs.writeFile(path.join(homeDir, '.qwen', 'loop.md'), content),
67
+ );
68
+
69
+ // Wrap the next fs.open so each handle.read() length is recorded against a real
70
+ // handle. Lets a test prove the reader stays bounded: a "read the whole file,
71
+ // then slice" regression pulls the full file through these reads and trips the
72
+ // per-read / cumulative cap assertions. Returns the array, filled by reference.
73
+ const recordHandleReadLengths = async (): Promise<number[]> => {
74
+ const lengths: number[] = [];
75
+ const actual =
76
+ await vi.importActual<typeof import('node:fs/promises')>(
77
+ 'node:fs/promises',
78
+ );
79
+ vi.mocked(fs.open).mockImplementationOnce(async (p) => {
80
+ const handle = await actual.open(
81
+ p as Parameters<typeof actual.open>[0],
82
+ 'r',
83
+ );
84
+ const realRead = handle.read.bind(handle);
85
+ handle.read = ((...readArgs: Parameters<typeof handle.read>) => {
86
+ // Impl calls read(buffer, offset, length, position); record length.
87
+ lengths.push((readArgs as unknown[])[2] as number);
88
+ return realRead(...(readArgs as Parameters<typeof handle.read>));
89
+ }) as typeof handle.read;
90
+ return handle;
91
+ });
92
+ return lengths;
93
+ };
94
+
95
+ it('reads the project loop task file first', async () => {
96
+ await writeProject('project tasks');
97
+ await writeHome('user tasks');
98
+
99
+ const result = await readLoopTaskFile({
100
+ projectRoot,
101
+ homeDir,
102
+ allowProjectFile: true,
103
+ });
104
+
105
+ expect(result).toEqual({
106
+ status: 'found',
107
+ path: path.join(projectRoot, '.qwen', 'loop.md'),
108
+ source: 'project',
109
+ content: 'project tasks',
110
+ truncated: false,
111
+ });
112
+ });
113
+
114
+ it('falls back to the user loop task file', async () => {
115
+ await writeHome('user tasks');
116
+
117
+ const result = await readLoopTaskFile({
118
+ projectRoot,
119
+ homeDir,
120
+ allowProjectFile: true,
121
+ });
122
+
123
+ expect(result).toEqual({
124
+ status: 'found',
125
+ path: path.join(homeDir, '.qwen', 'loop.md'),
126
+ source: 'home',
127
+ content: 'user tasks',
128
+ truncated: false,
129
+ });
130
+ });
131
+
132
+ it('does not follow symlinked project loop task files', async () => {
133
+ await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
134
+ const outside = path.join(tempDir, 'secret.txt');
135
+ await fs.writeFile(outside, 'secret tasks');
136
+ await fs.symlink(outside, path.join(projectRoot, '.qwen', 'loop.md'));
137
+ await writeHome('user tasks');
138
+
139
+ const result = await readLoopTaskFile({
140
+ projectRoot,
141
+ homeDir,
142
+ allowProjectFile: true,
143
+ });
144
+
145
+ expect(result).toEqual({
146
+ status: 'found',
147
+ path: path.join(homeDir, '.qwen', 'loop.md'),
148
+ source: 'home',
149
+ content: 'user tasks',
150
+ truncated: false,
151
+ });
152
+ });
153
+
154
+ it('refuses a project loop.md whose .qwen ancestor symlinks outside the workspace', async () => {
155
+ // `.qwen -> <outside>` makes a final-component lstat pass while the file
156
+ // resolves outside the project; realpath must catch the ancestor symlink.
157
+ const outside = path.join(tempDir, 'outside');
158
+ await fs.mkdir(outside, { recursive: true });
159
+ await fs.writeFile(path.join(outside, 'loop.md'), 'escaped tasks');
160
+ await fs.symlink(outside, path.join(projectRoot, '.qwen'));
161
+ await writeHome('user tasks');
162
+
163
+ const result = await readLoopTaskFile({
164
+ projectRoot,
165
+ homeDir,
166
+ allowProjectFile: true,
167
+ });
168
+
169
+ expect(result).toEqual({
170
+ status: 'found',
171
+ path: path.join(homeDir, '.qwen', 'loop.md'),
172
+ source: 'home',
173
+ content: 'user tasks',
174
+ truncated: false,
175
+ });
176
+ });
177
+
178
+ it('refuses a project loop.md resolving to a SIBLING dir that shares a name prefix', async () => {
179
+ // isWithin appends path.sep before startsWith, so root `<ws>/foo` must NOT
180
+ // accept a candidate under the sibling `<ws>/foobar`. Make projectRoot
181
+ // `<ws>/foo` and symlink its `.qwen` to `<ws>/foobar/.qwen`; realpath then
182
+ // resolves loop.md into `foobar`, whose canonical path bare-startsWith
183
+ // `<ws>/foo` yet is NOT a descendant. A regression to a bare
184
+ // `real.startsWith(root)` (no separator) would wave this cross-workspace
185
+ // read through — this test fails the moment that separator is dropped.
186
+ const fooRoot = path.join(tempDir, 'foo');
187
+ const siblingQwen = path.join(tempDir, 'foobar', '.qwen');
188
+ await fs.mkdir(fooRoot, { recursive: true });
189
+ await fs.mkdir(siblingQwen, { recursive: true });
190
+ await fs.writeFile(path.join(siblingQwen, 'loop.md'), 'sibling tasks');
191
+ await fs.symlink(siblingQwen, path.join(fooRoot, '.qwen'));
192
+ await writeHome('user tasks');
193
+
194
+ const result = await readLoopTaskFile({
195
+ projectRoot: fooRoot,
196
+ homeDir,
197
+ allowProjectFile: true,
198
+ });
199
+
200
+ // Refused → falls through to home; the sibling content is never returned.
201
+ expect(result).toEqual({
202
+ status: 'found',
203
+ path: path.join(homeDir, '.qwen', 'loop.md'),
204
+ source: 'home',
205
+ content: 'user tasks',
206
+ truncated: false,
207
+ });
208
+ });
209
+
210
+ it('does not read a project loop.md symlinked to an in-workspace file (exfiltration guard)', async () => {
211
+ // The dangerous case confinement alone misses: a repo-committed
212
+ // `.qwen/loop.md -> ../.env` resolves INSIDE the workspace, so the realpath
213
+ // confinement passes — yet it must NOT be read. A symlinked project loop.md
214
+ // is refused outright; only a real regular file at the literal path is read.
215
+ await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
216
+ const secret = path.join(projectRoot, '.env');
217
+ await fs.writeFile(secret, 'SECRET=should-not-be-read');
218
+ await fs.symlink(
219
+ path.join('..', '.env'),
220
+ path.join(projectRoot, '.qwen', 'loop.md'),
221
+ );
222
+ await writeHome('user tasks');
223
+
224
+ const result = await readLoopTaskFile({
225
+ projectRoot,
226
+ homeDir,
227
+ allowProjectFile: true,
228
+ });
229
+
230
+ expect(result).toEqual({
231
+ status: 'found',
232
+ path: path.join(homeDir, '.qwen', 'loop.md'),
233
+ source: 'home',
234
+ content: 'user tasks',
235
+ truncated: false,
236
+ });
237
+ });
238
+
239
+ it('does not read a HARD-LINKED project loop.md (exfiltration guard)', async () => {
240
+ // The case the symlink guard misses: `ln <secret> .qwen/loop.md` makes
241
+ // loop.md an ordinary regular file (lstat sees no symlink, isFile() true)
242
+ // that SHARES the secret's inode (nlink === 2). It resolves to itself inside
243
+ // the workspace, so confinement passes too — only the `nlink > 1` guard
244
+ // refuses it. Mutation check: drop that guard and the secret is returned as
245
+ // the project source instead of falling through to home.
246
+ await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
247
+ const secret = path.join(tempDir, 'secret-env');
248
+ await fs.writeFile(secret, 'SECRET=should-not-be-read');
249
+ const projectLoop = path.join(projectRoot, '.qwen', 'loop.md');
250
+ await fs.link(secret, projectLoop); // hard link → nlink 2, same inode
251
+ // Precondition: the link really is a hard link to the secret, not a symlink.
252
+ const linkStat = await fs.lstat(projectLoop);
253
+ expect(linkStat.isSymbolicLink()).toBe(false);
254
+ expect(linkStat.nlink).toBeGreaterThan(1);
255
+ await writeHome('user tasks');
256
+
257
+ const result = await readLoopTaskFile({
258
+ projectRoot,
259
+ homeDir,
260
+ allowProjectFile: true,
261
+ });
262
+
263
+ // The hard-linked project file is skipped → home is read; the secret content
264
+ // is never returned from any candidate.
265
+ expect(result).toEqual({
266
+ status: 'found',
267
+ path: path.join(homeDir, '.qwen', 'loop.md'),
268
+ source: 'home',
269
+ content: 'user tasks',
270
+ truncated: false,
271
+ });
272
+ if (result.status !== 'found') {
273
+ throw new Error('expected loop.md to be found');
274
+ }
275
+ expect(result.content).not.toContain('SECRET');
276
+ });
277
+
278
+ it('does not read a HARD-LINKED home loop.md (exfiltration guard)', async () => {
279
+ // Same hard-link vector on the home candidate: `ln <secret> ~/.qwen/loop.md`.
280
+ // fs.stat follows to a regular file with nlink 2, so isFile()/confinement
281
+ // pass — only the `nlink > 1` guard refuses it. No project file here, so the
282
+ // result is `missing`; the secret content is never returned.
283
+ await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
284
+ const secret = path.join(tempDir, 'home-secret');
285
+ await fs.writeFile(secret, 'SECRET=should-not-be-read');
286
+ const homeLoop = path.join(homeDir, '.qwen', 'loop.md');
287
+ await fs.link(secret, homeLoop);
288
+ const linkStat = await fs.lstat(homeLoop);
289
+ expect(linkStat.nlink).toBeGreaterThan(1);
290
+
291
+ const result = await readLoopTaskFile({
292
+ projectRoot,
293
+ homeDir,
294
+ allowProjectFile: true,
295
+ });
296
+
297
+ expect(result).toEqual({
298
+ status: 'missing',
299
+ checkedPaths: [
300
+ path.join(projectRoot, '.qwen', 'loop.md'),
301
+ path.join(homeDir, '.qwen', 'loop.md'),
302
+ ],
303
+ });
304
+ });
305
+
306
+ it('does not falsely refuse a project loop.md when the workspace root is a filesystem root', async () => {
307
+ // When the CLI runs from a filesystem root, realRoot is `/` (or `C:\`), so the
308
+ // old `realRoot + path.sep` prefix became `//` (`C:\\`) — which no descendant
309
+ // startsWith, wrongly refusing every project loop.md. Drive realRoot to the
310
+ // filesystem root via a realpath mock; the real loop.md still resolves to a
311
+ // normal absolute path (a descendant of the root) and must be read, not refused.
312
+ await writeProject('- root-level tasks');
313
+ const root = path.parse(projectRoot).root; // '/' on POSIX, e.g. 'C:\\' on Windows
314
+ const actual =
315
+ await vi.importActual<typeof import('node:fs/promises')>(
316
+ 'node:fs/promises',
317
+ );
318
+ vi.spyOn(fs, 'realpath').mockImplementation((p) =>
319
+ String(p) === projectRoot
320
+ ? Promise.resolve(root)
321
+ : actual.realpath(p as string),
322
+ );
323
+
324
+ const result = await readLoopTaskFile({
325
+ projectRoot,
326
+ homeDir,
327
+ allowProjectFile: true,
328
+ });
329
+
330
+ expect(result).toMatchObject({
331
+ status: 'found',
332
+ source: 'project',
333
+ content: '- root-level tasks',
334
+ });
335
+ });
336
+
337
+ it('skips a FIFO/non-regular project loop.md before opening it (does not hang)', async () => {
338
+ // A FIFO at the project path must be rejected BEFORE the blocking fs.open:
339
+ // open() on a FIFO blocks until a writer appears, wedging the tick forever.
340
+ // Drive a FIFO-typed node via a mocked lstat (a real mkfifo is platform-
341
+ // fragile); the load-bearing proof is that fs.open is never called on the
342
+ // project path, so no blocking open() can happen.
343
+ await writeHome('user tasks');
344
+ const projectLoop = path.join(projectRoot, '.qwen', 'loop.md');
345
+ const actual =
346
+ await vi.importActual<typeof import('node:fs/promises')>(
347
+ 'node:fs/promises',
348
+ );
349
+ const fifoStat = {
350
+ isSymbolicLink: () => false,
351
+ isFile: () => false,
352
+ isFIFO: () => true,
353
+ } as unknown as Awaited<ReturnType<typeof fs.lstat>>;
354
+ vi.spyOn(fs, 'lstat').mockImplementation(async (p) =>
355
+ String(p) === projectLoop ? fifoStat : actual.lstat(p as string),
356
+ );
357
+ const openSpy = vi.mocked(fs.open);
358
+ openSpy.mockClear();
359
+
360
+ const result = await readLoopTaskFile({
361
+ projectRoot,
362
+ homeDir,
363
+ allowProjectFile: true,
364
+ });
365
+
366
+ expect(result).toMatchObject({ source: 'home', content: 'user tasks' });
367
+ // The project FIFO path is never opened — proof there is no blocking open().
368
+ for (const call of openSpy.mock.calls) {
369
+ expect(String(call[0])).not.toBe(projectLoop);
370
+ }
371
+ });
372
+
373
+ it('reads a home loop.md that is a symlink to a real regular file inside $HOME', async () => {
374
+ // The user's own dotfile may legitimately be a symlink (e.g. into a synced
375
+ // dotfiles repo). Follow it, as long as the target is a real regular file
376
+ // that resolves WITHIN $HOME (the confinement added for escapes).
377
+ await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
378
+ const target = path.join(homeDir, 'dotfiles', 'loop.md');
379
+ await fs.mkdir(path.dirname(target), { recursive: true });
380
+ await fs.writeFile(target, 'symlinked user tasks');
381
+ await fs.symlink(target, path.join(homeDir, '.qwen', 'loop.md'));
382
+
383
+ const result = await readLoopTaskFile({
384
+ projectRoot,
385
+ homeDir,
386
+ allowProjectFile: true,
387
+ });
388
+
389
+ expect(result).toEqual({
390
+ status: 'found',
391
+ path: path.join(homeDir, '.qwen', 'loop.md'),
392
+ source: 'home',
393
+ content: 'symlinked user tasks',
394
+ truncated: false,
395
+ });
396
+ });
397
+
398
+ it('skips a home loop.md whose symlink target escapes $HOME', async () => {
399
+ // Home symlinks are allowed (dotfiles repos), but only if they resolve
400
+ // WITHIN $HOME. A `~/.qwen/loop.md -> /etc/passwd`-style escape (here a
401
+ // sibling outside homeDir) must be skipped, not read and fed to the model.
402
+ await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
403
+ const outside = path.join(tempDir, 'outside-secret');
404
+ await fs.writeFile(outside, 'SECRET=should-not-be-read');
405
+ await fs.symlink(outside, path.join(homeDir, '.qwen', 'loop.md'));
406
+
407
+ const result = await readLoopTaskFile({
408
+ projectRoot,
409
+ homeDir,
410
+ allowProjectFile: true,
411
+ });
412
+
413
+ expect(result).toEqual({
414
+ status: 'missing',
415
+ checkedPaths: [
416
+ path.join(projectRoot, '.qwen', 'loop.md'),
417
+ path.join(homeDir, '.qwen', 'loop.md'),
418
+ ],
419
+ });
420
+ });
421
+
422
+ it('reads the home loop.md from a relocated homeQwenDir (QWEN_HOME)', async () => {
423
+ // The home candidate lives in the QWEN_HOME-aware global dir, not always
424
+ // <homeDir>/.qwen — write loop.md into a relocated global dir and confirm it
425
+ // is read as the `home` source from <homeQwenDir>/loop.md.
426
+ const relocated = path.join(tempDir, 'relocated-qwen');
427
+ await fs.mkdir(relocated, { recursive: true });
428
+ await fs.writeFile(path.join(relocated, 'loop.md'), 'relocated user tasks');
429
+
430
+ const result = await readLoopTaskFile({
431
+ projectRoot,
432
+ // Caller passes the global dir as both candidate dir and confinement root
433
+ // when QWEN_HOME is set (see Session.#getLoopTickResolver).
434
+ homeDir: relocated,
435
+ homeQwenDir: relocated,
436
+ allowProjectFile: true,
437
+ });
438
+
439
+ expect(result).toEqual({
440
+ status: 'found',
441
+ path: path.join(relocated, 'loop.md'),
442
+ source: 'home',
443
+ content: 'relocated user tasks',
444
+ truncated: false,
445
+ });
446
+ });
447
+
448
+ it('keeps confinement for a relocated homeQwenDir (escaping symlink refused)', async () => {
449
+ // Relocation must not loosen the earlier confinement: a symlink whose target
450
+ // escapes the home confinement root is still refused, not read.
451
+ const relocated = path.join(tempDir, 'relocated-qwen');
452
+ await fs.mkdir(relocated, { recursive: true });
453
+ const outside = path.join(tempDir, 'outside-secret');
454
+ await fs.writeFile(outside, 'SECRET=should-not-be-read');
455
+ await fs.symlink(outside, path.join(relocated, 'loop.md'));
456
+
457
+ const result = await readLoopTaskFile({
458
+ projectRoot,
459
+ homeDir: relocated,
460
+ homeQwenDir: relocated,
461
+ allowProjectFile: true,
462
+ });
463
+
464
+ expect(result).toEqual({
465
+ status: 'missing',
466
+ checkedPaths: [
467
+ path.join(projectRoot, '.qwen', 'loop.md'),
468
+ path.join(relocated, 'loop.md'),
469
+ ],
470
+ });
471
+ });
472
+
473
+ it('skips a home loop.md that is a self-referential symlink (ELOOP) instead of throwing', async () => {
474
+ // fs.stat follows the home symlink; a self-referential link raises ELOOP.
475
+ // That must be treated as a skippable candidate (→ missing), not crash the
476
+ // tick — without ELOOP in the skip whitelist this rethrows and aborts.
477
+ await fs.mkdir(path.join(homeDir, '.qwen'), { recursive: true });
478
+ const loop = path.join(homeDir, '.qwen', 'loop.md');
479
+ await fs.symlink(loop, loop); // points at itself → ELOOP on stat
480
+
481
+ const result = await readLoopTaskFile({
482
+ projectRoot,
483
+ homeDir,
484
+ allowProjectFile: true,
485
+ });
486
+
487
+ expect(result).toEqual({
488
+ status: 'missing',
489
+ checkedPaths: [path.join(projectRoot, '.qwen', 'loop.md'), loop],
490
+ });
491
+ });
492
+
493
+ it('skips a home loop.md that resolves to a non-regular file (directory/FIFO)', async () => {
494
+ // The home candidate follows symlinks via fs.stat; if the (possibly
495
+ // symlinked) target is a directory/FIFO it must be skipped — the project
496
+ // path proves this via lstat, but the home path's fs.stat needs its own
497
+ // coverage so a blocking open / directory read never happens.
498
+ const homeLoop = path.join(homeDir, '.qwen', 'loop.md');
499
+ await fs.mkdir(path.dirname(homeLoop), { recursive: true });
500
+ await fs.writeFile(homeLoop, '- user tasks'); // real file so realpath resolves
501
+ const actual =
502
+ await vi.importActual<typeof import('node:fs/promises')>(
503
+ 'node:fs/promises',
504
+ );
505
+ const dirStat = {
506
+ isFile: () => false,
507
+ isDirectory: () => true,
508
+ } as unknown as Awaited<ReturnType<typeof fs.stat>>;
509
+ vi.spyOn(fs, 'stat').mockImplementation(async (p) =>
510
+ String(p) === homeLoop ? dirStat : actual.stat(p as string),
511
+ );
512
+ const openSpy = vi.mocked(fs.open);
513
+ openSpy.mockClear();
514
+
515
+ const result = await readLoopTaskFile({
516
+ projectRoot,
517
+ homeDir,
518
+ allowProjectFile: true,
519
+ });
520
+
521
+ expect(result.status).toBe('missing');
522
+ // The non-regular guard fired before any open() on the home path.
523
+ for (const call of openSpy.mock.calls) {
524
+ expect(String(call[0])).not.toBe(homeLoop);
525
+ }
526
+ });
527
+
528
+ it('defaults to fail-secure: omitting allowProjectFile skips the project file', async () => {
529
+ // This function is re-exported from the core barrel; an external caller that
530
+ // forgets the option must NOT read the repo-controlled project loop.md from
531
+ // an untrusted workspace. The default is false — callers opt IN to trust.
532
+ await writeProject('repo-controlled tasks');
533
+ await writeHome('user tasks');
534
+
535
+ const result = await readLoopTaskFile({ projectRoot, homeDir });
536
+
537
+ expect(result).toEqual({
538
+ status: 'found',
539
+ path: path.join(homeDir, '.qwen', 'loop.md'),
540
+ source: 'home',
541
+ content: 'user tasks',
542
+ truncated: false,
543
+ });
544
+ });
545
+
546
+ it('skips the project candidate entirely when allowProjectFile is false', async () => {
547
+ // Untrusted folder: the repo-controlled project loop.md is not read even
548
+ // when present; the user-owned home loop.md still is.
549
+ await writeProject('repo-controlled tasks');
550
+ await writeHome('user tasks');
551
+
552
+ const result = await readLoopTaskFile({
553
+ projectRoot,
554
+ homeDir,
555
+ allowProjectFile: false,
556
+ });
557
+
558
+ expect(result).toEqual({
559
+ status: 'found',
560
+ path: path.join(homeDir, '.qwen', 'loop.md'),
561
+ source: 'home',
562
+ content: 'user tasks',
563
+ truncated: false,
564
+ });
565
+ });
566
+
567
+ it('reports only the home path as missing when allowProjectFile is false', async () => {
568
+ const result = await readLoopTaskFile({
569
+ projectRoot,
570
+ homeDir,
571
+ allowProjectFile: false,
572
+ });
573
+
574
+ expect(result).toEqual({
575
+ status: 'missing',
576
+ checkedPaths: [path.join(homeDir, '.qwen', 'loop.md')],
577
+ });
578
+ });
579
+
580
+ it('skips a non-directory component at .qwen (ENOTDIR) and falls through', async () => {
581
+ // A regular file where the `.qwen` dir should be → reading .qwen/loop.md
582
+ // raises ENOTDIR; skip to home rather than throwing.
583
+ await fs.writeFile(path.join(projectRoot, '.qwen'), 'not a dir');
584
+ await writeHome('user tasks');
585
+
586
+ const result = await readLoopTaskFile({
587
+ projectRoot,
588
+ homeDir,
589
+ allowProjectFile: true,
590
+ });
591
+
592
+ expect(result).toEqual({
593
+ status: 'found',
594
+ path: path.join(homeDir, '.qwen', 'loop.md'),
595
+ source: 'home',
596
+ content: 'user tasks',
597
+ truncated: false,
598
+ });
599
+ });
600
+
601
+ it('skips a directory at the loop.md path and falls through', async () => {
602
+ // A directory at the project path yields EISDIR on read — skip it, not throw.
603
+ await fs.mkdir(path.join(projectRoot, '.qwen', 'loop.md'), {
604
+ recursive: true,
605
+ });
606
+ await writeHome('user tasks');
607
+
608
+ const result = await readLoopTaskFile({
609
+ projectRoot,
610
+ homeDir,
611
+ allowProjectFile: true,
612
+ });
613
+
614
+ expect(result).toEqual({
615
+ status: 'found',
616
+ path: path.join(homeDir, '.qwen', 'loop.md'),
617
+ source: 'home',
618
+ content: 'user tasks',
619
+ truncated: false,
620
+ });
621
+ });
622
+
623
+ it('rethrows non-whitelisted fs errors (e.g. EACCES)', async () => {
624
+ // Only ENOENT/EISDIR/ENOTDIR fall through to the next candidate; a real
625
+ // error such as a permission denial must surface, not be swallowed.
626
+ await writeProject('project tasks');
627
+ const eacces = Object.assign(new Error('EACCES: permission denied'), {
628
+ code: 'EACCES',
629
+ });
630
+ vi.mocked(fs.open).mockRejectedValueOnce(eacces);
631
+
632
+ await expect(
633
+ readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
634
+ ).rejects.toThrow(/EACCES/);
635
+ });
636
+
637
+ it('evicts the cached project-root realpath after a transient failure and retries on the next tick', async () => {
638
+ // The project-root realpath is cached per process. A TRANSIENT failure
639
+ // (EACCES/ENOENT) must NOT be pinned: the entry is evicted on rejection so
640
+ // the next tick re-resolves instead of replaying a permanently-cached
641
+ // rejection. Drop that eviction and one transient error would break loop.md
642
+ // resolution for this root forever. Drive it purely via the realpath mock.
643
+ await writeProject('project tasks');
644
+
645
+ const eacces = Object.assign(new Error('EACCES: permission denied'), {
646
+ code: 'EACCES',
647
+ });
648
+ const actual =
649
+ await vi.importActual<typeof import('node:fs/promises')>(
650
+ 'node:fs/promises',
651
+ );
652
+ const realpathSpy = vi.spyOn(fs, 'realpath');
653
+ // Fail the first project-root resolution, then resolve normally.
654
+ realpathSpy.mockRejectedValueOnce(eacces);
655
+ realpathSpy.mockImplementation((p) => actual.realpath(p as string));
656
+
657
+ // First tick: the transient error surfaces (current per-tick semantics).
658
+ await expect(
659
+ readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
660
+ ).rejects.toThrow(/EACCES/);
661
+
662
+ // Second tick: the poisoned entry was evicted, so realpath is retried and
663
+ // the project loop.md resolves — proving the rejection was not cached.
664
+ const result = await readLoopTaskFile({
665
+ projectRoot,
666
+ homeDir,
667
+ allowProjectFile: true,
668
+ });
669
+
670
+ expect(result).toEqual({
671
+ status: 'found',
672
+ path: path.join(projectRoot, '.qwen', 'loop.md'),
673
+ source: 'project',
674
+ content: 'project tasks',
675
+ truncated: false,
676
+ });
677
+ // The root was re-resolved on the retry (call #2), not served from a
678
+ // poisoned cache entry; #3 is the loop.md realpath on the successful tick.
679
+ expect(realpathSpy.mock.calls.length).toBeGreaterThanOrEqual(2);
680
+ });
681
+
682
+ it('skips an empty or whitespace-only file and falls through', async () => {
683
+ await writeProject(' \n\t \n');
684
+ await writeHome('user tasks');
685
+
686
+ const result = await readLoopTaskFile({
687
+ projectRoot,
688
+ homeDir,
689
+ allowProjectFile: true,
690
+ });
691
+
692
+ expect(result).toEqual({
693
+ status: 'found',
694
+ path: path.join(homeDir, '.qwen', 'loop.md'),
695
+ source: 'home',
696
+ content: 'user tasks',
697
+ truncated: false,
698
+ });
699
+ });
700
+
701
+ it('logs a debug line when it skips a whitespace-only loop.md', async () => {
702
+ // The whitespace-only skip was the ONLY skip branch with no debug log, so a
703
+ // present-but-empty file was indistinguishable from an absent one in logs.
704
+ // Assert the labelled skip line fires for the project candidate before the
705
+ // fall-through to home.
706
+ await writeProject(' \n\t \n');
707
+ await writeHome('user tasks');
708
+ debugSpy.mockClear();
709
+
710
+ const result = await readLoopTaskFile({
711
+ projectRoot,
712
+ homeDir,
713
+ allowProjectFile: true,
714
+ });
715
+
716
+ expect(result).toMatchObject({ source: 'home', content: 'user tasks' });
717
+ expect(debugSpy).toHaveBeenCalledWith('skipping whitespace-only loop.md', {
718
+ source: 'project',
719
+ filePath: path.join(projectRoot, '.qwen', 'loop.md'),
720
+ });
721
+ });
722
+
723
+ it('returns missing when every candidate is empty', async () => {
724
+ await writeProject('');
725
+ await writeHome('\n \n');
726
+
727
+ const result = await readLoopTaskFile({
728
+ projectRoot,
729
+ homeDir,
730
+ allowProjectFile: true,
731
+ });
732
+
733
+ expect(result).toEqual({
734
+ status: 'missing',
735
+ checkedPaths: [
736
+ path.join(projectRoot, '.qwen', 'loop.md'),
737
+ path.join(homeDir, '.qwen', 'loop.md'),
738
+ ],
739
+ });
740
+ });
741
+
742
+ it('returns a missing result when no task file exists', async () => {
743
+ await expect(
744
+ readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
745
+ ).resolves.toEqual({
746
+ status: 'missing',
747
+ checkedPaths: [
748
+ path.join(projectRoot, '.qwen', 'loop.md'),
749
+ path.join(homeDir, '.qwen', 'loop.md'),
750
+ ],
751
+ });
752
+ });
753
+
754
+ it('byte-caps task files above the cap and flags them truncated', async () => {
755
+ await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES + 5));
756
+
757
+ const result = await readLoopTaskFile({
758
+ projectRoot,
759
+ homeDir,
760
+ allowProjectFile: true,
761
+ });
762
+
763
+ expect(result.status).toBe('found');
764
+ if (result.status !== 'found') {
765
+ throw new Error('expected loop.md to be found');
766
+ }
767
+ expect(Buffer.byteLength(result.content, 'utf8')).toBe(
768
+ LOOP_TASK_FILE_MAX_BYTES,
769
+ );
770
+ expect(result.truncated).toBe(true);
771
+ });
772
+
773
+ it('bounds the read for a very large file (never reads past the cap)', async () => {
774
+ // A multi-MB file must not be fully read/decoded every tick. Observe the
775
+ // actual handle.read() calls: neither any single read nor their sum may
776
+ // exceed the cap budget — so a "read the whole file, then slice" regression
777
+ // (which would pull all 2 MB through these reads) fails this test.
778
+ await writeProject('x'.repeat(2_000_000));
779
+ const cap = LOOP_TASK_FILE_MAX_BYTES + 1;
780
+ const openSpy = vi.mocked(fs.open);
781
+ openSpy.mockClear();
782
+ const readLengths = await recordHandleReadLengths();
783
+
784
+ const result = await readLoopTaskFile({
785
+ projectRoot,
786
+ homeDir,
787
+ allowProjectFile: true,
788
+ });
789
+
790
+ expect(result.status).toBe('found');
791
+ if (result.status !== 'found') {
792
+ throw new Error('expected loop.md to be found');
793
+ }
794
+ expect(result.truncated).toBe(true);
795
+ expect(Buffer.byteLength(result.content, 'utf8')).toBe(
796
+ LOOP_TASK_FILE_MAX_BYTES,
797
+ );
798
+ // A single bounded fs.open handle, not fs.readFile of the whole.
799
+ expect(openSpy).toHaveBeenCalledTimes(1);
800
+ // Load-bearing: every read, and the total bytes requested, stay within cap.
801
+ expect(readLengths.length).toBeGreaterThan(0);
802
+ for (const length of readLengths) {
803
+ expect(length).toBeLessThanOrEqual(cap);
804
+ }
805
+ expect(readLengths.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(cap);
806
+ });
807
+
808
+ it('reads a short file fully via bounded reads that never exceed the cap', async () => {
809
+ // The EOF path: a sub-cap file is returned whole (not truncated), and the
810
+ // bounded reader still never requests past the cap on any read.
811
+ const body = 'short tasks\n';
812
+ await writeProject(body);
813
+ const cap = LOOP_TASK_FILE_MAX_BYTES + 1;
814
+ const readLengths = await recordHandleReadLengths();
815
+
816
+ const result = await readLoopTaskFile({
817
+ projectRoot,
818
+ homeDir,
819
+ allowProjectFile: true,
820
+ });
821
+
822
+ expect(result).toMatchObject({
823
+ status: 'found',
824
+ content: body,
825
+ truncated: false,
826
+ });
827
+ expect(readLengths.length).toBeGreaterThan(0);
828
+ for (const length of readLengths) {
829
+ expect(length).toBeLessThanOrEqual(cap);
830
+ }
831
+ // Load-bearing: the buffer is sized to the file (+1 for truncation
832
+ // detection), NOT the 25 KB cap — so a tiny loop.md doesn't zero-fill 25 KB
833
+ // every tick. The first read requests exactly that bounded length.
834
+ expect(readLengths[0]).toBe(body.length + 1);
835
+ });
836
+
837
+ it('does not truncate task files at exactly the byte cap', async () => {
838
+ await writeProject('x'.repeat(LOOP_TASK_FILE_MAX_BYTES));
839
+
840
+ const result = await readLoopTaskFile({
841
+ projectRoot,
842
+ homeDir,
843
+ allowProjectFile: true,
844
+ });
845
+
846
+ expect(result.status).toBe('found');
847
+ if (result.status !== 'found') {
848
+ throw new Error('expected loop.md to be found');
849
+ }
850
+ expect(Buffer.byteLength(result.content, 'utf8')).toBe(
851
+ LOOP_TASK_FILE_MAX_BYTES,
852
+ );
853
+ expect(result.truncated).toBe(false);
854
+ });
855
+
856
+ it('truncates on a UTF-8 boundary without exceeding the cap or inserting a replacement char', async () => {
857
+ // 3-byte chars make the raw byte cap land mid-character.
858
+ await writeProject('一'.repeat(LOOP_TASK_FILE_MAX_BYTES));
859
+
860
+ const result = await readLoopTaskFile({
861
+ projectRoot,
862
+ homeDir,
863
+ allowProjectFile: true,
864
+ });
865
+
866
+ expect(result.status).toBe('found');
867
+ if (result.status !== 'found') {
868
+ throw new Error('expected loop.md to be found');
869
+ }
870
+ expect(result.truncated).toBe(true);
871
+ expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual(
872
+ LOOP_TASK_FILE_MAX_BYTES,
873
+ );
874
+ expect(result.content).not.toContain('�');
875
+ });
876
+
877
+ it('drops an INCOMPLETE trailing multi-byte sequence at the cap (no orphan lead / U+FFFD)', async () => {
878
+ // A buffer cut mid-4-byte-sequence whose final byte is NOT a continuation
879
+ // (the sequence is incomplete) defeats the continuation-only back-off: it
880
+ // would keep `f0 9f a6` and decode a trailing U+FFFD. Sized so the orphan
881
+ // lands exactly on the cap, so the byte-length re-clamp can't mask it — only
882
+ // dropping the whole incomplete lead keeps the tail clean.
883
+ const head = Buffer.alloc(LOOP_TASK_FILE_MAX_BYTES - 3, 0x61); // 'a' * (cap-3)
884
+ const partial = Buffer.from([0xf0, 0x9f, 0xa6]); // 3 of a 4-byte char...
885
+ const tail = Buffer.from([0x62]); // ...then 'b' (non-continuation) → incomplete
886
+ const raw = Buffer.concat([head, partial, tail]); // cap + 1 bytes → truncated
887
+ await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
888
+ await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
889
+
890
+ const result = await readLoopTaskFile({
891
+ projectRoot,
892
+ homeDir,
893
+ allowProjectFile: true,
894
+ });
895
+
896
+ expect(result.status).toBe('found');
897
+ if (result.status !== 'found') {
898
+ throw new Error('expected loop.md to be found');
899
+ }
900
+ expect(result.truncated).toBe(true);
901
+ expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual(
902
+ LOOP_TASK_FILE_MAX_BYTES,
903
+ );
904
+ // The incomplete sequence is gone entirely — no replacement char, and the
905
+ // body ends on the last complete ('a') char.
906
+ expect(result.content).not.toContain('�');
907
+ expect(result.content.endsWith('a')).toBe(true);
908
+ });
909
+
910
+ it('drops an INCOMPLETE trailing 2-byte lead at the cap (covers the 2-byte width branch)', async () => {
911
+ // A lone 2-byte lead (0xc3, its continuation replaced by a non-continuation)
912
+ // must be dropped by the width branch ((b & 0xe0) === 0xc0 → width 2), not
913
+ // kept as an orphan decoding to U+FFFD. The two trailing continuation bytes
914
+ // are sized so a width-table regression (treating 0xc3 as width 1) leaves
915
+ // the orphan's U+FFFD at exactly the cap, where the byte-length re-clamp
916
+ // can't mask it — catching a regression the re-clamp alone would hide.
917
+ const N = LOOP_TASK_FILE_MAX_BYTES;
918
+ const head = Buffer.alloc(N - 3, 0x61); // 'a' * (N-3)
919
+ const tail = Buffer.from([0xc3, 0x41, 0x80, 0x80]); // 2-byte lead, 'A', 2 conts
920
+ const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated
921
+ await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
922
+ await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
923
+
924
+ const result = await readLoopTaskFile({
925
+ projectRoot,
926
+ homeDir,
927
+ allowProjectFile: true,
928
+ });
929
+
930
+ expect(result.status).toBe('found');
931
+ if (result.status !== 'found') {
932
+ throw new Error('expected loop.md to be found');
933
+ }
934
+ expect(result.truncated).toBe(true);
935
+ expect(result.content).not.toContain('�');
936
+ expect(result.content).toBe('a'.repeat(N - 3));
937
+ });
938
+
939
+ it('drops an INCOMPLETE trailing 3-byte lead at the cap (covers the 3-byte width branch)', async () => {
940
+ // A 3-byte lead with only ONE of its two continuations (0xe4 0xb8) followed
941
+ // by a non-continuation must be dropped by the width branch
942
+ // ((b & 0xf0) === 0xe0 → width 3). Sized so a width-table regression (0xe4
943
+ // treated as width 1 or 2) leaves the orphan's U+FFFD below the cap, where
944
+ // it survives the re-clamp — so the regression is observable.
945
+ const N = LOOP_TASK_FILE_MAX_BYTES;
946
+ const head = Buffer.alloc(N - 4, 0x61); // 'a' * (N-4)
947
+ const tail = Buffer.from([0xe4, 0xb8, 0x41, 0x80, 0x80]); // lead+1 cont, 'A', 2 conts
948
+ const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated
949
+ await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
950
+ await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
951
+
952
+ const result = await readLoopTaskFile({
953
+ projectRoot,
954
+ homeDir,
955
+ allowProjectFile: true,
956
+ });
957
+
958
+ expect(result.status).toBe('found');
959
+ if (result.status !== 'found') {
960
+ throw new Error('expected loop.md to be found');
961
+ }
962
+ expect(result.truncated).toBe(true);
963
+ expect(result.content).not.toContain('�');
964
+ expect(result.content).toBe('a'.repeat(N - 4));
965
+ });
966
+
967
+ it('drops an ORPHAN lead followed by an ASCII byte and stray continuations (no U+FFFD)', async () => {
968
+ // The continuation back-off walks `lead` to the LAST non-continuation byte,
969
+ // so a `> end` width check alone stops at the ASCII `0x41` (a complete 1-byte
970
+ // char) and keeps the orphan `0xc3` before it plus the three stray `0x80`
971
+ // continuations after it — all of which decode to trailing U+FFFD. Re-checking
972
+ // the boundary against the EXACT char width (and re-running after each trim)
973
+ // is what strips the whole malformed tail. The trailing `0x61` defeats the
974
+ // initial continuation back-off, so the stray `0x80` bytes are not at the very
975
+ // end and only the boundary loop removes them.
976
+ const N = LOOP_TASK_FILE_MAX_BYTES;
977
+ const head = Buffer.alloc(N - 5, 0x61); // 'a' * (N-5)
978
+ const tail = Buffer.from([0xc3, 0x41, 0x80, 0x80, 0x80, 0x61]); // orphan lead, 'A', 3 conts, 'a'
979
+ const raw = Buffer.concat([head, tail]); // N + 1 bytes → truncated
980
+ await fs.mkdir(path.join(projectRoot, '.qwen'), { recursive: true });
981
+ await fs.writeFile(path.join(projectRoot, '.qwen', 'loop.md'), raw);
982
+
983
+ const result = await readLoopTaskFile({
984
+ projectRoot,
985
+ homeDir,
986
+ allowProjectFile: true,
987
+ });
988
+
989
+ expect(result.status).toBe('found');
990
+ if (result.status !== 'found') {
991
+ throw new Error('expected loop.md to be found');
992
+ }
993
+ expect(result.truncated).toBe(true);
994
+ expect(Buffer.byteLength(result.content, 'utf8')).toBeLessThanOrEqual(
995
+ LOOP_TASK_FILE_MAX_BYTES,
996
+ );
997
+ // The whole malformed tail is gone: no replacement char, and the body ends
998
+ // on the last complete ('a') char — a clean UTF-8 boundary.
999
+ expect(result.content).not.toContain('�');
1000
+ expect(result.content).toBe('a'.repeat(N - 5));
1001
+ });
1002
+
1003
+ it('skips a candidate that raises ENAMETOOLONG and falls through instead of throwing', async () => {
1004
+ // The over-long-path code is in the skip whitelist but otherwise untested; a
1005
+ // typo'd entry would start throwing on a real ENAMETOOLONG instead of falling
1006
+ // through. Drive it via a mocked lstat on the project path; home still reads.
1007
+ await writeHome('user tasks');
1008
+ const projectLoop = path.join(projectRoot, '.qwen', 'loop.md');
1009
+ const actual =
1010
+ await vi.importActual<typeof import('node:fs/promises')>(
1011
+ 'node:fs/promises',
1012
+ );
1013
+ const enametoolong = Object.assign(new Error('ENAMETOOLONG'), {
1014
+ code: 'ENAMETOOLONG',
1015
+ });
1016
+ vi.spyOn(fs, 'lstat').mockImplementation(async (p) =>
1017
+ String(p) === projectLoop
1018
+ ? Promise.reject(enametoolong)
1019
+ : actual.lstat(p as string),
1020
+ );
1021
+
1022
+ const result = await readLoopTaskFile({
1023
+ projectRoot,
1024
+ homeDir,
1025
+ allowProjectFile: true,
1026
+ });
1027
+
1028
+ expect(result).toMatchObject({
1029
+ status: 'found',
1030
+ source: 'home',
1031
+ content: 'user tasks',
1032
+ });
1033
+ });
1034
+
1035
+ it('lets the original read error propagate when handle.close() also throws', async () => {
1036
+ // readBoundedTaskFile closes the handle in a `finally`. If read() throws
1037
+ // (e.g. EIO) AND close() also throws (e.g. EBADF), an unguarded `finally`
1038
+ // would replace the original I/O error with the close error, masking the
1039
+ // real cause. The close is guarded, so the ORIGINAL read error must survive.
1040
+ await writeProject('project tasks'); // real file so lstat/realpath/confine pass
1041
+ const eio = Object.assign(new Error('EIO: i/o error, read'), {
1042
+ code: 'EIO',
1043
+ });
1044
+ const ebadf = Object.assign(
1045
+ new Error('EBADF: bad file descriptor, close'),
1046
+ { code: 'EBADF' },
1047
+ );
1048
+ const close = vi.fn().mockRejectedValue(ebadf);
1049
+ const fakeHandle = {
1050
+ stat: async () => ({ isFile: () => true, size: 100 }),
1051
+ read: vi.fn().mockRejectedValue(eio),
1052
+ close,
1053
+ } as unknown as Awaited<ReturnType<typeof fs.open>>;
1054
+ // The first fs.open is the project candidate (read first); hand it the
1055
+ // fake handle. lstat/realpath above this still run against the real file.
1056
+ vi.mocked(fs.open).mockImplementationOnce(async () => fakeHandle);
1057
+
1058
+ await expect(
1059
+ readLoopTaskFile({ projectRoot, homeDir, allowProjectFile: true }),
1060
+ ).rejects.toBe(eio);
1061
+ // The close was still attempted (we swallow its failure, not skip it).
1062
+ expect(close).toHaveBeenCalled();
1063
+ });
1064
+ });