@band-app/server 0.27.0 → 0.27.2

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 (162) hide show
  1. package/dist/client/assets/{DockviewBrowserContainer-CGJxcrlC.js → DockviewBrowserContainer-COwf3WHv.js} +1 -1
  2. package/dist/client/assets/{DockviewTerminalContainer-Ch1hGjB3.js → DockviewTerminalContainer-CHShiUtc.js} +2 -2
  3. package/dist/client/assets/{TerminalPanel-D6316IRS.js → TerminalPanel-Do5FQ6ub.js} +1 -1
  4. package/dist/client/assets/{_basePickBy-CmBV18rc.js → _basePickBy-CsdxTHqK.js} +1 -1
  5. package/dist/client/assets/{_baseUniq-U0BTd0Rz.js → _baseUniq-B2EJ08Mj.js} +1 -1
  6. package/dist/client/assets/{arc-BsQxyIE1.js → arc-4o4sSvn3.js} +1 -1
  7. package/dist/client/assets/{architectureDiagram-VXUJARFQ-utzNWMoy.js → architectureDiagram-VXUJARFQ-BuLso7-7.js} +1 -1
  8. package/dist/client/assets/{blockDiagram-VD42YOAC-xxfCrQvu.js → blockDiagram-VD42YOAC-DLy4c1v0.js} +1 -1
  9. package/dist/client/assets/{c4Diagram-YG6GDRKO-CYbkaWJ4.js → c4Diagram-YG6GDRKO-CxGgKDvC.js} +1 -1
  10. package/dist/client/assets/channel-DK9j4T1y.js +1 -0
  11. package/dist/client/assets/{chunk-4BX2VUAB-DKCxjb37.js → chunk-4BX2VUAB-BjZOiC5V.js} +1 -1
  12. package/dist/client/assets/{chunk-55IACEB6-B9W9ltjh.js → chunk-55IACEB6-wLT4Ekdw.js} +1 -1
  13. package/dist/client/assets/{chunk-B4BG7PRW-0MSaUsdv.js → chunk-B4BG7PRW-DNHRjTkG.js} +1 -1
  14. package/dist/client/assets/{chunk-DI55MBZ5-K1HZY-B4.js → chunk-DI55MBZ5-xiMesNd-.js} +1 -1
  15. package/dist/client/assets/{chunk-FMBD7UC4-DL19-zkO.js → chunk-FMBD7UC4-wxbkh2Ei.js} +1 -1
  16. package/dist/client/assets/{chunk-QN33PNHL-Bjj5Xo40.js → chunk-QN33PNHL-C9S-hWrw.js} +1 -1
  17. package/dist/client/assets/{chunk-QZHKN3VN-B7u9UlRD.js → chunk-QZHKN3VN-BbSdiGqJ.js} +1 -1
  18. package/dist/client/assets/{chunk-TZMSLE5B-1C4Ii4Su.js → chunk-TZMSLE5B-qzA2kORG.js} +1 -1
  19. package/dist/client/assets/classDiagram-2ON5EDUG-ao4KqSJW.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-WZHVMYZB-ao4KqSJW.js +1 -0
  21. package/dist/client/assets/clone-DwKf57Xw.js +1 -0
  22. package/dist/client/assets/{cose-bilkent-S5V4N54A-PGTI-4nv.js → cose-bilkent-S5V4N54A-Ce-91kdO.js} +1 -1
  23. package/dist/client/assets/{dagre-6UL2VRFP-SduuRX7O.js → dagre-6UL2VRFP-BJfEL8HP.js} +1 -1
  24. package/dist/client/assets/{diagram-PSM6KHXK-CBSJaBQx.js → diagram-PSM6KHXK-D9QZY1LM.js} +1 -1
  25. package/dist/client/assets/{diagram-QEK2KX5R-BtO_0TYR.js → diagram-QEK2KX5R-Dq_XSqOb.js} +1 -1
  26. package/dist/client/assets/{diagram-S2PKOQOG-CU-3HUn7.js → diagram-S2PKOQOG-NXHJcJ16.js} +1 -1
  27. package/dist/client/assets/{erDiagram-Q2GNP2WA-B7YcBWa7.js → erDiagram-Q2GNP2WA-BXnG9VYr.js} +1 -1
  28. package/dist/client/assets/{flowDiagram-NV44I4VS-B_exjXhH.js → flowDiagram-NV44I4VS-DtBdd0js.js} +1 -1
  29. package/dist/client/assets/{ganttDiagram-JELNMOA3-K_s2lhZo.js → ganttDiagram-JELNMOA3-BL9zC1XT.js} +1 -1
  30. package/dist/client/assets/{gitGraphDiagram-V2S2FVAM-Cy8dWU3T.js → gitGraphDiagram-V2S2FVAM-DixbOeLs.js} +1 -1
  31. package/dist/client/assets/{graph-F7Go3eb_.js → graph-BHFgIvQj.js} +1 -1
  32. package/dist/client/assets/{highlighted-body-B3W2YXNL-JIbcl_Gj.js → highlighted-body-B3W2YXNL-BREAoDZ4.js} +1 -1
  33. package/dist/client/assets/{index-C8kfQI4r.js → index-9wcsPWJR.js} +1 -1
  34. package/dist/client/assets/{index-CpaiC9qY.js → index-BWCi_Zs0.js} +1 -1
  35. package/dist/client/assets/{index-DKc3v7ID.js → index-C-bVTGUD.js} +1 -1
  36. package/dist/client/assets/{index-Cg4Lw3Yz.js → index-CF-5li7T.js} +1 -1
  37. package/dist/client/assets/{index-DR5Cdr17.js → index-COFoUJ6K.js} +1 -1
  38. package/dist/client/assets/{index-Cy5Bsovp.js → index-CRC_o9Ev.js} +1 -1
  39. package/dist/client/assets/{index-2I1ScAnS.js → index-Cl45brGw.js} +1 -1
  40. package/dist/client/assets/{index-DtwjNrea.js → index-CltbWfI8.js} +1 -1
  41. package/dist/client/assets/{index-VnMyG1bC.js → index-CqEMg5bi.js} +1 -1
  42. package/dist/client/assets/{index-BwLCRFTA.js → index-CvMgaEGN.js} +1 -1
  43. package/dist/client/assets/{index-QMtr_UY_.js → index-DDNdnPFA.js} +1 -1
  44. package/dist/client/assets/{index-Ckir_3bE.js → index-DEK-0WIp.js} +1 -1
  45. package/dist/client/assets/{index-CyKK9pLi.js → index-DJlZKXEl.js} +1 -1
  46. package/dist/client/assets/{index-qr9Za9SW.js → index-DK9JYQdj.js} +1 -1
  47. package/dist/client/assets/{index-D2dWAqXm.js → index-DTFreZSi.js} +1 -1
  48. package/dist/client/assets/{index-T2mcmOLE.js → index-Dw-2va3g.js} +1 -1
  49. package/dist/client/assets/{index-O8FbcZ5P.js → index-Z0JH8qku.js} +1 -1
  50. package/dist/client/assets/{index-D3_75AIk.js → index-sbEmQu7V.js} +1 -1
  51. package/dist/client/assets/{infoDiagram-HS3SLOUP-DwTbDgO5.js → infoDiagram-HS3SLOUP-Ci0THpdm.js} +1 -1
  52. package/dist/client/assets/{journeyDiagram-XKPGCS4Q-Di8V2Erc.js → journeyDiagram-XKPGCS4Q-BoRGQ0eN.js} +1 -1
  53. package/dist/client/assets/{kanban-definition-3W4ZIXB7-CsLOWnzV.js → kanban-definition-3W4ZIXB7-AQv1UOIJ.js} +1 -1
  54. package/dist/client/assets/{layout-C3qPiTeF.js → layout-D2p6hLD3.js} +1 -1
  55. package/dist/client/assets/{main-C0_EuI87.js → main-BOL9tKxz.js} +242 -242
  56. package/dist/client/assets/main-Cd3W1s-m.css +1 -0
  57. package/dist/client/assets/{mindmap-definition-VGOIOE7T-CTcR694h.js → mindmap-definition-VGOIOE7T-C9wrq9rZ.js} +1 -1
  58. package/dist/client/assets/{pieDiagram-ADFJNKIX-B-gdfE-T.js → pieDiagram-ADFJNKIX-TDDajIhh.js} +1 -1
  59. package/dist/client/assets/{quadrantDiagram-AYHSOK5B-B0wmCtBi.js → quadrantDiagram-AYHSOK5B-c7rUV0Lx.js} +1 -1
  60. package/dist/client/assets/{requirementDiagram-UZGBJVZJ-B1DgDXmR.js → requirementDiagram-UZGBJVZJ-DTSqqMot.js} +1 -1
  61. package/dist/client/assets/{sankeyDiagram-TZEHDZUN-B-onab8X.js → sankeyDiagram-TZEHDZUN-BB3ht3wT.js} +1 -1
  62. package/dist/client/assets/{sequenceDiagram-WL72ISMW-B8sIiN1d.js → sequenceDiagram-WL72ISMW-DqBzLinO.js} +1 -1
  63. package/dist/client/assets/{square-terminal-Bi7EDEf7.js → square-terminal-Dp_ALPq8.js} +1 -1
  64. package/dist/client/assets/{stateDiagram-FKZM4ZOC-CTETJ7q1.js → stateDiagram-FKZM4ZOC-0RwNDoMO.js} +1 -1
  65. package/dist/client/assets/stateDiagram-v2-4FDKWEC3-EK4CX-2u.js +1 -0
  66. package/dist/client/assets/{timeline-definition-IT6M3QCI-DYNUQPry.js → timeline-definition-IT6M3QCI-oQ14X47P.js} +1 -1
  67. package/dist/client/assets/{treemap-GDKQZRPO-extyB4z8.js → treemap-GDKQZRPO-BaGbKw1i.js} +1 -1
  68. package/dist/client/assets/workspace._workspaceId-7sbHEjW9.js +2 -0
  69. package/dist/client/assets/{workspace._workspaceId-D0Fj26bF.js → workspace._workspaceId-CmPT8dnn.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-PRI3JC2R-BtUY3rKA.js → xychartDiagram-PRI3JC2R-BWgqOpde.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-BjdfNGy8.js → DockviewBrowserContainer-Cwa58lvF.js} +1 -1
  75. package/dist/server/assets/{DockviewTerminalContainer-CwXOWGN4.js → DockviewTerminalContainer-C1qdx1Wr.js} +3 -3
  76. package/dist/server/assets/{TerminalPanel-BkB-gDqg.js → TerminalPanel-D6sfYBb6.js} +1 -1
  77. package/dist/server/assets/{_basePickBy-8tzGwV15.js → _basePickBy-BRuNlqVs.js} +2 -2
  78. package/dist/server/assets/{_baseUniq-CTXsu8pj.js → _baseUniq-C9mURyHm.js} +1 -1
  79. package/dist/server/assets/_tanstack-start-manifest_v-CwczGC2T.js +4 -0
  80. package/dist/server/assets/{arc-DfLShGGX.js → arc-BtzL8k-u.js} +1 -1
  81. package/dist/server/assets/{architecture-7HQA4BMR-B3ThBUTU.js → architecture-7HQA4BMR-CKZ65Ll7.js} +6 -6
  82. package/dist/server/assets/{architectureDiagram-VXUJARFQ-CWzfFX__.js → architectureDiagram-VXUJARFQ-DTAYCIJW.js} +6 -6
  83. package/dist/server/assets/{blockDiagram-VD42YOAC-DCaPOrQR.js → blockDiagram-VD42YOAC-4VOCVg9L.js} +6 -6
  84. package/dist/server/assets/{c4Diagram-YG6GDRKO-BU-dO_db.js → c4Diagram-YG6GDRKO-BgCS3qmn.js} +2 -2
  85. package/dist/server/assets/{channel-Bs5JejOk.js → channel-DFQ08j-2.js} +1 -1
  86. package/dist/server/assets/{chunk-4BX2VUAB-BuiyFSiN.js → chunk-4BX2VUAB-DB8ZP4qQ.js} +1 -1
  87. package/dist/server/assets/{chunk-55IACEB6-CPYazKTg.js → chunk-55IACEB6-CfA2yw1X.js} +1 -1
  88. package/dist/server/assets/{chunk-B4BG7PRW-Kknt_fVn.js → chunk-B4BG7PRW-Cg_IGrn-.js} +4 -4
  89. package/dist/server/assets/{chunk-DI55MBZ5-9Ix3Oo-P.js → chunk-DI55MBZ5-C0P1Cguj.js} +3 -3
  90. package/dist/server/assets/{chunk-FMBD7UC4-OhxFQXHM.js → chunk-FMBD7UC4-BvqL2ldk.js} +1 -1
  91. package/dist/server/assets/{chunk-QN33PNHL-LsrLj9Ip.js → chunk-QN33PNHL-DG3BqN6c.js} +1 -1
  92. package/dist/server/assets/{chunk-QZHKN3VN-BwJQ_Q1g.js → chunk-QZHKN3VN-DbSVY1CL.js} +1 -1
  93. package/dist/server/assets/{chunk-TZMSLE5B-Bl5v6Ae6.js → chunk-TZMSLE5B-CQddTr4s.js} +1 -1
  94. package/dist/server/assets/{classDiagram-v2-WZHVMYZB-C6GK2Aoj.js → classDiagram-2ON5EDUG-lqwd66j1.js} +5 -5
  95. package/dist/server/assets/{classDiagram-2ON5EDUG-C6GK2Aoj.js → classDiagram-v2-WZHVMYZB-lqwd66j1.js} +5 -5
  96. package/dist/server/assets/{clone-DqNWgXxT.js → clone-Bm83-s49.js} +1 -1
  97. package/dist/server/assets/{cose-bilkent-S5V4N54A-qzfBNLDd.js → cose-bilkent-S5V4N54A-DJIkSye6.js} +1 -1
  98. package/dist/server/assets/{dagre-6UL2VRFP-DNZS86ly.js → dagre-6UL2VRFP-8RUa3xMj.js} +6 -6
  99. package/dist/server/assets/{diagram-PSM6KHXK-C6ImURyq.js → diagram-PSM6KHXK-flj7QMtm.js} +7 -7
  100. package/dist/server/assets/{diagram-QEK2KX5R-diOF3OBK.js → diagram-QEK2KX5R-CDO-gP_F.js} +6 -6
  101. package/dist/server/assets/{diagram-S2PKOQOG-CP1GoHeA.js → diagram-S2PKOQOG-WsMSDccc.js} +6 -6
  102. package/dist/server/assets/{erDiagram-Q2GNP2WA-DnkGfUio.js → erDiagram-Q2GNP2WA-C8BE13tX.js} +4 -4
  103. package/dist/server/assets/{flowDiagram-NV44I4VS-B1dpItw0.js → flowDiagram-NV44I4VS-CGQvsM8y.js} +5 -5
  104. package/dist/server/assets/{ganttDiagram-JELNMOA3-CfB_h9HP.js → ganttDiagram-JELNMOA3-CEYzmLyS.js} +1 -1
  105. package/dist/server/assets/{gitGraph-G5XIXVHT-uIxmtjOh.js → gitGraph-G5XIXVHT-CoBgFiav.js} +6 -6
  106. package/dist/server/assets/{gitGraphDiagram-V2S2FVAM-BWi1KybI.js → gitGraphDiagram-V2S2FVAM-CTvj2CUF.js} +7 -7
  107. package/dist/server/assets/{graph-BpINl381.js → graph-CcLRlZbU.js} +2 -2
  108. package/dist/server/assets/{highlighted-body-B3W2YXNL-X1-_bh6z.js → highlighted-body-B3W2YXNL-B-KIjXRR.js} +1 -1
  109. package/dist/server/assets/{index-CGqMcWZv.js → index-B4Nb7YRL.js} +5 -5
  110. package/dist/server/assets/{index-Bqv1Yqbp.js → index-BO8SzaHU.js} +2 -2
  111. package/dist/server/assets/{index-YfWUTx8U.js → index-BOujpirM.js} +2 -2
  112. package/dist/server/assets/{index-oLF27Nfu.js → index-BaRsZ-GG.js} +2 -2
  113. package/dist/server/assets/{index-BUCj1Yl_.js → index-BfY0gR9U.js} +4 -4
  114. package/dist/server/assets/{index-B_uZBCQp.js → index-BkzW7_Ky.js} +2 -2
  115. package/dist/server/assets/{index-CF9A2ZU-.js → index-Bn2w4FXC.js} +2 -2
  116. package/dist/server/assets/{index-CyY0UzHL.js → index-C9huWiJ-.js} +2 -2
  117. package/dist/server/assets/{index-CecHlx7n.js → index-CEtsZqNS.js} +1 -1
  118. package/dist/server/assets/{index-IM5Lznch.js → index-CWreRmYM.js} +3 -3
  119. package/dist/server/assets/{index-Cuy6v4sQ.js → index-Cf9HnAif.js} +5 -5
  120. package/dist/server/assets/{index-DXfbzq6y.js → index-CoXEmnej.js} +2 -2
  121. package/dist/server/assets/{index-D3oif9Da.js → index-Cwo9DINN.js} +2 -2
  122. package/dist/server/assets/{index-Bcu0C2js.js → index-DG7cMtrE.js} +3 -3
  123. package/dist/server/assets/{index-Ch5w2qx-.js → index-DGLaWKzg.js} +2 -2
  124. package/dist/server/assets/{index-DEYBFbnW.js → index-DL8Aq-MC.js} +2 -2
  125. package/dist/server/assets/{index-Bm4xacwx.js → index-DnkAwjsP.js} +1 -1
  126. package/dist/server/assets/{index-BIn7ueGd.js → index-DtBrHSR6.js} +2 -2
  127. package/dist/server/assets/{info-VBDWY6EO-CqVqNO_U.js → info-VBDWY6EO-dPnWGou0.js} +6 -6
  128. package/dist/server/assets/{infoDiagram-HS3SLOUP-LstCwT5M.js → infoDiagram-HS3SLOUP-CanJ2UdU.js} +5 -5
  129. package/dist/server/assets/{journeyDiagram-XKPGCS4Q-Bq4xdGvc.js → journeyDiagram-XKPGCS4Q-DHO36eI-.js} +4 -4
  130. package/dist/server/assets/{kanban-definition-3W4ZIXB7-Cz8QJrE-.js → kanban-definition-3W4ZIXB7-CVMwRab0.js} +2 -2
  131. package/dist/server/assets/{layout-BR6KziPh.js → layout-4KBouLlF.js} +4 -4
  132. package/dist/server/assets/{mermaid-3ZIDBTTL-CX1H3heG.js → mermaid-3ZIDBTTL-B7ZUN_Qp.js} +1 -1
  133. package/dist/server/assets/{mermaid-parser.core-DErnTWrV.js → mermaid-parser.core-X4fas7Ck.js} +11 -11
  134. package/dist/server/assets/{mindmap-definition-VGOIOE7T-OX1eekPS.js → mindmap-definition-VGOIOE7T-abL0gs0k.js} +3 -3
  135. package/dist/server/assets/{packet-DYOGHKS2-C8FvmUVf.js → packet-DYOGHKS2-CXiTEP4R.js} +6 -6
  136. package/dist/server/assets/{pie-VRWISCQL-Bb_jFdxC.js → pie-VRWISCQL-LzzDSiEA.js} +6 -6
  137. package/dist/server/assets/{pieDiagram-ADFJNKIX-CyhwaYTd.js → pieDiagram-ADFJNKIX-Dcjcisnd.js} +7 -7
  138. package/dist/server/assets/{quadrantDiagram-AYHSOK5B-BRzFRm7k.js → quadrantDiagram-AYHSOK5B-PVZmGqa8.js} +1 -1
  139. package/dist/server/assets/{radar-ZZBFDIW7-C2cWeYfK.js → radar-ZZBFDIW7-D-RH85sp.js} +6 -6
  140. package/dist/server/assets/{requirementDiagram-UZGBJVZJ-fJ6FCY2X.js → requirementDiagram-UZGBJVZJ-o3XZfWHL.js} +3 -3
  141. package/dist/server/assets/{router-BSilWMbB.js → router-CR-9ZnLb.js} +800 -517
  142. package/dist/server/assets/{sankeyDiagram-TZEHDZUN-DkGxRBc4.js → sankeyDiagram-TZEHDZUN-DJ9W5U5_.js} +1 -1
  143. package/dist/server/assets/{sequenceDiagram-WL72ISMW-Y9h8QzpA.js → sequenceDiagram-WL72ISMW-DLHwNIY3.js} +3 -3
  144. package/dist/server/assets/{square-terminal-lPOPYTqx.js → square-terminal-fwIFFrU0.js} +1 -1
  145. package/dist/server/assets/{stateDiagram-FKZM4ZOC-D9Xo5bg5.js → stateDiagram-FKZM4ZOC-Doy5tRH0.js} +8 -8
  146. package/dist/server/assets/{stateDiagram-v2-4FDKWEC3-BBj6Aiwx.js → stateDiagram-v2-4FDKWEC3-C9UezhSz.js} +4 -4
  147. package/dist/server/assets/{timeline-definition-IT6M3QCI-oAS-tbgM.js → timeline-definition-IT6M3QCI-DQgqc0il.js} +2 -2
  148. package/dist/server/assets/{treemap-GDKQZRPO-BA7EQwOb.js → treemap-GDKQZRPO-BYUsfiTc.js} +6 -6
  149. package/dist/server/assets/{workspace._workspaceId-BAO3HXDS.js → workspace._workspaceId-D89v3gxu.js} +1 -1
  150. package/dist/server/assets/{workspace._workspaceId-CREreypp.js → workspace._workspaceId-Du6uUhkr.js} +4 -4
  151. package/dist/server/assets/{xychartDiagram-PRI3JC2R-CYgqsSC_.js → xychartDiagram-PRI3JC2R-Bu3TCZ6_.js} +1 -1
  152. package/dist/server/server.js +2 -2
  153. package/dist/start-server.mjs +1225 -976
  154. package/package.json +4 -4
  155. package/dist/client/assets/channel-CpSzKNMk.js +0 -1
  156. package/dist/client/assets/classDiagram-2ON5EDUG-BgIuIwCk.js +0 -1
  157. package/dist/client/assets/classDiagram-v2-WZHVMYZB-BgIuIwCk.js +0 -1
  158. package/dist/client/assets/clone-BnxcJIG7.js +0 -1
  159. package/dist/client/assets/main-DFLn265W.css +0 -1
  160. package/dist/client/assets/stateDiagram-v2-4FDKWEC3-BvVewf6E.js +0 -1
  161. package/dist/client/assets/workspace._workspaceId-C8Z_0tr5.js +0 -2
  162. package/dist/server/assets/_tanstack-start-manifest_v-Bu_aDRUe.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,
@@ -120638,7 +120729,7 @@ function mimeTypeFromFilename(filename) {
120638
120729
  init_src();
120639
120730
  init_zod();
120640
120731
  import { execFile as execFile6 } from "node:child_process";
120641
- import { randomUUID as randomUUID3 } from "node:crypto";
120732
+ import { randomUUID as randomUUID4 } from "node:crypto";
120642
120733
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3 } from "node:fs";
120643
120734
  import { rm } from "node:fs/promises";
120644
120735
  import { join as join20 } from "node:path";
@@ -121554,6 +121645,7 @@ setBrowserLookup((browserId) => browserService.get(browserId));
121554
121645
 
121555
121646
  // src/server/services/cronjob-service.ts
121556
121647
  init_src();
121648
+ import { randomUUID as randomUUID3 } from "node:crypto";
121557
121649
 
121558
121650
  // ../../node_modules/.pnpm/croner@10.0.1/node_modules/croner/dist/croner.js
121559
121651
  function T(s6) {
@@ -122247,10 +122339,12 @@ var CronjobQueries = class _CronjobQueries {
122247
122339
  cronExpression: job.cronExpression,
122248
122340
  scope: job.scope,
122249
122341
  workspaceId: job.workspaceId ?? null,
122342
+ via: job.via ?? "chat",
122250
122343
  enabled: job.enabled,
122251
122344
  createdAt: job.createdAt,
122252
122345
  lastRunAt: job.lastRunAt ?? null,
122253
- lastRunStatus: job.lastRunStatus ?? null
122346
+ lastRunStatus: job.lastRunStatus ?? null,
122347
+ lastTerminalId: job.lastTerminalId ?? null
122254
122348
  }).run();
122255
122349
  }
122256
122350
  });
@@ -122282,6 +122376,19 @@ var CronjobQueries = class _CronjobQueries {
122282
122376
  const db2 = getDb();
122283
122377
  db2.update(cronjobs).set({ lastRunAt: (/* @__PURE__ */ new Date()).toISOString(), lastRunStatus: status }).where(eq2(cronjobs.id, jobId)).run();
122284
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
+ }
122285
122392
  /**
122286
122393
  * Convert a Drizzle row into the optional-T-shaped `CronjobDefinition`.
122287
122394
  *
@@ -122296,1097 +122403,1242 @@ var CronjobQueries = class _CronjobQueries {
122296
122403
  cronExpression: row.cronExpression,
122297
122404
  scope: row.scope,
122298
122405
  workspaceId: row.workspaceId ?? void 0,
122406
+ via: row.via ?? "chat",
122299
122407
  enabled: row.enabled,
122300
122408
  createdAt: row.createdAt,
122301
122409
  lastRunAt: row.lastRunAt ?? void 0,
122302
- lastRunStatus: row.lastRunStatus
122410
+ lastRunStatus: row.lastRunStatus,
122411
+ lastTerminalId: row.lastTerminalId ?? void 0
122303
122412
  };
122304
122413
  }
122305
122414
  };
122306
122415
 
122307
- // src/server/services/cronjob-service.ts
122308
- var log20 = createLogger("cronjob-service");
122309
- var SCHEDULER_KEY = Symbol.for("band.cronjob-scheduler");
122310
- var g12 = globalThis;
122311
- if (!g12[SCHEDULER_KEY]) {
122312
- g12[SCHEDULER_KEY] = {
122313
- jobs: /* @__PURE__ */ new Map(),
122314
- started: false
122315
- };
122316
- }
122317
- function schedulerState() {
122318
- return g12[SCHEDULER_KEY];
122319
- }
122320
- var cronjobCreateInput = external_exports.object({
122321
- key: external_exports.string().min(1),
122322
- name: external_exports.string().min(1),
122323
- prompt: external_exports.string().min(1),
122324
- cronExpression: external_exports.string().min(1),
122325
- scope: external_exports.enum(["project", "workspace"]),
122326
- workspaceId: external_exports.string().optional(),
122327
- enabled: external_exports.boolean().default(true)
122328
- });
122329
- var cronjobUpdateInput = external_exports.object({
122330
- // `.min(1)` rejects the obviously invalid empty-string `key`/`id` at the
122331
- // validator instead of letting it reach the service, which would
122332
- // `loadFile("")` an empty result and 404 on `findIndex`. The legacy
122333
- // router accepted bare `z.string()` here; the migration is the natural
122334
- // moment to tighten the boundary so the create + update shapes stay
122335
- // consistent (`cronjobCreateInput.key` already has `.min(1)`).
122336
- key: external_exports.string().min(1),
122337
- id: external_exports.string().min(1),
122338
- name: external_exports.string().min(1).optional(),
122339
- prompt: external_exports.string().min(1).optional(),
122340
- cronExpression: external_exports.string().min(1).optional(),
122341
- enabled: external_exports.boolean().optional()
122342
- });
122343
- var cronjobByIdInput = external_exports.object({
122344
- key: external_exports.string().min(1),
122345
- id: external_exports.string().min(1)
122346
- });
122347
- var CronjobNotFoundError = class extends Error {
122348
- constructor(message = "Cronjob not found") {
122349
- super(message);
122350
- this.name = "CronjobNotFoundError";
122351
- }
122352
- };
122353
- var InvalidCronExpressionError = class extends Error {
122354
- constructor(message = "Invalid cron expression") {
122355
- super(message);
122356
- this.name = "InvalidCronExpressionError";
122357
- }
122358
- };
122359
- var CronjobWorkspaceMissingError = class extends Error {
122360
- constructor(message = "workspaceId is required for workspace-scoped cronjobs") {
122361
- super(message);
122362
- this.name = "CronjobWorkspaceMissingError";
122363
- }
122364
- };
122365
- var CronjobProjectNotFoundError = class extends Error {
122366
- constructor(message = "Project not found") {
122367
- super(message);
122368
- this.name = "CronjobProjectNotFoundError";
122369
- }
122370
- };
122371
- var CronjobService = class {
122372
- constructor(queries = new CronjobQueries()) {
122373
- this.queries = queries;
122374
- }
122375
- // -------------------------------------------------------------------------
122376
- // Read operations
122377
- // -------------------------------------------------------------------------
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();
122378
122435
  /**
122379
- * List jobs for a key, or — if neither `project` nor `workspaceId` is
122380
- * 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.
122381
122448
  *
122382
- * Mirrors the legacy tRPC `cronjobs.list` shape so the router stays a
122383
- * straight delegate. The "all" branch returns `fileKey` on each row
122384
- * because callers (the dashboard's cronjob settings page) need both
122385
- * 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.
122386
122455
  */
122387
- list(filter) {
122388
- if (filter?.project) {
122389
- const { project } = filter;
122390
- const file = this.queries.loadFile(project);
122391
- return { jobs: file.jobs.map((j) => ({ ...j, fileKey: project })) };
122392
- }
122393
- if (filter?.workspaceId) {
122394
- const { workspaceId } = filter;
122395
- const file = this.queries.loadFile(workspaceId);
122396
- return {
122397
- jobs: file.jobs.map((j) => ({ ...j, fileKey: workspaceId }))
122398
- };
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);
122399
122467
  }
122400
- return { jobs: this.queries.listAll() };
122401
122468
  }
122402
- /** Get a single job by `key` + `id`. Throws `CronjobNotFoundError` if absent. */
122403
- get(key, id) {
122404
- const job = this.findJob(key, id);
122405
- if (!job) throw new CronjobNotFoundError();
122406
- 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;
122407
122583
  }
122408
- // -------------------------------------------------------------------------
122409
- // Mutations
122410
- // -------------------------------------------------------------------------
122411
122584
  /**
122412
- * Create a new cronjob and re-bind the scheduler.
122585
+ * Auto-run the workspace's initial command inside a freshly spawned PTY,
122586
+ * robustly.
122413
122587
  *
122414
- * Validates the cron expression by attempting to construct a `Cron` with
122415
- * `maxRuns: 0` (parses without scheduling). Throws
122416
- * `InvalidCronExpressionError` for a bad expression and
122417
- * `CronjobWorkspaceMissingError` when a workspace-scoped job is missing
122418
- * 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.
122419
122610
  */
122420
- create(input) {
122421
- this.assertValidCron(input.cronExpression);
122422
- if (input.scope === "workspace" && !input.workspaceId) {
122423
- 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;
122424
122627
  }
122425
- const file = this.queries.loadFile(input.key);
122426
- const job = {
122427
- id: generateCronjobId(),
122428
- name: input.name,
122429
- prompt: input.prompt,
122430
- cronExpression: input.cronExpression,
122431
- scope: input.scope,
122432
- workspaceId: input.workspaceId,
122433
- enabled: input.enabled,
122434
- 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
+ }
122435
122640
  };
122436
- file.jobs.push(job);
122437
- this.queries.saveFile(input.key, file);
122438
- this.reloadSchedules();
122439
- 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
+ });
122440
122662
  }
122441
122663
  /**
122442
- * Partially update an existing cronjob.
122443
- *
122444
- * Each optional field is applied only when present, matching the legacy
122445
- * router behavior. If `cronExpression` is supplied it's validated before
122446
- * the write. Throws `CronjobNotFoundError` if no job matches `key`+`id`.
122664
+ * Returns an existing PTY session by terminalId, or undefined.
122447
122665
  */
122448
- update(input) {
122449
- if (input.cronExpression !== void 0) {
122450
- this.assertValidCron(input.cronExpression);
122451
- }
122452
- const file = this.queries.loadFile(input.key);
122453
- const job = file.jobs.find((j) => j.id === input.id);
122454
- if (!job) throw new CronjobNotFoundError();
122455
- if (input.name !== void 0) job.name = input.name;
122456
- if (input.prompt !== void 0) job.prompt = input.prompt;
122457
- if (input.cronExpression !== void 0) job.cronExpression = input.cronExpression;
122458
- if (input.enabled !== void 0) job.enabled = input.enabled;
122459
- this.queries.saveFile(input.key, file);
122460
- this.reloadSchedules();
122461
- return { job };
122666
+ get(terminalId) {
122667
+ return this.terminals.get(terminalId);
122462
122668
  }
122463
122669
  /**
122464
- * Delete a cronjob and re-bind the scheduler.
122465
- *
122466
- * Throws `CronjobNotFoundError` if the job is already gone.
122670
+ * Resize the underlying PTY. No-op if the terminal isn't known to the pool.
122467
122671
  */
122468
- delete(key, id) {
122469
- const file = this.queries.loadFile(key);
122470
- const index3 = file.jobs.findIndex((j) => j.id === id);
122471
- if (index3 === -1) throw new CronjobNotFoundError();
122472
- file.jobs.splice(index3, 1);
122473
- this.queries.saveFile(key, file);
122474
- this.reloadSchedules();
122475
- 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
+ }
122476
122677
  }
122477
122678
  /**
122478
- * Fire a cronjob synchronously (manual "run now" path).
122479
- *
122480
- * Uses the same `band:cronId`-labeled chat as the scheduled fire so the
122481
- * user sees both paths converge on a single dedicated conversation
122482
- * (issue #520). `submitTask` may throw `TaskConflictError`, which the API
122483
- * tier translates into a 409 Conflict response.
122679
+ * List metadata for every PTY session bound to the given workspace.
122484
122680
  *
122485
- * Atomicity caveat: `getOrCreateCronjobChat` and `submitTask` are not run
122486
- * inside a single transaction. If `createChat` succeeds but `submitTask`
122487
- * later throws an unexpected (non-`TaskConflictError`) error, the
122488
- * freshly-created chat remains in the workspace as an orphan with no
122489
- * task ever attached. The next manual `trigger` (or scheduled fire)
122490
- * reuses that chat via its `band:cronId` label, so the orphan
122491
- * self-heals — but the user may briefly see an empty chat pane. The
122492
- * legacy implementation had the same gap; preserved here to match
122493
- * existing behavior. If this ever needs to be tightened, the fix is
122494
- * either (a) wrap both calls in a try/catch that deletes the chat on
122495
- * unexpected failure, or (b) defer `createChat` until `submitTask`
122496
- * succeeds (which would require buffering the chat name/labels on the
122497
- * 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.
122498
122684
  */
122499
- trigger(key, id) {
122500
- const job = this.findJob(key, id);
122501
- if (!job) throw new CronjobNotFoundError();
122502
- const workspaceId = this.resolveWorkspaceId(job, key);
122503
- if (!workspaceId) throw new CronjobProjectNotFoundError();
122504
- const cronChat = this.getOrCreateCronjobChat(workspaceId, job);
122505
- const task = taskService.submitTask({ workspaceId, chatId: cronChat.id, prompt: job.prompt });
122506
- return { taskId: task.id, workspaceId, chatId: cronChat.id };
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;
122507
122707
  }
122508
- // -------------------------------------------------------------------------
122509
- // Lifecycle hooks for workspace/project removal
122510
- // -------------------------------------------------------------------------
122511
122708
  /**
122512
- * Stop all timers for jobs in `key` and remove the file rows.
122513
- *
122514
- * Called when a workspace or project is removed. Order matters: stop
122515
- * timers first so no further fires queue up between the timer-stop and
122516
- * the row-delete. An in-flight fire is already safe in either order
122517
- * because `executeCronjob` operates on closed-over `job`/`fileKey` (it
122518
- * never re-reads the row) and `safeUpdateLastRun` swallows the
122519
- * row-missing case. The two steps were previously two separate calls
122520
- * (`stopJobsForKey` + `deleteCronjobFile`) at every call site — the
122521
- * legacy callers always invoked them in pairs, so they're collapsed
122522
- * here to make the contract harder to misuse.
122523
- *
122524
- * Partial-failure recovery: if `deleteFile` throws (e.g. a transient DB
122525
- * lock), the timers are already stopped but the rows remain in the DB.
122526
- * The next `reloadSchedules()` call — triggered by any subsequent
122527
- * cronjob mutation, or by a server restart — re-reads the rows from the
122528
- * DB and re-registers the timers, so the orphaned rows self-heal
122529
- * without manual intervention. We do NOT roll back the `stop()` calls
122530
- * because re-binding timers for a workspace that's mid-deletion is
122531
- * worse than leaving them stopped: the workspace path is about to be
122532
- * removed and any fire that races the removal would target a
122533
- * non-existent worktree. Re-registration on the next reload is the
122534
- * intentional recovery strategy; operators encountering a partial
122535
- * failure on this path should expect the next reload to be a no-op
122536
- * once the workspace removal completes and the rows are re-attempted.
122709
+ * Returns the scrollback buffer for a terminal, optionally limited to the
122710
+ * last N lines. Returns `null` if the terminal is not found.
122537
122711
  */
122538
- removeForKey(key) {
122539
- this.stopJobsForKey(key);
122540
- this.queries.deleteFile(key);
122541
- }
122542
- // -------------------------------------------------------------------------
122543
- // Scheduler lifecycle
122544
- // -------------------------------------------------------------------------
122545
- /** Start the cronjob scheduler. Called once on server boot. */
122546
- start() {
122547
- const state = schedulerState();
122548
- if (state.started) return;
122549
- state.started = true;
122550
- this.loadAndScheduleAll();
122551
- log20.info("cronjob scheduler started");
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");
122552
122718
  }
122553
- /** Stop the cronjob scheduler. Called on graceful shutdown. */
122554
- stop() {
122555
- const state = schedulerState();
122556
- for (const [, cron] of state.jobs) {
122557
- cron.stop();
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;
122728
+ }
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);
122558
122738
  }
122559
- state.jobs.clear();
122560
- state.started = false;
122561
- 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
+ };
122562
122746
  }
122563
- /** Reload all schedules from the database. Call after cronjob mutations. */
122564
- reloadSchedules() {
122565
- if (!schedulerState().started) return;
122566
- 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
+ }
122567
122763
  }
122568
122764
  /**
122569
- * Stop all scheduled jobs for a key (e.g. when a workspace is removed).
122570
- *
122571
- * Private because the only correct call site is `removeForKey` above:
122572
- * stopping the timers without also dropping the rows would re-schedule
122573
- * the same jobs on the next `reloadSchedules()` tick. Keeping it
122574
- * accessible only through `removeForKey` prevents future callers from
122575
- * accidentally invoking just one half of the pair.
122765
+ * Kill all terminals for a workspace.
122576
122766
  */
122577
- stopJobsForKey(key) {
122578
- const state = schedulerState();
122579
- const file = this.queries.loadFile(key);
122580
- for (const job of file.jobs) {
122581
- const cron = state.jobs.get(job.id);
122582
- if (cron) {
122583
- cron.stop();
122584
- state.jobs.delete(job.id);
122585
- 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);
122586
122775
  }
122587
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;
122588
122844
  }
122589
122845
  // -------------------------------------------------------------------------
122590
- // Shared resolution helpers (used by both the API + the scheduled fire path)
122846
+ // PTY lifecycle
122591
122847
  // -------------------------------------------------------------------------
122592
122848
  /**
122593
- * Find — or create — the dedicated chat this cronjob writes into.
122849
+ * Spawn a new PTY for the given workspace + terminalId.
122594
122850
  *
122595
- * Issue #520: each cronjob owns its own chat in the workspace, looked up
122596
- * by the canonical `band:cronId` label. First call creates the chat;
122597
- * subsequent calls reuse it. If the user deletes the chat, the next call
122598
- * recreates it (intentional soft reset). This replaces the old
122599
- * `getOrCreateDefaultChat` dispatch, which silently latched onto whatever
122600
- * chat the user happened to be focused on. Shared between the scheduled
122601
- * fire path (`executeCronjob` below) and the manual `trigger` route so
122602
- * 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).
122603
122857
  */
122604
- getOrCreateCronjobChat(workspaceId, job) {
122605
- const labelMatch = { [BAND_CRON_ID_LABEL]: job.id };
122606
- const existing = chatService.findByLabels(workspaceId, labelMatch);
122607
- if (existing) return existing;
122608
- return chatService.create(workspaceId, {
122609
- name: job.name,
122610
- labels: labelMatch,
122611
- // Cronjob scheduler is a trusted server-side caller — it's allowed
122612
- // to write the reserved `band:` prefix that user-facing surfaces
122613
- // are blocked from touching.
122614
- 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
122615
122874
  });
122875
+ return session;
122616
122876
  }
122617
122877
  /**
122618
- * Resolve the workspace the cronjob fires into.
122619
- *
122620
- * Returns `null` for project-scoped jobs whose project no longer exists
122621
- * (which the scheduled fire path treats as a soft failure — see
122622
- * `executeCronjob`). The manual trigger route maps the same condition to
122623
- * a `CronjobProjectNotFoundError`.
122624
- *
122625
- * Workspace-scoped jobs return `null` rather than falling through to the
122626
- * project-resolution branch when `workspaceId` is somehow missing. The
122627
- * `create` path already rejects this via `CronjobWorkspaceMissingError`,
122628
- * but a workspace-scoped row written by an older server version could
122629
- * theoretically reach here with a null `workspaceId`. Falling through
122630
- * would silently misfire into the project's default branch; returning
122631
- * `null` makes the failure explicit (logged + `lastRunStatus = "failed"`
122632
- * for the scheduler; `CronjobProjectNotFoundError` for the manual
122633
- * 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.
122634
122881
  */
122635
- resolveWorkspaceId(job, fileKey) {
122636
- if (job.scope === "workspace") {
122637
- 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);
122638
122888
  }
122639
- const appState = loadState();
122640
- const project = appState.projects.find((p6) => p6.name === fileKey);
122641
- if (!project) return null;
122642
- 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();
122643
122915
  }
122644
122916
  // -------------------------------------------------------------------------
122645
- // Internals
122917
+ // Per-terminal accessors
122646
122918
  // -------------------------------------------------------------------------
122647
- findJob(key, id) {
122648
- const file = this.queries.loadFile(key);
122649
- return file.jobs.find((j) => j.id === id);
122919
+ list(workspaceId) {
122920
+ return this.pool.list(workspaceId);
122650
122921
  }
122651
- assertValidCron(expression) {
122652
- try {
122653
- const options2 = { maxRuns: 0 };
122654
- void new E2(expression, options2);
122655
- } catch {
122656
- throw new InvalidCronExpressionError();
122657
- }
122922
+ getSession(terminalId) {
122923
+ return this.pool.get(terminalId);
122658
122924
  }
122659
- scheduleJob(job, fileKey) {
122660
- const state = schedulerState();
122661
- const existing = state.jobs.get(job.id);
122662
- if (existing) {
122663
- existing.stop();
122664
- state.jobs.delete(job.id);
122665
- }
122666
- if (!job.enabled) return;
122667
- try {
122668
- const cronInstance = new E2(job.cronExpression, () => {
122669
- this.executeCronjob(job, fileKey).catch((err) => {
122670
- log20.error({ jobId: job.id, err }, "unhandled error in cronjob execution");
122671
- });
122672
- });
122673
- state.jobs.set(job.id, cronInstance);
122674
- log20.info(
122675
- { jobId: job.id, name: job.name, cron: job.cronExpression, scope: job.scope },
122676
- "scheduled cronjob"
122677
- );
122678
- } catch (err) {
122679
- log20.error(
122680
- { jobId: job.id, cronExpression: job.cronExpression, err },
122681
- "invalid cron expression, skipping job"
122682
- );
122683
- }
122925
+ getScrollback(terminalId, lines) {
122926
+ return this.pool.getScrollback(terminalId, lines);
122684
122927
  }
122685
- async executeCronjob(job, fileKey) {
122686
- const workspaceId = this.resolveWorkspaceId(job, fileKey);
122687
- if (!workspaceId) {
122688
- log20.warn({ jobId: job.id, fileKey }, "project not found for cronjob, skipping");
122689
- this.safeUpdateLastRun(job.id, "failed");
122690
- return;
122691
- }
122692
- log20.info({ jobId: job.id, name: job.name, workspaceId }, "executing cronjob");
122693
- try {
122694
- const chat = this.getOrCreateCronjobChat(workspaceId, job);
122695
- const task = taskService.submitTask({ workspaceId, chatId: chat.id, prompt: job.prompt });
122696
- log20.info(
122697
- { jobId: job.id, taskId: task.id, workspaceId, chatId: chat.id },
122698
- "cronjob task submitted"
122699
- );
122700
- this.safeUpdateLastRun(job.id, "completed");
122701
- } catch (err) {
122702
- if (err instanceof TaskConflictError) {
122703
- log20.info(
122704
- { jobId: job.id, workspaceId },
122705
- "task already running, skipping cronjob execution"
122706
- );
122707
- this.safeUpdateLastRun(job.id, "skipped");
122708
- return;
122709
- }
122710
- log20.error({ jobId: job.id, err }, "cronjob execution failed");
122711
- this.safeUpdateLastRun(job.id, "failed");
122712
- }
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);
122713
122949
  }
122950
+ saveLayout(workspaceId, tree) {
122951
+ saveTerminalLayout(workspaceId, tree);
122952
+ }
122953
+ deleteLayout(workspaceId) {
122954
+ deleteTerminalLayout(workspaceId);
122955
+ }
122956
+ // -------------------------------------------------------------------------
122957
+ // Workspace `.band/config.json` `workspace.terminal` block
122958
+ // -------------------------------------------------------------------------
122714
122959
  /**
122715
- * 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.
122716
122964
  *
122717
- * Errors here are diagnostic noise during the fire path — the cronjob
122718
- * already executed (or failed/skipped), and we don't want a transient
122719
- * DB lock to mask the real outcome by re-raising. The legacy
122720
- * 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.
122721
122969
  */
122722
- safeUpdateLastRun(jobId, status) {
122723
- try {
122724
- this.queries.updateLastRun(jobId, status);
122725
- } catch (err) {
122726
- log20.warn({ jobId, err }, "failed to update lastRun on cronjob");
122727
- }
122970
+ getWorkspaceConfig(workspaceId) {
122971
+ const workspace = workspaceService.resolve(workspaceId);
122972
+ if (!workspace) return null;
122973
+ return this.loadWorkspaceConfigFromPaths(workspace.worktree.path, workspace.project.path);
122728
122974
  }
122729
- loadAndScheduleAll() {
122730
- const state = schedulerState();
122731
- for (const [, cron] of state.jobs) {
122732
- cron.stop();
122733
- }
122734
- state.jobs.clear();
122735
- for (const job of this.queries.listAll()) {
122736
- 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;
122737
122991
  }
122738
- log20.info({ count: state.jobs.size }, "loaded cronjob schedules");
122992
+ return result.data;
122739
122993
  }
122740
122994
  };
122741
- var cronjobService = new CronjobService();
122995
+ var terminalService = new TerminalService();
122742
122996
 
122743
- // src/server/services/panel-focus-service.ts
122744
- init_src();
122745
- var log21 = createLogger("panel-focus-service");
122746
- var PANEL_FOCUS_TYPE = "panel_focus";
122747
- function focusRowId(workspaceId) {
122748
- 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
+ };
122749
123007
  }
122750
- var PanelFocusService = class {
122751
- /** workspaceId → last-focused panel ids. */
122752
- focus = /* @__PURE__ */ new Map();
122753
- /**
122754
- * Workspaces whose focus row has been loaded from the DB into `focus`.
122755
- * Separate from the map's own key presence because a workspace can have a
122756
- * loaded-but-empty record (row absent) — we still don't want to re-hit the
122757
- * DB on every read.
122758
- */
122759
- hydrated = /* @__PURE__ */ new Set();
122760
- /** Workspaces that already have a `panel_states` row (drives insert vs update). */
122761
- 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
+ // -------------------------------------------------------------------------
122762
123073
  /**
122763
- * Load a workspace's focus row from the DB on first access. Cheap and
122764
- * 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.
122765
123081
  */
122766
- ensureHydrated(workspaceId) {
122767
- if (this.hydrated.has(workspaceId)) return;
122768
- this.hydrated.add(workspaceId);
122769
- const rows = listPanelStatesForWorkspace(workspaceId, PANEL_FOCUS_TYPE);
122770
- const row = rows[0];
122771
- if (!row) return;
122772
- this.persisted.add(workspaceId);
122773
- try {
122774
- const parsed = JSON.parse(row.state);
122775
- this.focus.set(workspaceId, {
122776
- chat: parsed.chat,
122777
- terminal: parsed.terminal,
122778
- browser: parsed.browser
122779
- });
122780
- } catch (err) {
122781
- 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 })) };
122782
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() };
122783
123096
  }
122784
- /** Get the last-focused panel ids for a workspace. */
122785
- get(workspaceId) {
122786
- this.ensureHydrated(workspaceId);
122787
- 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 };
122788
123102
  }
123103
+ // -------------------------------------------------------------------------
123104
+ // Mutations
123105
+ // -------------------------------------------------------------------------
122789
123106
  /**
122790
- * Record that `panelId` is the last-focused panel of `panelType` in
122791
- * `workspaceId`. Write-through: updates the in-memory map and upserts the
122792
- * 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`.
122793
123114
  */
122794
- set(workspaceId, panelType, panelId) {
122795
- this.ensureHydrated(workspaceId);
122796
- const current2 = this.focus.get(workspaceId) ?? {};
122797
- if (current2[panelType] === panelId) return;
122798
- const next = { ...current2, [panelType]: panelId };
122799
- this.focus.set(workspaceId, next);
122800
- const now = Date.now();
122801
- const state = JSON.stringify(next);
122802
- if (this.persisted.has(workspaceId)) {
122803
- updatePanelState(focusRowId(workspaceId), { state, updatedAt: now });
122804
- } else {
122805
- insertPanelState({
122806
- id: focusRowId(workspaceId),
122807
- workspaceId,
122808
- panelType: PANEL_FOCUS_TYPE,
122809
- state,
122810
- createdAt: now,
122811
- updatedAt: now
122812
- });
122813
- this.persisted.add(workspaceId);
123115
+ create(input) {
123116
+ this.assertValidCron(input.cronExpression);
123117
+ if (input.scope === "workspace" && !input.workspaceId) {
123118
+ throw new CronjobWorkspaceMissingError();
122814
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 };
122815
123138
  }
122816
123139
  /**
122817
- * Drop all focus tracking for a workspace. Called from the workspace delete
122818
- * 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`.
122819
123145
  */
122820
- remove(workspaceId) {
122821
- this.focus.delete(workspaceId);
122822
- this.hydrated.delete(workspaceId);
122823
- this.persisted.delete(workspaceId);
122824
- 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 };
122825
123160
  }
122826
- };
122827
- var panelFocusService = new PanelFocusService();
122828
-
122829
- // src/server/services/terminal-service.ts
122830
- init_src();
122831
- init_zod();
122832
-
122833
- // src/server/infra/terminals/terminal-pool.ts
122834
- init_src();
122835
- import { randomUUID as randomUUID2 } from "node:crypto";
122836
- import { existsSync as existsSync8, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
122837
- import { tmpdir } from "node:os";
122838
- import { join as join19, sep as sep3 } from "node:path";
122839
- var log22 = createLogger("terminal-pool");
122840
- var MAX_SCROLLBACK_SIZE = 1e5;
122841
- var TerminalPool = class {
122842
- /** terminalId -> session */
122843
- terminals = /* @__PURE__ */ new Map();
122844
- /** workspaceId -> Set<terminalId> (reverse index for workspace-level cleanup) */
122845
- workspaceTerminals = /* @__PURE__ */ new Map();
122846
- /** terminalId -> Set<listener> for live output streaming */
122847
- outputListeners = /* @__PURE__ */ new Map();
122848
- /**
122849
- * terminalId -> in-flight spawn promise. A single terminal is created via TWO
122850
- * concurrent paths — the WebSocket handler (spawn-on-`getSession`-miss) and
122851
- * the tRPC `terminal.create` mutation. Without deduplication both spawn a PTY
122852
- * and the second `terminals.set` overwrites the first, so the client stays
122853
- * wired to one PTY while the server's session map (and thus scrollback /
122854
- * `terminals output` / reconnect-replay) points at the other. That's the
122855
- * "output on screen but empty server scrollback until reload" bug
122856
- * (band-app/band#617). This map makes concurrent spawns share ONE PTY.
122857
- */
122858
- spawning = /* @__PURE__ */ new Map();
122859
123161
  /**
122860
- * Spawn (or return the already-spawning/spawned) PTY for a terminalId.
123162
+ * Delete a cronjob and re-bind the scheduler.
122861
123163
  *
122862
- * Idempotent per terminalId: if a session already exists it is returned
122863
- * as-is, and concurrent spawn calls for the same id share a single in-flight
122864
- * promise — so the WebSocket and tRPC create paths never create competing
122865
- * PTYs. `workspaceRoot` is the absolute worktree path; resolving the
122866
- * workspaceId to a path is the service tier's job so the pool stays oblivious
122867
- * to the workspace registry.
123164
+ * Throws `CronjobNotFoundError` if the job is already gone.
122868
123165
  */
122869
- async spawn(workspaceId, terminalId, workspaceRoot, options2) {
122870
- const existing = this.terminals.get(terminalId);
122871
- if (existing) return existing;
122872
- const inflight = this.spawning.get(terminalId);
122873
- if (inflight) return inflight;
122874
- const promise = this.spawnNew(workspaceId, terminalId, workspaceRoot, options2);
122875
- this.spawning.set(terminalId, promise);
122876
- try {
122877
- return await promise;
122878
- } finally {
122879
- this.spawning.delete(terminalId);
122880
- }
122881
- }
122882
- async spawnNew(workspaceId, terminalId, workspaceRoot, options2) {
122883
- const shell = defaultShell();
122884
- const resolvedPath = await shellPath();
122885
- const env = {};
122886
- for (const [key, value] of Object.entries(process.env)) {
122887
- if (value != null) {
122888
- env[key] = value;
122889
- }
122890
- }
122891
- env.PATH = resolvedPath;
122892
- env.TERM = "xterm-256color";
122893
- env.COLORTERM = "truecolor";
122894
- env.COLORFGBG = "15;0";
122895
- delete env.PORT;
122896
- if (options2?.env) {
122897
- Object.assign(env, options2.env);
122898
- }
122899
- env.BAND_DISPATCH = "terminal";
122900
- let cwd = workspaceRoot;
122901
- if (options2?.cwd) {
122902
- const resolved = join19(workspaceRoot, options2.cwd);
122903
- if (resolved !== workspaceRoot && !resolved.startsWith(workspaceRoot + sep3)) {
122904
- log22.warn(
122905
- "Ignoring cwd %s \u2014 resolves outside workspace root %s",
122906
- options2.cwd,
122907
- workspaceRoot
122908
- );
122909
- } else if (existsSync8(resolved)) {
122910
- cwd = resolved;
122911
- } else {
122912
- log22.warn("Ignoring cwd %s \u2014 directory does not exist", options2.cwd);
122913
- }
122914
- }
122915
- if (!existsSync8(cwd)) {
122916
- throw new Error(`Workspace directory does not exist: ${cwd}`);
122917
- }
122918
- if (!existsSync8(shell)) {
122919
- throw new Error(`Shell not found: ${shell}`);
122920
- }
122921
- log22.debug(
122922
- "Spawning shell %s in %s for terminal %s (PATH=%s)",
122923
- shell,
122924
- cwd,
122925
- terminalId,
122926
- resolvedPath.slice(0, 200)
122927
- );
122928
- const nodePty = await import("node-pty");
122929
- let ptyProcess;
122930
- try {
122931
- ptyProcess = nodePty.spawn(shell, [], {
122932
- name: "xterm-256color",
122933
- cols: 80,
122934
- rows: 24,
122935
- cwd,
122936
- env
122937
- });
122938
- } catch (err) {
122939
- const msg = err instanceof Error ? err.message : String(err);
122940
- log22.error("pty.spawn failed: %s (shell=%s, cwd=%s)", msg, shell, cwd);
122941
- throw err;
122942
- }
122943
- const session = { pty: ptyProcess, scrollback: "", workspaceId };
122944
- this.terminals.set(terminalId, session);
122945
- if (options2?.command) {
122946
- this.autoRunCommand(session, options2.command);
122947
- }
122948
- let ids = this.workspaceTerminals.get(workspaceId);
122949
- if (!ids) {
122950
- ids = /* @__PURE__ */ new Set();
122951
- 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);
122952
123174
  }
122953
- ids.add(terminalId);
122954
- ptyProcess.onData((data) => {
122955
- session.scrollback += data;
122956
- if (session.scrollback.length > MAX_SCROLLBACK_SIZE) {
122957
- session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_SIZE);
122958
- }
122959
- const listeners3 = this.outputListeners.get(terminalId);
122960
- if (listeners3) {
122961
- for (const cb2 of listeners3) {
122962
- try {
122963
- cb2(data);
122964
- } catch {
122965
- }
122966
- }
122967
- }
122968
- });
122969
- ptyProcess.onExit(() => {
122970
- log22.debug("Terminal exited: %s (workspace %s)", terminalId, workspaceId);
122971
- this.terminals.delete(terminalId);
122972
- this.outputListeners.delete(terminalId);
122973
- const set = this.workspaceTerminals.get(workspaceId);
122974
- if (set) {
122975
- set.delete(terminalId);
122976
- if (set.size === 0) {
122977
- this.workspaceTerminals.delete(workspaceId);
122978
- }
122979
- }
122980
- if (session.autoRunFile) {
122981
- try {
122982
- unlinkSync2(session.autoRunFile);
122983
- } catch {
122984
- }
122985
- }
122986
- });
122987
- return session;
123175
+ this.reloadSchedules();
123176
+ return { ok: true };
122988
123177
  }
122989
123178
  /**
122990
- * Auto-run the workspace's initial command inside a freshly spawned PTY,
122991
- * robustly.
122992
- *
122993
- * Writing a long command line straight into a cold PTY is racy on three
122994
- * fronts, all of which surfaced in production with `via=terminal`
122995
- * prompt-as-argv launches (`'<agent-binary>' '<entire-prompt>'`):
123179
+ * Fire a cronjob synchronously (manual "run now" path).
122996
123180
  *
122997
- * 1. **Dropped newline.** The shell may not have finished sourcing its
122998
- * rc files, and the tty can still be in its startup canonical-mode
122999
- * window. A trailing `\n` written then is swallowed — the command
123000
- * is typed at the prompt but never executed.
123001
- * 2. **Truncation.** A single input line longer than the tty's
123002
- * canonical buffer (`MAX_CANON`, ~4 KB) is clipped. Long prompts
123003
- * blow past that easily.
123004
- * 3. **UTF-8 corruption.** A multi-byte sequence split across a tty
123005
- * 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.
123006
123185
  *
123007
- * Fix both layers: (1) stage the command's bytes in a temp file so they
123008
- * never traverse the tty's canonical input buffer — sidestepping the
123009
- * truncation and UTF-8-split failures entirely — and (2) wait for the
123010
- * shell to emit its first output (its prompt) before writing the
123011
- * *short* `source <file>` line. The short line is well under the
123012
- * canonical limit and lands after the cold-start window, so its newline
123013
- * survives. `source` runs the command in the current interactive shell,
123014
- * 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).
123015
123199
  */
123016
- autoRunCommand(session, command) {
123017
- const pty = session.pty;
123018
- let cmdFile;
123019
- try {
123020
- cmdFile = join19(tmpdir(), `band-autorun-${randomUUID2()}.sh`);
123021
- writeFileSync2(cmdFile, `${command}
123022
- `, { encoding: "utf-8", mode: 384, flag: "wx" });
123023
- session.autoRunFile = cmdFile;
123024
- } catch (err) {
123025
- log22.warn(
123026
- "autoRunCommand: temp-file staging failed (%s); writing command directly",
123027
- err instanceof Error ? err.message : String(err)
123028
- );
123029
- pty.write(`${command}
123030
- `);
123031
- return;
123032
- }
123033
- const quotedPath = cmdFile.replace(/'/g, `'\\''`);
123034
- const sourceLine = `. '${quotedPath}'
123035
- `;
123036
- let done = false;
123037
- const inject = () => {
123038
- if (done) return;
123039
- done = true;
123040
- try {
123041
- pty.write(sourceLine);
123042
- } catch (err) {
123043
- 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 };
123044
123209
  }
123045
- };
123046
- let dataDisposed = false;
123047
- const disposeData = () => {
123048
- if (dataDisposed) return;
123049
- dataDisposed = true;
123050
- dataDisposable.dispose();
123051
- };
123052
- const dataDisposable = pty.onData(() => {
123053
- disposeData();
123054
- inject();
123055
- });
123056
- const timer = setTimeout(inject, 750);
123057
- timer.unref();
123058
- let exitDisposed = false;
123059
- const exitDisposable = pty.onExit(() => {
123060
- done = true;
123061
- clearTimeout(timer);
123062
- disposeData();
123063
- if (exitDisposed) return;
123064
- exitDisposed = true;
123065
- exitDisposable.dispose();
123066
- });
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 };
123067
123214
  }
123215
+ // -------------------------------------------------------------------------
123216
+ // Lifecycle hooks for workspace/project removal
123217
+ // -------------------------------------------------------------------------
123068
123218
  /**
123069
- * 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.
123070
123244
  */
123071
- get(terminalId) {
123072
- return this.terminals.get(terminalId);
123245
+ removeForKey(key) {
123246
+ this.stopJobsForKey(key);
123247
+ this.queries.deleteFile(key);
123073
123248
  }
123074
- /**
123075
- * Resize the underlying PTY. No-op if the terminal isn't known to the pool.
123076
- */
123077
- resize(terminalId, cols, rows) {
123078
- const session = this.terminals.get(terminalId);
123079
- if (session) {
123080
- 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();
123081
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();
123082
123274
  }
123083
123275
  /**
123084
- * 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).
123085
123277
  *
123086
- * The returned `title` is the foreground process name reported by the
123087
- * PTY; reading `pty.process` can throw if the process has already exited
123088
- * 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.
123089
123283
  */
123090
- list(workspaceId) {
123091
- const ids = this.workspaceTerminals.get(workspaceId);
123092
- if (!ids) return [];
123093
- const result = [];
123094
- for (const terminalId of ids) {
123095
- const session = this.terminals.get(terminalId);
123096
- if (session) {
123097
- let title = "";
123098
- try {
123099
- title = session.pty.process;
123100
- } catch {
123101
- }
123102
- result.push({
123103
- terminalId,
123104
- workspaceId,
123105
- pid: session.pty.pid,
123106
- scrollbackLength: session.scrollback.length,
123107
- title
123108
- });
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");
123109
123293
  }
123110
123294
  }
123111
- return result;
123112
- }
123113
- /**
123114
- * Returns the scrollback buffer for a terminal, optionally limited to the
123115
- * last N lines. Returns `null` if the terminal is not found.
123116
- */
123117
- getScrollback(terminalId, lines) {
123118
- const session = this.terminals.get(terminalId);
123119
- if (!session) return null;
123120
- if (lines == null) return session.scrollback;
123121
- const allLines = session.scrollback.split("\n");
123122
- return allLines.slice(-lines).join("\n");
123123
123295
  }
123296
+ // -------------------------------------------------------------------------
123297
+ // Shared resolution helpers (used by both the API + the scheduled fire path)
123298
+ // -------------------------------------------------------------------------
123124
123299
  /**
123125
- * Writes data to a terminal's PTY stdin.
123126
- * 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.
123127
123310
  */
123128
- write(terminalId, data) {
123129
- const session = this.terminals.get(terminalId);
123130
- if (!session) return false;
123131
- session.pty.write(data);
123132
- 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
+ });
123133
123323
  }
123134
123324
  /**
123135
- * Subscribe to live output from a terminal's PTY.
123136
- * 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.
123137
123363
  */
123138
- subscribeOutput(terminalId, callback) {
123139
- let listeners3 = this.outputListeners.get(terminalId);
123140
- if (!listeners3) {
123141
- listeners3 = /* @__PURE__ */ new Set();
123142
- 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`);
123143
123368
  }
123144
- listeners3.add(callback);
123145
- return () => {
123146
- listeners3.delete(callback);
123147
- if (listeners3.size === 0) {
123148
- 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 };
123149
123378
  }
123150
- };
123151
- }
123152
- /**
123153
- * Kill a single terminal by terminalId.
123154
- */
123155
- kill(terminalId) {
123156
- const session = this.terminals.get(terminalId);
123157
- if (session) {
123158
- session.pty.kill();
123159
- this.terminals.delete(terminalId);
123160
- const set = this.workspaceTerminals.get(session.workspaceId);
123161
- if (set) {
123162
- set.delete(terminalId);
123163
- if (set.size === 0) {
123164
- this.workspaceTerminals.delete(session.workspaceId);
123165
- }
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`);
123166
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);
123167
123403
  }
123168
123404
  }
123169
123405
  /**
123170
- * 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).
123171
123422
  */
123172
- killWorkspace(workspaceId) {
123173
- const ids = this.workspaceTerminals.get(workspaceId);
123174
- if (!ids) return;
123175
- for (const terminalId of ids) {
123176
- const session = this.terminals.get(terminalId);
123177
- if (session) {
123178
- session.pty.kill();
123179
- this.terminals.delete(terminalId);
123180
- }
123423
+ resolveWorkspaceId(job, fileKey) {
123424
+ if (job.scope === "workspace") {
123425
+ return job.workspaceId ?? null;
123181
123426
  }
123182
- 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);
123183
123431
  }
123184
- /**
123185
- * Kill every tracked PTY (server shutdown path).
123186
- */
123187
- killAll() {
123188
- for (const [, session] of this.terminals) {
123189
- 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();
123190
123445
  }
123191
- this.terminals.clear();
123192
- this.workspaceTerminals.clear();
123193
123446
  }
123194
- };
123195
- var terminalPool = new TerminalPool();
123196
-
123197
- // src/server/services/_utils/terminal-layout-manager.ts
123198
- var manager = new DockviewLayoutManager("terminal_layout");
123199
- var getTerminalLayout = (workspaceId) => manager.get(workspaceId);
123200
- var saveTerminalLayout = (workspaceId, tree) => manager.save(workspaceId, tree);
123201
- var deleteTerminalLayout = (workspaceId) => manager.delete(workspaceId);
123202
- function addTerminalToLayout(workspaceId, terminalId, opts) {
123203
- manager.addPanel(workspaceId, {
123204
- id: terminalId,
123205
- contentComponent: "terminalTab",
123206
- tabComponent: "terminalTab",
123207
- title: opts?.title ?? "Terminal",
123208
- params: {
123209
- workspaceId,
123210
- terminalId,
123211
- ...opts?.command ? { command: opts.command } : {},
123212
- ...opts?.cwd ? { cwd: opts.cwd } : {},
123213
- ...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");
123214
123526
  }
123215
- });
123216
- }
123217
- function removeTerminalFromLayout(workspaceId, terminalId) {
123218
- manager.removePanel(workspaceId, terminalId);
123219
- }
123220
-
123221
- // src/server/services/terminal-service.ts
123222
- var log23 = createLogger("terminal-service");
123223
- var TerminalPaneConfigSchema = external_exports.object({
123224
- name: external_exports.string().optional(),
123225
- command: external_exports.string().optional(),
123226
- cwd: external_exports.string().optional(),
123227
- env: external_exports.record(external_exports.string()).optional(),
123228
- focus: external_exports.boolean().optional()
123229
- });
123230
- var PaneNodeSchema = external_exports.object({
123231
- pane: TerminalPaneConfigSchema
123232
- });
123233
- var TerminalLayoutNodeSchema = external_exports.lazy(
123234
- () => external_exports.union([
123235
- PaneNodeSchema,
123236
- external_exports.object({
123237
- direction: external_exports.enum(["horizontal", "vertical"]),
123238
- split: external_exports.number().min(0.1).max(0.9).optional().default(0.5),
123239
- children: external_exports.tuple([TerminalLayoutNodeSchema, TerminalLayoutNodeSchema])
123240
- })
123241
- ])
123242
- );
123243
- var WorkspaceTerminalConfigSchema = external_exports.object({
123244
- layout: TerminalLayoutNodeSchema
123245
- });
123246
- var TerminalService = class {
123247
- constructor(pool2 = terminalPool) {
123248
- this.pool = pool2;
123249
123527
  }
123250
- // -------------------------------------------------------------------------
123251
- // PTY lifecycle
123252
- // -------------------------------------------------------------------------
123253
123528
  /**
123254
- * Spawn a new PTY for the given workspace + terminalId.
123529
+ * Update `lastRunAt`/`lastRunStatus`, swallowing DB errors.
123255
123530
  *
123256
- * Resolves `workspaceId` to a worktree path before delegating to the
123257
- * pool, and registers the new terminal in the saved dockview layout so it
123258
- * survives a server restart (mirrors `chatService.create` /
123259
- * `browserService.create`). Does NOT emit a `terminal-created` event —
123260
- * the API entry points decide whether to broadcast (the WebSocket handler
123261
- * 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.
123262
123535
  */
123263
- async spawn(workspaceId, terminalId, options2) {
123264
- const workspace = workspaceService.resolve(workspaceId);
123265
- if (!workspace) {
123266
- 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");
123267
123541
  }
123268
- const session = await this.pool.spawn(
123269
- workspaceId,
123270
- terminalId,
123271
- workspace.worktree.path,
123272
- options2
123273
- );
123274
- addTerminalToLayout(workspaceId, terminalId, {
123275
- command: options2?.command,
123276
- cwd: options2?.cwd,
123277
- env: options2?.env
123278
- });
123279
- return session;
123280
123542
  }
123281
- /**
123282
- * Kill a single terminal. Removes it from the saved dockview layout and
123283
- * emits `terminal-killed` so the dashboard's status stream prunes the
123284
- * panel. Safe to call with an unknown terminalId — no-op.
123285
- */
123286
- kill(terminalId) {
123287
- const session = this.pool.get(terminalId);
123288
- const workspaceId = session?.workspaceId;
123289
- this.pool.kill(terminalId);
123290
- if (workspaceId) {
123291
- removeTerminalFromLayout(workspaceId, terminalId);
123292
- 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);
123293
123551
  }
123552
+ log22.info({ count: state.jobs.size }, "loaded cronjob schedules");
123294
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();
123295
123567
  /**
123296
- * Kill every PTY associated with a workspace. Used by the workspace
123297
- * deletion path — the caller is responsible for tearing down the layout
123298
- * 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.
123299
123572
  */
123300
- killWorkspace(workspaceId) {
123301
- this.pool.killWorkspace(workspaceId);
123302
- }
123573
+ hydrated = /* @__PURE__ */ new Set();
123574
+ /** Workspaces that already have a `panel_states` row (drives insert vs update). */
123575
+ persisted = /* @__PURE__ */ new Set();
123303
123576
  /**
123304
- * Kill every tracked PTY across all workspaces. Used by the server
123305
- * 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.
123306
123579
  */
123307
- killAll() {
123308
- this.pool.killAll();
123309
- }
123310
- // -------------------------------------------------------------------------
123311
- // Per-terminal accessors
123312
- // -------------------------------------------------------------------------
123313
- list(workspaceId) {
123314
- return this.pool.list(workspaceId);
123315
- }
123316
- getSession(terminalId) {
123317
- return this.pool.get(terminalId);
123318
- }
123319
- getScrollback(terminalId, lines) {
123320
- return this.pool.getScrollback(terminalId, lines);
123321
- }
123322
- write(terminalId, data) {
123323
- return this.pool.write(terminalId, data);
123324
- }
123325
- resize(terminalId, cols, rows) {
123326
- this.pool.resize(terminalId, cols, rows);
123327
- }
123328
- subscribeOutput(terminalId, callback) {
123329
- return this.pool.subscribeOutput(terminalId, callback);
123330
- }
123331
- // -------------------------------------------------------------------------
123332
- // Layout persistence (dockview tree)
123333
- //
123334
- // Thin pass-throughs to `lib/terminal-layout-manager` so the API tier
123335
- // doesn't reach across into `lib/` directly. The layout helpers will be
123336
- // folded into a dedicated infra adapter in a follow-up phase (when the
123337
- // chats/browsers domains get their own infra adapters); leaving them in
123338
- // `lib/` for now is the minimum-disruption shape during the phased
123339
- // migration.
123340
- // -------------------------------------------------------------------------
123341
- getLayout(workspaceId) {
123342
- return getTerminalLayout(workspaceId);
123343
- }
123344
- saveLayout(workspaceId, tree) {
123345
- 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
+ }
123346
123597
  }
123347
- deleteLayout(workspaceId) {
123348
- 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) ?? {} };
123349
123602
  }
123350
- // -------------------------------------------------------------------------
123351
- // Workspace `.band/config.json` `workspace.terminal` block
123352
- // -------------------------------------------------------------------------
123353
123603
  /**
123354
- * Load and validate the `workspace.terminal` block from
123355
- * `.band/config.json`. Returns `null` when the workspace can't be
123356
- * resolved, the config file is absent, the block is missing, or the
123357
- * payload fails validation.
123358
- *
123359
- * Absorbed from the old `lib/terminal-config.ts:loadWorkspaceTerminalConfig`
123360
- * — same parsing semantics, but the lookup now goes through the service
123361
- * tier so callers (currently the workspace router's `getTerminalConfig`
123362
- * 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.
123363
123607
  */
123364
- getWorkspaceConfig(workspaceId) {
123365
- const workspace = workspaceService.resolve(workspaceId);
123366
- if (!workspace) return null;
123367
- 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
+ }
123368
123629
  }
123369
123630
  /**
123370
- * Internal: the path-driven parse so tests / future non-tRPC entry
123371
- * 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.
123372
123633
  */
123373
- loadWorkspaceConfigFromPaths(worktreePath, projectPath) {
123374
- const raw = loadProjectConfig(worktreePath, projectPath);
123375
- if (!raw) return null;
123376
- const terminalBlock = raw.workspace && typeof raw.workspace === "object" ? raw.workspace.terminal : void 0;
123377
- if (!terminalBlock) return null;
123378
- const result = WorkspaceTerminalConfigSchema.safeParse(terminalBlock);
123379
- if (!result.success) {
123380
- log23.warn(
123381
- "Invalid workspace.terminal config: %s",
123382
- result.error.issues.map((i2) => `${i2.path.join(".")}: ${i2.message}`).join("; ")
123383
- );
123384
- return null;
123385
- }
123386
- 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));
123387
123639
  }
123388
123640
  };
123389
- var terminalService = new TerminalService();
123641
+ var panelFocusService = new PanelFocusService();
123390
123642
 
123391
123643
  // src/server/services/workspace-service.ts
123392
123644
  var execFileAsync2 = promisify2(execFile6);
@@ -123608,7 +123860,7 @@ var WorkspaceService = class {
123608
123860
  via = "chat";
123609
123861
  } else {
123610
123862
  terminalCommand = formatShellCommand(invocation.command, invocation.args);
123611
- terminalId = randomUUID3();
123863
+ terminalId = randomUUID4();
123612
123864
  }
123613
123865
  } catch (err) {
123614
123866
  log24.warn(
@@ -123697,7 +123949,7 @@ var WorkspaceService = class {
123697
123949
  };
123698
123950
  }
123699
123951
  const command = formatShellCommand(invocation.command, invocation.args);
123700
- const terminalId = randomUUID3();
123952
+ const terminalId = randomUUID4();
123701
123953
  await terminalService.spawn(chat.workspaceId, terminalId, { command });
123702
123954
  emit({ kind: "terminal-created", workspaceId: chat.workspaceId, terminalId });
123703
123955
  log24.info(
@@ -137690,9 +137942,9 @@ var cronjobsRouter = t2.router({
137690
137942
  throwAsTRPCError(err);
137691
137943
  }
137692
137944
  }),
137693
- trigger: publicProcedure.input(cronjobByIdInput).mutation(({ input }) => {
137945
+ trigger: publicProcedure.input(cronjobByIdInput).mutation(async ({ input }) => {
137694
137946
  try {
137695
- return cronjobService.trigger(input.key, input.id);
137947
+ return await cronjobService.trigger(input.key, input.id);
137696
137948
  } catch (err) {
137697
137949
  throwAsTRPCError(err);
137698
137950
  }
@@ -137706,10 +137958,7 @@ function throwAsTRPCError(err) {
137706
137958
  throw new TRPCError({ code: "BAD_REQUEST", message: err.message });
137707
137959
  }
137708
137960
  if (err instanceof TaskConflictError) {
137709
- throw new TRPCError({
137710
- code: "CONFLICT",
137711
- message: "Task already running for this chat pane"
137712
- });
137961
+ throw new TRPCError({ code: "CONFLICT", message: err.message });
137713
137962
  }
137714
137963
  throw err;
137715
137964
  }
@@ -140479,7 +140728,7 @@ function throwAsTrpcError2(err) {
140479
140728
  }
140480
140729
 
140481
140730
  // src/server/api/terminals/router.ts
140482
- import { randomUUID as randomUUID4 } from "node:crypto";
140731
+ import { randomUUID as randomUUID5 } from "node:crypto";
140483
140732
  init_zod();
140484
140733
 
140485
140734
  // src/server/api/terminals/strip-queries.ts
@@ -140505,7 +140754,7 @@ var terminalRouter = t2.router({
140505
140754
  env: external_exports.record(external_exports.string()).optional()
140506
140755
  })
140507
140756
  ).mutation(async ({ input }) => {
140508
- const terminalId = input.id ?? randomUUID4();
140757
+ const terminalId = input.id ?? randomUUID5();
140509
140758
  const session = await terminalService.spawn(input.workspaceId, terminalId, {
140510
140759
  command: input.command,
140511
140760
  cwd: input.cwd,