@band-app/server 0.26.1 → 0.27.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 (163) hide show
  1. package/dist/client/assets/{DockviewBrowserContainer-CNieygqn.js → DockviewBrowserContainer-BT0Xf-Qd.js} +1 -1
  2. package/dist/client/assets/{DockviewTerminalContainer-rpSQamnG.js → DockviewTerminalContainer-Cfq2Horm.js} +2 -2
  3. package/dist/client/assets/{TerminalPanel-BSSgNpJl.js → TerminalPanel-BmTjgsHC.js} +1 -1
  4. package/dist/client/assets/{_basePickBy-D4sc4sgx.js → _basePickBy-C5e3lPfw.js} +1 -1
  5. package/dist/client/assets/_baseUniq-BWawrl1m.js +1 -0
  6. package/dist/client/assets/{arc-BP7O8Ofj.js → arc-kL4UMvqY.js} +1 -1
  7. package/dist/client/assets/{architectureDiagram-VXUJARFQ-CLeIf9de.js → architectureDiagram-VXUJARFQ-CgqIs59Y.js} +1 -1
  8. package/dist/client/assets/{blockDiagram-VD42YOAC-GF1d7qvY.js → blockDiagram-VD42YOAC-CMeufhUZ.js} +1 -1
  9. package/dist/client/assets/{c4Diagram-YG6GDRKO-CPOD-mTp.js → c4Diagram-YG6GDRKO-CAuYwvwS.js} +1 -1
  10. package/dist/client/assets/channel-C29TlOk_.js +1 -0
  11. package/dist/client/assets/{chunk-4BX2VUAB-lgknUVTn.js → chunk-4BX2VUAB-6Xmnr9re.js} +1 -1
  12. package/dist/client/assets/{chunk-55IACEB6-BZZgpdJp.js → chunk-55IACEB6-Dyqy3jY2.js} +1 -1
  13. package/dist/client/assets/{chunk-B4BG7PRW-D5j_7n78.js → chunk-B4BG7PRW-CHkzDkkl.js} +1 -1
  14. package/dist/client/assets/{chunk-DI55MBZ5-CNWAKrv-.js → chunk-DI55MBZ5-Bm8dHcM7.js} +1 -1
  15. package/dist/client/assets/{chunk-FMBD7UC4-C4ZfwGxd.js → chunk-FMBD7UC4-DUSTPPko.js} +1 -1
  16. package/dist/client/assets/{chunk-QN33PNHL-B-1Abtg1.js → chunk-QN33PNHL-lANEHpc4.js} +1 -1
  17. package/dist/client/assets/{chunk-QZHKN3VN-BpzKtAWD.js → chunk-QZHKN3VN-CdOUQvqm.js} +1 -1
  18. package/dist/client/assets/{chunk-TZMSLE5B-SXcvrXdW.js → chunk-TZMSLE5B-5WqV_fuV.js} +1 -1
  19. package/dist/client/assets/classDiagram-2ON5EDUG-Ce71vgj4.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-WZHVMYZB-Ce71vgj4.js +1 -0
  21. package/dist/client/assets/clone-DV3pdAAp.js +1 -0
  22. package/dist/client/assets/{cose-bilkent-S5V4N54A-Bct5sd16.js → cose-bilkent-S5V4N54A-DysgQPxt.js} +1 -1
  23. package/dist/client/assets/{dagre-6UL2VRFP-DLF3mura.js → dagre-6UL2VRFP-Dlzo9Msc.js} +1 -1
  24. package/dist/client/assets/{diagram-PSM6KHXK-ChWVmXI7.js → diagram-PSM6KHXK-BYb3WSVe.js} +1 -1
  25. package/dist/client/assets/{diagram-QEK2KX5R-DhzUuHM4.js → diagram-QEK2KX5R-DQ47fOtM.js} +1 -1
  26. package/dist/client/assets/{diagram-S2PKOQOG-D1i0mdIg.js → diagram-S2PKOQOG-8W5mRzVZ.js} +1 -1
  27. package/dist/client/assets/{erDiagram-Q2GNP2WA-BRj386XH.js → erDiagram-Q2GNP2WA-mgQREmQi.js} +1 -1
  28. package/dist/client/assets/{flowDiagram-NV44I4VS-DStEWZIF.js → flowDiagram-NV44I4VS-B3xMnHpg.js} +1 -1
  29. package/dist/client/assets/{ganttDiagram-JELNMOA3-B9PBn8dz.js → ganttDiagram-JELNMOA3-uAMwC_fw.js} +1 -1
  30. package/dist/client/assets/{gitGraphDiagram-V2S2FVAM-laJAIbJ3.js → gitGraphDiagram-V2S2FVAM-CMzRWjLs.js} +1 -1
  31. package/dist/client/assets/{graph-CbQhcBO1.js → graph-BmHmHBlj.js} +1 -1
  32. package/dist/client/assets/{highlighted-body-B3W2YXNL-DBxcPCX_.js → highlighted-body-B3W2YXNL-CktG8_2q.js} +1 -1
  33. package/dist/client/assets/{index-CK16S7B-.js → index-7ksyuzec.js} +1 -1
  34. package/dist/client/assets/{index-Ct5D5rHM.js → index-B5eLE5g9.js} +1 -1
  35. package/dist/client/assets/{index-D7C86bYw.js → index-BAkL31T9.js} +1 -1
  36. package/dist/client/assets/{index-CWLwMHnR.js → index-BVM1YUvk.js} +1 -1
  37. package/dist/client/assets/{index-DsEib-PT.js → index-Bl_qcgmA.js} +1 -1
  38. package/dist/client/assets/{index-Cd26dS4P.js → index-C17JkYK9.js} +1 -1
  39. package/dist/client/assets/{index-Dev2D0I9.js → index-CE-YdiiQ.js} +1 -1
  40. package/dist/client/assets/{index-86TW9gUX.js → index-CO5pYvVV.js} +1 -1
  41. package/dist/client/assets/{index-C5U256M6.js → index-CVhvgW2f.js} +1 -1
  42. package/dist/client/assets/{index-9XCTqmje.js → index-CmBpodpp.js} +1 -1
  43. package/dist/client/assets/{index-CLa-0ZwC.js → index-CuFyr77F.js} +1 -1
  44. package/dist/client/assets/{index-CfO5yuew.js → index-CyIB4sGJ.js} +1 -1
  45. package/dist/client/assets/{index-BqMDNoZp.js → index-DDjGWZqJ.js} +1 -1
  46. package/dist/client/assets/{index-BzwcSYCa.js → index-DOcXD6ZB.js} +1 -1
  47. package/dist/client/assets/{index-Cd5F9rkU.js → index-DWrfvgIb.js} +1 -1
  48. package/dist/client/assets/{index-DGvFFhe0.js → index-Db-V2kYr.js} +1 -1
  49. package/dist/client/assets/{index-cRf4PoQf.js → index-Dt1N14f0.js} +1 -1
  50. package/dist/client/assets/{index-CyO2GJFA.js → index-cr1Bo2U9.js} +1 -1
  51. package/dist/client/assets/{infoDiagram-HS3SLOUP-0QLwbPV-.js → infoDiagram-HS3SLOUP-BfGNNAH4.js} +1 -1
  52. package/dist/client/assets/{journeyDiagram-XKPGCS4Q-xGdny9DO.js → journeyDiagram-XKPGCS4Q-Ds8ah4VH.js} +1 -1
  53. package/dist/client/assets/{kanban-definition-3W4ZIXB7-Don0Okra.js → kanban-definition-3W4ZIXB7-C8PaE0y4.js} +1 -1
  54. package/dist/client/assets/{layout-WN5NQdX3.js → layout-D5O5pfhp.js} +1 -1
  55. package/dist/client/assets/{main-Bbh731ex.js → main-CWjuaHE2.js} +221 -221
  56. package/dist/client/assets/main-Di-3Gk2i.css +1 -0
  57. package/dist/client/assets/{mindmap-definition-VGOIOE7T-CWF6i4Zi.js → mindmap-definition-VGOIOE7T-DmB2qgHj.js} +1 -1
  58. package/dist/client/assets/{pieDiagram-ADFJNKIX-CXk9oMu_.js → pieDiagram-ADFJNKIX-BhZD1Pjg.js} +1 -1
  59. package/dist/client/assets/{quadrantDiagram-AYHSOK5B-D2s0zA4_.js → quadrantDiagram-AYHSOK5B-Dm615XC6.js} +1 -1
  60. package/dist/client/assets/{requirementDiagram-UZGBJVZJ-B5g3eZDa.js → requirementDiagram-UZGBJVZJ-ezxSkCjp.js} +1 -1
  61. package/dist/client/assets/{sankeyDiagram-TZEHDZUN-BKYy9nLE.js → sankeyDiagram-TZEHDZUN-NLwGtj2u.js} +1 -1
  62. package/dist/client/assets/{sequenceDiagram-WL72ISMW-Of-7JsaP.js → sequenceDiagram-WL72ISMW-D8C_g8jv.js} +1 -1
  63. package/dist/client/assets/{square-terminal-wqR_rG8g.js → square-terminal-DAMJq3wS.js} +1 -1
  64. package/dist/client/assets/{stateDiagram-FKZM4ZOC-CLygxzeu.js → stateDiagram-FKZM4ZOC-CkXjpkTE.js} +1 -1
  65. package/dist/client/assets/stateDiagram-v2-4FDKWEC3-Cp2lp6Ub.js +1 -0
  66. package/dist/client/assets/{timeline-definition-IT6M3QCI-HEdTZ9lg.js → timeline-definition-IT6M3QCI-DyPfcP9V.js} +1 -1
  67. package/dist/client/assets/{treemap-GDKQZRPO-BLCSYUuQ.js → treemap-GDKQZRPO-rH_7Hpkq.js} +1 -1
  68. package/dist/client/assets/workspace._workspaceId-CEIguAmj.js +2 -0
  69. package/dist/client/assets/{workspace._workspaceId-CvIXlcVP.js → workspace._workspaceId-COpAN_jv.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-PRI3JC2R-Da4m2pn4.js → xychartDiagram-PRI3JC2R-DkwSq2gu.js} +1 -1
  71. package/dist/migrations/20260707132201_moaning_cerebro/migration.sql +2 -0
  72. package/dist/migrations/20260707132201_moaning_cerebro/snapshot.json +1187 -0
  73. package/dist/openapi.json +69 -15
  74. package/dist/server/assets/{DockviewBrowserContainer-BxukCnRj.js → DockviewBrowserContainer-CxzZY1qq.js} +1 -1
  75. package/dist/server/assets/{DockviewTerminalContainer-DQYYK-79.js → DockviewTerminalContainer-BIJPTTun.js} +3 -3
  76. package/dist/server/assets/{TerminalPanel-TU2tfPvh.js → TerminalPanel-kEsEEcuZ.js} +1 -1
  77. package/dist/server/assets/{_basePickBy-BSdtfHB0.js → _basePickBy-n4A54Vhz.js} +2 -2
  78. package/dist/server/assets/{_baseUniq-DkSqb3YR.js → _baseUniq-BuxfXYiw.js} +1 -1
  79. package/dist/server/assets/_tanstack-start-manifest_v-CbRnkjTe.js +4 -0
  80. package/dist/server/assets/{arc-DxGkupwT.js → arc-C0mFTnQ4.js} +1 -1
  81. package/dist/server/assets/{architecture-7HQA4BMR-N3irVT54.js → architecture-7HQA4BMR-D3tywrA5.js} +6 -6
  82. package/dist/server/assets/{architectureDiagram-VXUJARFQ-B0sHbG9y.js → architectureDiagram-VXUJARFQ-B9hdFdgc.js} +6 -6
  83. package/dist/server/assets/{blockDiagram-VD42YOAC-1FtDdqBo.js → blockDiagram-VD42YOAC-Bjf6umts.js} +6 -6
  84. package/dist/server/assets/{c4Diagram-YG6GDRKO-Bw9IfZMM.js → c4Diagram-YG6GDRKO-B1Go7qzd.js} +2 -2
  85. package/dist/server/assets/{channel-D4IKgr-x.js → channel-Dqy7JuGG.js} +1 -1
  86. package/dist/server/assets/{chunk-4BX2VUAB-ByMuI7Fp.js → chunk-4BX2VUAB-DABywcCU.js} +1 -1
  87. package/dist/server/assets/{chunk-55IACEB6-BAdInBZF.js → chunk-55IACEB6-WikJyoE2.js} +1 -1
  88. package/dist/server/assets/{chunk-B4BG7PRW-C8vV9ouR.js → chunk-B4BG7PRW-CUCrrQLt.js} +4 -4
  89. package/dist/server/assets/{chunk-DI55MBZ5-BnPjxwDt.js → chunk-DI55MBZ5-CAaqT17X.js} +3 -3
  90. package/dist/server/assets/{chunk-FMBD7UC4-DxER1-MJ.js → chunk-FMBD7UC4-CX_v_cac.js} +1 -1
  91. package/dist/server/assets/{chunk-QN33PNHL-DzFGKjvS.js → chunk-QN33PNHL-VLbTMtxB.js} +1 -1
  92. package/dist/server/assets/{chunk-QZHKN3VN-DKIreZRT.js → chunk-QZHKN3VN-QB2sRPFp.js} +1 -1
  93. package/dist/server/assets/{chunk-TZMSLE5B-Dyw_E6V-.js → chunk-TZMSLE5B-C9MxtgP2.js} +1 -1
  94. package/dist/server/assets/{classDiagram-v2-WZHVMYZB-Bu-X2Gbw.js → classDiagram-2ON5EDUG-UX-9XQ-6.js} +5 -5
  95. package/dist/server/assets/{classDiagram-2ON5EDUG-Bu-X2Gbw.js → classDiagram-v2-WZHVMYZB-UX-9XQ-6.js} +5 -5
  96. package/dist/server/assets/{clone-CwBTz6wc.js → clone-Jt6nkb9-.js} +1 -1
  97. package/dist/server/assets/{cose-bilkent-S5V4N54A-BsFFjtud.js → cose-bilkent-S5V4N54A-j4JEpP33.js} +1 -1
  98. package/dist/server/assets/{dagre-6UL2VRFP-CdgGxkBj.js → dagre-6UL2VRFP-CRjo2D7K.js} +6 -6
  99. package/dist/server/assets/{diagram-PSM6KHXK-C-LWq-ju.js → diagram-PSM6KHXK-CdcM_CoU.js} +7 -7
  100. package/dist/server/assets/{diagram-QEK2KX5R-CFa3sLiJ.js → diagram-QEK2KX5R-DXbEv4yb.js} +6 -6
  101. package/dist/server/assets/{diagram-S2PKOQOG-CyAHBi5H.js → diagram-S2PKOQOG-Bzc1B5Be.js} +6 -6
  102. package/dist/server/assets/{erDiagram-Q2GNP2WA-COrhvUZD.js → erDiagram-Q2GNP2WA-Gt2ujaN1.js} +4 -4
  103. package/dist/server/assets/{flowDiagram-NV44I4VS-DxR9v2a8.js → flowDiagram-NV44I4VS-CONOX4ru.js} +5 -5
  104. package/dist/server/assets/{ganttDiagram-JELNMOA3-COoz3lCc.js → ganttDiagram-JELNMOA3-LO1CZhBF.js} +1 -1
  105. package/dist/server/assets/{gitGraph-G5XIXVHT-BtVXZr1d.js → gitGraph-G5XIXVHT-CYME5GNN.js} +6 -6
  106. package/dist/server/assets/{gitGraphDiagram-V2S2FVAM-DrH_1hhd.js → gitGraphDiagram-V2S2FVAM-JAQOltND.js} +7 -7
  107. package/dist/server/assets/{graph-BqKB55Uo.js → graph-DDw3y63Y.js} +2 -2
  108. package/dist/server/assets/{highlighted-body-B3W2YXNL-C6VVTevi.js → highlighted-body-B3W2YXNL-Bl56ZglV.js} +1 -1
  109. package/dist/server/assets/{index-BUnwtpbx.js → index-B9mYm2Iv.js} +3 -3
  110. package/dist/server/assets/{index-BHRz1U1c.js → index-BDXNcXpX.js} +2 -2
  111. package/dist/server/assets/{index-Cdu8cpPJ.js → index-BFxM6c1Y.js} +2 -2
  112. package/dist/server/assets/{index-Bkx5Foei.js → index-BFyODGGJ.js} +4 -4
  113. package/dist/server/assets/{index-ChJXmItC.js → index-BPA-V89G.js} +2 -2
  114. package/dist/server/assets/{index-DAqzmsPy.js → index-BVjGe10C.js} +1 -1
  115. package/dist/server/assets/{index-CQ12PKsH.js → index-BuqNnUeC.js} +2 -2
  116. package/dist/server/assets/{index-DeNkOFC0.js → index-CEHzgT6Q.js} +5 -5
  117. package/dist/server/assets/{index-nrGOopfX.js → index-CGdiDxDM.js} +2 -2
  118. package/dist/server/assets/{index-Dnp4nLLc.js → index-CX4yKGY7.js} +2 -2
  119. package/dist/server/assets/{index-BvmYCz2n.js → index-CXNHt9Li.js} +2 -2
  120. package/dist/server/assets/{index-IxUUG90S.js → index-CZITjlKR.js} +1 -1
  121. package/dist/server/assets/{index-FVQ1p-ZQ.js → index-D022D6yP.js} +5 -5
  122. package/dist/server/assets/{index-CkbMi8ML.js → index-DRNvWTdD.js} +2 -2
  123. package/dist/server/assets/{index-DIZ-5H2r.js → index-DsR_UKQx.js} +3 -3
  124. package/dist/server/assets/{index-DLOoQUcR.js → index-DsyhmvSr.js} +2 -2
  125. package/dist/server/assets/{index-DgpTNIz9.js → index-Dvcd3xwL.js} +2 -2
  126. package/dist/server/assets/{index-fzh31gPI.js → index-jCxJanyY.js} +2 -2
  127. package/dist/server/assets/{info-VBDWY6EO-C1EJd9ah.js → info-VBDWY6EO-DnY-exPR.js} +6 -6
  128. package/dist/server/assets/{infoDiagram-HS3SLOUP-BgR_JFRD.js → infoDiagram-HS3SLOUP-wZZ4hEZ9.js} +5 -5
  129. package/dist/server/assets/{journeyDiagram-XKPGCS4Q-TInLDEBY.js → journeyDiagram-XKPGCS4Q-CBD0jzUu.js} +4 -4
  130. package/dist/server/assets/{kanban-definition-3W4ZIXB7-Ds9-Rky6.js → kanban-definition-3W4ZIXB7-9SKGIQ4n.js} +2 -2
  131. package/dist/server/assets/{layout-02chZ-oO.js → layout-C-xcL44a.js} +4 -4
  132. package/dist/server/assets/{mermaid-3ZIDBTTL-CF0njLRi.js → mermaid-3ZIDBTTL-DyXquuLm.js} +1 -1
  133. package/dist/server/assets/{mermaid-parser.core-DGyqTX8L.js → mermaid-parser.core-DruUthGx.js} +11 -11
  134. package/dist/server/assets/{mindmap-definition-VGOIOE7T-B2VHBojz.js → mindmap-definition-VGOIOE7T-jjxDAUjJ.js} +3 -3
  135. package/dist/server/assets/{packet-DYOGHKS2-BPQQ4vpy.js → packet-DYOGHKS2-CyXoagV-.js} +6 -6
  136. package/dist/server/assets/{pie-VRWISCQL-D_ISlmZV.js → pie-VRWISCQL-1fjbRV6a.js} +6 -6
  137. package/dist/server/assets/{pieDiagram-ADFJNKIX-DfLOLRYh.js → pieDiagram-ADFJNKIX-5evhP6Hm.js} +7 -7
  138. package/dist/server/assets/{quadrantDiagram-AYHSOK5B-C-S0tUnS.js → quadrantDiagram-AYHSOK5B-Cg2-kPDC.js} +1 -1
  139. package/dist/server/assets/{radar-ZZBFDIW7-Btz0_zMX.js → radar-ZZBFDIW7-DCBXoSHQ.js} +6 -6
  140. package/dist/server/assets/{requirementDiagram-UZGBJVZJ-CX-LPkZO.js → requirementDiagram-UZGBJVZJ-DjOyZt1t.js} +3 -3
  141. package/dist/server/assets/{router-6QHcrvmQ.js → router-BtsNvWXM.js} +766 -615
  142. package/dist/server/assets/{sankeyDiagram-TZEHDZUN-C3bzmTeJ.js → sankeyDiagram-TZEHDZUN-Bun0ZHSn.js} +1 -1
  143. package/dist/server/assets/{sequenceDiagram-WL72ISMW-BlZBlq8N.js → sequenceDiagram-WL72ISMW-fn9HXFXZ.js} +3 -3
  144. package/dist/server/assets/{square-terminal-1wS601vv.js → square-terminal-CDDUQc-O.js} +1 -1
  145. package/dist/server/assets/{stateDiagram-FKZM4ZOC-BIRMcaiS.js → stateDiagram-FKZM4ZOC-Ct7juWxD.js} +8 -8
  146. package/dist/server/assets/{stateDiagram-v2-4FDKWEC3-D2NYWxQw.js → stateDiagram-v2-4FDKWEC3-Bp505XWR.js} +4 -4
  147. package/dist/server/assets/{timeline-definition-IT6M3QCI-BiY_XUAp.js → timeline-definition-IT6M3QCI-Dw8oeGiX.js} +2 -2
  148. package/dist/server/assets/{treemap-GDKQZRPO-CYGEAd8a.js → treemap-GDKQZRPO-BdIdWFqZ.js} +6 -6
  149. package/dist/server/assets/{workspace._workspaceId-MLWQzLSj.js → workspace._workspaceId-GwRuI3oS.js} +10 -4
  150. package/dist/server/assets/{workspace._workspaceId-CHMs1pW4.js → workspace._workspaceId-joQQNCnQ.js} +1 -1
  151. package/dist/server/assets/{xychartDiagram-PRI3JC2R-Cv8buTCr.js → xychartDiagram-PRI3JC2R-BRCOemWX.js} +1 -1
  152. package/dist/server/server.js +2 -2
  153. package/dist/start-server.mjs +1398 -1140
  154. package/package.json +4 -4
  155. package/dist/client/assets/_baseUniq-CExXcLYT.js +0 -1
  156. package/dist/client/assets/channel-BAHGob6X.js +0 -1
  157. package/dist/client/assets/classDiagram-2ON5EDUG-_5AM6YM0.js +0 -1
  158. package/dist/client/assets/classDiagram-v2-WZHVMYZB-_5AM6YM0.js +0 -1
  159. package/dist/client/assets/clone-C12DbMN-.js +0 -1
  160. package/dist/client/assets/main-DpPgdfV2.css +0 -1
  161. package/dist/client/assets/stateDiagram-v2-4FDKWEC3-D3J4rLvX.js +0 -1
  162. package/dist/client/assets/workspace._workspaceId-9mP5lL7t.js +0 -2
  163. package/dist/server/assets/_tanstack-start-manifest_v-CjnZwsJN.js +0 -4
@@ -30394,6 +30394,20 @@ var init_claude_code = __esm({
30394
30394
  args: [prompt]
30395
30395
  };
30396
30396
  }
30397
+ /**
30398
+ * Headless one-shot invocation for automated terminal dispatch (cronjobs,
30399
+ * issue #581). `claude -p "<prompt>"` runs the prompt in print
30400
+ * (non-interactive) mode and exits when the turn completes — unlike
30401
+ * `cliInvocation`'s bare `claude "<prompt>"`, which opens the REPL and
30402
+ * stays parked. The prompt is the value of `-p`, so a leading `-` in it is
30403
+ * consumed as the flag's argument rather than parsed as a flag.
30404
+ */
30405
+ cliHeadlessInvocation(prompt) {
30406
+ return {
30407
+ command: this.executablePath ?? CLAUDE_CODE_DEFAULT_BINARY,
30408
+ args: ["-p", prompt]
30409
+ };
30410
+ }
30397
30411
  /**
30398
30412
  * Resume CLI invocation for the chat tab's "Continue in terminal" action.
30399
30413
  * `claude --resume <session-id>` reopens the interactive REPL with that
@@ -31148,6 +31162,18 @@ var init_cursor_cli = __esm({
31148
31162
  reason: "Cursor CLI has no interactive prompt-loading invocation; falling back to chat."
31149
31163
  };
31150
31164
  }
31165
+ /**
31166
+ * Cursor CLI is SDK-only (JSON over a managed subprocess) with no
31167
+ * non-interactive vendor CLI to run in a pane, so headless terminal
31168
+ * dispatch (cronjobs `via: "terminal"`, issue #581) also returns the
31169
+ * `unsupported` sentinel — the cronjob service then falls back to chat.
31170
+ */
31171
+ cliHeadlessInvocation(_prompt) {
31172
+ return {
31173
+ unsupported: true,
31174
+ reason: "Cursor CLI has no non-interactive CLI invocation; falling back to chat."
31175
+ };
31176
+ }
31151
31177
  /**
31152
31178
  * The chat tab's "Continue in terminal" action has no Cursor equivalent:
31153
31179
  * the agent runs over the SDK's managed JSON subprocess, with no
@@ -32346,6 +32372,18 @@ var init_codex = __esm({
32346
32372
  args: [prompt]
32347
32373
  };
32348
32374
  }
32375
+ /**
32376
+ * Headless one-shot invocation for automated terminal dispatch (cronjobs,
32377
+ * issue #581). `codex exec "<prompt>"` runs the prompt non-interactively
32378
+ * and exits on completion — unlike `cliInvocation`'s bare `codex
32379
+ * "<prompt>"`, which opens the interactive TUI and stays parked.
32380
+ */
32381
+ cliHeadlessInvocation(prompt) {
32382
+ return {
32383
+ command: this.executablePath ?? CODEX_DEFAULT_BINARY,
32384
+ args: ["exec", prompt]
32385
+ };
32386
+ }
32349
32387
  /**
32350
32388
  * Resume CLI invocation for the chat tab's "Continue in terminal" action.
32351
32389
  * `codex resume <session-id>` reopens the interactive Codex TUI with that
@@ -32735,6 +32773,25 @@ var init_gemini_cli = __esm({
32735
32773
  args: ["--", prompt]
32736
32774
  };
32737
32775
  }
32776
+ /**
32777
+ * Headless one-shot invocation for automated terminal dispatch (cronjobs,
32778
+ * issue #581). `gemini --prompt=<prompt>` runs non-interactively and exits
32779
+ * on completion — unlike `cliInvocation`'s `gemini -- "<prompt>"`, which
32780
+ * opens the interactive REPL and stays parked. The `--prompt=<value>`
32781
+ * joined form (rather than `--prompt <value>`) binds the prompt as the
32782
+ * flag's argument even when it starts with `-`, mirroring the leading-dash
32783
+ * safety the interactive `--` guard provides.
32784
+ *
32785
+ * `--output-format` is deliberately omitted (unlike `runSession`, which asks
32786
+ * for NDJSON): a cron terminal shows plain human-readable output in the pane,
32787
+ * not a machine-parsed stream.
32788
+ */
32789
+ cliHeadlessInvocation(prompt) {
32790
+ return {
32791
+ command: this.executablePath,
32792
+ args: [`--prompt=${prompt}`]
32793
+ };
32794
+ }
32738
32795
  /**
32739
32796
  * The chat tab's "Continue in terminal" action has no Gemini equivalent:
32740
32797
  * the Gemini CLI has no session model, so there's no session ID to resume
@@ -33230,6 +33287,21 @@ var init_opencode = __esm({
33230
33287
  args: ["--prompt", prompt]
33231
33288
  };
33232
33289
  }
33290
+ /**
33291
+ * Headless one-shot invocation for automated terminal dispatch (cronjobs,
33292
+ * issue #581). `opencode run "<prompt>"` executes the prompt
33293
+ * non-interactively and exits on completion — unlike `cliInvocation`'s
33294
+ * `opencode --prompt "<prompt>"`, which opens the interactive TUI and stays
33295
+ * parked. `run` takes the prompt as its trailing positional (same shape as
33296
+ * the SDK `runSession` path above, minus the JSON formatting we only need
33297
+ * when parsing programmatically).
33298
+ */
33299
+ cliHeadlessInvocation(prompt) {
33300
+ return {
33301
+ command: this.executablePath,
33302
+ args: ["run", prompt]
33303
+ };
33304
+ }
33233
33305
  /**
33234
33306
  * Resume CLI invocation for the chat tab's "Continue in terminal" action.
33235
33307
  * `opencode --session <id>` launches the interactive TUI on that exact
@@ -101849,10 +101921,19 @@ var cronjobs = sqliteTable("cronjobs", {
101849
101921
  cronExpression: text2("cron_expression").notNull(),
101850
101922
  scope: text2("scope", { enum: ["project", "workspace"] }).notNull(),
101851
101923
  workspaceId: text2("workspace_id"),
101924
+ // Where a fire dispatches the prompt (issue #581): "chat" submits a task to
101925
+ // the workspace's cron chat pane (default, backward-compatible); "terminal"
101926
+ // spawns the agent's vendor CLI in a fresh self-closing PTY pane. Mirrors the
101927
+ // `via` discriminator on `workspaces.create` (#551).
101928
+ via: text2("via", { enum: ["chat", "terminal"] }).notNull().default("chat"),
101852
101929
  enabled: integer2("enabled", { mode: "boolean" }).notNull().default(true),
101853
101930
  createdAt: text2("created_at").notNull(),
101854
101931
  lastRunAt: text2("last_run_at"),
101855
- lastRunStatus: text2("last_run_status", { enum: ["completed", "failed", "skipped"] })
101932
+ lastRunStatus: text2("last_run_status", { enum: ["completed", "failed", "skipped"] }),
101933
+ // For via="terminal" jobs, the id of the terminal spawned by the most recent
101934
+ // fire. Used as the overlap-check handle: if this PTY is still alive when the
101935
+ // next tick fires, the run is skipped rather than launching a second agent.
101936
+ lastTerminalId: text2("last_terminal_id")
101856
101937
  });
101857
101938
  var usageEvents = sqliteTable(
101858
101939
  "usage_events",
@@ -111498,7 +111579,7 @@ var badgeVariants = cva(
111498
111579
  // ../../packages/ui/src/components/button.tsx
111499
111580
  var import_jsx_runtime20 = __toESM(require_jsx_runtime(), 1);
111500
111581
  var buttonVariants = cva(
111501
- "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
111582
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity,transform] outline-none active:scale-[0.97] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
111502
111583
  {
111503
111584
  variants: {
111504
111585
  variant: {
@@ -111597,10 +111678,10 @@ function DialogOverlay2({
111597
111678
  );
111598
111679
  }
111599
111680
  var DIALOG_CONTENT_VARIANTS = {
111600
- default: "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
111681
+ default: "ease-out fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
111601
111682
  "bottom-sheet": [
111602
111683
  // Shared
111603
- "fixed z-50 flex flex-col bg-background shadow-lg duration-200 outline-none",
111684
+ "ease-out fixed z-50 flex flex-col bg-background shadow-lg duration-200 outline-none",
111604
111685
  "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
111605
111686
  // Mobile (< lg): bottom drawer
111606
111687
  "inset-x-0 bottom-0 w-full max-w-none rounded-t-2xl border border-b-0 p-6",
@@ -111612,8 +111693,11 @@ var DIALOG_CONTENT_VARIANTS = {
111612
111693
  ].join(" "),
111613
111694
  "command-palette": [
111614
111695
  // Shared
111615
- "fixed z-50 flex flex-col bg-background shadow-lg duration-200 outline-none",
111616
- "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
111696
+ "fixed z-50 flex flex-col bg-background shadow-lg outline-none",
111697
+ // Mobile-only open/close animation: the drawer slide is touch-initiated.
111698
+ // Desktop (lg+) gets NO open/close animation — this is a keyboard-summoned
111699
+ // surface hit dozens of times a day; it must appear instantly.
111700
+ "max-lg:duration-200 max-lg:data-[state=closed]:animate-out max-lg:data-[state=closed]:fade-out-0 max-lg:data-[state=open]:animate-in max-lg:data-[state=open]:fade-in-0",
111617
111701
  // Mobile (< lg): bottom drawer with the input pinned to the bottom. Flip
111618
111702
  // the command's flex direction so the input sits below the results list,
111619
111703
  // and flip the input wrapper's divider to a top border to match. The sheet
@@ -111626,8 +111710,7 @@ var DIALOG_CONTENT_VARIANTS = {
111626
111710
  "max-lg:data-[state=open]:slide-in-from-bottom max-lg:data-[state=closed]:slide-out-to-bottom",
111627
111711
  // Desktop (lg+): anchored in the upper third by its TOP edge — no
111628
111712
  // `translate-y`, so the input never moves as the list grows downward.
111629
- "lg:inset-x-auto lg:bottom-auto lg:top-[12vh] lg:left-1/2 lg:w-full lg:max-w-lg lg:max-h-[70vh] lg:-translate-x-1/2 lg:rounded-lg lg:border",
111630
- "lg:data-[state=open]:zoom-in-95 lg:data-[state=closed]:zoom-out-95"
111713
+ "lg:inset-x-auto lg:bottom-auto lg:top-[12vh] lg:left-1/2 lg:w-full lg:max-w-lg lg:max-h-[70vh] lg:-translate-x-1/2 lg:rounded-lg lg:border"
111631
111714
  ].join(" ")
111632
111715
  };
111633
111716
  var keyboardInsetHolders = /* @__PURE__ */ new Set();
@@ -111663,7 +111746,15 @@ function DialogContent2({
111663
111746
  }) {
111664
111747
  useKeyboardInset(variant === "command-palette");
111665
111748
  return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(DialogPortal2, { "data-slot": "dialog-portal", children: [
111666
- /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(DialogOverlay2, { className: overlayClassName }),
111749
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
111750
+ DialogOverlay2,
111751
+ {
111752
+ className: cn(
111753
+ variant === "command-palette" && "lg:data-[state=open]:animate-none lg:data-[state=closed]:animate-none",
111754
+ overlayClassName
111755
+ )
111756
+ }
111757
+ ),
111667
111758
  /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(
111668
111759
  dist_exports2.Content,
111669
111760
  {
@@ -111774,7 +111865,7 @@ function ContextMenuContent2({
111774
111865
  if (event.button === 2) event.stopPropagation();
111775
111866
  },
111776
111867
  className: cn(
111777
- "z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
111868
+ "ease-out duration-150 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
111778
111869
  className
111779
111870
  ),
111780
111871
  ...props
@@ -111868,7 +111959,7 @@ function TooltipContent2({
111868
111959
  "data-slot": "tooltip-content",
111869
111960
  sideOffset,
111870
111961
  className: cn(
111871
- "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
111962
+ "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in fade-in-0 duration-100 ease-out rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-75",
111872
111963
  className
111873
111964
  ),
111874
111965
  ...props,
@@ -114728,8 +114819,24 @@ function SetupStatusIndicator({ setup }) {
114728
114819
  return null;
114729
114820
  }
114730
114821
 
114731
- // src/dashboard/components/WorkspaceCard.tsx
114822
+ // src/dashboard/components/WorkspaceLabel.tsx
114732
114823
  var import_jsx_runtime58 = __toESM(require_jsx_runtime(), 1);
114824
+ function WorkspaceLabel({
114825
+ name: name3,
114826
+ projectName,
114827
+ isActive,
114828
+ tone = "sidebar"
114829
+ }) {
114830
+ const nameClass = tone === "switcher" ? `text-foreground ${isActive ? "font-semibold" : "font-medium"}` : isActive ? "font-bold text-foreground" : "font-medium text-muted-foreground";
114831
+ const projectClass = tone === "switcher" ? "text-foreground/70" : isActive ? "text-foreground/80" : "text-muted-foreground";
114832
+ return /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)("div", { className: "flex flex-col min-w-0 leading-tight", children: [
114833
+ /* @__PURE__ */ (0, import_jsx_runtime58.jsx)("span", { className: cn("text-sm truncate", nameClass), children: name3 }),
114834
+ /* @__PURE__ */ (0, import_jsx_runtime58.jsx)("span", { className: cn("text-xs truncate", projectClass), children: projectName })
114835
+ ] });
114836
+ }
114837
+
114838
+ // src/dashboard/components/WorkspaceCard.tsx
114839
+ var import_jsx_runtime59 = __toESM(require_jsx_runtime(), 1);
114733
114840
  var RECENT_ACTIVATION_WINDOW_MS = 300;
114734
114841
  var recentlyActivatedWorkspaceId = null;
114735
114842
  var recentlyActivatedTimer = null;
@@ -114835,21 +114942,21 @@ var WorkspaceCard = (0, import_react22.memo)(function WorkspaceCard2({
114835
114942
  });
114836
114943
  }
114837
114944
  };
114838
- return /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenu2, { children: [
114839
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(ContextMenuTrigger2, { asChild: true, children: /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)("div", { ...containerProps, children: [
114840
- /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(Tooltip2, { delayDuration: 800, children: [
114841
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(TooltipTrigger2, { asChild: true, children: /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)("div", { className: "flex items-center gap-2 min-w-0 overflow-hidden", children: [
114945
+ return /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenu2, { children: [
114946
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(ContextMenuTrigger2, { asChild: true, children: /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)("div", { ...containerProps, children: [
114947
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(Tooltip2, { delayDuration: 800, children: [
114948
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(TooltipTrigger2, { asChild: true, children: /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)("div", { className: "flex items-center gap-2 min-w-0 overflow-hidden", children: [
114842
114949
  isRoot ? (
114843
114950
  // On the root card the house is AgentStatusIndicator's idle
114844
114951
  // fallback (the slot other cards use for the branch glyph),
114845
114952
  // so an active agent's status dot replaces the house rather
114846
114953
  // than sitting beside it.
114847
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(
114954
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(
114848
114955
  AgentStatusIndicator,
114849
114956
  {
114850
114957
  agent: status?.agent,
114851
114958
  isActive,
114852
- fallback: /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(
114959
+ fallback: /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(
114853
114960
  import_lucide_react5.Home,
114854
114961
  {
114855
114962
  "data-testid": "workspace-card__home-icon",
@@ -114858,29 +114965,22 @@ var WorkspaceCard = (0, import_react22.memo)(function WorkspaceCard2({
114858
114965
  )
114859
114966
  }
114860
114967
  )
114861
- ) : /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(AgentStatusIndicator, { agent: status?.agent, isActive }),
114968
+ ) : /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(AgentStatusIndicator, { agent: status?.agent, isActive }),
114862
114969
  showProjectName ? (
114863
114970
  // Pinned section: stack the branch name over the project name
114864
114971
  // as a compact two-line block so a mixed list of pinned cards
114865
114972
  // stays scannable without a long, mid-truncated
114866
- // `project/branch` string on one line.
114867
- /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)("div", { className: "flex flex-col min-w-0 leading-tight", children: [
114868
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(
114869
- "span",
114870
- {
114871
- className: `text-sm truncate ${isActive ? "font-bold text-foreground" : "font-medium text-muted-foreground"}`,
114872
- children: worktree.name
114873
- }
114874
- ),
114875
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(
114876
- "span",
114877
- {
114878
- className: `text-xs truncate ${isActive ? "text-foreground/80" : "text-muted-foreground"}`,
114879
- children: projectName
114880
- }
114881
- )
114882
- ] })
114883
- ) : /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(
114973
+ // `project/branch` string on one line. Shared with the ⌘K
114974
+ // workspace picker via `WorkspaceLabel`.
114975
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(
114976
+ WorkspaceLabel,
114977
+ {
114978
+ name: worktree.name,
114979
+ projectName,
114980
+ isActive
114981
+ }
114982
+ )
114983
+ ) : /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(
114884
114984
  "span",
114885
114985
  {
114886
114986
  className: `text-sm truncate ${isActive ? "font-bold text-foreground" : "font-medium text-muted-foreground"}`,
@@ -114888,46 +114988,46 @@ var WorkspaceCard = (0, import_react22.memo)(function WorkspaceCard2({
114888
114988
  }
114889
114989
  )
114890
114990
  ] }) }),
114891
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(TooltipContent2, { side: "right", children: `${projectName}/${worktree.name}` })
114991
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(TooltipContent2, { side: "right", children: `${projectName}/${worktree.name}` })
114892
114992
  ] }),
114893
- /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)("div", { className: "hidden @[10rem]:flex group-hover:flex items-center gap-2 shrink-0 ml-auto pl-2", children: [
114894
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(SetupStatusIndicator, { setup: setupStatus }),
114895
- !isPlain && branchStatus && /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(GitStatusIndicator, { git: branchStatus.git }),
114896
- !isPlain && branchStatus && /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(CIStatusIndicator, { ci: branchStatus.ci })
114993
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)("div", { className: "hidden @[10rem]:flex group-hover:flex items-center gap-2 shrink-0 ml-auto pl-2", children: [
114994
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(SetupStatusIndicator, { setup: setupStatus }),
114995
+ !isPlain && branchStatus && /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(GitStatusIndicator, { git: branchStatus.git }),
114996
+ !isPlain && branchStatus && /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(CIStatusIndicator, { ci: branchStatus.ci })
114897
114997
  ] })
114898
114998
  ] }) }),
114899
- /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuContent2, { children: [
114900
- /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { onClick: () => onTogglePinned(projectName, worktree.name, isPinned), children: [
114901
- isPinned ? /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.PinOff, {}) : /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.Pin, {}),
114999
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuContent2, { children: [
115000
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { onClick: () => onTogglePinned(projectName, worktree.name, isPinned), children: [
115001
+ isPinned ? /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.PinOff, {}) : /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.Pin, {}),
114902
115002
  isPinned ? "Unpin workspace" : "Pin workspace"
114903
115003
  ] }),
114904
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(ContextMenuSeparator2, {}),
114905
- capabilities.copyPath && /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { onClick: () => navigator.clipboard.writeText(worktree.path), children: [
114906
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.Clipboard, {}),
115004
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(ContextMenuSeparator2, {}),
115005
+ capabilities.copyPath && /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { onClick: () => navigator.clipboard.writeText(worktree.path), children: [
115006
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.Clipboard, {}),
114907
115007
  "Copy path"
114908
115008
  ] }),
114909
- capabilities.revealInFinder && /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { onClick: () => capabilities.revealInFinder(worktree.path), children: [
114910
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.FolderOpen, {}),
115009
+ capabilities.revealInFinder && /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { onClick: () => capabilities.revealInFinder(worktree.path), children: [
115010
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.FolderOpen, {}),
114911
115011
  "Open in Finder"
114912
115012
  ] }),
114913
- worktree.hasSetup && /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { onClick: () => runScript(worktree.path, "setup"), children: [
114914
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.Play, {}),
115013
+ worktree.hasSetup && /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { onClick: () => runScript(worktree.path, "setup"), children: [
115014
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.Play, {}),
114915
115015
  "Run setup"
114916
115016
  ] }),
114917
- worktree.hasTeardown && /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { onClick: () => runScript(worktree.path, "teardown"), children: [
114918
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.Square, {}),
115017
+ worktree.hasTeardown && /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { onClick: () => runScript(worktree.path, "teardown"), children: [
115018
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.Square, {}),
114919
115019
  "Run teardown"
114920
115020
  ] }),
114921
- !isPlain && /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { onClick: () => gitPull(projectName, worktree.name), children: [
114922
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.ArrowDownToLine, {}),
115021
+ !isPlain && /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { onClick: () => gitPull(projectName, worktree.name), children: [
115022
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.ArrowDownToLine, {}),
114923
115023
  "Git pull"
114924
115024
  ] }),
114925
- !isPlain && /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { onClick: () => gitPush(projectName, worktree.name), children: [
114926
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.ArrowUpFromLine, {}),
115025
+ !isPlain && /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { onClick: () => gitPush(projectName, worktree.name), children: [
115026
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.ArrowUpFromLine, {}),
114927
115027
  "Git push"
114928
115028
  ] }),
114929
- !isPlain && worktree.name !== defaultBranch && /* @__PURE__ */ (0, import_jsx_runtime58.jsxs)(ContextMenuItem2, { variant: "destructive", onClick: handleDelete, children: [
114930
- /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(import_lucide_react5.Trash2, {}),
115029
+ !isPlain && worktree.name !== defaultBranch && /* @__PURE__ */ (0, import_jsx_runtime59.jsxs)(ContextMenuItem2, { variant: "destructive", onClick: handleDelete, children: [
115030
+ /* @__PURE__ */ (0, import_jsx_runtime59.jsx)(import_lucide_react5.Trash2, {}),
114931
115031
  "Delete workspace"
114932
115032
  ] })
114933
115033
  ] })
@@ -114935,7 +115035,7 @@ var WorkspaceCard = (0, import_react22.memo)(function WorkspaceCard2({
114935
115035
  });
114936
115036
 
114937
115037
  // src/dashboard/components/ProjectList.tsx
114938
- var import_jsx_runtime59 = __toESM(require_jsx_runtime(), 1);
115038
+ var import_jsx_runtime60 = __toESM(require_jsx_runtime(), 1);
114939
115039
 
114940
115040
  // src/dashboard/components/SettingsPage.tsx
114941
115041
  var import_react25 = __toESM(require_react(), 1);
@@ -114944,16 +115044,16 @@ var import_react25 = __toESM(require_react(), 1);
114944
115044
  var import_react24 = __toESM(require_react(), 1);
114945
115045
 
114946
115046
  // src/dashboard/components/settings/SettingsRow.tsx
114947
- var import_jsx_runtime60 = __toESM(require_jsx_runtime(), 1);
115047
+ var import_jsx_runtime61 = __toESM(require_jsx_runtime(), 1);
114948
115048
 
114949
115049
  // src/dashboard/components/settings/SettingsSection.tsx
114950
- var import_jsx_runtime61 = __toESM(require_jsx_runtime(), 1);
115050
+ var import_jsx_runtime62 = __toESM(require_jsx_runtime(), 1);
114951
115051
 
114952
115052
  // src/dashboard/components/SettingsPage.tsx
114953
- var import_jsx_runtime62 = __toESM(require_jsx_runtime(), 1);
115053
+ var import_jsx_runtime63 = __toESM(require_jsx_runtime(), 1);
114954
115054
 
114955
115055
  // src/dashboard/components/DashboardShell.tsx
114956
- var import_jsx_runtime63 = __toESM(require_jsx_runtime(), 1);
115056
+ var import_jsx_runtime64 = __toESM(require_jsx_runtime(), 1);
114957
115057
  var isElectron = typeof window !== "undefined" && "__BAND_DESKTOP__" in window;
114958
115058
 
114959
115059
  // src/dashboard/components/DiffView.tsx
@@ -115219,18 +115319,18 @@ function joinWorkspacePath(workspaceRoot, relativePath) {
115219
115319
  }
115220
115320
 
115221
115321
  // src/dashboard/components/FileStatusBadge.tsx
115222
- var import_jsx_runtime64 = __toESM(require_jsx_runtime(), 1);
115322
+ var import_jsx_runtime65 = __toESM(require_jsx_runtime(), 1);
115223
115323
 
115224
115324
  // src/dashboard/components/ChangesFileTree.tsx
115225
- var import_jsx_runtime65 = __toESM(require_jsx_runtime(), 1);
115325
+ var import_jsx_runtime66 = __toESM(require_jsx_runtime(), 1);
115226
115326
 
115227
115327
  // src/dashboard/components/RevertFileDialog.tsx
115228
- var import_jsx_runtime66 = __toESM(require_jsx_runtime(), 1);
115328
+ var import_jsx_runtime67 = __toESM(require_jsx_runtime(), 1);
115229
115329
 
115230
115330
  // src/dashboard/components/SearchBar.tsx
115231
115331
  var import_lucide_react6 = __toESM(require_lucide_react(), 1);
115232
115332
  var import_react34 = __toESM(require_react(), 1);
115233
- var import_jsx_runtime67 = __toESM(require_jsx_runtime(), 1);
115333
+ var import_jsx_runtime68 = __toESM(require_jsx_runtime(), 1);
115234
115334
  var DEFAULT_VISIBLE_OPTIONS = ["caseSensitive", "wholeWord", "regex"];
115235
115335
  function ToggleButton({
115236
115336
  active,
@@ -115238,7 +115338,7 @@ function ToggleButton({
115238
115338
  title,
115239
115339
  children
115240
115340
  }) {
115241
- return /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(
115341
+ return /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(
115242
115342
  "button",
115243
115343
  {
115244
115344
  type: "button",
@@ -115279,7 +115379,7 @@ var SearchBar = (0, import_react34.forwardRef)(function SearchBar2({
115279
115379
  const toggleRegex = (0, import_react34.useCallback)(() => {
115280
115380
  onOptionsChange({ ...options2, regex: !options2.regex });
115281
115381
  }, [options2, onOptionsChange]);
115282
- return /* @__PURE__ */ (0, import_jsx_runtime67.jsxs)(
115382
+ return /* @__PURE__ */ (0, import_jsx_runtime68.jsxs)(
115283
115383
  "div",
115284
115384
  {
115285
115385
  className: cn(
@@ -115287,8 +115387,8 @@ var SearchBar = (0, import_react34.forwardRef)(function SearchBar2({
115287
115387
  className
115288
115388
  ),
115289
115389
  children: [
115290
- /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(import_lucide_react6.Search, { className: "size-3.5 shrink-0 text-muted-foreground" }),
115291
- /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(
115390
+ /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(import_lucide_react6.Search, { className: "size-3.5 shrink-0 text-muted-foreground" }),
115391
+ /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(
115292
115392
  "input",
115293
115393
  {
115294
115394
  ref: inputRef,
@@ -115309,11 +115409,11 @@ var SearchBar = (0, import_react34.forwardRef)(function SearchBar2({
115309
115409
  }
115310
115410
  }
115311
115411
  ),
115312
- showCase && /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(ToggleButton, { active: options2.caseSensitive, onClick: toggleCase, title: "Match Case", children: /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(import_lucide_react6.CaseSensitive, { className: "size-4" }) }),
115313
- showWholeWord && /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(ToggleButton, { active: options2.wholeWord, onClick: toggleWholeWord, title: "Match Whole Word", children: /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(import_lucide_react6.WholeWord, { className: "size-4" }) }),
115314
- showRegex && /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(ToggleButton, { active: options2.regex, onClick: toggleRegex, title: "Use Regular Expression", children: /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(import_lucide_react6.Regex, { className: "size-4" }) }),
115315
- matchInfo && query && /* @__PURE__ */ (0, import_jsx_runtime67.jsx)("span", { className: "shrink-0 text-xs text-muted-foreground tabular-nums", children: matchInfo.total > 0 ? `${matchInfo.current} of ${matchInfo.total}` : "No results" }),
115316
- onPrevious && /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(
115412
+ showCase && /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(ToggleButton, { active: options2.caseSensitive, onClick: toggleCase, title: "Match Case", children: /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(import_lucide_react6.CaseSensitive, { className: "size-4" }) }),
115413
+ showWholeWord && /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(ToggleButton, { active: options2.wholeWord, onClick: toggleWholeWord, title: "Match Whole Word", children: /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(import_lucide_react6.WholeWord, { className: "size-4" }) }),
115414
+ showRegex && /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(ToggleButton, { active: options2.regex, onClick: toggleRegex, title: "Use Regular Expression", children: /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(import_lucide_react6.Regex, { className: "size-4" }) }),
115415
+ matchInfo && query && /* @__PURE__ */ (0, import_jsx_runtime68.jsx)("span", { className: "shrink-0 text-xs text-muted-foreground tabular-nums", children: matchInfo.total > 0 ? `${matchInfo.current} of ${matchInfo.total}` : "No results" }),
115416
+ onPrevious && /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(
115317
115417
  "button",
115318
115418
  {
115319
115419
  type: "button",
@@ -115321,10 +115421,10 @@ var SearchBar = (0, import_react34.forwardRef)(function SearchBar2({
115321
115421
  disabled: !matchInfo || matchInfo.total === 0,
115322
115422
  className: "inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:opacity-30",
115323
115423
  title: "Previous match (Shift+Enter)",
115324
- children: /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(import_lucide_react6.ChevronUp, { className: "size-3.5" })
115424
+ children: /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(import_lucide_react6.ChevronUp, { className: "size-3.5" })
115325
115425
  }
115326
115426
  ),
115327
- onNext && /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(
115427
+ onNext && /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(
115328
115428
  "button",
115329
115429
  {
115330
115430
  type: "button",
@@ -115332,17 +115432,17 @@ var SearchBar = (0, import_react34.forwardRef)(function SearchBar2({
115332
115432
  disabled: !matchInfo || matchInfo.total === 0,
115333
115433
  className: "inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:opacity-30",
115334
115434
  title: "Next match (Enter)",
115335
- children: /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(import_lucide_react6.ChevronDown, { className: "size-3.5" })
115435
+ children: /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(import_lucide_react6.ChevronDown, { className: "size-3.5" })
115336
115436
  }
115337
115437
  ),
115338
- onClose && /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(
115438
+ onClose && /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(
115339
115439
  "button",
115340
115440
  {
115341
115441
  type: "button",
115342
115442
  onClick: onClose,
115343
115443
  className: "inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:text-foreground",
115344
115444
  title: "Close (Escape)",
115345
- children: /* @__PURE__ */ (0, import_jsx_runtime67.jsx)(import_lucide_react6.X, { className: "size-3.5" })
115445
+ children: /* @__PURE__ */ (0, import_jsx_runtime68.jsx)(import_lucide_react6.X, { className: "size-3.5" })
115346
115446
  }
115347
115447
  )
115348
115448
  ]
@@ -115351,7 +115451,7 @@ var SearchBar = (0, import_react34.forwardRef)(function SearchBar2({
115351
115451
  });
115352
115452
 
115353
115453
  // src/dashboard/components/DiffView.tsx
115354
- var import_jsx_runtime68 = __toESM(require_jsx_runtime(), 1);
115454
+ var import_jsx_runtime69 = __toESM(require_jsx_runtime(), 1);
115355
115455
  var diffTheme = EditorView.theme({
115356
115456
  ".cm-insertedLine": { backgroundColor: "rgba(34, 197, 94, 0.1)" },
115357
115457
  ".cm-deletedLine": { backgroundColor: "rgba(239, 68, 68, 0.1)" },
@@ -115403,7 +115503,7 @@ var diffTheme = EditorView.theme({
115403
115503
  // src/dashboard/components/FileBrowser.tsx
115404
115504
  var import_lucide_react7 = __toESM(require_lucide_react(), 1);
115405
115505
  var import_react36 = __toESM(require_react(), 1);
115406
- var import_jsx_runtime69 = __toESM(require_jsx_runtime(), 1);
115506
+ var import_jsx_runtime70 = __toESM(require_jsx_runtime(), 1);
115407
115507
  var expandedStateCache = /* @__PURE__ */ new Map();
115408
115508
  var dirContentsCache = /* @__PURE__ */ new Map();
115409
115509
  function getCachedExpanded(wsId) {
@@ -115484,21 +115584,21 @@ function EntryNameInput({
115484
115584
  }
115485
115585
  };
115486
115586
  const Icon = kind === "directory" ? import_lucide_react7.Folder : import_lucide_react7.File;
115487
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)("div", { className: "flex w-full flex-col", style: { paddingLeft: `${depth * indent + basePad}px` }, children: [
115488
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115587
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)("div", { className: "flex w-full flex-col", style: { paddingLeft: `${depth * indent + basePad}px` }, children: [
115588
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115489
115589
  "div",
115490
115590
  {
115491
115591
  className: `flex items-center ${compact ? "h-[28px] gap-1 pr-3 text-[13px]" : "h-[32px] gap-1.5 pr-4 text-[15px]"}`,
115492
115592
  style: { height },
115493
115593
  children: [
115494
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("span", { className: "size-3.5 shrink-0" }),
115495
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115594
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("span", { className: "size-3.5 shrink-0" }),
115595
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115496
115596
  Icon,
115497
115597
  {
115498
115598
  className: kind === "directory" ? "size-4 shrink-0 text-blue-600 dark:text-blue-400" : "size-4 shrink-0 text-muted-foreground"
115499
115599
  }
115500
115600
  ),
115501
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115601
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115502
115602
  "input",
115503
115603
  {
115504
115604
  ref: inputRef,
@@ -115535,7 +115635,7 @@ function EntryNameInput({
115535
115635
  ]
115536
115636
  }
115537
115637
  ),
115538
- error2 && /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115638
+ error2 && /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115539
115639
  "div",
115540
115640
  {
115541
115641
  className: "pointer-events-none text-xs text-destructive",
@@ -115603,7 +115703,7 @@ function TreeNode2({
115603
115703
  onOpenFilePinned(entryPath);
115604
115704
  }
115605
115705
  };
115606
- const button = /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115706
+ const button = /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115607
115707
  "button",
115608
115708
  {
115609
115709
  ref: isSelected ? selectedRef : void 0,
@@ -115617,15 +115717,15 @@ function TreeNode2({
115617
115717
  className: `flex w-full items-center text-left select-none hover:bg-accent/50 [-webkit-touch-callout:none] ${compact ? "h-[28px] gap-1 pr-3 text-[13px]" : "h-[32px] gap-1.5 pr-4 text-[15px]"} ${isSelected ? "bg-blue-500/30 text-foreground outline outline-1 -outline-offset-1 outline-blue-400/60 hover:bg-blue-500/30 dark:bg-blue-500/40 dark:outline-blue-400/70 dark:hover:bg-blue-500/40" : ""} ${isCut ? "opacity-50" : ""}`,
115618
115718
  style: { paddingLeft: `${depth * indent + basePad}px` },
115619
115719
  children: [
115620
- isDir ? isExpanded ? /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.ChevronDown, { className: "size-3.5 shrink-0 text-muted-foreground/70" }) : /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.ChevronRight, { className: "size-3.5 shrink-0 text-muted-foreground/70" }) : /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("span", { className: "size-3.5 shrink-0" }),
115720
+ isDir ? isExpanded ? /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.ChevronDown, { className: "size-3.5 shrink-0 text-muted-foreground/70" }) : /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.ChevronRight, { className: "size-3.5 shrink-0 text-muted-foreground/70" }) : /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("span", { className: "size-3.5 shrink-0" }),
115621
115721
  isDir ? (() => {
115622
115722
  const FolderIcon = getFolderIcon(entry.name, isExpanded);
115623
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(FolderIcon, { className: "size-4 shrink-0" });
115723
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(FolderIcon, { className: "size-4 shrink-0" });
115624
115724
  })() : (() => {
115625
115725
  const FileIcon = getFileIcon(entry.name);
115626
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(FileIcon, { className: "size-4 shrink-0" });
115726
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(FileIcon, { className: "size-4 shrink-0" });
115627
115727
  })(),
115628
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("span", { className: "min-w-0 flex-1 truncate", children: entry.name })
115728
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("span", { className: "min-w-0 flex-1 truncate", children: entry.name })
115629
115729
  ]
115630
115730
  }
115631
115731
  );
@@ -115647,7 +115747,7 @@ function TreeNode2({
115647
115747
  // a padding div with `depth={0}` would double-apply `basePad`, shifting
115648
115748
  // the input ~4px to the right and giving it visibly less horizontal
115649
115749
  // room than the rest of the tree.
115650
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115750
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115651
115751
  EntryNameInput,
115652
115752
  {
115653
115753
  kind: isDir ? "directory" : "file",
@@ -115661,97 +115761,97 @@ function TreeNode2({
115661
115761
  },
115662
115762
  `rename-${entryPath}`
115663
115763
  )
115664
- ) : /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115764
+ ) : /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115665
115765
  ContextMenu2,
115666
115766
  {
115667
115767
  onOpenChange: (open3) => {
115668
115768
  if (open3) onSelectRow(entryPath, isDir ? "directory" : "file");
115669
115769
  },
115670
115770
  children: [
115671
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(ContextMenuTrigger2, { asChild: true, children: button }),
115672
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(ContextMenuContent2, { onCloseAutoFocus: menu.flush, children: [
115673
- isDir && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(import_jsx_runtime69.Fragment, { children: [
115674
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115771
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(ContextMenuTrigger2, { asChild: true, children: button }),
115772
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(ContextMenuContent2, { onCloseAutoFocus: menu.flush, children: [
115773
+ isDir && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(import_jsx_runtime70.Fragment, { children: [
115774
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115675
115775
  ContextMenuItem2,
115676
115776
  {
115677
115777
  onSelect: () => menu.queue(() => onRequestNewEntry(entryPath, "file")),
115678
115778
  children: [
115679
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.File, { className: "size-4" }),
115779
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.File, { className: "size-4" }),
115680
115780
  "New File"
115681
115781
  ]
115682
115782
  }
115683
115783
  ),
115684
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115784
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115685
115785
  ContextMenuItem2,
115686
115786
  {
115687
115787
  onSelect: () => menu.queue(() => onRequestNewEntry(entryPath, "directory")),
115688
115788
  children: [
115689
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.FolderPlus, { className: "size-4" }),
115789
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.FolderPlus, { className: "size-4" }),
115690
115790
  "New Folder"
115691
115791
  ]
115692
115792
  }
115693
115793
  ),
115694
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(ContextMenuSeparator2, {})
115794
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(ContextMenuSeparator2, {})
115695
115795
  ] }),
115696
- canCut && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115796
+ canCut && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115697
115797
  ContextMenuItem2,
115698
115798
  {
115699
115799
  onSelect: () => menu.queue(() => onCut(entryPath, isDir ? "directory" : "file")),
115700
115800
  children: [
115701
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.Scissors, { className: "size-4" }),
115801
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.Scissors, { className: "size-4" }),
115702
115802
  "Cut"
115703
115803
  ]
115704
115804
  }
115705
115805
  ),
115706
- canCopy && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115806
+ canCopy && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115707
115807
  ContextMenuItem2,
115708
115808
  {
115709
115809
  onSelect: () => menu.queue(() => onCopy(entryPath, isDir ? "directory" : "file")),
115710
115810
  children: [
115711
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.Copy, { className: "size-4" }),
115811
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.Copy, { className: "size-4" }),
115712
115812
  "Copy"
115713
115813
  ]
115714
115814
  }
115715
115815
  ),
115716
- isDir && canPaste && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(ContextMenuItem2, { onSelect: () => menu.queue(() => void onPaste(entryPath)), children: [
115717
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.ClipboardPaste, { className: "size-4" }),
115816
+ isDir && canPaste && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(ContextMenuItem2, { onSelect: () => menu.queue(() => void onPaste(entryPath)), children: [
115817
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.ClipboardPaste, { className: "size-4" }),
115718
115818
  "Paste"
115719
115819
  ] }),
115720
- (canCut || canCopy || isDir && canPaste) && /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(ContextMenuSeparator2, {}),
115721
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115820
+ (canCut || canCopy || isDir && canPaste) && /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(ContextMenuSeparator2, {}),
115821
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115722
115822
  ContextMenuItem2,
115723
115823
  {
115724
115824
  "data-testid": "file-tree__copy-relative-path",
115725
115825
  onSelect: () => menu.queue(() => void writeClipboardText(entryPath)),
115726
115826
  children: [
115727
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.ClipboardCopy, { className: "size-4" }),
115827
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.ClipboardCopy, { className: "size-4" }),
115728
115828
  "Copy relative path"
115729
115829
  ]
115730
115830
  }
115731
115831
  ),
115732
- workspacePath && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115832
+ workspacePath && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115733
115833
  ContextMenuItem2,
115734
115834
  {
115735
115835
  "data-testid": "file-tree__copy-absolute-path",
115736
115836
  onSelect: () => menu.queue(() => void writeClipboardText(joinWorkspacePath(workspacePath, entryPath))),
115737
115837
  children: [
115738
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.ClipboardCopy, { className: "size-4" }),
115838
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.ClipboardCopy, { className: "size-4" }),
115739
115839
  "Copy absolute path"
115740
115840
  ]
115741
115841
  }
115742
115842
  ),
115743
- (canRename || canDelete) && /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(ContextMenuSeparator2, {}),
115744
- canRename && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(ContextMenuItem2, { onSelect: () => menu.queue(() => onRequestRename(entryPath)), children: [
115745
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.Pencil, { className: "size-4" }),
115843
+ (canRename || canDelete) && /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(ContextMenuSeparator2, {}),
115844
+ canRename && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(ContextMenuItem2, { onSelect: () => menu.queue(() => onRequestRename(entryPath)), children: [
115845
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.Pencil, { className: "size-4" }),
115746
115846
  "Rename"
115747
115847
  ] }),
115748
- canDelete && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
115848
+ canDelete && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
115749
115849
  ContextMenuItem2,
115750
115850
  {
115751
115851
  variant: "destructive",
115752
115852
  onSelect: () => menu.queue(() => onRequestDelete(entryPath, isDir ? "directory" : "file")),
115753
115853
  children: [
115754
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.Trash2, { className: "size-4" }),
115854
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.Trash2, { className: "size-4" }),
115755
115855
  "Delete"
115756
115856
  ]
115757
115857
  }
@@ -115761,13 +115861,13 @@ function TreeNode2({
115761
115861
  }
115762
115862
  );
115763
115863
  const showInlineInput = newEntry?.parentPath === entryPath;
115764
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(import_jsx_runtime69.Fragment, { children: [
115864
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(import_jsx_runtime70.Fragment, { children: [
115765
115865
  row,
115766
115866
  isExpanded && (() => {
115767
115867
  const splitIdx = children?.findIndex((c2) => c2.type === "file") ?? -1;
115768
115868
  const folderChildren = splitIdx === -1 ? children ?? [] : (children ?? []).slice(0, splitIdx);
115769
115869
  const fileChildren = splitIdx === -1 ? [] : (children ?? []).slice(splitIdx);
115770
- const renderChild = (child) => /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115870
+ const renderChild = (child) => /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115771
115871
  TreeNode2,
115772
115872
  {
115773
115873
  entry: child,
@@ -115805,7 +115905,7 @@ function TreeNode2({
115805
115905
  },
115806
115906
  child.name
115807
115907
  );
115808
- const newEntryInput = showInlineInput ? /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115908
+ const newEntryInput = showInlineInput ? /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115809
115909
  EntryNameInput,
115810
115910
  {
115811
115911
  kind: newEntry.kind,
@@ -115817,9 +115917,9 @@ function TreeNode2({
115817
115917
  },
115818
115918
  `new-${newEntry.kind}-${entryPath}`
115819
115919
  ) : null;
115820
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(import_jsx_runtime69.Fragment, { children: [
115920
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(import_jsx_runtime70.Fragment, { children: [
115821
115921
  showInlineInput && newEntry.kind === "directory" && newEntryInput,
115822
- isLoading && !children?.length && /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115922
+ isLoading && !children?.length && /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115823
115923
  "div",
115824
115924
  {
115825
115925
  className: `flex items-center text-muted-foreground/70 ${compact ? "text-[13px]" : "text-[15px]"}`,
@@ -115830,7 +115930,7 @@ function TreeNode2({
115830
115930
  children: "Loading\u2026"
115831
115931
  }
115832
115932
  ),
115833
- !isLoading && !showInlineInput && children && children.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
115933
+ !isLoading && !showInlineInput && children && children.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
115834
115934
  "div",
115835
115935
  {
115836
115936
  className: `flex items-center italic text-muted-foreground/50 ${compact ? "text-[13px]" : "text-[15px]"}`,
@@ -116276,7 +116376,7 @@ var FileBrowser = (0, import_react36.forwardRef)(function FileBrowser2({
116276
116376
  );
116277
116377
  const rootMenu = useDeferredMenuAction();
116278
116378
  if (!adapter.listWorkspaceFiles) {
116279
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("div", { className: "flex h-32 items-center justify-center text-sm text-muted-foreground", children: "File browsing not supported" });
116379
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("div", { className: "flex h-32 items-center justify-center text-sm text-muted-foreground", children: "File browsing not supported" });
116280
116380
  }
116281
116381
  const rootEntries = dirContents.get("") ?? [];
116282
116382
  const rootLoading = loadingPaths.has("");
@@ -116313,20 +116413,20 @@ var FileBrowser = (0, import_react36.forwardRef)(function FileBrowser2({
116313
116413
  void pasteInto(resolveDefaultTarget());
116314
116414
  }
116315
116415
  };
116316
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)("div", { className: "flex h-full flex-col overflow-hidden", onKeyDown: handleKeyDown, children: [
116317
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(
116416
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)("div", { className: "flex h-full flex-col overflow-hidden", onKeyDown: handleKeyDown, children: [
116417
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(
116318
116418
  ContextMenu2,
116319
116419
  {
116320
116420
  onOpenChange: (open3) => {
116321
116421
  if (open3) clearTreeSelection();
116322
116422
  },
116323
116423
  children: [
116324
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(ContextMenuTrigger2, { asChild: true, children: /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)("div", { className: "min-h-0 flex-1 overflow-y-auto py-1 pl-px", children: [
116424
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(ContextMenuTrigger2, { asChild: true, children: /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)("div", { className: "min-h-0 flex-1 overflow-y-auto py-1 pl-px", children: [
116325
116425
  (() => {
116326
116426
  const rootSplitIdx = rootEntries.findIndex((e2) => e2.type === "file");
116327
116427
  const rootFolders = rootSplitIdx === -1 ? rootEntries : rootEntries.slice(0, rootSplitIdx);
116328
116428
  const rootFiles = rootSplitIdx === -1 ? [] : rootEntries.slice(rootSplitIdx);
116329
- const renderRow = (entry) => /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
116429
+ const renderRow = (entry) => /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
116330
116430
  TreeNode2,
116331
116431
  {
116332
116432
  entry,
@@ -116364,7 +116464,7 @@ var FileBrowser = (0, import_react36.forwardRef)(function FileBrowser2({
116364
116464
  },
116365
116465
  entry.name
116366
116466
  );
116367
- const rootInput = showRootInput ? /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
116467
+ const rootInput = showRootInput ? /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
116368
116468
  EntryNameInput,
116369
116469
  {
116370
116470
  kind: newEntry.kind,
@@ -116376,30 +116476,30 @@ var FileBrowser = (0, import_react36.forwardRef)(function FileBrowser2({
116376
116476
  },
116377
116477
  `new-${newEntry.kind}-root`
116378
116478
  ) : null;
116379
- return /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(import_jsx_runtime69.Fragment, { children: [
116479
+ return /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(import_jsx_runtime70.Fragment, { children: [
116380
116480
  showRootInput && newEntry.kind === "directory" && rootInput,
116381
- rootLoading && rootEntries.length === 0 && !showRootInput && /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("div", { className: "flex h-32 items-center justify-center text-sm text-muted-foreground", children: "Loading\u2026" }),
116382
- !rootLoading && rootEntries.length === 0 && !showRootInput && /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("div", { className: "flex h-32 items-center justify-center text-sm text-muted-foreground", children: "Empty directory" }),
116481
+ rootLoading && rootEntries.length === 0 && !showRootInput && /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("div", { className: "flex h-32 items-center justify-center text-sm text-muted-foreground", children: "Loading\u2026" }),
116482
+ !rootLoading && rootEntries.length === 0 && !showRootInput && /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("div", { className: "flex h-32 items-center justify-center text-sm text-muted-foreground", children: "Empty directory" }),
116383
116483
  rootFolders.map(renderRow),
116384
116484
  showRootInput && newEntry.kind === "file" && rootInput,
116385
116485
  rootFiles.map(renderRow)
116386
116486
  ] });
116387
116487
  })(),
116388
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("div", { className: "min-h-[40px] flex-1" })
116488
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("div", { className: "min-h-[40px] flex-1" })
116389
116489
  ] }) }),
116390
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(ContextMenuContent2, { onCloseAutoFocus: rootMenu.flush, children: [
116391
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(ContextMenuItem2, { onSelect: () => rootMenu.queue(() => requestNewEntry("", "file")), children: [
116392
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.File, { className: "size-4" }),
116490
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(ContextMenuContent2, { onCloseAutoFocus: rootMenu.flush, children: [
116491
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(ContextMenuItem2, { onSelect: () => rootMenu.queue(() => requestNewEntry("", "file")), children: [
116492
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.File, { className: "size-4" }),
116393
116493
  "New File"
116394
116494
  ] }),
116395
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(ContextMenuItem2, { onSelect: () => rootMenu.queue(() => requestNewEntry("", "directory")), children: [
116396
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.FolderPlus, { className: "size-4" }),
116495
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(ContextMenuItem2, { onSelect: () => rootMenu.queue(() => requestNewEntry("", "directory")), children: [
116496
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.FolderPlus, { className: "size-4" }),
116397
116497
  "New Folder"
116398
116498
  ] }),
116399
- canPaste && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(import_jsx_runtime69.Fragment, { children: [
116400
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(ContextMenuSeparator2, {}),
116401
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(ContextMenuItem2, { onSelect: () => rootMenu.queue(() => void pasteInto("")), children: [
116402
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.ClipboardPaste, { className: "size-4" }),
116499
+ canPaste && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(import_jsx_runtime70.Fragment, { children: [
116500
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(ContextMenuSeparator2, {}),
116501
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(ContextMenuItem2, { onSelect: () => rootMenu.queue(() => void pasteInto("")), children: [
116502
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.ClipboardPaste, { className: "size-4" }),
116403
116503
  "Paste"
116404
116504
  ] })
116405
116505
  ] })
@@ -116407,42 +116507,42 @@ var FileBrowser = (0, import_react36.forwardRef)(function FileBrowser2({
116407
116507
  ]
116408
116508
  }
116409
116509
  ),
116410
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
116510
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
116411
116511
  Dialog2,
116412
116512
  {
116413
116513
  open: pendingDelete !== null,
116414
116514
  onOpenChange: (open3) => {
116415
116515
  if (!open3) cancelDelete();
116416
116516
  },
116417
- children: /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(DialogContent2, { className: "sm:max-w-[425px]", onClick: (e2) => e2.stopPropagation(), children: [
116418
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(DialogHeader, { children: [
116419
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(DialogTitle2, { children: [
116517
+ children: /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(DialogContent2, { className: "sm:max-w-[425px]", onClick: (e2) => e2.stopPropagation(), children: [
116518
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(DialogHeader, { children: [
116519
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(DialogTitle2, { children: [
116420
116520
  "Delete ",
116421
116521
  pendingDelete?.kind === "directory" ? "folder" : "file"
116422
116522
  ] }),
116423
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(DialogDescription2, { children: [
116523
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(DialogDescription2, { children: [
116424
116524
  "Are you sure you want to delete ",
116425
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("strong", { children: pendingDelete?.path }),
116525
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("strong", { children: pendingDelete?.path }),
116426
116526
  "?"
116427
116527
  ] })
116428
116528
  ] }),
116429
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)("div", { className: "flex flex-col gap-2 text-sm", children: [
116430
- pendingDelete?.kind === "directory" && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)("div", { className: "flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3", children: [
116431
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.AlertTriangle, { className: "size-4 shrink-0 text-yellow-500 mt-0.5" }),
116432
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("span", { children: "The folder and all of its contents will be deleted from disk. This cannot be undone." })
116529
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)("div", { className: "flex flex-col gap-2 text-sm", children: [
116530
+ pendingDelete?.kind === "directory" && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)("div", { className: "flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3", children: [
116531
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.AlertTriangle, { className: "size-4 shrink-0 text-yellow-500 mt-0.5" }),
116532
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("span", { children: "The folder and all of its contents will be deleted from disk. This cannot be undone." })
116433
116533
  ] }),
116434
- pendingDelete?.kind === "file" && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)("div", { className: "flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3", children: [
116435
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.AlertTriangle, { className: "size-4 shrink-0 text-yellow-500 mt-0.5" }),
116436
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("span", { children: "The file will be deleted from disk. This cannot be undone." })
116534
+ pendingDelete?.kind === "file" && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)("div", { className: "flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3", children: [
116535
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.AlertTriangle, { className: "size-4 shrink-0 text-yellow-500 mt-0.5" }),
116536
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("span", { children: "The file will be deleted from disk. This cannot be undone." })
116437
116537
  ] }),
116438
- deleteError && /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)("div", { className: "flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive", children: [
116439
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(import_lucide_react7.AlertTriangle, { className: "size-4 shrink-0 mt-0.5" }),
116440
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)("span", { children: deleteError })
116538
+ deleteError && /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)("div", { className: "flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive", children: [
116539
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(import_lucide_react7.AlertTriangle, { className: "size-4 shrink-0 mt-0.5" }),
116540
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)("span", { children: deleteError })
116441
116541
  ] })
116442
116542
  ] }),
116443
- /* @__PURE__ */ (0, import_jsx_runtime69.jsxs)(DialogFooter, { children: [
116444
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(Button, { variant: "ghost", onClick: cancelDelete, disabled: deleteSubmitting, children: "Cancel" }),
116445
- /* @__PURE__ */ (0, import_jsx_runtime69.jsx)(
116543
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsxs)(DialogFooter, { children: [
116544
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(Button, { variant: "ghost", onClick: cancelDelete, disabled: deleteSubmitting, children: "Cancel" }),
116545
+ /* @__PURE__ */ (0, import_jsx_runtime70.jsx)(
116446
116546
  Button,
116447
116547
  {
116448
116548
  variant: "destructive",
@@ -116463,32 +116563,32 @@ var import_react39 = __toESM(require_react(), 1);
116463
116563
 
116464
116564
  // src/dashboard/components/ImagePreview.tsx
116465
116565
  var import_react37 = __toESM(require_react(), 1);
116466
- var import_jsx_runtime70 = __toESM(require_jsx_runtime(), 1);
116566
+ var import_jsx_runtime71 = __toESM(require_jsx_runtime(), 1);
116467
116567
 
116468
116568
  // src/dashboard/components/LanguagePickerDialog.tsx
116469
116569
  var import_react38 = __toESM(require_react(), 1);
116470
- var import_jsx_runtime71 = __toESM(require_jsx_runtime(), 1);
116570
+ var import_jsx_runtime72 = __toESM(require_jsx_runtime(), 1);
116471
116571
 
116472
116572
  // src/dashboard/components/PdfPreview.tsx
116473
- var import_jsx_runtime72 = __toESM(require_jsx_runtime(), 1);
116573
+ var import_jsx_runtime73 = __toESM(require_jsx_runtime(), 1);
116474
116574
 
116475
116575
  // src/dashboard/components/FileViewer.tsx
116476
- var import_jsx_runtime73 = __toESM(require_jsx_runtime(), 1);
116576
+ var import_jsx_runtime74 = __toESM(require_jsx_runtime(), 1);
116477
116577
 
116478
116578
  // src/dashboard/components/QuickOpenDialog.tsx
116479
116579
  var import_react40 = __toESM(require_react(), 1);
116480
- var import_jsx_runtime74 = __toESM(require_jsx_runtime(), 1);
116580
+ var import_jsx_runtime75 = __toESM(require_jsx_runtime(), 1);
116481
116581
 
116482
116582
  // src/dashboard/components/SearchFilesDialog.tsx
116483
116583
  var import_react41 = __toESM(require_react(), 1);
116484
- var import_jsx_runtime75 = __toESM(require_jsx_runtime(), 1);
116584
+ var import_jsx_runtime76 = __toESM(require_jsx_runtime(), 1);
116485
116585
 
116486
116586
  // src/dashboard/components/WorkspacePickerDialog.tsx
116487
116587
  var import_react42 = __toESM(require_react(), 1);
116488
- var import_jsx_runtime76 = __toESM(require_jsx_runtime(), 1);
116588
+ var import_jsx_runtime77 = __toESM(require_jsx_runtime(), 1);
116489
116589
 
116490
116590
  // src/dashboard/components/WorkspaceTabNav.tsx
116491
- var import_jsx_runtime77 = __toESM(require_jsx_runtime(), 1);
116591
+ var import_jsx_runtime78 = __toESM(require_jsx_runtime(), 1);
116492
116592
 
116493
116593
  // src/dashboard/hooks/use-editor-history.ts
116494
116594
  var import_react43 = __toESM(require_react(), 1);
@@ -120629,7 +120729,7 @@ function mimeTypeFromFilename(filename) {
120629
120729
  init_src();
120630
120730
  init_zod();
120631
120731
  import { execFile as execFile6 } from "node:child_process";
120632
- import { randomUUID as randomUUID3 } from "node:crypto";
120732
+ import { randomUUID as randomUUID4 } from "node:crypto";
120633
120733
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3 } from "node:fs";
120634
120734
  import { rm } from "node:fs/promises";
120635
120735
  import { join as join20 } from "node:path";
@@ -121545,6 +121645,7 @@ setBrowserLookup((browserId) => browserService.get(browserId));
121545
121645
 
121546
121646
  // src/server/services/cronjob-service.ts
121547
121647
  init_src();
121648
+ import { randomUUID as randomUUID3 } from "node:crypto";
121548
121649
 
121549
121650
  // ../../node_modules/.pnpm/croner@10.0.1/node_modules/croner/dist/croner.js
121550
121651
  function T(s6) {
@@ -122238,10 +122339,12 @@ var CronjobQueries = class _CronjobQueries {
122238
122339
  cronExpression: job.cronExpression,
122239
122340
  scope: job.scope,
122240
122341
  workspaceId: job.workspaceId ?? null,
122342
+ via: job.via ?? "chat",
122241
122343
  enabled: job.enabled,
122242
122344
  createdAt: job.createdAt,
122243
122345
  lastRunAt: job.lastRunAt ?? null,
122244
- lastRunStatus: job.lastRunStatus ?? null
122346
+ lastRunStatus: job.lastRunStatus ?? null,
122347
+ lastTerminalId: job.lastTerminalId ?? null
122245
122348
  }).run();
122246
122349
  }
122247
122350
  });
@@ -122273,6 +122376,19 @@ var CronjobQueries = class _CronjobQueries {
122273
122376
  const db2 = getDb();
122274
122377
  db2.update(cronjobs).set({ lastRunAt: (/* @__PURE__ */ new Date()).toISOString(), lastRunStatus: status }).where(eq2(cronjobs.id, jobId)).run();
122275
122378
  }
122379
+ /**
122380
+ * Record (or clear) the terminal a via="terminal" fire spawned.
122381
+ *
122382
+ * Dedicated targeted UPDATE — same rationale as `updateLastRun`: the fire
122383
+ * path shouldn't load + diff + rewrite the whole file just to stamp one
122384
+ * column. The stored id is the handle the scheduler uses on the next tick to
122385
+ * decide whether the previous run is still in flight (skip) or finished
122386
+ * (spawn a fresh pane). Pass `null` to clear it.
122387
+ */
122388
+ updateLastTerminal(jobId, terminalId) {
122389
+ const db2 = getDb();
122390
+ db2.update(cronjobs).set({ lastTerminalId: terminalId }).where(eq2(cronjobs.id, jobId)).run();
122391
+ }
122276
122392
  /**
122277
122393
  * Convert a Drizzle row into the optional-T-shaped `CronjobDefinition`.
122278
122394
  *
@@ -122287,1097 +122403,1242 @@ var CronjobQueries = class _CronjobQueries {
122287
122403
  cronExpression: row.cronExpression,
122288
122404
  scope: row.scope,
122289
122405
  workspaceId: row.workspaceId ?? void 0,
122406
+ via: row.via ?? "chat",
122290
122407
  enabled: row.enabled,
122291
122408
  createdAt: row.createdAt,
122292
122409
  lastRunAt: row.lastRunAt ?? void 0,
122293
- lastRunStatus: row.lastRunStatus
122410
+ lastRunStatus: row.lastRunStatus,
122411
+ lastTerminalId: row.lastTerminalId ?? void 0
122294
122412
  };
122295
122413
  }
122296
122414
  };
122297
122415
 
122298
- // src/server/services/cronjob-service.ts
122299
- var log20 = createLogger("cronjob-service");
122300
- var SCHEDULER_KEY = Symbol.for("band.cronjob-scheduler");
122301
- var g12 = globalThis;
122302
- if (!g12[SCHEDULER_KEY]) {
122303
- g12[SCHEDULER_KEY] = {
122304
- jobs: /* @__PURE__ */ new Map(),
122305
- started: false
122306
- };
122307
- }
122308
- function schedulerState() {
122309
- return g12[SCHEDULER_KEY];
122310
- }
122311
- var cronjobCreateInput = external_exports.object({
122312
- key: external_exports.string().min(1),
122313
- name: external_exports.string().min(1),
122314
- prompt: external_exports.string().min(1),
122315
- cronExpression: external_exports.string().min(1),
122316
- scope: external_exports.enum(["project", "workspace"]),
122317
- workspaceId: external_exports.string().optional(),
122318
- enabled: external_exports.boolean().default(true)
122319
- });
122320
- var cronjobUpdateInput = external_exports.object({
122321
- // `.min(1)` rejects the obviously invalid empty-string `key`/`id` at the
122322
- // validator instead of letting it reach the service, which would
122323
- // `loadFile("")` an empty result and 404 on `findIndex`. The legacy
122324
- // router accepted bare `z.string()` here; the migration is the natural
122325
- // moment to tighten the boundary so the create + update shapes stay
122326
- // consistent (`cronjobCreateInput.key` already has `.min(1)`).
122327
- key: external_exports.string().min(1),
122328
- id: external_exports.string().min(1),
122329
- name: external_exports.string().min(1).optional(),
122330
- prompt: external_exports.string().min(1).optional(),
122331
- cronExpression: external_exports.string().min(1).optional(),
122332
- enabled: external_exports.boolean().optional()
122333
- });
122334
- var cronjobByIdInput = external_exports.object({
122335
- key: external_exports.string().min(1),
122336
- id: external_exports.string().min(1)
122337
- });
122338
- var CronjobNotFoundError = class extends Error {
122339
- constructor(message = "Cronjob not found") {
122340
- super(message);
122341
- this.name = "CronjobNotFoundError";
122342
- }
122343
- };
122344
- var InvalidCronExpressionError = class extends Error {
122345
- constructor(message = "Invalid cron expression") {
122346
- super(message);
122347
- this.name = "InvalidCronExpressionError";
122348
- }
122349
- };
122350
- var CronjobWorkspaceMissingError = class extends Error {
122351
- constructor(message = "workspaceId is required for workspace-scoped cronjobs") {
122352
- super(message);
122353
- this.name = "CronjobWorkspaceMissingError";
122354
- }
122355
- };
122356
- var CronjobProjectNotFoundError = class extends Error {
122357
- constructor(message = "Project not found") {
122358
- super(message);
122359
- this.name = "CronjobProjectNotFoundError";
122360
- }
122361
- };
122362
- var CronjobService = class {
122363
- constructor(queries = new CronjobQueries()) {
122364
- this.queries = queries;
122365
- }
122366
- // -------------------------------------------------------------------------
122367
- // Read operations
122368
- // -------------------------------------------------------------------------
122416
+ // src/server/services/terminal-service.ts
122417
+ init_src();
122418
+ init_zod();
122419
+
122420
+ // src/server/infra/terminals/terminal-pool.ts
122421
+ init_src();
122422
+ import { randomUUID as randomUUID2 } from "node:crypto";
122423
+ import { existsSync as existsSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
122424
+ import { tmpdir } from "node:os";
122425
+ import { join as join19, sep as sep3 } from "node:path";
122426
+ var log20 = createLogger("terminal-pool");
122427
+ var MAX_SCROLLBACK_SIZE = 1e5;
122428
+ var TerminalPool = class {
122429
+ /** terminalId -> session */
122430
+ terminals = /* @__PURE__ */ new Map();
122431
+ /** workspaceId -> Set<terminalId> (reverse index for workspace-level cleanup) */
122432
+ workspaceTerminals = /* @__PURE__ */ new Map();
122433
+ /** terminalId -> Set<listener> for live output streaming */
122434
+ outputListeners = /* @__PURE__ */ new Map();
122369
122435
  /**
122370
- * List jobs for a key, or — if neither `project` nor `workspaceId` is
122371
- * provided — every cronjob across every key.
122436
+ * terminalId -> in-flight spawn promise. A single terminal is created via TWO
122437
+ * concurrent paths — the WebSocket handler (spawn-on-`getSession`-miss) and
122438
+ * the tRPC `terminal.create` mutation. Without deduplication both spawn a PTY
122439
+ * and the second `terminals.set` overwrites the first, so the client stays
122440
+ * wired to one PTY while the server's session map (and thus scrollback /
122441
+ * `terminals output` / reconnect-replay) points at the other. That's the
122442
+ * "output on screen but empty server scrollback until reload" bug
122443
+ * (band-app/band#617). This map makes concurrent spawns share ONE PTY.
122444
+ */
122445
+ spawning = /* @__PURE__ */ new Map();
122446
+ /**
122447
+ * Spawn (or return the already-spawning/spawned) PTY for a terminalId.
122372
122448
  *
122373
- * Mirrors the legacy tRPC `cronjobs.list` shape so the router stays a
122374
- * straight delegate. The "all" branch returns `fileKey` on each row
122375
- * because callers (the dashboard's cronjob settings page) need both
122376
- * pieces to render the project/workspace breadcrumbs.
122449
+ * Idempotent per terminalId: if a session already exists it is returned
122450
+ * as-is, and concurrent spawn calls for the same id share a single in-flight
122451
+ * promise — so the WebSocket and tRPC create paths never create competing
122452
+ * PTYs. `workspaceRoot` is the absolute worktree path; resolving the
122453
+ * workspaceId to a path is the service tier's job so the pool stays oblivious
122454
+ * to the workspace registry.
122377
122455
  */
122378
- list(filter) {
122379
- if (filter?.project) {
122380
- const { project } = filter;
122381
- const file = this.queries.loadFile(project);
122382
- return { jobs: file.jobs.map((j) => ({ ...j, fileKey: project })) };
122383
- }
122384
- if (filter?.workspaceId) {
122385
- const { workspaceId } = filter;
122386
- const file = this.queries.loadFile(workspaceId);
122387
- return {
122388
- jobs: file.jobs.map((j) => ({ ...j, fileKey: workspaceId }))
122389
- };
122456
+ async spawn(workspaceId, terminalId, workspaceRoot, options2, onExit) {
122457
+ const existing = this.terminals.get(terminalId);
122458
+ if (existing) return existing;
122459
+ const inflight = this.spawning.get(terminalId);
122460
+ if (inflight) return inflight;
122461
+ const promise = this.spawnNew(workspaceId, terminalId, workspaceRoot, options2, onExit);
122462
+ this.spawning.set(terminalId, promise);
122463
+ try {
122464
+ return await promise;
122465
+ } finally {
122466
+ this.spawning.delete(terminalId);
122390
122467
  }
122391
- return { jobs: this.queries.listAll() };
122392
122468
  }
122393
- /** Get a single job by `key` + `id`. Throws `CronjobNotFoundError` if absent. */
122394
- get(key, id) {
122395
- const job = this.findJob(key, id);
122396
- if (!job) throw new CronjobNotFoundError();
122397
- return { job };
122469
+ async spawnNew(workspaceId, terminalId, workspaceRoot, options2, onExit) {
122470
+ const shell = defaultShell();
122471
+ const resolvedPath = await shellPath();
122472
+ const env = {};
122473
+ for (const [key, value] of Object.entries(process.env)) {
122474
+ if (value != null) {
122475
+ env[key] = value;
122476
+ }
122477
+ }
122478
+ env.PATH = resolvedPath;
122479
+ env.TERM = "xterm-256color";
122480
+ env.COLORTERM = "truecolor";
122481
+ env.COLORFGBG = "15;0";
122482
+ delete env.PORT;
122483
+ if (options2?.env) {
122484
+ Object.assign(env, options2.env);
122485
+ }
122486
+ env.BAND_DISPATCH = "terminal";
122487
+ let cwd = workspaceRoot;
122488
+ if (options2?.cwd) {
122489
+ const resolved = join19(workspaceRoot, options2.cwd);
122490
+ if (resolved !== workspaceRoot && !resolved.startsWith(workspaceRoot + sep3)) {
122491
+ log20.warn(
122492
+ "Ignoring cwd %s \u2014 resolves outside workspace root %s",
122493
+ options2.cwd,
122494
+ workspaceRoot
122495
+ );
122496
+ } else if (existsSync8(resolved)) {
122497
+ cwd = resolved;
122498
+ } else {
122499
+ log20.warn("Ignoring cwd %s \u2014 directory does not exist", options2.cwd);
122500
+ }
122501
+ }
122502
+ if (!existsSync8(cwd)) {
122503
+ throw new Error(`Workspace directory does not exist: ${cwd}`);
122504
+ }
122505
+ if (!existsSync8(shell)) {
122506
+ throw new Error(`Shell not found: ${shell}`);
122507
+ }
122508
+ log20.debug(
122509
+ "Spawning shell %s in %s for terminal %s (PATH=%s)",
122510
+ shell,
122511
+ cwd,
122512
+ terminalId,
122513
+ resolvedPath.slice(0, 200)
122514
+ );
122515
+ const nodePty = await import("node-pty");
122516
+ let ptyProcess;
122517
+ try {
122518
+ ptyProcess = nodePty.spawn(shell, [], {
122519
+ name: "xterm-256color",
122520
+ cols: 80,
122521
+ rows: 24,
122522
+ cwd,
122523
+ env
122524
+ });
122525
+ } catch (err) {
122526
+ const msg = err instanceof Error ? err.message : String(err);
122527
+ log20.error("pty.spawn failed: %s (shell=%s, cwd=%s)", msg, shell, cwd);
122528
+ throw err;
122529
+ }
122530
+ const session = { pty: ptyProcess, scrollback: "", workspaceId };
122531
+ this.terminals.set(terminalId, session);
122532
+ if (options2?.command) {
122533
+ this.autoRunCommand(session, options2.command);
122534
+ }
122535
+ let ids = this.workspaceTerminals.get(workspaceId);
122536
+ if (!ids) {
122537
+ ids = /* @__PURE__ */ new Set();
122538
+ this.workspaceTerminals.set(workspaceId, ids);
122539
+ }
122540
+ ids.add(terminalId);
122541
+ ptyProcess.onData((data) => {
122542
+ session.scrollback += data;
122543
+ if (session.scrollback.length > MAX_SCROLLBACK_SIZE) {
122544
+ session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_SIZE);
122545
+ }
122546
+ const listeners3 = this.outputListeners.get(terminalId);
122547
+ if (listeners3) {
122548
+ for (const cb2 of listeners3) {
122549
+ try {
122550
+ cb2(data);
122551
+ } catch {
122552
+ }
122553
+ }
122554
+ }
122555
+ });
122556
+ ptyProcess.onExit(() => {
122557
+ log20.debug("Terminal exited: %s (workspace %s)", terminalId, workspaceId);
122558
+ const explicitlyKilled = !this.terminals.has(terminalId);
122559
+ this.terminals.delete(terminalId);
122560
+ this.outputListeners.delete(terminalId);
122561
+ const set = this.workspaceTerminals.get(workspaceId);
122562
+ if (set) {
122563
+ set.delete(terminalId);
122564
+ if (set.size === 0) {
122565
+ this.workspaceTerminals.delete(workspaceId);
122566
+ }
122567
+ }
122568
+ if (session.autoRunFile) {
122569
+ try {
122570
+ unlinkSync2(session.autoRunFile);
122571
+ } catch {
122572
+ }
122573
+ }
122574
+ if (onExit && !explicitlyKilled) {
122575
+ try {
122576
+ onExit();
122577
+ } catch (err) {
122578
+ log20.warn("onExit callback threw for terminal %s: %s", terminalId, err);
122579
+ }
122580
+ }
122581
+ });
122582
+ return session;
122398
122583
  }
122399
- // -------------------------------------------------------------------------
122400
- // Mutations
122401
- // -------------------------------------------------------------------------
122402
122584
  /**
122403
- * Create a new cronjob and re-bind the scheduler.
122585
+ * Auto-run the workspace's initial command inside a freshly spawned PTY,
122586
+ * robustly.
122404
122587
  *
122405
- * Validates the cron expression by attempting to construct a `Cron` with
122406
- * `maxRuns: 0` (parses without scheduling). Throws
122407
- * `InvalidCronExpressionError` for a bad expression and
122408
- * `CronjobWorkspaceMissingError` when a workspace-scoped job is missing
122409
- * its `workspaceId`.
122588
+ * Writing a long command line straight into a cold PTY is racy on three
122589
+ * fronts, all of which surfaced in production with `via=terminal`
122590
+ * prompt-as-argv launches (`'<agent-binary>' '<entire-prompt>'`):
122591
+ *
122592
+ * 1. **Dropped newline.** The shell may not have finished sourcing its
122593
+ * rc files, and the tty can still be in its startup canonical-mode
122594
+ * window. A trailing `\n` written then is swallowed — the command
122595
+ * is typed at the prompt but never executed.
122596
+ * 2. **Truncation.** A single input line longer than the tty's
122597
+ * canonical buffer (`MAX_CANON`, ~4 KB) is clipped. Long prompts
122598
+ * blow past that easily.
122599
+ * 3. **UTF-8 corruption.** A multi-byte sequence split across a tty
122600
+ * buffer boundary is mangled (the classic em-dash → mojibake).
122601
+ *
122602
+ * Fix both layers: (1) stage the command's bytes in a temp file so they
122603
+ * never traverse the tty's canonical input buffer — sidestepping the
122604
+ * truncation and UTF-8-split failures entirely — and (2) wait for the
122605
+ * shell to emit its first output (its prompt) before writing the
122606
+ * *short* `source <file>` line. The short line is well under the
122607
+ * canonical limit and lands after the cold-start window, so its newline
122608
+ * survives. `source` runs the command in the current interactive shell,
122609
+ * so the user keeps their session afterwards.
122410
122610
  */
122411
- create(input) {
122412
- this.assertValidCron(input.cronExpression);
122413
- if (input.scope === "workspace" && !input.workspaceId) {
122414
- throw new CronjobWorkspaceMissingError();
122611
+ autoRunCommand(session, command) {
122612
+ const pty = session.pty;
122613
+ let cmdFile;
122614
+ try {
122615
+ cmdFile = join19(tmpdir(), `band-autorun-${randomUUID2()}.sh`);
122616
+ writeFileSync2(cmdFile, `${command}
122617
+ `, { encoding: "utf-8", mode: 384, flag: "wx" });
122618
+ session.autoRunFile = cmdFile;
122619
+ } catch (err) {
122620
+ log20.warn(
122621
+ "autoRunCommand: temp-file staging failed (%s); writing command directly",
122622
+ err instanceof Error ? err.message : String(err)
122623
+ );
122624
+ pty.write(`${command}
122625
+ `);
122626
+ return;
122415
122627
  }
122416
- const file = this.queries.loadFile(input.key);
122417
- const job = {
122418
- id: generateCronjobId(),
122419
- name: input.name,
122420
- prompt: input.prompt,
122421
- cronExpression: input.cronExpression,
122422
- scope: input.scope,
122423
- workspaceId: input.workspaceId,
122424
- enabled: input.enabled,
122425
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
122628
+ const quotedPath = cmdFile.replace(/'/g, `'\\''`);
122629
+ const sourceLine = `. '${quotedPath}'
122630
+ `;
122631
+ let done = false;
122632
+ const inject = () => {
122633
+ if (done) return;
122634
+ done = true;
122635
+ try {
122636
+ pty.write(sourceLine);
122637
+ } catch (err) {
122638
+ log20.warn("autoRunCommand: failed to inject command line (%s)", err);
122639
+ }
122426
122640
  };
122427
- file.jobs.push(job);
122428
- this.queries.saveFile(input.key, file);
122429
- this.reloadSchedules();
122430
- return { job };
122641
+ let dataDisposed = false;
122642
+ const disposeData = () => {
122643
+ if (dataDisposed) return;
122644
+ dataDisposed = true;
122645
+ dataDisposable.dispose();
122646
+ };
122647
+ const dataDisposable = pty.onData(() => {
122648
+ disposeData();
122649
+ inject();
122650
+ });
122651
+ const timer = setTimeout(inject, 750);
122652
+ timer.unref();
122653
+ let exitDisposed = false;
122654
+ const exitDisposable = pty.onExit(() => {
122655
+ done = true;
122656
+ clearTimeout(timer);
122657
+ disposeData();
122658
+ if (exitDisposed) return;
122659
+ exitDisposed = true;
122660
+ exitDisposable.dispose();
122661
+ });
122431
122662
  }
122432
122663
  /**
122433
- * Partially update an existing cronjob.
122434
- *
122435
- * Each optional field is applied only when present, matching the legacy
122436
- * router behavior. If `cronExpression` is supplied it's validated before
122437
- * the write. Throws `CronjobNotFoundError` if no job matches `key`+`id`.
122664
+ * Returns an existing PTY session by terminalId, or undefined.
122438
122665
  */
122439
- update(input) {
122440
- if (input.cronExpression !== void 0) {
122441
- this.assertValidCron(input.cronExpression);
122442
- }
122443
- const file = this.queries.loadFile(input.key);
122444
- const job = file.jobs.find((j) => j.id === input.id);
122445
- if (!job) throw new CronjobNotFoundError();
122446
- if (input.name !== void 0) job.name = input.name;
122447
- if (input.prompt !== void 0) job.prompt = input.prompt;
122448
- if (input.cronExpression !== void 0) job.cronExpression = input.cronExpression;
122449
- if (input.enabled !== void 0) job.enabled = input.enabled;
122450
- this.queries.saveFile(input.key, file);
122451
- this.reloadSchedules();
122452
- return { job };
122666
+ get(terminalId) {
122667
+ return this.terminals.get(terminalId);
122453
122668
  }
122454
122669
  /**
122455
- * Delete a cronjob and re-bind the scheduler.
122456
- *
122457
- * Throws `CronjobNotFoundError` if the job is already gone.
122670
+ * Resize the underlying PTY. No-op if the terminal isn't known to the pool.
122458
122671
  */
122459
- delete(key, id) {
122460
- const file = this.queries.loadFile(key);
122461
- const index3 = file.jobs.findIndex((j) => j.id === id);
122462
- if (index3 === -1) throw new CronjobNotFoundError();
122463
- file.jobs.splice(index3, 1);
122464
- this.queries.saveFile(key, file);
122465
- this.reloadSchedules();
122466
- return { ok: true };
122672
+ resize(terminalId, cols, rows) {
122673
+ const session = this.terminals.get(terminalId);
122674
+ if (session) {
122675
+ session.pty.resize(cols, rows);
122676
+ }
122467
122677
  }
122468
122678
  /**
122469
- * Fire a cronjob synchronously (manual "run now" path).
122470
- *
122471
- * Uses the same `band:cronId`-labeled chat as the scheduled fire so the
122472
- * user sees both paths converge on a single dedicated conversation
122473
- * (issue #520). `submitTask` may throw `TaskConflictError`, which the API
122474
- * tier translates into a 409 Conflict response.
122679
+ * List metadata for every PTY session bound to the given workspace.
122475
122680
  *
122476
- * Atomicity caveat: `getOrCreateCronjobChat` and `submitTask` are not run
122477
- * inside a single transaction. If `createChat` succeeds but `submitTask`
122478
- * later throws an unexpected (non-`TaskConflictError`) error, the
122479
- * freshly-created chat remains in the workspace as an orphan with no
122480
- * task ever attached. The next manual `trigger` (or scheduled fire)
122481
- * reuses that chat via its `band:cronId` label, so the orphan
122482
- * self-heals — but the user may briefly see an empty chat pane. The
122483
- * legacy implementation had the same gap; preserved here to match
122484
- * existing behavior. If this ever needs to be tightened, the fix is
122485
- * either (a) wrap both calls in a try/catch that deletes the chat on
122486
- * unexpected failure, or (b) defer `createChat` until `submitTask`
122487
- * succeeds (which would require buffering the chat name/labels on the
122488
- * task row).
122681
+ * The returned `title` is the foreground process name reported by the
122682
+ * PTY; reading `pty.process` can throw if the process has already exited
122683
+ * between the reverse-index lookup and the read, so the read is wrapped.
122489
122684
  */
122490
- trigger(key, id) {
122491
- const job = this.findJob(key, id);
122492
- if (!job) throw new CronjobNotFoundError();
122493
- const workspaceId = this.resolveWorkspaceId(job, key);
122494
- if (!workspaceId) throw new CronjobProjectNotFoundError();
122495
- const cronChat = this.getOrCreateCronjobChat(workspaceId, job);
122496
- const task = taskService.submitTask({ workspaceId, chatId: cronChat.id, prompt: job.prompt });
122497
- return { taskId: task.id, workspaceId, chatId: cronChat.id };
122498
- }
122499
- // -------------------------------------------------------------------------
122500
- // Lifecycle hooks for workspace/project removal
122501
- // -------------------------------------------------------------------------
122502
- /**
122503
- * Stop all timers for jobs in `key` and remove the file rows.
122504
- *
122505
- * Called when a workspace or project is removed. Order matters: stop
122506
- * timers first so no further fires queue up between the timer-stop and
122507
- * the row-delete. An in-flight fire is already safe in either order
122508
- * because `executeCronjob` operates on closed-over `job`/`fileKey` (it
122509
- * never re-reads the row) and `safeUpdateLastRun` swallows the
122510
- * row-missing case. The two steps were previously two separate calls
122511
- * (`stopJobsForKey` + `deleteCronjobFile`) at every call site — the
122512
- * legacy callers always invoked them in pairs, so they're collapsed
122513
- * here to make the contract harder to misuse.
122514
- *
122515
- * Partial-failure recovery: if `deleteFile` throws (e.g. a transient DB
122516
- * lock), the timers are already stopped but the rows remain in the DB.
122517
- * The next `reloadSchedules()` call — triggered by any subsequent
122518
- * cronjob mutation, or by a server restart — re-reads the rows from the
122519
- * DB and re-registers the timers, so the orphaned rows self-heal
122520
- * without manual intervention. We do NOT roll back the `stop()` calls
122521
- * because re-binding timers for a workspace that's mid-deletion is
122522
- * worse than leaving them stopped: the workspace path is about to be
122523
- * removed and any fire that races the removal would target a
122524
- * non-existent worktree. Re-registration on the next reload is the
122525
- * intentional recovery strategy; operators encountering a partial
122526
- * failure on this path should expect the next reload to be a no-op
122527
- * once the workspace removal completes and the rows are re-attempted.
122685
+ list(workspaceId) {
122686
+ const ids = this.workspaceTerminals.get(workspaceId);
122687
+ if (!ids) return [];
122688
+ const result = [];
122689
+ for (const terminalId of ids) {
122690
+ const session = this.terminals.get(terminalId);
122691
+ if (session) {
122692
+ let title = "";
122693
+ try {
122694
+ title = session.pty.process;
122695
+ } catch {
122696
+ }
122697
+ result.push({
122698
+ terminalId,
122699
+ workspaceId,
122700
+ pid: session.pty.pid,
122701
+ scrollbackLength: session.scrollback.length,
122702
+ title
122703
+ });
122704
+ }
122705
+ }
122706
+ return result;
122707
+ }
122708
+ /**
122709
+ * Returns the scrollback buffer for a terminal, optionally limited to the
122710
+ * last N lines. Returns `null` if the terminal is not found.
122528
122711
  */
122529
- removeForKey(key) {
122530
- this.stopJobsForKey(key);
122531
- this.queries.deleteFile(key);
122712
+ getScrollback(terminalId, lines) {
122713
+ const session = this.terminals.get(terminalId);
122714
+ if (!session) return null;
122715
+ if (lines == null) return session.scrollback;
122716
+ const allLines = session.scrollback.split("\n");
122717
+ return allLines.slice(-lines).join("\n");
122532
122718
  }
122533
- // -------------------------------------------------------------------------
122534
- // Scheduler lifecycle
122535
- // -------------------------------------------------------------------------
122536
- /** Start the cronjob scheduler. Called once on server boot. */
122537
- start() {
122538
- const state = schedulerState();
122539
- if (state.started) return;
122540
- state.started = true;
122541
- this.loadAndScheduleAll();
122542
- log20.info("cronjob scheduler started");
122719
+ /**
122720
+ * Writes data to a terminal's PTY stdin.
122721
+ * Returns false if the terminal is not found.
122722
+ */
122723
+ write(terminalId, data) {
122724
+ const session = this.terminals.get(terminalId);
122725
+ if (!session) return false;
122726
+ session.pty.write(data);
122727
+ return true;
122543
122728
  }
122544
- /** Stop the cronjob scheduler. Called on graceful shutdown. */
122545
- stop() {
122546
- const state = schedulerState();
122547
- for (const [, cron] of state.jobs) {
122548
- cron.stop();
122729
+ /**
122730
+ * Subscribe to live output from a terminal's PTY.
122731
+ * Returns an unsubscribe function.
122732
+ */
122733
+ subscribeOutput(terminalId, callback) {
122734
+ let listeners3 = this.outputListeners.get(terminalId);
122735
+ if (!listeners3) {
122736
+ listeners3 = /* @__PURE__ */ new Set();
122737
+ this.outputListeners.set(terminalId, listeners3);
122549
122738
  }
122550
- state.jobs.clear();
122551
- state.started = false;
122552
- log20.info("cronjob scheduler stopped");
122739
+ listeners3.add(callback);
122740
+ return () => {
122741
+ listeners3.delete(callback);
122742
+ if (listeners3.size === 0) {
122743
+ this.outputListeners.delete(terminalId);
122744
+ }
122745
+ };
122553
122746
  }
122554
- /** Reload all schedules from the database. Call after cronjob mutations. */
122555
- reloadSchedules() {
122556
- if (!schedulerState().started) return;
122557
- this.loadAndScheduleAll();
122747
+ /**
122748
+ * Kill a single terminal by terminalId.
122749
+ */
122750
+ kill(terminalId) {
122751
+ const session = this.terminals.get(terminalId);
122752
+ if (session) {
122753
+ session.pty.kill();
122754
+ this.terminals.delete(terminalId);
122755
+ const set = this.workspaceTerminals.get(session.workspaceId);
122756
+ if (set) {
122757
+ set.delete(terminalId);
122758
+ if (set.size === 0) {
122759
+ this.workspaceTerminals.delete(session.workspaceId);
122760
+ }
122761
+ }
122762
+ }
122558
122763
  }
122559
122764
  /**
122560
- * Stop all scheduled jobs for a key (e.g. when a workspace is removed).
122561
- *
122562
- * Private because the only correct call site is `removeForKey` above:
122563
- * stopping the timers without also dropping the rows would re-schedule
122564
- * the same jobs on the next `reloadSchedules()` tick. Keeping it
122565
- * accessible only through `removeForKey` prevents future callers from
122566
- * accidentally invoking just one half of the pair.
122765
+ * Kill all terminals for a workspace.
122567
122766
  */
122568
- stopJobsForKey(key) {
122569
- const state = schedulerState();
122570
- const file = this.queries.loadFile(key);
122571
- for (const job of file.jobs) {
122572
- const cron = state.jobs.get(job.id);
122573
- if (cron) {
122574
- cron.stop();
122575
- state.jobs.delete(job.id);
122576
- log20.info({ jobId: job.id, key }, "stopped cronjob");
122767
+ killWorkspace(workspaceId) {
122768
+ const ids = this.workspaceTerminals.get(workspaceId);
122769
+ if (!ids) return;
122770
+ for (const terminalId of ids) {
122771
+ const session = this.terminals.get(terminalId);
122772
+ if (session) {
122773
+ session.pty.kill();
122774
+ this.terminals.delete(terminalId);
122577
122775
  }
122578
122776
  }
122777
+ this.workspaceTerminals.delete(workspaceId);
122778
+ }
122779
+ /**
122780
+ * Kill every tracked PTY (server shutdown path).
122781
+ */
122782
+ killAll() {
122783
+ for (const [, session] of this.terminals) {
122784
+ session.pty.kill();
122785
+ }
122786
+ this.terminals.clear();
122787
+ this.workspaceTerminals.clear();
122788
+ }
122789
+ };
122790
+ var terminalPool = new TerminalPool();
122791
+
122792
+ // src/server/services/_utils/terminal-layout-manager.ts
122793
+ var manager = new DockviewLayoutManager("terminal_layout");
122794
+ var getTerminalLayout = (workspaceId) => manager.get(workspaceId);
122795
+ var saveTerminalLayout = (workspaceId, tree) => manager.save(workspaceId, tree);
122796
+ var deleteTerminalLayout = (workspaceId) => manager.delete(workspaceId);
122797
+ function addTerminalToLayout(workspaceId, terminalId, opts) {
122798
+ manager.addPanel(workspaceId, {
122799
+ id: terminalId,
122800
+ contentComponent: "terminalTab",
122801
+ tabComponent: "terminalTab",
122802
+ title: opts?.title ?? "Terminal",
122803
+ params: {
122804
+ workspaceId,
122805
+ terminalId,
122806
+ ...opts?.command ? { command: opts.command } : {},
122807
+ ...opts?.cwd ? { cwd: opts.cwd } : {},
122808
+ ...opts?.env ? { env: opts.env } : {}
122809
+ }
122810
+ });
122811
+ }
122812
+ function removeTerminalFromLayout(workspaceId, terminalId) {
122813
+ manager.removePanel(workspaceId, terminalId);
122814
+ }
122815
+
122816
+ // src/server/services/terminal-service.ts
122817
+ var log21 = createLogger("terminal-service");
122818
+ var TerminalPaneConfigSchema = external_exports.object({
122819
+ name: external_exports.string().optional(),
122820
+ command: external_exports.string().optional(),
122821
+ cwd: external_exports.string().optional(),
122822
+ env: external_exports.record(external_exports.string()).optional(),
122823
+ focus: external_exports.boolean().optional()
122824
+ });
122825
+ var PaneNodeSchema = external_exports.object({
122826
+ pane: TerminalPaneConfigSchema
122827
+ });
122828
+ var TerminalLayoutNodeSchema = external_exports.lazy(
122829
+ () => external_exports.union([
122830
+ PaneNodeSchema,
122831
+ external_exports.object({
122832
+ direction: external_exports.enum(["horizontal", "vertical"]),
122833
+ split: external_exports.number().min(0.1).max(0.9).optional().default(0.5),
122834
+ children: external_exports.tuple([TerminalLayoutNodeSchema, TerminalLayoutNodeSchema])
122835
+ })
122836
+ ])
122837
+ );
122838
+ var WorkspaceTerminalConfigSchema = external_exports.object({
122839
+ layout: TerminalLayoutNodeSchema
122840
+ });
122841
+ var TerminalService = class {
122842
+ constructor(pool2 = terminalPool) {
122843
+ this.pool = pool2;
122579
122844
  }
122580
122845
  // -------------------------------------------------------------------------
122581
- // Shared resolution helpers (used by both the API + the scheduled fire path)
122846
+ // PTY lifecycle
122582
122847
  // -------------------------------------------------------------------------
122583
122848
  /**
122584
- * Find — or create — the dedicated chat this cronjob writes into.
122849
+ * Spawn a new PTY for the given workspace + terminalId.
122585
122850
  *
122586
- * Issue #520: each cronjob owns its own chat in the workspace, looked up
122587
- * by the canonical `band:cronId` label. First call creates the chat;
122588
- * subsequent calls reuse it. If the user deletes the chat, the next call
122589
- * recreates it (intentional soft reset). This replaces the old
122590
- * `getOrCreateDefaultChat` dispatch, which silently latched onto whatever
122591
- * chat the user happened to be focused on. Shared between the scheduled
122592
- * fire path (`executeCronjob` below) and the manual `trigger` route so
122593
- * both stay aligned.
122851
+ * Resolves `workspaceId` to a worktree path before delegating to the
122852
+ * pool, and registers the new terminal in the saved dockview layout so it
122853
+ * survives a server restart (mirrors `chatService.create` /
122854
+ * `browserService.create`). Does NOT emit a `terminal-created` event —
122855
+ * the API entry points decide whether to broadcast (the WebSocket handler
122856
+ * stays silent; the tRPC `create` mutation emits explicitly).
122594
122857
  */
122595
- getOrCreateCronjobChat(workspaceId, job) {
122596
- const labelMatch = { [BAND_CRON_ID_LABEL]: job.id };
122597
- const existing = chatService.findByLabels(workspaceId, labelMatch);
122598
- if (existing) return existing;
122599
- return chatService.create(workspaceId, {
122600
- name: job.name,
122601
- labels: labelMatch,
122602
- // Cronjob scheduler is a trusted server-side caller — it's allowed
122603
- // to write the reserved `band:` prefix that user-facing surfaces
122604
- // are blocked from touching.
122605
- allowReservedLabels: true
122858
+ async spawn(workspaceId, terminalId, options2, opts) {
122859
+ const workspace = workspaceService.resolve(workspaceId);
122860
+ if (!workspace) {
122861
+ throw new Error(`Workspace not found: ${workspaceId}`);
122862
+ }
122863
+ const session = await this.pool.spawn(
122864
+ workspaceId,
122865
+ terminalId,
122866
+ workspace.worktree.path,
122867
+ options2,
122868
+ opts?.cleanupOnExit ? () => this.emitRemoved(workspaceId, terminalId) : void 0
122869
+ );
122870
+ addTerminalToLayout(workspaceId, terminalId, {
122871
+ command: options2?.command,
122872
+ cwd: options2?.cwd,
122873
+ env: options2?.env
122606
122874
  });
122875
+ return session;
122607
122876
  }
122608
122877
  /**
122609
- * Resolve the workspace the cronjob fires into.
122610
- *
122611
- * Returns `null` for project-scoped jobs whose project no longer exists
122612
- * (which the scheduled fire path treats as a soft failure — see
122613
- * `executeCronjob`). The manual trigger route maps the same condition to
122614
- * a `CronjobProjectNotFoundError`.
122615
- *
122616
- * Workspace-scoped jobs return `null` rather than falling through to the
122617
- * project-resolution branch when `workspaceId` is somehow missing. The
122618
- * `create` path already rejects this via `CronjobWorkspaceMissingError`,
122619
- * but a workspace-scoped row written by an older server version could
122620
- * theoretically reach here with a null `workspaceId`. Falling through
122621
- * would silently misfire into the project's default branch; returning
122622
- * `null` makes the failure explicit (logged + `lastRunStatus = "failed"`
122623
- * for the scheduler; `CronjobProjectNotFoundError` for the manual
122624
- * trigger path).
122878
+ * Kill a single terminal. Removes it from the saved dockview layout and
122879
+ * emits `terminal-killed` so the dashboard's status stream prunes the
122880
+ * panel. Safe to call with an unknown terminalId — no-op.
122625
122881
  */
122626
- resolveWorkspaceId(job, fileKey) {
122627
- if (job.scope === "workspace") {
122628
- return job.workspaceId ?? null;
122882
+ kill(terminalId) {
122883
+ const session = this.pool.get(terminalId);
122884
+ const workspaceId = session?.workspaceId;
122885
+ this.pool.kill(terminalId);
122886
+ if (workspaceId) {
122887
+ this.emitRemoved(workspaceId, terminalId);
122629
122888
  }
122630
- const appState = loadState();
122631
- const project = appState.projects.find((p6) => p6.name === fileKey);
122632
- if (!project) return null;
122633
- return toWorkspaceId(project.name, project.defaultBranch);
122889
+ }
122890
+ /**
122891
+ * Drop a terminal from the saved dockview layout and broadcast
122892
+ * `terminal-killed` so an open dashboard prunes the panel. Shared by the
122893
+ * explicit {@link kill} path and the `cleanupOnExit` natural-exit hook wired
122894
+ * up in {@link spawn}. Idempotent: `removeTerminalFromLayout` is a no-op when
122895
+ * the panel is already gone, and a duplicate `terminal-killed` is harmless.
122896
+ */
122897
+ emitRemoved(workspaceId, terminalId) {
122898
+ removeTerminalFromLayout(workspaceId, terminalId);
122899
+ emit({ kind: "terminal-killed", workspaceId, terminalId });
122900
+ }
122901
+ /**
122902
+ * Kill every PTY associated with a workspace. Used by the workspace
122903
+ * deletion path — the caller is responsible for tearing down the layout
122904
+ * tree via {@link deleteLayout} as well.
122905
+ */
122906
+ killWorkspace(workspaceId) {
122907
+ this.pool.killWorkspace(workspaceId);
122908
+ }
122909
+ /**
122910
+ * Kill every tracked PTY across all workspaces. Used by the server
122911
+ * shutdown path in `start-server.ts`.
122912
+ */
122913
+ killAll() {
122914
+ this.pool.killAll();
122634
122915
  }
122635
122916
  // -------------------------------------------------------------------------
122636
- // Internals
122917
+ // Per-terminal accessors
122637
122918
  // -------------------------------------------------------------------------
122638
- findJob(key, id) {
122639
- const file = this.queries.loadFile(key);
122640
- return file.jobs.find((j) => j.id === id);
122919
+ list(workspaceId) {
122920
+ return this.pool.list(workspaceId);
122641
122921
  }
122642
- assertValidCron(expression) {
122643
- try {
122644
- const options2 = { maxRuns: 0 };
122645
- void new E2(expression, options2);
122646
- } catch {
122647
- throw new InvalidCronExpressionError();
122648
- }
122922
+ getSession(terminalId) {
122923
+ return this.pool.get(terminalId);
122649
122924
  }
122650
- scheduleJob(job, fileKey) {
122651
- const state = schedulerState();
122652
- const existing = state.jobs.get(job.id);
122653
- if (existing) {
122654
- existing.stop();
122655
- state.jobs.delete(job.id);
122656
- }
122657
- if (!job.enabled) return;
122658
- try {
122659
- const cronInstance = new E2(job.cronExpression, () => {
122660
- this.executeCronjob(job, fileKey).catch((err) => {
122661
- log20.error({ jobId: job.id, err }, "unhandled error in cronjob execution");
122662
- });
122663
- });
122664
- state.jobs.set(job.id, cronInstance);
122665
- log20.info(
122666
- { jobId: job.id, name: job.name, cron: job.cronExpression, scope: job.scope },
122667
- "scheduled cronjob"
122668
- );
122669
- } catch (err) {
122670
- log20.error(
122671
- { jobId: job.id, cronExpression: job.cronExpression, err },
122672
- "invalid cron expression, skipping job"
122673
- );
122674
- }
122925
+ getScrollback(terminalId, lines) {
122926
+ return this.pool.getScrollback(terminalId, lines);
122675
122927
  }
122676
- async executeCronjob(job, fileKey) {
122677
- const workspaceId = this.resolveWorkspaceId(job, fileKey);
122678
- if (!workspaceId) {
122679
- log20.warn({ jobId: job.id, fileKey }, "project not found for cronjob, skipping");
122680
- this.safeUpdateLastRun(job.id, "failed");
122681
- return;
122682
- }
122683
- log20.info({ jobId: job.id, name: job.name, workspaceId }, "executing cronjob");
122684
- try {
122685
- const chat = this.getOrCreateCronjobChat(workspaceId, job);
122686
- const task = taskService.submitTask({ workspaceId, chatId: chat.id, prompt: job.prompt });
122687
- log20.info(
122688
- { jobId: job.id, taskId: task.id, workspaceId, chatId: chat.id },
122689
- "cronjob task submitted"
122690
- );
122691
- this.safeUpdateLastRun(job.id, "completed");
122692
- } catch (err) {
122693
- if (err instanceof TaskConflictError) {
122694
- log20.info(
122695
- { jobId: job.id, workspaceId },
122696
- "task already running, skipping cronjob execution"
122697
- );
122698
- this.safeUpdateLastRun(job.id, "skipped");
122699
- return;
122700
- }
122701
- log20.error({ jobId: job.id, err }, "cronjob execution failed");
122702
- this.safeUpdateLastRun(job.id, "failed");
122703
- }
122928
+ write(terminalId, data) {
122929
+ return this.pool.write(terminalId, data);
122930
+ }
122931
+ resize(terminalId, cols, rows) {
122932
+ this.pool.resize(terminalId, cols, rows);
122933
+ }
122934
+ subscribeOutput(terminalId, callback) {
122935
+ return this.pool.subscribeOutput(terminalId, callback);
122936
+ }
122937
+ // -------------------------------------------------------------------------
122938
+ // Layout persistence (dockview tree)
122939
+ //
122940
+ // Thin pass-throughs to `lib/terminal-layout-manager` so the API tier
122941
+ // doesn't reach across into `lib/` directly. The layout helpers will be
122942
+ // folded into a dedicated infra adapter in a follow-up phase (when the
122943
+ // chats/browsers domains get their own infra adapters); leaving them in
122944
+ // `lib/` for now is the minimum-disruption shape during the phased
122945
+ // migration.
122946
+ // -------------------------------------------------------------------------
122947
+ getLayout(workspaceId) {
122948
+ return getTerminalLayout(workspaceId);
122949
+ }
122950
+ saveLayout(workspaceId, tree) {
122951
+ saveTerminalLayout(workspaceId, tree);
122952
+ }
122953
+ deleteLayout(workspaceId) {
122954
+ deleteTerminalLayout(workspaceId);
122704
122955
  }
122956
+ // -------------------------------------------------------------------------
122957
+ // Workspace `.band/config.json` `workspace.terminal` block
122958
+ // -------------------------------------------------------------------------
122705
122959
  /**
122706
- * Update `lastRunAt`/`lastRunStatus`, swallowing DB errors.
122960
+ * Load and validate the `workspace.terminal` block from
122961
+ * `.band/config.json`. Returns `null` when the workspace can't be
122962
+ * resolved, the config file is absent, the block is missing, or the
122963
+ * payload fails validation.
122707
122964
  *
122708
- * Errors here are diagnostic noise during the fire path — the cronjob
122709
- * already executed (or failed/skipped), and we don't want a transient
122710
- * DB lock to mask the real outcome by re-raising. The legacy
122711
- * implementation logged at `warn` and moved on; preserved here.
122965
+ * Absorbed from the old `lib/terminal-config.ts:loadWorkspaceTerminalConfig`
122966
+ * — same parsing semantics, but the lookup now goes through the service
122967
+ * tier so callers (currently the workspace router's `getTerminalConfig`
122968
+ * query) reach it via `terminalService` instead of a stand-alone helper.
122712
122969
  */
122713
- safeUpdateLastRun(jobId, status) {
122714
- try {
122715
- this.queries.updateLastRun(jobId, status);
122716
- } catch (err) {
122717
- log20.warn({ jobId, err }, "failed to update lastRun on cronjob");
122718
- }
122970
+ getWorkspaceConfig(workspaceId) {
122971
+ const workspace = workspaceService.resolve(workspaceId);
122972
+ if (!workspace) return null;
122973
+ return this.loadWorkspaceConfigFromPaths(workspace.worktree.path, workspace.project.path);
122719
122974
  }
122720
- loadAndScheduleAll() {
122721
- const state = schedulerState();
122722
- for (const [, cron] of state.jobs) {
122723
- cron.stop();
122724
- }
122725
- state.jobs.clear();
122726
- for (const job of this.queries.listAll()) {
122727
- this.scheduleJob(job, job.fileKey);
122975
+ /**
122976
+ * Internal: the path-driven parse so tests / future non-tRPC entry
122977
+ * points can plug raw paths in without going through `resolveWorkspace`.
122978
+ */
122979
+ loadWorkspaceConfigFromPaths(worktreePath, projectPath) {
122980
+ const raw = loadProjectConfig(worktreePath, projectPath);
122981
+ if (!raw) return null;
122982
+ const terminalBlock = raw.workspace && typeof raw.workspace === "object" ? raw.workspace.terminal : void 0;
122983
+ if (!terminalBlock) return null;
122984
+ const result = WorkspaceTerminalConfigSchema.safeParse(terminalBlock);
122985
+ if (!result.success) {
122986
+ log21.warn(
122987
+ "Invalid workspace.terminal config: %s",
122988
+ result.error.issues.map((i2) => `${i2.path.join(".")}: ${i2.message}`).join("; ")
122989
+ );
122990
+ return null;
122728
122991
  }
122729
- log20.info({ count: state.jobs.size }, "loaded cronjob schedules");
122992
+ return result.data;
122730
122993
  }
122731
122994
  };
122732
- var cronjobService = new CronjobService();
122995
+ var terminalService = new TerminalService();
122733
122996
 
122734
- // src/server/services/panel-focus-service.ts
122735
- init_src();
122736
- var log21 = createLogger("panel-focus-service");
122737
- var PANEL_FOCUS_TYPE = "panel_focus";
122738
- function focusRowId(workspaceId) {
122739
- return `${PANEL_FOCUS_TYPE}:${workspaceId}`;
122997
+ // src/server/services/cronjob-service.ts
122998
+ var log22 = createLogger("cronjob-service");
122999
+ var SCHEDULER_KEY = Symbol.for("band.cronjob-scheduler");
123000
+ var g12 = globalThis;
123001
+ if (!g12[SCHEDULER_KEY]) {
123002
+ g12[SCHEDULER_KEY] = {
123003
+ jobs: /* @__PURE__ */ new Map(),
123004
+ started: false,
123005
+ spawningTerminals: /* @__PURE__ */ new Set()
123006
+ };
122740
123007
  }
122741
- var PanelFocusService = class {
122742
- /** workspaceId → last-focused panel ids. */
122743
- focus = /* @__PURE__ */ new Map();
122744
- /**
122745
- * Workspaces whose focus row has been loaded from the DB into `focus`.
122746
- * Separate from the map's own key presence because a workspace can have a
122747
- * loaded-but-empty record (row absent) — we still don't want to re-hit the
122748
- * DB on every read.
122749
- */
122750
- hydrated = /* @__PURE__ */ new Set();
122751
- /** Workspaces that already have a `panel_states` row (drives insert vs update). */
122752
- persisted = /* @__PURE__ */ new Set();
123008
+ function schedulerState() {
123009
+ return g12[SCHEDULER_KEY];
123010
+ }
123011
+ var cronjobVia = external_exports.enum(["chat", "terminal"]);
123012
+ var cronjobCreateInput = external_exports.object({
123013
+ key: external_exports.string().min(1),
123014
+ name: external_exports.string().min(1),
123015
+ prompt: external_exports.string().min(1),
123016
+ cronExpression: external_exports.string().min(1),
123017
+ scope: external_exports.enum(["project", "workspace"]),
123018
+ workspaceId: external_exports.string().optional(),
123019
+ // Dispatch target for scheduled + manual fires. Optional — absent defaults to
123020
+ // "chat" in `create()` so existing callers (the web UI) keep their behavior.
123021
+ via: cronjobVia.optional(),
123022
+ enabled: external_exports.boolean().default(true)
123023
+ });
123024
+ var cronjobUpdateInput = external_exports.object({
123025
+ // `.min(1)` rejects the obviously invalid empty-string `key`/`id` at the
123026
+ // validator instead of letting it reach the service, which would
123027
+ // `loadFile("")` an empty result and 404 on `findIndex`. The legacy
123028
+ // router accepted bare `z.string()` here; the migration is the natural
123029
+ // moment to tighten the boundary so the create + update shapes stay
123030
+ // consistent (`cronjobCreateInput.key` already has `.min(1)`).
123031
+ key: external_exports.string().min(1),
123032
+ id: external_exports.string().min(1),
123033
+ name: external_exports.string().min(1).optional(),
123034
+ prompt: external_exports.string().min(1).optional(),
123035
+ cronExpression: external_exports.string().min(1).optional(),
123036
+ enabled: external_exports.boolean().optional()
123037
+ });
123038
+ var cronjobByIdInput = external_exports.object({
123039
+ key: external_exports.string().min(1),
123040
+ id: external_exports.string().min(1)
123041
+ });
123042
+ var CronjobNotFoundError = class extends Error {
123043
+ constructor(message = "Cronjob not found") {
123044
+ super(message);
123045
+ this.name = "CronjobNotFoundError";
123046
+ }
123047
+ };
123048
+ var InvalidCronExpressionError = class extends Error {
123049
+ constructor(message = "Invalid cron expression") {
123050
+ super(message);
123051
+ this.name = "InvalidCronExpressionError";
123052
+ }
123053
+ };
123054
+ var CronjobWorkspaceMissingError = class extends Error {
123055
+ constructor(message = "workspaceId is required for workspace-scoped cronjobs") {
123056
+ super(message);
123057
+ this.name = "CronjobWorkspaceMissingError";
123058
+ }
123059
+ };
123060
+ var CronjobProjectNotFoundError = class extends Error {
123061
+ constructor(message = "Project not found") {
123062
+ super(message);
123063
+ this.name = "CronjobProjectNotFoundError";
123064
+ }
123065
+ };
123066
+ var CronjobService = class {
123067
+ constructor(queries = new CronjobQueries()) {
123068
+ this.queries = queries;
123069
+ }
123070
+ // -------------------------------------------------------------------------
123071
+ // Read operations
123072
+ // -------------------------------------------------------------------------
122753
123073
  /**
122754
- * Load a workspace's focus row from the DB on first access. Cheap and
122755
- * idempotent — subsequent calls short-circuit on the `hydrated` set.
123074
+ * List jobs for a key, or — if neither `project` nor `workspaceId` is
123075
+ * provided — every cronjob across every key.
123076
+ *
123077
+ * Mirrors the legacy tRPC `cronjobs.list` shape so the router stays a
123078
+ * straight delegate. The "all" branch returns `fileKey` on each row
123079
+ * because callers (the dashboard's cronjob settings page) need both
123080
+ * pieces to render the project/workspace breadcrumbs.
122756
123081
  */
122757
- ensureHydrated(workspaceId) {
122758
- if (this.hydrated.has(workspaceId)) return;
122759
- this.hydrated.add(workspaceId);
122760
- const rows = listPanelStatesForWorkspace(workspaceId, PANEL_FOCUS_TYPE);
122761
- const row = rows[0];
122762
- if (!row) return;
122763
- this.persisted.add(workspaceId);
122764
- try {
122765
- const parsed = JSON.parse(row.state);
122766
- this.focus.set(workspaceId, {
122767
- chat: parsed.chat,
122768
- terminal: parsed.terminal,
122769
- browser: parsed.browser
122770
- });
122771
- } catch (err) {
122772
- log21.warn({ workspaceId, err }, "failed to parse persisted panel focus; ignoring");
123082
+ list(filter) {
123083
+ if (filter?.project) {
123084
+ const { project } = filter;
123085
+ const file = this.queries.loadFile(project);
123086
+ return { jobs: file.jobs.map((j) => ({ ...j, fileKey: project })) };
122773
123087
  }
123088
+ if (filter?.workspaceId) {
123089
+ const { workspaceId } = filter;
123090
+ const file = this.queries.loadFile(workspaceId);
123091
+ return {
123092
+ jobs: file.jobs.map((j) => ({ ...j, fileKey: workspaceId }))
123093
+ };
123094
+ }
123095
+ return { jobs: this.queries.listAll() };
122774
123096
  }
122775
- /** Get the last-focused panel ids for a workspace. */
122776
- get(workspaceId) {
122777
- this.ensureHydrated(workspaceId);
122778
- return { ...this.focus.get(workspaceId) ?? {} };
123097
+ /** Get a single job by `key` + `id`. Throws `CronjobNotFoundError` if absent. */
123098
+ get(key, id) {
123099
+ const job = this.findJob(key, id);
123100
+ if (!job) throw new CronjobNotFoundError();
123101
+ return { job };
122779
123102
  }
123103
+ // -------------------------------------------------------------------------
123104
+ // Mutations
123105
+ // -------------------------------------------------------------------------
122780
123106
  /**
122781
- * Record that `panelId` is the last-focused panel of `panelType` in
122782
- * `workspaceId`. Write-through: updates the in-memory map and upserts the
122783
- * backing `panel_states` row.
123107
+ * Create a new cronjob and re-bind the scheduler.
123108
+ *
123109
+ * Validates the cron expression by attempting to construct a `Cron` with
123110
+ * `maxRuns: 0` (parses without scheduling). Throws
123111
+ * `InvalidCronExpressionError` for a bad expression and
123112
+ * `CronjobWorkspaceMissingError` when a workspace-scoped job is missing
123113
+ * its `workspaceId`.
122784
123114
  */
122785
- set(workspaceId, panelType, panelId) {
122786
- this.ensureHydrated(workspaceId);
122787
- const current2 = this.focus.get(workspaceId) ?? {};
122788
- if (current2[panelType] === panelId) return;
122789
- const next = { ...current2, [panelType]: panelId };
122790
- this.focus.set(workspaceId, next);
122791
- const now = Date.now();
122792
- const state = JSON.stringify(next);
122793
- if (this.persisted.has(workspaceId)) {
122794
- updatePanelState(focusRowId(workspaceId), { state, updatedAt: now });
122795
- } else {
122796
- insertPanelState({
122797
- id: focusRowId(workspaceId),
122798
- workspaceId,
122799
- panelType: PANEL_FOCUS_TYPE,
122800
- state,
122801
- createdAt: now,
122802
- updatedAt: now
122803
- });
122804
- this.persisted.add(workspaceId);
123115
+ create(input) {
123116
+ this.assertValidCron(input.cronExpression);
123117
+ if (input.scope === "workspace" && !input.workspaceId) {
123118
+ throw new CronjobWorkspaceMissingError();
122805
123119
  }
123120
+ const file = this.queries.loadFile(input.key);
123121
+ const job = {
123122
+ id: generateCronjobId(),
123123
+ name: input.name,
123124
+ prompt: input.prompt,
123125
+ cronExpression: input.cronExpression,
123126
+ scope: input.scope,
123127
+ workspaceId: input.workspaceId,
123128
+ // Absent `via` means the web UI (or a pre-#581 caller) — default to chat
123129
+ // so existing cronjobs keep dispatching exactly as before.
123130
+ via: input.via ?? "chat",
123131
+ enabled: input.enabled,
123132
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
123133
+ };
123134
+ file.jobs.push(job);
123135
+ this.queries.saveFile(input.key, file);
123136
+ this.reloadSchedules();
123137
+ return { job };
122806
123138
  }
122807
123139
  /**
122808
- * Drop all focus tracking for a workspace. Called from the workspace delete
122809
- * path so the row doesn't outlive the workspace.
123140
+ * Partially update an existing cronjob.
123141
+ *
123142
+ * Each optional field is applied only when present, matching the legacy
123143
+ * router behavior. If `cronExpression` is supplied it's validated before
123144
+ * the write. Throws `CronjobNotFoundError` if no job matches `key`+`id`.
122810
123145
  */
122811
- remove(workspaceId) {
122812
- this.focus.delete(workspaceId);
122813
- this.hydrated.delete(workspaceId);
122814
- this.persisted.delete(workspaceId);
122815
- deletePanelState(focusRowId(workspaceId));
123146
+ update(input) {
123147
+ if (input.cronExpression !== void 0) {
123148
+ this.assertValidCron(input.cronExpression);
123149
+ }
123150
+ const file = this.queries.loadFile(input.key);
123151
+ const job = file.jobs.find((j) => j.id === input.id);
123152
+ if (!job) throw new CronjobNotFoundError();
123153
+ if (input.name !== void 0) job.name = input.name;
123154
+ if (input.prompt !== void 0) job.prompt = input.prompt;
123155
+ if (input.cronExpression !== void 0) job.cronExpression = input.cronExpression;
123156
+ if (input.enabled !== void 0) job.enabled = input.enabled;
123157
+ this.queries.saveFile(input.key, file);
123158
+ this.reloadSchedules();
123159
+ return { job };
122816
123160
  }
122817
- };
122818
- var panelFocusService = new PanelFocusService();
122819
-
122820
- // src/server/services/terminal-service.ts
122821
- init_src();
122822
- init_zod();
122823
-
122824
- // src/server/infra/terminals/terminal-pool.ts
122825
- init_src();
122826
- import { randomUUID as randomUUID2 } from "node:crypto";
122827
- import { existsSync as existsSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
122828
- import { tmpdir } from "node:os";
122829
- import { join as join19, sep as sep3 } from "node:path";
122830
- var log22 = createLogger("terminal-pool");
122831
- var MAX_SCROLLBACK_SIZE = 1e5;
122832
- var TerminalPool = class {
122833
- /** terminalId -> session */
122834
- terminals = /* @__PURE__ */ new Map();
122835
- /** workspaceId -> Set<terminalId> (reverse index for workspace-level cleanup) */
122836
- workspaceTerminals = /* @__PURE__ */ new Map();
122837
- /** terminalId -> Set<listener> for live output streaming */
122838
- outputListeners = /* @__PURE__ */ new Map();
122839
123161
  /**
122840
- * terminalId -> in-flight spawn promise. A single terminal is created via TWO
122841
- * concurrent paths — the WebSocket handler (spawn-on-`getSession`-miss) and
122842
- * the tRPC `terminal.create` mutation. Without deduplication both spawn a PTY
122843
- * and the second `terminals.set` overwrites the first, so the client stays
122844
- * wired to one PTY while the server's session map (and thus scrollback /
122845
- * `terminals output` / reconnect-replay) points at the other. That's the
122846
- * "output on screen but empty server scrollback until reload" bug
122847
- * (band-app/band#617). This map makes concurrent spawns share ONE PTY.
122848
- */
122849
- spawning = /* @__PURE__ */ new Map();
122850
- /**
122851
- * Spawn (or return the already-spawning/spawned) PTY for a terminalId.
123162
+ * Delete a cronjob and re-bind the scheduler.
122852
123163
  *
122853
- * Idempotent per terminalId: if a session already exists it is returned
122854
- * as-is, and concurrent spawn calls for the same id share a single in-flight
122855
- * promise — so the WebSocket and tRPC create paths never create competing
122856
- * PTYs. `workspaceRoot` is the absolute worktree path; resolving the
122857
- * workspaceId to a path is the service tier's job so the pool stays oblivious
122858
- * to the workspace registry.
123164
+ * Throws `CronjobNotFoundError` if the job is already gone.
122859
123165
  */
122860
- async spawn(workspaceId, terminalId, workspaceRoot, options2) {
122861
- const existing = this.terminals.get(terminalId);
122862
- if (existing) return existing;
122863
- const inflight = this.spawning.get(terminalId);
122864
- if (inflight) return inflight;
122865
- const promise = this.spawnNew(workspaceId, terminalId, workspaceRoot, options2);
122866
- this.spawning.set(terminalId, promise);
122867
- try {
122868
- return await promise;
122869
- } finally {
122870
- this.spawning.delete(terminalId);
122871
- }
122872
- }
122873
- async spawnNew(workspaceId, terminalId, workspaceRoot, options2) {
122874
- const shell = defaultShell();
122875
- const resolvedPath = await shellPath();
122876
- const env = {};
122877
- for (const [key, value] of Object.entries(process.env)) {
122878
- if (value != null) {
122879
- env[key] = value;
122880
- }
122881
- }
122882
- env.PATH = resolvedPath;
122883
- env.TERM = "xterm-256color";
122884
- env.COLORTERM = "truecolor";
122885
- env.COLORFGBG = "15;0";
122886
- delete env.PORT;
122887
- if (options2?.env) {
122888
- Object.assign(env, options2.env);
122889
- }
122890
- env.BAND_DISPATCH = "terminal";
122891
- let cwd = workspaceRoot;
122892
- if (options2?.cwd) {
122893
- const resolved = join19(workspaceRoot, options2.cwd);
122894
- if (resolved !== workspaceRoot && !resolved.startsWith(workspaceRoot + sep3)) {
122895
- log22.warn(
122896
- "Ignoring cwd %s \u2014 resolves outside workspace root %s",
122897
- options2.cwd,
122898
- workspaceRoot
122899
- );
122900
- } else if (existsSync8(resolved)) {
122901
- cwd = resolved;
122902
- } else {
122903
- log22.warn("Ignoring cwd %s \u2014 directory does not exist", options2.cwd);
122904
- }
122905
- }
122906
- if (!existsSync8(cwd)) {
122907
- throw new Error(`Workspace directory does not exist: ${cwd}`);
122908
- }
122909
- if (!existsSync8(shell)) {
122910
- throw new Error(`Shell not found: ${shell}`);
122911
- }
122912
- log22.debug(
122913
- "Spawning shell %s in %s for terminal %s (PATH=%s)",
122914
- shell,
122915
- cwd,
122916
- terminalId,
122917
- resolvedPath.slice(0, 200)
122918
- );
122919
- const nodePty = await import("node-pty");
122920
- let ptyProcess;
122921
- try {
122922
- ptyProcess = nodePty.spawn(shell, [], {
122923
- name: "xterm-256color",
122924
- cols: 80,
122925
- rows: 24,
122926
- cwd,
122927
- env
122928
- });
122929
- } catch (err) {
122930
- const msg = err instanceof Error ? err.message : String(err);
122931
- log22.error("pty.spawn failed: %s (shell=%s, cwd=%s)", msg, shell, cwd);
122932
- throw err;
122933
- }
122934
- const session = { pty: ptyProcess, scrollback: "", workspaceId };
122935
- this.terminals.set(terminalId, session);
122936
- if (options2?.command) {
122937
- this.autoRunCommand(session, options2.command);
122938
- }
122939
- let ids = this.workspaceTerminals.get(workspaceId);
122940
- if (!ids) {
122941
- ids = /* @__PURE__ */ new Set();
122942
- this.workspaceTerminals.set(workspaceId, ids);
123166
+ delete(key, id) {
123167
+ const file = this.queries.loadFile(key);
123168
+ const index3 = file.jobs.findIndex((j) => j.id === id);
123169
+ if (index3 === -1) throw new CronjobNotFoundError();
123170
+ const [removed] = file.jobs.splice(index3, 1);
123171
+ this.queries.saveFile(key, file);
123172
+ if (removed?.lastTerminalId) {
123173
+ terminalService.kill(removed.lastTerminalId);
122943
123174
  }
122944
- ids.add(terminalId);
122945
- ptyProcess.onData((data) => {
122946
- session.scrollback += data;
122947
- if (session.scrollback.length > MAX_SCROLLBACK_SIZE) {
122948
- session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_SIZE);
122949
- }
122950
- const listeners3 = this.outputListeners.get(terminalId);
122951
- if (listeners3) {
122952
- for (const cb2 of listeners3) {
122953
- try {
122954
- cb2(data);
122955
- } catch {
122956
- }
122957
- }
122958
- }
122959
- });
122960
- ptyProcess.onExit(() => {
122961
- log22.debug("Terminal exited: %s (workspace %s)", terminalId, workspaceId);
122962
- this.terminals.delete(terminalId);
122963
- this.outputListeners.delete(terminalId);
122964
- const set = this.workspaceTerminals.get(workspaceId);
122965
- if (set) {
122966
- set.delete(terminalId);
122967
- if (set.size === 0) {
122968
- this.workspaceTerminals.delete(workspaceId);
122969
- }
122970
- }
122971
- if (session.autoRunFile) {
122972
- try {
122973
- unlinkSync2(session.autoRunFile);
122974
- } catch {
122975
- }
122976
- }
122977
- });
122978
- return session;
123175
+ this.reloadSchedules();
123176
+ return { ok: true };
122979
123177
  }
122980
123178
  /**
122981
- * Auto-run the workspace's initial command inside a freshly spawned PTY,
122982
- * robustly.
122983
- *
122984
- * Writing a long command line straight into a cold PTY is racy on three
122985
- * fronts, all of which surfaced in production with `via=terminal`
122986
- * prompt-as-argv launches (`'<agent-binary>' '<entire-prompt>'`):
123179
+ * Fire a cronjob synchronously (manual "run now" path).
122987
123180
  *
122988
- * 1. **Dropped newline.** The shell may not have finished sourcing its
122989
- * rc files, and the tty can still be in its startup canonical-mode
122990
- * window. A trailing `\n` written then is swallowed — the command
122991
- * is typed at the prompt but never executed.
122992
- * 2. **Truncation.** A single input line longer than the tty's
122993
- * canonical buffer (`MAX_CANON`, ~4 KB) is clipped. Long prompts
122994
- * blow past that easily.
122995
- * 3. **UTF-8 corruption.** A multi-byte sequence split across a tty
122996
- * buffer boundary is mangled (the classic em-dash → mojibake).
123181
+ * Uses the same `band:cronId`-labeled chat as the scheduled fire so the
123182
+ * user sees both paths converge on a single dedicated conversation
123183
+ * (issue #520). `submitTask` may throw `TaskConflictError`, which the API
123184
+ * tier translates into a 409 Conflict response.
122997
123185
  *
122998
- * Fix both layers: (1) stage the command's bytes in a temp file so they
122999
- * never traverse the tty's canonical input buffer — sidestepping the
123000
- * truncation and UTF-8-split failures entirely — and (2) wait for the
123001
- * shell to emit its first output (its prompt) before writing the
123002
- * *short* `source <file>` line. The short line is well under the
123003
- * canonical limit and lands after the cold-start window, so its newline
123004
- * survives. `source` runs the command in the current interactive shell,
123005
- * so the user keeps their session afterwards.
123186
+ * Atomicity caveat: `getOrCreateCronjobChat` and `submitTask` are not run
123187
+ * inside a single transaction. If `createChat` succeeds but `submitTask`
123188
+ * later throws an unexpected (non-`TaskConflictError`) error, the
123189
+ * freshly-created chat remains in the workspace as an orphan with no
123190
+ * task ever attached. The next manual `trigger` (or scheduled fire)
123191
+ * reuses that chat via its `band:cronId` label, so the orphan
123192
+ * self-heals — but the user may briefly see an empty chat pane. The
123193
+ * legacy implementation had the same gap; preserved here to match
123194
+ * existing behavior. If this ever needs to be tightened, the fix is
123195
+ * either (a) wrap both calls in a try/catch that deletes the chat on
123196
+ * unexpected failure, or (b) defer `createChat` until `submitTask`
123197
+ * succeeds (which would require buffering the chat name/labels on the
123198
+ * task row).
123006
123199
  */
123007
- autoRunCommand(session, command) {
123008
- const pty = session.pty;
123009
- let cmdFile;
123010
- try {
123011
- cmdFile = join19(tmpdir(), `band-autorun-${randomUUID2()}.sh`);
123012
- writeFileSync2(cmdFile, `${command}
123013
- `, { encoding: "utf-8", mode: 384, flag: "wx" });
123014
- session.autoRunFile = cmdFile;
123015
- } catch (err) {
123016
- log22.warn(
123017
- "autoRunCommand: temp-file staging failed (%s); writing command directly",
123018
- err instanceof Error ? err.message : String(err)
123019
- );
123020
- pty.write(`${command}
123021
- `);
123022
- return;
123023
- }
123024
- const quotedPath = cmdFile.replace(/'/g, `'\\''`);
123025
- const sourceLine = `. '${quotedPath}'
123026
- `;
123027
- let done = false;
123028
- const inject = () => {
123029
- if (done) return;
123030
- done = true;
123031
- try {
123032
- pty.write(sourceLine);
123033
- } catch (err) {
123034
- log22.warn("autoRunCommand: failed to inject command line (%s)", err);
123200
+ async trigger(key, id) {
123201
+ const job = this.findJob(key, id);
123202
+ if (!job) throw new CronjobNotFoundError();
123203
+ const workspaceId = this.resolveWorkspaceId(job, key);
123204
+ if (!workspaceId) throw new CronjobProjectNotFoundError();
123205
+ if (job.via === "terminal") {
123206
+ const { terminalId } = await this.spawnCronTerminal(job, workspaceId, key);
123207
+ if (terminalId) {
123208
+ return { via: "terminal", terminalId, workspaceId };
123035
123209
  }
123036
- };
123037
- let dataDisposed = false;
123038
- const disposeData = () => {
123039
- if (dataDisposed) return;
123040
- dataDisposed = true;
123041
- dataDisposable.dispose();
123042
- };
123043
- const dataDisposable = pty.onData(() => {
123044
- disposeData();
123045
- inject();
123046
- });
123047
- const timer = setTimeout(inject, 750);
123048
- timer.unref();
123049
- let exitDisposed = false;
123050
- const exitDisposable = pty.onExit(() => {
123051
- done = true;
123052
- clearTimeout(timer);
123053
- disposeData();
123054
- if (exitDisposed) return;
123055
- exitDisposed = true;
123056
- exitDisposable.dispose();
123057
- });
123210
+ }
123211
+ const cronChat = this.getOrCreateCronjobChat(workspaceId, job);
123212
+ const task = taskService.submitTask({ workspaceId, chatId: cronChat.id, prompt: job.prompt });
123213
+ return { via: "chat", taskId: task.id, workspaceId, chatId: cronChat.id };
123058
123214
  }
123215
+ // -------------------------------------------------------------------------
123216
+ // Lifecycle hooks for workspace/project removal
123217
+ // -------------------------------------------------------------------------
123059
123218
  /**
123060
- * Returns an existing PTY session by terminalId, or undefined.
123219
+ * Stop all timers for jobs in `key` and remove the file rows.
123220
+ *
123221
+ * Called when a workspace or project is removed. Order matters: stop
123222
+ * timers first so no further fires queue up between the timer-stop and
123223
+ * the row-delete. An in-flight fire is already safe in either order
123224
+ * because `executeCronjob` operates on closed-over `job`/`fileKey` (it
123225
+ * never re-reads the row) and `safeUpdateLastRun` swallows the
123226
+ * row-missing case. The two steps were previously two separate calls
123227
+ * (`stopJobsForKey` + `deleteCronjobFile`) at every call site — the
123228
+ * legacy callers always invoked them in pairs, so they're collapsed
123229
+ * here to make the contract harder to misuse.
123230
+ *
123231
+ * Partial-failure recovery: if `deleteFile` throws (e.g. a transient DB
123232
+ * lock), the timers are already stopped but the rows remain in the DB.
123233
+ * The next `reloadSchedules()` call — triggered by any subsequent
123234
+ * cronjob mutation, or by a server restart — re-reads the rows from the
123235
+ * DB and re-registers the timers, so the orphaned rows self-heal
123236
+ * without manual intervention. We do NOT roll back the `stop()` calls
123237
+ * because re-binding timers for a workspace that's mid-deletion is
123238
+ * worse than leaving them stopped: the workspace path is about to be
123239
+ * removed and any fire that races the removal would target a
123240
+ * non-existent worktree. Re-registration on the next reload is the
123241
+ * intentional recovery strategy; operators encountering a partial
123242
+ * failure on this path should expect the next reload to be a no-op
123243
+ * once the workspace removal completes and the rows are re-attempted.
123061
123244
  */
123062
- get(terminalId) {
123063
- return this.terminals.get(terminalId);
123245
+ removeForKey(key) {
123246
+ this.stopJobsForKey(key);
123247
+ this.queries.deleteFile(key);
123064
123248
  }
123065
- /**
123066
- * Resize the underlying PTY. No-op if the terminal isn't known to the pool.
123067
- */
123068
- resize(terminalId, cols, rows) {
123069
- const session = this.terminals.get(terminalId);
123070
- if (session) {
123071
- session.pty.resize(cols, rows);
123249
+ // -------------------------------------------------------------------------
123250
+ // Scheduler lifecycle
123251
+ // -------------------------------------------------------------------------
123252
+ /** Start the cronjob scheduler. Called once on server boot. */
123253
+ start() {
123254
+ const state = schedulerState();
123255
+ if (state.started) return;
123256
+ state.started = true;
123257
+ this.loadAndScheduleAll();
123258
+ log22.info("cronjob scheduler started");
123259
+ }
123260
+ /** Stop the cronjob scheduler. Called on graceful shutdown. */
123261
+ stop() {
123262
+ const state = schedulerState();
123263
+ for (const [, cron] of state.jobs) {
123264
+ cron.stop();
123072
123265
  }
123266
+ state.jobs.clear();
123267
+ state.started = false;
123268
+ log22.info("cronjob scheduler stopped");
123269
+ }
123270
+ /** Reload all schedules from the database. Call after cronjob mutations. */
123271
+ reloadSchedules() {
123272
+ if (!schedulerState().started) return;
123273
+ this.loadAndScheduleAll();
123073
123274
  }
123074
123275
  /**
123075
- * List metadata for every PTY session bound to the given workspace.
123276
+ * Stop all scheduled jobs for a key (e.g. when a workspace is removed).
123076
123277
  *
123077
- * The returned `title` is the foreground process name reported by the
123078
- * PTY; reading `pty.process` can throw if the process has already exited
123079
- * between the reverse-index lookup and the read, so the read is wrapped.
123278
+ * Private because the only correct call site is `removeForKey` above:
123279
+ * stopping the timers without also dropping the rows would re-schedule
123280
+ * the same jobs on the next `reloadSchedules()` tick. Keeping it
123281
+ * accessible only through `removeForKey` prevents future callers from
123282
+ * accidentally invoking just one half of the pair.
123080
123283
  */
123081
- list(workspaceId) {
123082
- const ids = this.workspaceTerminals.get(workspaceId);
123083
- if (!ids) return [];
123084
- const result = [];
123085
- for (const terminalId of ids) {
123086
- const session = this.terminals.get(terminalId);
123087
- if (session) {
123088
- let title = "";
123089
- try {
123090
- title = session.pty.process;
123091
- } catch {
123092
- }
123093
- result.push({
123094
- terminalId,
123095
- workspaceId,
123096
- pid: session.pty.pid,
123097
- scrollbackLength: session.scrollback.length,
123098
- title
123099
- });
123284
+ stopJobsForKey(key) {
123285
+ const state = schedulerState();
123286
+ const file = this.queries.loadFile(key);
123287
+ for (const job of file.jobs) {
123288
+ const cron = state.jobs.get(job.id);
123289
+ if (cron) {
123290
+ cron.stop();
123291
+ state.jobs.delete(job.id);
123292
+ log22.info({ jobId: job.id, key }, "stopped cronjob");
123100
123293
  }
123101
123294
  }
123102
- return result;
123103
- }
123104
- /**
123105
- * Returns the scrollback buffer for a terminal, optionally limited to the
123106
- * last N lines. Returns `null` if the terminal is not found.
123107
- */
123108
- getScrollback(terminalId, lines) {
123109
- const session = this.terminals.get(terminalId);
123110
- if (!session) return null;
123111
- if (lines == null) return session.scrollback;
123112
- const allLines = session.scrollback.split("\n");
123113
- return allLines.slice(-lines).join("\n");
123114
123295
  }
123296
+ // -------------------------------------------------------------------------
123297
+ // Shared resolution helpers (used by both the API + the scheduled fire path)
123298
+ // -------------------------------------------------------------------------
123115
123299
  /**
123116
- * Writes data to a terminal's PTY stdin.
123117
- * Returns false if the terminal is not found.
123300
+ * Find — or create — the dedicated chat this cronjob writes into.
123301
+ *
123302
+ * Issue #520: each cronjob owns its own chat in the workspace, looked up
123303
+ * by the canonical `band:cronId` label. First call creates the chat;
123304
+ * subsequent calls reuse it. If the user deletes the chat, the next call
123305
+ * recreates it (intentional soft reset). This replaces the old
123306
+ * `getOrCreateDefaultChat` dispatch, which silently latched onto whatever
123307
+ * chat the user happened to be focused on. Shared between the scheduled
123308
+ * fire path (`executeCronjob` below) and the manual `trigger` route so
123309
+ * both stay aligned.
123118
123310
  */
123119
- write(terminalId, data) {
123120
- const session = this.terminals.get(terminalId);
123121
- if (!session) return false;
123122
- session.pty.write(data);
123123
- return true;
123311
+ getOrCreateCronjobChat(workspaceId, job) {
123312
+ const labelMatch = { [BAND_CRON_ID_LABEL]: job.id };
123313
+ const existing = chatService.findByLabels(workspaceId, labelMatch);
123314
+ if (existing) return existing;
123315
+ return chatService.create(workspaceId, {
123316
+ name: job.name,
123317
+ labels: labelMatch,
123318
+ // Cronjob scheduler is a trusted server-side caller — it's allowed
123319
+ // to write the reserved `band:` prefix that user-facing surfaces
123320
+ // are blocked from touching.
123321
+ allowReservedLabels: true
123322
+ });
123124
123323
  }
123125
123324
  /**
123126
- * Subscribe to live output from a terminal's PTY.
123127
- * Returns an unsubscribe function.
123325
+ * Dispatch a `via="terminal"` fire: spawn the agent's *headless* vendor CLI
123326
+ * in a fresh, self-closing PTY pane. Mirrors the `via=terminal` branch of
123327
+ * `WorkspaceService.create` (#551), but adapted for a recurring cron in two
123328
+ * important ways:
123329
+ *
123330
+ * - **Headless, not interactive.** We use `adapter.cliHeadlessInvocation`
123331
+ * (`claude -p`, `codex exec`, …), NOT the interactive `cliInvocation`
123332
+ * that workspace-create uses. The interactive REPL never exits on its
123333
+ * own, so the pane would stay parked after the turn — the shell's `exit`
123334
+ * would never run and the overlap guard below would treat "idle at the
123335
+ * REPL" as "still running", blocking every subsequent tick (issue #581
123336
+ * follow-up). The headless form runs the prompt and exits, which is what
123337
+ * makes both the self-close and the overlap guard actually work.
123338
+ * - **Overlap guard.** If the terminal spawned by this job's previous fire
123339
+ * is still alive, throw `TaskConflictError` instead of launching a second
123340
+ * agent — the caller records "skipped" (scheduled) or surfaces 409
123341
+ * (manual trigger), exactly like the chat running-task conflict. Because
123342
+ * the headless process exits when the turn completes, a live PTY reliably
123343
+ * means the previous run is genuinely still working.
123344
+ * - **Self-closing pane.** The command is `<headless-cli>\nexit`, staged
123345
+ * and sourced by the pool via `.`, so the shell exits the moment the
123346
+ * headless CLI returns. Combined with `cleanupOnExit`, the finished pane
123347
+ * removes itself from the layout and emits `terminal-killed` — bounding a
123348
+ * frequent cron to ~1 pane and keeping the next tick's liveness check
123349
+ * accurate.
123350
+ *
123351
+ * Returns `{ terminalId: null }` when the agent exposes no headless CLI (or
123352
+ * the workspace can't be resolved), signalling the caller to fall back to
123353
+ * chat. Spawn failures propagate (recorded as "failed").
123354
+ *
123355
+ * Edge case: if `terminalService.spawn` throws *after* the PTY is forked but
123356
+ * before `updateLastTerminal` runs, `lastTerminalId` stays unset for this
123357
+ * fire, so the next tick's overlap guard won't see that (now likely dead)
123358
+ * PTY. This is acceptable — a spawn that rejects has almost certainly failed
123359
+ * to produce a usable pane, and the synchronous in-process lock above still
123360
+ * prevents a *concurrent* double-spawn; only a subsequent, well-separated
123361
+ * tick could add a second pane, which is the same at-most-transient
123362
+ * duplication the self-close bound already tolerates.
123128
123363
  */
123129
- subscribeOutput(terminalId, callback) {
123130
- let listeners3 = this.outputListeners.get(terminalId);
123131
- if (!listeners3) {
123132
- listeners3 = /* @__PURE__ */ new Set();
123133
- this.outputListeners.set(terminalId, listeners3);
123364
+ async spawnCronTerminal(job, workspaceId, fileKey) {
123365
+ const state = schedulerState();
123366
+ if (state.spawningTerminals.has(job.id)) {
123367
+ throw new TaskConflictError(`Cronjob ${job.id} terminal run is already being spawned`);
123134
123368
  }
123135
- listeners3.add(callback);
123136
- return () => {
123137
- listeners3.delete(callback);
123138
- if (listeners3.size === 0) {
123139
- this.outputListeners.delete(terminalId);
123369
+ state.spawningTerminals.add(job.id);
123370
+ try {
123371
+ const workspace = workspaceService.resolve(workspaceId);
123372
+ if (!workspace) {
123373
+ log22.warn(
123374
+ { jobId: job.id, workspaceId },
123375
+ "workspace not resolvable for via=terminal cronjob; falling back to chat"
123376
+ );
123377
+ return { terminalId: null };
123140
123378
  }
123141
- };
123142
- }
123143
- /**
123144
- * Kill a single terminal by terminalId.
123145
- */
123146
- kill(terminalId) {
123147
- const session = this.terminals.get(terminalId);
123148
- if (session) {
123149
- session.pty.kill();
123150
- this.terminals.delete(terminalId);
123151
- const set = this.workspaceTerminals.get(session.workspaceId);
123152
- if (set) {
123153
- set.delete(terminalId);
123154
- if (set.size === 0) {
123155
- this.workspaceTerminals.delete(session.workspaceId);
123156
- }
123379
+ const prevTerminalId = this.findJob(fileKey, job.id)?.lastTerminalId;
123380
+ if (prevTerminalId && terminalService.getSession(prevTerminalId)) {
123381
+ throw new TaskConflictError(`Cronjob ${job.id} terminal run is still in progress`);
123157
123382
  }
123383
+ const adapter = await agentService.createWorkspaceAgent(workspace.worktree.path);
123384
+ const invocation = adapter.cliHeadlessInvocation?.(job.prompt);
123385
+ adapter.abort?.();
123386
+ if (!invocation || invocation.unsupported) {
123387
+ const reason = invocation?.reason ?? "adapter does not expose cliHeadlessInvocation";
123388
+ log22.warn(
123389
+ { jobId: job.id, workspaceId, reason },
123390
+ "via=terminal requested but agent has no headless CLI; falling back to chat"
123391
+ );
123392
+ return { terminalId: null };
123393
+ }
123394
+ const command = `${formatShellCommand(invocation.command, invocation.args)}
123395
+ exit`;
123396
+ const terminalId = randomUUID3();
123397
+ await terminalService.spawn(workspaceId, terminalId, { command }, { cleanupOnExit: true });
123398
+ this.queries.updateLastTerminal(job.id, terminalId);
123399
+ emit({ kind: "terminal-created", workspaceId, terminalId });
123400
+ return { terminalId };
123401
+ } finally {
123402
+ state.spawningTerminals.delete(job.id);
123158
123403
  }
123159
123404
  }
123160
123405
  /**
123161
- * Kill all terminals for a workspace.
123406
+ * Resolve the workspace the cronjob fires into.
123407
+ *
123408
+ * Returns `null` for project-scoped jobs whose project no longer exists
123409
+ * (which the scheduled fire path treats as a soft failure — see
123410
+ * `executeCronjob`). The manual trigger route maps the same condition to
123411
+ * a `CronjobProjectNotFoundError`.
123412
+ *
123413
+ * Workspace-scoped jobs return `null` rather than falling through to the
123414
+ * project-resolution branch when `workspaceId` is somehow missing. The
123415
+ * `create` path already rejects this via `CronjobWorkspaceMissingError`,
123416
+ * but a workspace-scoped row written by an older server version could
123417
+ * theoretically reach here with a null `workspaceId`. Falling through
123418
+ * would silently misfire into the project's default branch; returning
123419
+ * `null` makes the failure explicit (logged + `lastRunStatus = "failed"`
123420
+ * for the scheduler; `CronjobProjectNotFoundError` for the manual
123421
+ * trigger path).
123162
123422
  */
123163
- killWorkspace(workspaceId) {
123164
- const ids = this.workspaceTerminals.get(workspaceId);
123165
- if (!ids) return;
123166
- for (const terminalId of ids) {
123167
- const session = this.terminals.get(terminalId);
123168
- if (session) {
123169
- session.pty.kill();
123170
- this.terminals.delete(terminalId);
123171
- }
123423
+ resolveWorkspaceId(job, fileKey) {
123424
+ if (job.scope === "workspace") {
123425
+ return job.workspaceId ?? null;
123172
123426
  }
123173
- this.workspaceTerminals.delete(workspaceId);
123427
+ const appState = loadState();
123428
+ const project = appState.projects.find((p6) => p6.name === fileKey);
123429
+ if (!project) return null;
123430
+ return toWorkspaceId(project.name, project.defaultBranch);
123174
123431
  }
123175
- /**
123176
- * Kill every tracked PTY (server shutdown path).
123177
- */
123178
- killAll() {
123179
- for (const [, session] of this.terminals) {
123180
- session.pty.kill();
123432
+ // -------------------------------------------------------------------------
123433
+ // Internals
123434
+ // -------------------------------------------------------------------------
123435
+ findJob(key, id) {
123436
+ const file = this.queries.loadFile(key);
123437
+ return file.jobs.find((j) => j.id === id);
123438
+ }
123439
+ assertValidCron(expression) {
123440
+ try {
123441
+ const options2 = { maxRuns: 0 };
123442
+ void new E2(expression, options2);
123443
+ } catch {
123444
+ throw new InvalidCronExpressionError();
123181
123445
  }
123182
- this.terminals.clear();
123183
- this.workspaceTerminals.clear();
123184
123446
  }
123185
- };
123186
- var terminalPool = new TerminalPool();
123187
-
123188
- // src/server/services/_utils/terminal-layout-manager.ts
123189
- var manager = new DockviewLayoutManager("terminal_layout");
123190
- var getTerminalLayout = (workspaceId) => manager.get(workspaceId);
123191
- var saveTerminalLayout = (workspaceId, tree) => manager.save(workspaceId, tree);
123192
- var deleteTerminalLayout = (workspaceId) => manager.delete(workspaceId);
123193
- function addTerminalToLayout(workspaceId, terminalId, opts) {
123194
- manager.addPanel(workspaceId, {
123195
- id: terminalId,
123196
- contentComponent: "terminalTab",
123197
- tabComponent: "terminalTab",
123198
- title: opts?.title ?? "Terminal",
123199
- params: {
123200
- workspaceId,
123201
- terminalId,
123202
- ...opts?.command ? { command: opts.command } : {},
123203
- ...opts?.cwd ? { cwd: opts.cwd } : {},
123204
- ...opts?.env ? { env: opts.env } : {}
123447
+ scheduleJob(job, fileKey) {
123448
+ const state = schedulerState();
123449
+ const existing = state.jobs.get(job.id);
123450
+ if (existing) {
123451
+ existing.stop();
123452
+ state.jobs.delete(job.id);
123453
+ }
123454
+ if (!job.enabled) return;
123455
+ try {
123456
+ const cronInstance = new E2(job.cronExpression, () => {
123457
+ this.executeCronjob(job, fileKey).catch((err) => {
123458
+ log22.error({ jobId: job.id, err }, "unhandled error in cronjob execution");
123459
+ });
123460
+ });
123461
+ state.jobs.set(job.id, cronInstance);
123462
+ log22.info(
123463
+ { jobId: job.id, name: job.name, cron: job.cronExpression, scope: job.scope },
123464
+ "scheduled cronjob"
123465
+ );
123466
+ } catch (err) {
123467
+ log22.error(
123468
+ { jobId: job.id, cronExpression: job.cronExpression, err },
123469
+ "invalid cron expression, skipping job"
123470
+ );
123471
+ }
123472
+ }
123473
+ async executeCronjob(job, fileKey) {
123474
+ const workspaceId = this.resolveWorkspaceId(job, fileKey);
123475
+ if (!workspaceId) {
123476
+ log22.warn({ jobId: job.id, fileKey }, "project not found for cronjob, skipping");
123477
+ this.safeUpdateLastRun(job.id, "failed");
123478
+ return;
123479
+ }
123480
+ log22.info({ jobId: job.id, name: job.name, workspaceId }, "executing cronjob");
123481
+ if (job.via === "terminal") {
123482
+ try {
123483
+ const { terminalId } = await this.spawnCronTerminal(job, workspaceId, fileKey);
123484
+ if (terminalId) {
123485
+ log22.info({ jobId: job.id, terminalId, workspaceId }, "cronjob terminal spawned");
123486
+ this.safeUpdateLastRun(job.id, "completed");
123487
+ return;
123488
+ }
123489
+ log22.info(
123490
+ { jobId: job.id, workspaceId },
123491
+ "via=terminal not supported by agent; falling back to chat dispatch"
123492
+ );
123493
+ } catch (err) {
123494
+ if (err instanceof TaskConflictError) {
123495
+ log22.info(
123496
+ { jobId: job.id, workspaceId },
123497
+ "previous terminal run still active, skipping cronjob execution"
123498
+ );
123499
+ this.safeUpdateLastRun(job.id, "skipped");
123500
+ return;
123501
+ }
123502
+ log22.error({ jobId: job.id, err }, "cronjob terminal dispatch failed");
123503
+ this.safeUpdateLastRun(job.id, "failed");
123504
+ return;
123505
+ }
123506
+ }
123507
+ try {
123508
+ const chat = this.getOrCreateCronjobChat(workspaceId, job);
123509
+ const task = taskService.submitTask({ workspaceId, chatId: chat.id, prompt: job.prompt });
123510
+ log22.info(
123511
+ { jobId: job.id, taskId: task.id, workspaceId, chatId: chat.id },
123512
+ "cronjob task submitted"
123513
+ );
123514
+ this.safeUpdateLastRun(job.id, "completed");
123515
+ } catch (err) {
123516
+ if (err instanceof TaskConflictError) {
123517
+ log22.info(
123518
+ { jobId: job.id, workspaceId },
123519
+ "task already running, skipping cronjob execution"
123520
+ );
123521
+ this.safeUpdateLastRun(job.id, "skipped");
123522
+ return;
123523
+ }
123524
+ log22.error({ jobId: job.id, err }, "cronjob execution failed");
123525
+ this.safeUpdateLastRun(job.id, "failed");
123205
123526
  }
123206
- });
123207
- }
123208
- function removeTerminalFromLayout(workspaceId, terminalId) {
123209
- manager.removePanel(workspaceId, terminalId);
123210
- }
123211
-
123212
- // src/server/services/terminal-service.ts
123213
- var log23 = createLogger("terminal-service");
123214
- var TerminalPaneConfigSchema = external_exports.object({
123215
- name: external_exports.string().optional(),
123216
- command: external_exports.string().optional(),
123217
- cwd: external_exports.string().optional(),
123218
- env: external_exports.record(external_exports.string()).optional(),
123219
- focus: external_exports.boolean().optional()
123220
- });
123221
- var PaneNodeSchema = external_exports.object({
123222
- pane: TerminalPaneConfigSchema
123223
- });
123224
- var TerminalLayoutNodeSchema = external_exports.lazy(
123225
- () => external_exports.union([
123226
- PaneNodeSchema,
123227
- external_exports.object({
123228
- direction: external_exports.enum(["horizontal", "vertical"]),
123229
- split: external_exports.number().min(0.1).max(0.9).optional().default(0.5),
123230
- children: external_exports.tuple([TerminalLayoutNodeSchema, TerminalLayoutNodeSchema])
123231
- })
123232
- ])
123233
- );
123234
- var WorkspaceTerminalConfigSchema = external_exports.object({
123235
- layout: TerminalLayoutNodeSchema
123236
- });
123237
- var TerminalService = class {
123238
- constructor(pool2 = terminalPool) {
123239
- this.pool = pool2;
123240
123527
  }
123241
- // -------------------------------------------------------------------------
123242
- // PTY lifecycle
123243
- // -------------------------------------------------------------------------
123244
123528
  /**
123245
- * Spawn a new PTY for the given workspace + terminalId.
123529
+ * Update `lastRunAt`/`lastRunStatus`, swallowing DB errors.
123246
123530
  *
123247
- * Resolves `workspaceId` to a worktree path before delegating to the
123248
- * pool, and registers the new terminal in the saved dockview layout so it
123249
- * survives a server restart (mirrors `chatService.create` /
123250
- * `browserService.create`). Does NOT emit a `terminal-created` event —
123251
- * the API entry points decide whether to broadcast (the WebSocket handler
123252
- * stays silent; the tRPC `create` mutation emits explicitly).
123531
+ * Errors here are diagnostic noise during the fire path — the cronjob
123532
+ * already executed (or failed/skipped), and we don't want a transient
123533
+ * DB lock to mask the real outcome by re-raising. The legacy
123534
+ * implementation logged at `warn` and moved on; preserved here.
123253
123535
  */
123254
- async spawn(workspaceId, terminalId, options2) {
123255
- const workspace = workspaceService.resolve(workspaceId);
123256
- if (!workspace) {
123257
- throw new Error(`Workspace not found: ${workspaceId}`);
123536
+ safeUpdateLastRun(jobId, status) {
123537
+ try {
123538
+ this.queries.updateLastRun(jobId, status);
123539
+ } catch (err) {
123540
+ log22.warn({ jobId, err }, "failed to update lastRun on cronjob");
123258
123541
  }
123259
- const session = await this.pool.spawn(
123260
- workspaceId,
123261
- terminalId,
123262
- workspace.worktree.path,
123263
- options2
123264
- );
123265
- addTerminalToLayout(workspaceId, terminalId, {
123266
- command: options2?.command,
123267
- cwd: options2?.cwd,
123268
- env: options2?.env
123269
- });
123270
- return session;
123271
123542
  }
123272
- /**
123273
- * Kill a single terminal. Removes it from the saved dockview layout and
123274
- * emits `terminal-killed` so the dashboard's status stream prunes the
123275
- * panel. Safe to call with an unknown terminalId — no-op.
123276
- */
123277
- kill(terminalId) {
123278
- const session = this.pool.get(terminalId);
123279
- const workspaceId = session?.workspaceId;
123280
- this.pool.kill(terminalId);
123281
- if (workspaceId) {
123282
- removeTerminalFromLayout(workspaceId, terminalId);
123283
- emit({ kind: "terminal-killed", workspaceId, terminalId });
123543
+ loadAndScheduleAll() {
123544
+ const state = schedulerState();
123545
+ for (const [, cron] of state.jobs) {
123546
+ cron.stop();
123547
+ }
123548
+ state.jobs.clear();
123549
+ for (const job of this.queries.listAll()) {
123550
+ this.scheduleJob(job, job.fileKey);
123284
123551
  }
123552
+ log22.info({ count: state.jobs.size }, "loaded cronjob schedules");
123285
123553
  }
123554
+ };
123555
+ var cronjobService = new CronjobService();
123556
+
123557
+ // src/server/services/panel-focus-service.ts
123558
+ init_src();
123559
+ var log23 = createLogger("panel-focus-service");
123560
+ var PANEL_FOCUS_TYPE = "panel_focus";
123561
+ function focusRowId(workspaceId) {
123562
+ return `${PANEL_FOCUS_TYPE}:${workspaceId}`;
123563
+ }
123564
+ var PanelFocusService = class {
123565
+ /** workspaceId → last-focused panel ids. */
123566
+ focus = /* @__PURE__ */ new Map();
123286
123567
  /**
123287
- * Kill every PTY associated with a workspace. Used by the workspace
123288
- * deletion path — the caller is responsible for tearing down the layout
123289
- * tree via {@link deleteLayout} as well.
123568
+ * Workspaces whose focus row has been loaded from the DB into `focus`.
123569
+ * Separate from the map's own key presence because a workspace can have a
123570
+ * loaded-but-empty record (row absent) — we still don't want to re-hit the
123571
+ * DB on every read.
123290
123572
  */
123291
- killWorkspace(workspaceId) {
123292
- this.pool.killWorkspace(workspaceId);
123293
- }
123573
+ hydrated = /* @__PURE__ */ new Set();
123574
+ /** Workspaces that already have a `panel_states` row (drives insert vs update). */
123575
+ persisted = /* @__PURE__ */ new Set();
123294
123576
  /**
123295
- * Kill every tracked PTY across all workspaces. Used by the server
123296
- * shutdown path in `start-server.ts`.
123577
+ * Load a workspace's focus row from the DB on first access. Cheap and
123578
+ * idempotent — subsequent calls short-circuit on the `hydrated` set.
123297
123579
  */
123298
- killAll() {
123299
- this.pool.killAll();
123300
- }
123301
- // -------------------------------------------------------------------------
123302
- // Per-terminal accessors
123303
- // -------------------------------------------------------------------------
123304
- list(workspaceId) {
123305
- return this.pool.list(workspaceId);
123306
- }
123307
- getSession(terminalId) {
123308
- return this.pool.get(terminalId);
123309
- }
123310
- getScrollback(terminalId, lines) {
123311
- return this.pool.getScrollback(terminalId, lines);
123312
- }
123313
- write(terminalId, data) {
123314
- return this.pool.write(terminalId, data);
123315
- }
123316
- resize(terminalId, cols, rows) {
123317
- this.pool.resize(terminalId, cols, rows);
123318
- }
123319
- subscribeOutput(terminalId, callback) {
123320
- return this.pool.subscribeOutput(terminalId, callback);
123321
- }
123322
- // -------------------------------------------------------------------------
123323
- // Layout persistence (dockview tree)
123324
- //
123325
- // Thin pass-throughs to `lib/terminal-layout-manager` so the API tier
123326
- // doesn't reach across into `lib/` directly. The layout helpers will be
123327
- // folded into a dedicated infra adapter in a follow-up phase (when the
123328
- // chats/browsers domains get their own infra adapters); leaving them in
123329
- // `lib/` for now is the minimum-disruption shape during the phased
123330
- // migration.
123331
- // -------------------------------------------------------------------------
123332
- getLayout(workspaceId) {
123333
- return getTerminalLayout(workspaceId);
123334
- }
123335
- saveLayout(workspaceId, tree) {
123336
- saveTerminalLayout(workspaceId, tree);
123580
+ ensureHydrated(workspaceId) {
123581
+ if (this.hydrated.has(workspaceId)) return;
123582
+ this.hydrated.add(workspaceId);
123583
+ const rows = listPanelStatesForWorkspace(workspaceId, PANEL_FOCUS_TYPE);
123584
+ const row = rows[0];
123585
+ if (!row) return;
123586
+ this.persisted.add(workspaceId);
123587
+ try {
123588
+ const parsed = JSON.parse(row.state);
123589
+ this.focus.set(workspaceId, {
123590
+ chat: parsed.chat,
123591
+ terminal: parsed.terminal,
123592
+ browser: parsed.browser
123593
+ });
123594
+ } catch (err) {
123595
+ log23.warn({ workspaceId, err }, "failed to parse persisted panel focus; ignoring");
123596
+ }
123337
123597
  }
123338
- deleteLayout(workspaceId) {
123339
- deleteTerminalLayout(workspaceId);
123598
+ /** Get the last-focused panel ids for a workspace. */
123599
+ get(workspaceId) {
123600
+ this.ensureHydrated(workspaceId);
123601
+ return { ...this.focus.get(workspaceId) ?? {} };
123340
123602
  }
123341
- // -------------------------------------------------------------------------
123342
- // Workspace `.band/config.json` `workspace.terminal` block
123343
- // -------------------------------------------------------------------------
123344
123603
  /**
123345
- * Load and validate the `workspace.terminal` block from
123346
- * `.band/config.json`. Returns `null` when the workspace can't be
123347
- * resolved, the config file is absent, the block is missing, or the
123348
- * payload fails validation.
123349
- *
123350
- * Absorbed from the old `lib/terminal-config.ts:loadWorkspaceTerminalConfig`
123351
- * — same parsing semantics, but the lookup now goes through the service
123352
- * tier so callers (currently the workspace router's `getTerminalConfig`
123353
- * query) reach it via `terminalService` instead of a stand-alone helper.
123604
+ * Record that `panelId` is the last-focused panel of `panelType` in
123605
+ * `workspaceId`. Write-through: updates the in-memory map and upserts the
123606
+ * backing `panel_states` row.
123354
123607
  */
123355
- getWorkspaceConfig(workspaceId) {
123356
- const workspace = workspaceService.resolve(workspaceId);
123357
- if (!workspace) return null;
123358
- return this.loadWorkspaceConfigFromPaths(workspace.worktree.path, workspace.project.path);
123608
+ set(workspaceId, panelType, panelId) {
123609
+ this.ensureHydrated(workspaceId);
123610
+ const current2 = this.focus.get(workspaceId) ?? {};
123611
+ if (current2[panelType] === panelId) return;
123612
+ const next = { ...current2, [panelType]: panelId };
123613
+ this.focus.set(workspaceId, next);
123614
+ const now = Date.now();
123615
+ const state = JSON.stringify(next);
123616
+ if (this.persisted.has(workspaceId)) {
123617
+ updatePanelState(focusRowId(workspaceId), { state, updatedAt: now });
123618
+ } else {
123619
+ insertPanelState({
123620
+ id: focusRowId(workspaceId),
123621
+ workspaceId,
123622
+ panelType: PANEL_FOCUS_TYPE,
123623
+ state,
123624
+ createdAt: now,
123625
+ updatedAt: now
123626
+ });
123627
+ this.persisted.add(workspaceId);
123628
+ }
123359
123629
  }
123360
123630
  /**
123361
- * Internal: the path-driven parse so tests / future non-tRPC entry
123362
- * points can plug raw paths in without going through `resolveWorkspace`.
123631
+ * Drop all focus tracking for a workspace. Called from the workspace delete
123632
+ * path so the row doesn't outlive the workspace.
123363
123633
  */
123364
- loadWorkspaceConfigFromPaths(worktreePath, projectPath) {
123365
- const raw = loadProjectConfig(worktreePath, projectPath);
123366
- if (!raw) return null;
123367
- const terminalBlock = raw.workspace && typeof raw.workspace === "object" ? raw.workspace.terminal : void 0;
123368
- if (!terminalBlock) return null;
123369
- const result = WorkspaceTerminalConfigSchema.safeParse(terminalBlock);
123370
- if (!result.success) {
123371
- log23.warn(
123372
- "Invalid workspace.terminal config: %s",
123373
- result.error.issues.map((i2) => `${i2.path.join(".")}: ${i2.message}`).join("; ")
123374
- );
123375
- return null;
123376
- }
123377
- return result.data;
123634
+ remove(workspaceId) {
123635
+ this.focus.delete(workspaceId);
123636
+ this.hydrated.delete(workspaceId);
123637
+ this.persisted.delete(workspaceId);
123638
+ deletePanelState(focusRowId(workspaceId));
123378
123639
  }
123379
123640
  };
123380
- var terminalService = new TerminalService();
123641
+ var panelFocusService = new PanelFocusService();
123381
123642
 
123382
123643
  // src/server/services/workspace-service.ts
123383
123644
  var execFileAsync2 = promisify2(execFile6);
@@ -123599,7 +123860,7 @@ var WorkspaceService = class {
123599
123860
  via = "chat";
123600
123861
  } else {
123601
123862
  terminalCommand = formatShellCommand(invocation.command, invocation.args);
123602
- terminalId = randomUUID3();
123863
+ terminalId = randomUUID4();
123603
123864
  }
123604
123865
  } catch (err) {
123605
123866
  log24.warn(
@@ -123688,7 +123949,7 @@ var WorkspaceService = class {
123688
123949
  };
123689
123950
  }
123690
123951
  const command = formatShellCommand(invocation.command, invocation.args);
123691
- const terminalId = randomUUID3();
123952
+ const terminalId = randomUUID4();
123692
123953
  await terminalService.spawn(chat.workspaceId, terminalId, { command });
123693
123954
  emit({ kind: "terminal-created", workspaceId: chat.workspaceId, terminalId });
123694
123955
  log24.info(
@@ -137681,9 +137942,9 @@ var cronjobsRouter = t2.router({
137681
137942
  throwAsTRPCError(err);
137682
137943
  }
137683
137944
  }),
137684
- trigger: publicProcedure.input(cronjobByIdInput).mutation(({ input }) => {
137945
+ trigger: publicProcedure.input(cronjobByIdInput).mutation(async ({ input }) => {
137685
137946
  try {
137686
- return cronjobService.trigger(input.key, input.id);
137947
+ return await cronjobService.trigger(input.key, input.id);
137687
137948
  } catch (err) {
137688
137949
  throwAsTRPCError(err);
137689
137950
  }
@@ -137697,10 +137958,7 @@ function throwAsTRPCError(err) {
137697
137958
  throw new TRPCError({ code: "BAD_REQUEST", message: err.message });
137698
137959
  }
137699
137960
  if (err instanceof TaskConflictError) {
137700
- throw new TRPCError({
137701
- code: "CONFLICT",
137702
- message: "Task already running for this chat pane"
137703
- });
137961
+ throw new TRPCError({ code: "CONFLICT", message: err.message });
137704
137962
  }
137705
137963
  throw err;
137706
137964
  }
@@ -140470,7 +140728,7 @@ function throwAsTrpcError2(err) {
140470
140728
  }
140471
140729
 
140472
140730
  // src/server/api/terminals/router.ts
140473
- import { randomUUID as randomUUID4 } from "node:crypto";
140731
+ import { randomUUID as randomUUID5 } from "node:crypto";
140474
140732
  init_zod();
140475
140733
 
140476
140734
  // src/server/api/terminals/strip-queries.ts
@@ -140496,7 +140754,7 @@ var terminalRouter = t2.router({
140496
140754
  env: external_exports.record(external_exports.string()).optional()
140497
140755
  })
140498
140756
  ).mutation(async ({ input }) => {
140499
- const terminalId = input.id ?? randomUUID4();
140757
+ const terminalId = input.id ?? randomUUID5();
140500
140758
  const session = await terminalService.spawn(input.workspaceId, terminalId, {
140501
140759
  command: input.command,
140502
140760
  cwd: input.cwd,