very-happy-cli 0.2.164 → 0.2.166

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 (190) hide show
  1. package/README.md +1 -1
  2. package/dist/{AcpBackend-CHDqSii9.cjs → AcpBackend-20VHOXwb.cjs} +3 -3
  3. package/dist/{AcpBackend-B8l1ZePt.mjs → AcpBackend-tRWk7yi7.mjs} +3 -3
  4. package/dist/{AcpSessionManager-BQnDJvct.mjs → AcpSessionManager-B7FIrsys.mjs} +1 -1
  5. package/dist/{AcpSessionManager-9yN2iH4b.cjs → AcpSessionManager-Zf00QnlL.cjs} +1 -1
  6. package/dist/{BasePermissionHandler-DgtAtJTV.mjs → BasePermissionHandler-CDCLIrZJ.mjs} +1 -1
  7. package/dist/{BasePermissionHandler-DGQYvBPg.cjs → BasePermissionHandler-COKc4pmt.cjs} +1 -1
  8. package/dist/{BaseReasoningProcessor-Dfacnfhj.mjs → BaseReasoningProcessor-BDVKvooR.mjs} +1 -1
  9. package/dist/{BaseReasoningProcessor-BqalZhQO.cjs → BaseReasoningProcessor-DAGQdkNp.cjs} +1 -1
  10. package/dist/{agentGuidance-CQuNlNx8.mjs → agentGuidance-BDYt2Qy2.mjs} +4 -2
  11. package/dist/{agentGuidance-B5NATuFa.cjs → agentGuidance-BtBx2xxE.cjs} +4 -1
  12. package/dist/{agentHome-DM0urHEI.mjs → agentHome-2IbIjKCa.mjs} +4 -4
  13. package/dist/{agentHome-DtsPKfTS.cjs → agentHome-KVWj6nCl.cjs} +4 -4
  14. package/dist/{api-Yoh_zKdR.mjs → api-Cmz3XS4P.mjs} +14 -12
  15. package/dist/{api-EKlS0r1w.cjs → api-DnNL5fLw.cjs} +14 -12
  16. package/dist/{apiMachine-s3HiV-SL.mjs → apiMachine-B6ySCFe0.mjs} +2137 -65
  17. package/dist/{apiMachine-q71suojS.cjs → apiMachine-DeoQ8QbZ.cjs} +2134 -62
  18. package/dist/{attachmentContent-BLyyo1al.mjs → attachmentContent-CTXapi-_.mjs} +2 -2
  19. package/dist/{attachmentContent-Bakb0XV3.cjs → attachmentContent-DPWxHgX4.cjs} +2 -2
  20. package/dist/{attachmentLimits-eJZ4ZBjF.cjs → attachmentLimits-BfgKV5iE.cjs} +28 -2
  21. package/dist/{attachmentLimits-B8nzhw-i.mjs → attachmentLimits-C1PCCM79.mjs} +28 -2
  22. package/dist/{auth-BTfv8yW0.mjs → auth-BMaBjRAj.mjs} +8 -8
  23. package/dist/{auth-Hpg4Yxfa.cjs → auth-D4-eQz85.cjs} +8 -8
  24. package/dist/{auth-Cn3dQInF.cjs → auth-ietHtvwu.cjs} +5 -5
  25. package/dist/{auth-Cxhx5evk.mjs → auth-pzWIFdOD.mjs} +5 -5
  26. package/dist/{auto-BAAEVGZa.mjs → auto-DL75Dx3I.mjs} +83 -21
  27. package/dist/{auto-CK8irHum.cjs → auto-DOWTQDPa.cjs} +84 -20
  28. package/dist/{bootstrap-D9SUykG_.cjs → bootstrap-BpXIc2wn.cjs} +2 -2
  29. package/dist/{bootstrap-CTNjn_aj.mjs → bootstrap-XE8A7Ut2.mjs} +2 -2
  30. package/dist/{browser-C8ztaIkm.cjs → browser-CcG8GLRV.cjs} +1 -1
  31. package/dist/{browser-DydXWySF.mjs → browser-DDFi1T8C.mjs} +1 -1
  32. package/dist/{claudeLocal-CsLFMvFU.cjs → claudeLocal-DXBXwIOG.cjs} +6 -6
  33. package/dist/{claudeLocal-CdJJovWq.mjs → claudeLocal-DmM5Oyhv.mjs} +6 -6
  34. package/dist/{claudeLocalLauncher-DRc-tET5.cjs → claudeLocalLauncher-C41Y6Kp4.cjs} +14 -11
  35. package/dist/{claudeLocalLauncher-CKazNnl0.mjs → claudeLocalLauncher-Cs4eUC3P.mjs} +14 -11
  36. package/dist/{claudeSessionFork-BfPW9D2S.mjs → claudeSessionFork-BdLnc2Hv.mjs} +1 -1
  37. package/dist/{claudeSessionFork-BWdhyy9B.cjs → claudeSessionFork-Cs0I3eGI.cjs} +1 -1
  38. package/dist/{client-Do5CHQnZ.mjs → client-CbK33y_s.mjs} +3 -3
  39. package/dist/{client-BZtKFCJr.cjs → client-DJ0kX_8M.cjs} +2 -2
  40. package/dist/{client-DkoV3VWl.mjs → client-GjTnsq6F.mjs} +2 -2
  41. package/dist/{client-C1GAIJ04.cjs → client-JISJx2f-.cjs} +3 -3
  42. package/dist/codex/happyMcpStdioBridge.cjs +23 -18
  43. package/dist/codex/happyMcpStdioBridge.mjs +22 -17
  44. package/dist/{codexAppServerClient-JddkV_aP.cjs → codexAppServerClient-BTjn2ZCZ.cjs} +46 -2
  45. package/dist/{codexAppServerClient-CfF33X-8.mjs → codexAppServerClient-CldXEojt.mjs} +47 -3
  46. package/dist/{codexCommand-Bdj3XZbi.cjs → codexCommand-BpD0N1F7.cjs} +71 -51
  47. package/dist/{codexCommand-btJso8yC.mjs → codexCommand-DjMU-Ijf.mjs} +71 -51
  48. package/dist/{config-nziuMb7g.mjs → config-DjSJKatx.mjs} +3 -3
  49. package/dist/{config-DpCNivmg.cjs → config-DuLL0Ppe.cjs} +3 -3
  50. package/dist/{configuration-PXAE4-FP.cjs → configuration-B0cb3xA3.cjs} +1 -1
  51. package/dist/{configuration-CiBUH72C.mjs → configuration-D2v3puap.mjs} +1 -1
  52. package/dist/{connect-D7ehaW2C.cjs → connect-B7JWEzyg.cjs} +13 -13
  53. package/dist/{connect-XHdi6Vtc.mjs → connect-CnNNyM9I.mjs} +13 -13
  54. package/dist/{context-DhvXl1oC.cjs → context-BFUpYT39.cjs} +1 -1
  55. package/dist/{context-BpEYcuLv.mjs → context-DjfqLWzE.mjs} +1 -1
  56. package/dist/{controlClient-DzmCQrY_.mjs → controlClient-BMUJfUeF.mjs} +13 -6
  57. package/dist/{controlClient-DI-Er-Ww.cjs → controlClient-CmnHGcyl.cjs} +13 -5
  58. package/dist/{createSessionMetadata-B47GrTX6.mjs → createSessionMetadata-DU0bCg2m.mjs} +2 -2
  59. package/dist/{createSessionMetadata-D04x6OMW.cjs → createSessionMetadata-DbfRBhRu.cjs} +2 -2
  60. package/dist/{deterministicJson-CKqT3awl.mjs → deterministicJson-D-WPUrZb.mjs} +36 -52
  61. package/dist/{deterministicJson-BM8e8QwP.cjs → deterministicJson-Dp3TVyap.cjs} +41 -59
  62. package/dist/{doctor-CSgzbG5L.mjs → doctor-BlXXyQ1L.mjs} +6 -6
  63. package/dist/{doctor-Cl6cqvok.cjs → doctor-CkJmdPbi.cjs} +6 -6
  64. package/dist/{assistantTools-D3cZvgUX.cjs → downloadTarget-CiQqRIc-.cjs} +44 -6
  65. package/dist/{assistantTools-BWfAV349.mjs → downloadTarget-DjSwMHun.mjs} +45 -8
  66. package/dist/{ensureDaemonRunning-fPVSsjIm.mjs → ensureDaemonRunning-CAQag1zE.mjs} +5 -5
  67. package/dist/{ensureDaemonRunning-CJ4-U32n.cjs → ensureDaemonRunning-C_aIOhgG.cjs} +5 -5
  68. package/dist/{handleResumeCommand-bX-aNF_z.cjs → handleResumeCommand-6KzBGDnl.cjs} +5 -5
  69. package/dist/{handleResumeCommand-B84dt2aH.mjs → handleResumeCommand-Ci7wacT-.mjs} +5 -5
  70. package/dist/{index-vVWpCoTr.mjs → index-B0HEEYGh.mjs} +22 -5
  71. package/dist/{index-DqlyNgt4.cjs → index-C9hI8a10.cjs} +26 -4
  72. package/dist/{index-B2UvSWo_.cjs → index-CaFCAlX8.cjs} +3 -3
  73. package/dist/{index-CP2fh7JX.cjs → index-DBmNwMX9.cjs} +39 -38
  74. package/dist/{index-DaTxsAC3.cjs → index-DEKIgZUo.cjs} +4 -4
  75. package/dist/{index-OEGj3BxE.mjs → index-DQplgo0W.mjs} +4 -4
  76. package/dist/{index-CbF87SeZ.mjs → index-HQsigW_J.mjs} +3 -3
  77. package/dist/{index-CRJtZCK-.mjs → index-WGCT6SSV.mjs} +40 -39
  78. package/dist/index.cjs +2 -2
  79. package/dist/index.mjs +2 -2
  80. package/dist/{install-BO_W7VhL.mjs → install-BdvKiOQw.mjs} +2 -0
  81. package/dist/{install-__Mni4NU.cjs → install-CejEiRGW.cjs} +3 -3
  82. package/dist/{install-Cs5qAPrB.cjs → install-Cf6AFSWC.cjs} +2 -0
  83. package/dist/{install-CurvoW2u.mjs → install-oJUvsFu9.mjs} +3 -3
  84. package/dist/lib.cjs +12 -12
  85. package/dist/lib.d.cts +55 -2
  86. package/dist/lib.d.mts +55 -2
  87. package/dist/lib.mjs +12 -12
  88. package/dist/{lidState-BbR6y9rq.mjs → lidState-BfximpjX.mjs} +1 -1
  89. package/dist/{lidState-De2HP6nu.cjs → lidState-D8BmnJV1.cjs} +1 -1
  90. package/dist/{localHappyAgentAuth-C7rDePjF.mjs → localHappyAgentAuth-Ct1aXwqd.mjs} +1 -1
  91. package/dist/{localHappyAgentAuth-CboZb3_a.cjs → localHappyAgentAuth-d7B6S_o7.cjs} +1 -1
  92. package/dist/{logger-8dtZPn3n.mjs → logger-BpQcwNnt.mjs} +2 -2
  93. package/dist/{logger-B1k7eZnq.cjs → logger-JTcJU-uL.cjs} +2 -2
  94. package/dist/{machineMetadata-C4OfKn6I.cjs → machineMetadata-BBg_Njw3.cjs} +3 -3
  95. package/dist/{machineMetadata-Bhp_w3-_.mjs → machineMetadata-CBdxABeI.mjs} +3 -3
  96. package/dist/{main-BQTaUrz0.mjs → main-B5b1gA3K.mjs} +44 -44
  97. package/dist/{main-eAiLspBM.cjs → main-C16fOGgn.cjs} +44 -44
  98. package/dist/{mcp-fapbM7Q4.cjs → mcp-BnNiBBax.cjs} +24 -19
  99. package/dist/{mcp-DXCdbQHM.mjs → mcp-DGNWgVdv.mjs} +23 -18
  100. package/dist/{mirrorProtocol-BUtmg1ws.cjs → mirrorProtocol-BEGk2--0.cjs} +80 -0
  101. package/dist/{mirrorProtocol-BNLtoWjO.mjs → mirrorProtocol-h5WHpdhh.mjs} +79 -1
  102. package/dist/{package-tVYgFJoK.mjs → package-BqwrPxL0.mjs} +7 -7
  103. package/dist/{package-CC_txQ31.cjs → package-C1XvUQFy.cjs} +7 -7
  104. package/dist/{peerTools-C1cduHh1.mjs → peerTools-CeCe-3lk.mjs} +4 -4
  105. package/dist/{peerTools-CqIcCEIg.cjs → peerTools-DPvbcs8V.cjs} +4 -4
  106. package/dist/{persistence-d3ZyER6o.cjs → persistence-2Xp_o6x6.cjs} +3 -3
  107. package/dist/{persistence-C_xFQLsD.mjs → persistence-B9LmhAE2.mjs} +3 -3
  108. package/dist/{piRunnerArgs-aq0zJX1Q.mjs → piRunnerArgs-7FaTyNOq.mjs} +1 -1
  109. package/dist/{piRunnerArgs-DSxolPlV.cjs → piRunnerArgs-DI29dDNo.cjs} +1 -1
  110. package/dist/{piRuntime-BnNl5Lmb.mjs → piRuntime-0qEK5AqE.mjs} +1 -1
  111. package/dist/{piRuntime-Dne1LPu-.cjs → piRuntime-R8XVChxK.cjs} +1 -1
  112. package/dist/{piTerminal-fjado4cR.cjs → piTerminal-Ccj0KNPr.cjs} +17 -17
  113. package/dist/{piTerminal-CKWxWtW-.mjs → piTerminal-zcQKc8Sp.mjs} +17 -17
  114. package/dist/precheckCompat-CWOi5c9L.cjs +29 -0
  115. package/dist/precheckCompat-DeEbilbr.mjs +26 -0
  116. package/dist/rateLimitWindows-C2Jk3Ih7.cjs +258 -0
  117. package/dist/rateLimitWindows-Dhfhbtqu.mjs +248 -0
  118. package/dist/{registerCommonHandlers-Dausbdsb.cjs → registerCommonHandlers-Aha_KfM3.cjs} +2 -2
  119. package/dist/{registerCommonHandlers-bSskMveZ.mjs → registerCommonHandlers-DdfGsFbM.mjs} +2 -2
  120. package/dist/{registerKillSessionHandler-CgKyzwqw.cjs → registerKillSessionHandler-CjitOpUA.cjs} +6 -6
  121. package/dist/{registerKillSessionHandler-Ben5lAsN.mjs → registerKillSessionHandler-DboQlGcH.mjs} +6 -6
  122. package/dist/{remoteSessionClient-DnCIuZpQ.mjs → remoteSessionClient-B2DtWuNP.mjs} +5 -5
  123. package/dist/{remoteSessionClient-BkJt8SHa.cjs → remoteSessionClient-BwI8rpQw.cjs} +5 -5
  124. package/dist/{tools-BTBgpiRc.cjs → reportProgressTool-B-Rcy0s3.cjs} +73 -9
  125. package/dist/{tools-gUjkPpCw.mjs → reportProgressTool-DnAD8wOa.mjs} +67 -10
  126. package/dist/{run-B8dxbDsg.cjs → run-Bh66Xs40.cjs} +289 -133
  127. package/dist/{run-BEYMVE4J.mjs → run-DPEwJpQR.mjs} +287 -131
  128. package/dist/{runClaude-Cy_HzLyO.cjs → runClaude-C-lJdAKD.cjs} +25 -20
  129. package/dist/{runClaude-Dd_P1c0f.mjs → runClaude-DLr_nBzw.mjs} +25 -20
  130. package/dist/{runClaudeDeps-JG7EUH7B.mjs → runClaudeDeps-C7JV7nXG.mjs} +47 -37
  131. package/dist/{runClaudeDeps-dxv910DR.cjs → runClaudeDeps-vWWLod7J.cjs} +44 -35
  132. package/dist/{runGemini-DY1IDW_f.cjs → runGemini-o-CUSayc.cjs} +42 -35
  133. package/dist/{runGemini-CpaBuvoW.mjs → runGemini-tH6sLw-G.mjs} +42 -35
  134. package/dist/{runOpenClaw-C7vrDQbq.mjs → runOpenClaw-C5DZvVhn.mjs} +18 -18
  135. package/dist/{runOpenClaw-C0VARcYj.cjs → runOpenClaw-D24f35l0.cjs} +18 -18
  136. package/dist/{sandbox-DRUJZuaM.mjs → sandbox-BaL1aslA.mjs} +4 -4
  137. package/dist/{sandbox-Bx9-M9rN.cjs → sandbox-TJZJ6ngw.cjs} +4 -4
  138. package/dist/selfCheck-DPi4lqme.mjs +84 -0
  139. package/dist/{selfCheck-Ba1sECTO.cjs → selfCheck-Dugi0Nk_.cjs} +34 -34
  140. package/dist/{send-Dqdpx-w5.cjs → send-CkWOSYmU.cjs} +8 -8
  141. package/dist/{send-BrzkbMy2.mjs → send-DDcJ3ayi.mjs} +8 -8
  142. package/dist/{server-CfB8EWCg.cjs → server-7qFQ8J2u.cjs} +6 -6
  143. package/dist/{server-CPMPoEAA.mjs → server-CUjRvQSd.mjs} +4 -4
  144. package/dist/{sessionLock-BsP3Y_nG.cjs → sessionLock-DfhNICyS.cjs} +2 -2
  145. package/dist/{sessionLock-DukLVzAQ.mjs → sessionLock-DqVyHFAQ.mjs} +2 -2
  146. package/dist/{sessionModeFile-JWWF6Nn7.cjs → sessionModeFile-CIznKD7O.cjs} +1 -1
  147. package/dist/{sessionModeFile-CSvi4H_t.mjs → sessionModeFile-M4OpiyrF.mjs} +1 -1
  148. package/dist/{sessionOps-BlMg1uAa.mjs → sessionOps-BnaEaOKs.mjs} +4 -4
  149. package/dist/{sessionOps-HuoMXcIZ.cjs → sessionOps-DOyy4gkY.cjs} +4 -4
  150. package/dist/{sessionScanner-Ct-JoNHP.mjs → sessionScanner-CcuOn9uU.mjs} +75 -19
  151. package/dist/{sessionScanner-BWU6KvZw.cjs → sessionScanner-Cu1yKlj4.cjs} +75 -19
  152. package/dist/{sessions-AjflCj5y.cjs → sessions-BQgk5Kw8.cjs} +9 -9
  153. package/dist/{sessions-3Hw1xXMV.mjs → sessions-C9O91rL1.mjs} +9 -9
  154. package/dist/{setupOfflineReconnection-B_9xp36H.mjs → setupOfflineReconnection-Byy8aeKT.mjs} +2 -2
  155. package/dist/{setupOfflineReconnection-Cxh9Sz9M.cjs → setupOfflineReconnection-DFmQ8s6I.cjs} +2 -2
  156. package/dist/{spawn-CN8PH11G.cjs → spawn-BS7LPmSm.cjs} +13 -12
  157. package/dist/{spawn-BXEVKCQK.mjs → spawn-EZ2yrtWd.mjs} +13 -12
  158. package/dist/{spawnAgents-Czi2VDzj.mjs → spawnAgents-HDaE3C0-.mjs} +1 -1
  159. package/dist/{spawnAgents-McixdtV1.cjs → spawnAgents-vglexX9N.cjs} +1 -1
  160. package/dist/{spawnHappyCLI-BFN0h2fK.mjs → spawnHappyCLI-BAa49m0q.mjs} +1 -1
  161. package/dist/{spawnHappyCLI-Cj-9-d_7.cjs → spawnHappyCLI-DyaldIe5.cjs} +1 -1
  162. package/dist/specialCommands-CCWtwIGZ.mjs +93 -0
  163. package/dist/specialCommands-DouFaJ_0.cjs +96 -0
  164. package/dist/{startFileWatcher-BfMu444W.cjs → startFileWatcher-DQbbypJv.cjs} +1 -1
  165. package/dist/{startFileWatcher-Csql7gdv.mjs → startFileWatcher-Dfs4whzr.mjs} +1 -1
  166. package/dist/{streamRelay-C0AffuX6.cjs → streamRelay-Ce9sgcOV.cjs} +1 -1
  167. package/dist/{streamRelay-BzMEEvJU.mjs → streamRelay-Iogsh51a.mjs} +1 -1
  168. package/dist/{sync-SCvDzRy6.mjs → sync-BLpH13Kb.mjs} +1 -1
  169. package/dist/{sync--uxtLWbo.cjs → sync-x_lI_3j_.cjs} +1 -1
  170. package/dist/{systemPrompt-C1tRiZ-u.cjs → systemPrompt-4erwILTB.cjs} +3 -3
  171. package/dist/{systemPrompt-D0KMySrK.mjs → systemPrompt-BanyDihy.mjs} +3 -3
  172. package/dist/{teams-By-hcvMF.mjs → teams-D6BOT3lR.mjs} +7 -7
  173. package/dist/{teams-CYDIFXEC.cjs → teams-DzetlGWC.cjs} +7 -7
  174. package/dist/{time-CRvAaC5H.mjs → time-CfDKj6Bx.mjs} +1 -1
  175. package/dist/{time-C7BplxuS.cjs → time-T0YmeOhS.cjs} +1 -1
  176. package/dist/{titleGenerator-CUG-n2r3.mjs → titleGenerator-ChImQ4q-.mjs} +1 -1
  177. package/dist/{titleGenerator-Kw3sTnNQ.cjs → titleGenerator-CoMxqKfP.cjs} +2 -2
  178. package/dist/{todo-B7a7J5Ng.cjs → todo-CZZebKZg.cjs} +6 -6
  179. package/dist/{todo-yuIsUvU8.mjs → todo-SVtYxGON.mjs} +6 -6
  180. package/dist/{tools-RvMKw7yx.mjs → tools-3X2z3YDe.mjs} +1 -1
  181. package/dist/{tools-D7UeLMZ7.cjs → tools-IJ0TAcJs.cjs} +1 -1
  182. package/dist/transcriptCursor-B7T_gkQ0.mjs +102 -0
  183. package/dist/transcriptCursor-DcXGLeSc.cjs +106 -0
  184. package/dist/{uninstall-D5D16-KM.cjs → uninstall-CqqCWj7w.cjs} +3 -3
  185. package/dist/{uninstall-Dx9FmLXs.mjs → uninstall-CypD-kr1.mjs} +3 -3
  186. package/package.json +7 -7
  187. package/scripts/terminal_status_forwarder.cjs +89 -0
  188. package/dist/selfCheck-DdT8r263.mjs +0 -84
  189. package/dist/specialCommands-BSqhYg5X.mjs +0 -52
  190. package/dist/specialCommands-DSsTeRej.cjs +0 -54
@@ -1,52 +1,53 @@
1
1
  'use strict';
2
2
 
3
3
  var socket_ioClient = require('socket.io-client');
4
- var logger = require('./logger-B1k7eZnq.cjs');
5
- var lidState = require('./lidState-De2HP6nu.cjs');
6
- var mirrorProtocol = require('./mirrorProtocol-BUtmg1ws.cjs');
7
- var configuration = require('./configuration-PXAE4-FP.cjs');
4
+ var logger = require('./logger-JTcJU-uL.cjs');
5
+ var lidState = require('./lidState-D8BmnJV1.cjs');
6
+ var mirrorProtocol = require('./mirrorProtocol-BEGk2--0.cjs');
7
+ var configuration = require('./configuration-B0cb3xA3.cjs');
8
8
  var cliUpdate = require('./cliUpdate-DsKXPFYp.cjs');
9
- var registerCommonHandlers = require('./registerCommonHandlers-Dausbdsb.cjs');
9
+ var registerCommonHandlers = require('./registerCommonHandlers-Aha_KfM3.cjs');
10
10
  var promises = require('node:fs/promises');
11
11
  var path = require('node:path');
12
12
  var os = require('node:os');
13
13
  var node_child_process = require('node:child_process');
14
- var persistence = require('./persistence-d3ZyER6o.cjs');
14
+ var persistence = require('./persistence-2Xp_o6x6.cjs');
15
15
  var authRelay = require('./authRelay-BdYkC1Po.cjs');
16
16
  var limits = require('./limits-C-a7gr04.cjs');
17
- var time = require('./time-C7BplxuS.cjs');
17
+ var time = require('./time-T0YmeOhS.cjs');
18
18
  var pty = require('node-pty');
19
19
  var terminals = require('./terminals-B_bfC9c2.cjs');
20
20
  var tmuxSocket = require('./tmuxSocket-D-Xt6Vn-.cjs');
21
- var index = require('./index-DaTxsAC3.cjs');
21
+ var index = require('./index-DEKIgZUo.cjs');
22
22
  var node_crypto = require('node:crypto');
23
23
  var node_fs = require('node:fs');
24
24
  var axios = require('axios');
25
+ var projectPath = require('./projectPath-ChLhdOyt.cjs');
25
26
  var ids = require('./ids-B6ZC0HGt.cjs');
26
27
  var detectCLI = require('./detectCLI-DR9ZaN6B.cjs');
27
- var localHappyAgentAuth = require('./localHappyAgentAuth-CboZb3_a.cjs');
28
+ var localHappyAgentAuth = require('./localHappyAgentAuth-d7B6S_o7.cjs');
28
29
  var path$1 = require('./path-B7zW-WwE.cjs');
29
30
  var oneShotPrompts = require('./oneShotPrompts-CL0dip9C.cjs');
30
- var claudeSessionFork = require('./claudeSessionFork-BWdhyy9B.cjs');
31
- var codexAppServerClient = require('./codexAppServerClient-JddkV_aP.cjs');
32
- var index$1 = require('./index-DqlyNgt4.cjs');
31
+ var claudeSessionFork = require('./claudeSessionFork-Cs0I3eGI.cjs');
32
+ var codexAppServerClient = require('./codexAppServerClient-BTjn2ZCZ.cjs');
33
+ var index$1 = require('./index-C9hI8a10.cjs');
33
34
  var codexThreadFork = require('./codexThreadFork-DNekT4nQ.cjs');
35
+ var rateLimitWindows = require('./rateLimitWindows-C2Jk3Ih7.cjs');
34
36
  require('chalk');
35
37
  require('node:util');
36
38
  require('os');
37
39
  require('child_process');
38
- require('./package-CC_txQ31.cjs');
40
+ require('./package-C1XvUQFy.cjs');
39
41
  require('util');
40
42
  require('fs/promises');
41
43
  require('crypto');
42
44
  require('path');
43
45
  require('cross-spawn');
44
- require('./projectPath-ChLhdOyt.cjs');
45
- require('url');
46
46
  require('zod');
47
47
  require('tweetnacl');
48
48
  require('./terminalTitleSuggest-DhvRFX3H.cjs');
49
49
  require('./loginShellEnv-BkuYtExZ.cjs');
50
+ require('url');
50
51
  require('fs');
51
52
  require('node:readline');
52
53
  require('./contentLogMetadata-ZSFk_NjQ.cjs');
@@ -207,7 +208,7 @@ const TODO_LIST_MAX_ITEMS = 500;
207
208
  const TODO_TITLE_MAX_CHARS = 500;
208
209
  const FIELD_MAX_CHARS = 200;
209
210
  const PRIORITIES = ["none", "low", "medium", "high"];
210
- function str(value, max = FIELD_MAX_CHARS) {
211
+ function str$1(value, max = FIELD_MAX_CHARS) {
211
212
  if (typeof value !== "string") return void 0;
212
213
  const flat = value.replace(/\s+/g, " ").trim();
213
214
  if (!flat) return void 0;
@@ -238,8 +239,8 @@ function parseTodoList(stdout) {
238
239
  continue;
239
240
  }
240
241
  const r = raw;
241
- const id = str(r.id);
242
- const title = str(r.title, TODO_TITLE_MAX_CHARS);
242
+ const id = str$1(r.id);
243
+ const title = str$1(r.title, TODO_TITLE_MAX_CHARS);
243
244
  if (!id || !title) {
244
245
  dropped++;
245
246
  continue;
@@ -249,10 +250,10 @@ function parseTodoList(stdout) {
249
250
  id,
250
251
  title,
251
252
  status: r.status === "done" ? "done" : "open",
252
- ...str(r.due) ? { due: str(r.due) } : {},
253
+ ...str$1(r.due) ? { due: str$1(r.due) } : {},
253
254
  ...priority ? { priority } : {},
254
- ...str(r.group) ? { group: str(r.group) } : {},
255
- ...str(r.note) ? { note: str(r.note) } : {}
255
+ ...str$1(r.group) ? { group: str$1(r.group) } : {},
256
+ ...str$1(r.note) ? { note: str$1(r.note) } : {}
256
257
  });
257
258
  }
258
259
  return { items, dropped, truncated: hitCap };
@@ -4989,8 +4990,8 @@ function buildCaptureBatch(o) {
4989
4990
  const CAPTURE_FULL_BUDGET_BYTES = 1024 * 1024;
4990
4991
  const ALT_ENTER = Buffer.from("\x1B[?1049h", "ascii");
4991
4992
  const CURSOR_HOME = Buffer.from("\x1B[H", "ascii");
4992
- const CRLF = Buffer.from("\r\n", "ascii");
4993
- const SGR_RESET = Buffer.from("\x1B[0m", "ascii");
4993
+ const CRLF$1 = Buffer.from("\r\n", "ascii");
4994
+ const SGR_RESET$1 = Buffer.from("\x1B[0m", "ascii");
4994
4995
  function normalizeCaptureLines(blob) {
4995
4996
  if (blob.length === 0) return blob;
4996
4997
  const lines = [];
@@ -5005,7 +5006,7 @@ function normalizeCaptureLines(blob) {
5005
5006
  if (start < blob.length) lines.push(blob.subarray(start));
5006
5007
  const out = [];
5007
5008
  lines.forEach((l, i) => {
5008
- if (i > 0) out.push(CRLF);
5009
+ if (i > 0) out.push(CRLF$1);
5009
5010
  out.push(l);
5010
5011
  });
5011
5012
  return Buffer.concat(out);
@@ -5033,31 +5034,31 @@ function assembleRestore(responses, paneState, budget = CAPTURE_FULL_BUDGET_BYTE
5033
5034
  realCount = lines.length;
5034
5035
  while (lines.length < rows) lines.push(EMPTY);
5035
5036
  }
5036
- const realBody = joinCrlf(lines.slice(0, realCount));
5037
+ const realBody = joinCrlf$1(lines.slice(0, realCount));
5037
5038
  const padCount = lines.length - realCount;
5038
- const padBody = padCount > 0 ? Buffer.concat([realCount > 0 ? CRLF : EMPTY, SGR_RESET, joinCrlf(new Array(padCount).fill(EMPTY))]) : EMPTY;
5039
+ const padBody = padCount > 0 ? Buffer.concat([realCount > 0 ? CRLF$1 : EMPTY, SGR_RESET$1, joinCrlf$1(new Array(padCount).fill(EMPTY))]) : EMPTY;
5039
5040
  const cursor = paneState ? Buffer.from(`\x1B[${paneState.cursorY + 1};${paneState.cursorX + 1}H`, "ascii") : Buffer.alloc(0);
5040
- return Buffer.concat([realBody, padBody, SGR_RESET, tail, cursor]);
5041
+ return Buffer.concat([realBody, padBody, SGR_RESET$1, tail, cursor]);
5041
5042
  };
5042
- const wrapFull = (b) => Buffer.concat([SGR_RESET, b, SGR_RESET]);
5043
- const wrapSmall = (b) => Buffer.concat([b, SGR_RESET]);
5043
+ const wrapFull = (b) => Buffer.concat([SGR_RESET$1, b, SGR_RESET$1]);
5044
+ const wrapSmall = (b) => Buffer.concat([b, SGR_RESET$1]);
5044
5045
  if (!alternateOn) {
5045
5046
  const history2 = trimTrailingBlankRows(normalizeCaptureLines(get("history")));
5046
5047
  const smallHistory = trimTrailingBlankRows(normalizeCaptureLines(get("smallHistory")));
5047
5048
  const withHistory = (h) => Buffer.concat(
5048
- h.length > 0 ? [truncateToBudget(h, budget), SGR_RESET, CRLF, screen()] : [screen()]
5049
+ h.length > 0 ? [truncateToBudget(h, budget), SGR_RESET$1, CRLF$1, screen()] : [screen()]
5049
5050
  );
5050
5051
  return { full: wrapFull(withHistory(history2)), small: wrapSmall(withHistory(smallHistory)), alternateOn };
5051
5052
  }
5052
5053
  const history = trimTrailingBlankRows(normalizeCaptureLines(get("history")));
5053
5054
  const saved = trimTrailingBlankRows(normalizeCaptureLines(get("altSaved")));
5054
5055
  const scrollback = truncateToBudget(
5055
- Buffer.concat(history.length > 0 && saved.length > 0 ? [history, SGR_RESET, CRLF, saved] : [history, saved]),
5056
+ Buffer.concat(history.length > 0 && saved.length > 0 ? [history, SGR_RESET$1, CRLF$1, saved] : [history, saved]),
5056
5057
  budget
5057
5058
  );
5058
- const altScreen = Buffer.concat([ALT_ENTER, CURSOR_HOME, SGR_RESET, screen()]);
5059
+ const altScreen = Buffer.concat([ALT_ENTER, CURSOR_HOME, SGR_RESET$1, screen()]);
5059
5060
  return {
5060
- full: wrapFull(Buffer.concat([scrollback, scrollback.length > 0 ? CRLF : Buffer.alloc(0), altScreen])),
5061
+ full: wrapFull(Buffer.concat([scrollback, scrollback.length > 0 ? CRLF$1 : Buffer.alloc(0), altScreen])),
5061
5062
  small: wrapSmall(altScreen),
5062
5063
  alternateOn
5063
5064
  };
@@ -5071,7 +5072,7 @@ function stripTrailingNewlines(blob) {
5071
5072
  function trimTrailingBlankRows(blob) {
5072
5073
  const lines = splitLines(blob);
5073
5074
  while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop();
5074
- return joinCrlf(lines);
5075
+ return joinCrlf$1(lines);
5075
5076
  }
5076
5077
  function splitLines(blob) {
5077
5078
  const out = [];
@@ -5086,10 +5087,10 @@ function splitLines(blob) {
5086
5087
  if (start < blob.length) out.push(blob.subarray(start));
5087
5088
  return out;
5088
5089
  }
5089
- function joinCrlf(lines) {
5090
+ function joinCrlf$1(lines) {
5090
5091
  const parts = [];
5091
5092
  lines.forEach((l, i) => {
5092
- if (i > 0) parts.push(CRLF);
5093
+ if (i > 0) parts.push(CRLF$1);
5093
5094
  parts.push(l);
5094
5095
  });
5095
5096
  return Buffer.concat(parts);
@@ -5455,7 +5456,7 @@ class TerminalNotifyTracker {
5455
5456
  * Feed one observation for a terminal. Returns the notification event this
5456
5457
  * observation triggers, or null. Call once per terminal per tick.
5457
5458
  */
5458
- observe(terminalId, state, now, agentKind) {
5459
+ observe(terminalId, state, now, agentKind, opts = {}) {
5459
5460
  let t = this.tracks.get(terminalId);
5460
5461
  if (!t || t.agentKind !== agentKind || now - t.lastObservedAt > 45e3) {
5461
5462
  t = {
@@ -5486,6 +5487,7 @@ class TerminalNotifyTracker {
5486
5487
  t.candidateCount += 1;
5487
5488
  t.lastCountedAt = now;
5488
5489
  }
5490
+ if (opts.authoritative) t.candidateCount = Math.max(t.candidateCount, this.stableTicks);
5489
5491
  if (t.candidateCount < this.stableTicks) return null;
5490
5492
  const prev = t.confirmed;
5491
5493
  t.confirmed = t.candidate;
@@ -5542,6 +5544,403 @@ function sendTerminalNotification(opts) {
5542
5544
  });
5543
5545
  }
5544
5546
 
5547
+ const MIN_HOOK_CLAUDE_VERSION = "2.0.0";
5548
+ const EVENT_FIRST_VERSIONS = [
5549
+ ["SessionStart", "2.0.0"],
5550
+ ["UserPromptSubmit", "2.0.0"],
5551
+ ["Stop", "2.0.0"],
5552
+ ["Notification", "2.0.0"],
5553
+ ["PostToolUse", "2.0.0"],
5554
+ ["SessionEnd", "2.0.0"],
5555
+ ["PermissionRequest", "2.0.45"],
5556
+ ["PostCompact", "2.1.76"],
5557
+ ["StopFailure", "2.1.78"]
5558
+ ];
5559
+ const KNOWN_EVENTS = new Set(EVENT_FIRST_VERSIONS.map(([name]) => name));
5560
+ const TOOL_MATCHER_EVENTS = /* @__PURE__ */ new Set(["PostToolUse", "PermissionRequest"]);
5561
+ function parseVersion(v) {
5562
+ const m = typeof v === "string" ? v.trim().match(/^(\d+)\.(\d+)\.(\d+)$/) : null;
5563
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
5564
+ }
5565
+ function versionAtLeast(version, floor) {
5566
+ const a = parseVersion(version);
5567
+ const b = parseVersion(floor);
5568
+ if (!a || !b) return false;
5569
+ for (let i = 0; i < 3; i++) {
5570
+ if (a[i] !== b[i]) return a[i] > b[i];
5571
+ }
5572
+ return true;
5573
+ }
5574
+ function terminalHookEventPlan(claudeVersion) {
5575
+ if (!versionAtLeast(claudeVersion, MIN_HOOK_CLAUDE_VERSION)) return null;
5576
+ return EVENT_FIRST_VERSIONS.filter(([, first]) => versionAtLeast(claudeVersion, first)).map(([name]) => name);
5577
+ }
5578
+ const TERMINAL_HOOK_TIMEOUT_SECONDS = 10;
5579
+ const SAFE_PATH_RE = /^[A-Za-z0-9_./@:+ -]+$/;
5580
+ const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
5581
+ function terminalHookCommand(opts) {
5582
+ const { forwarderPath, terminalId, happyHome } = opts;
5583
+ if (!SAFE_PATH_RE.test(forwarderPath) || !SAFE_PATH_RE.test(happyHome) || !SAFE_ID_RE.test(terminalId)) return null;
5584
+ return `[ -f '${forwarderPath}' ] && node '${forwarderPath}' '${terminalId}' '${happyHome}' "$PPID" || true`;
5585
+ }
5586
+ function buildTerminalHookSettings(events, command) {
5587
+ const hooks = {};
5588
+ for (const event of events) {
5589
+ const entry = { type: "command", command, timeout: TERMINAL_HOOK_TIMEOUT_SECONDS };
5590
+ hooks[event] = [TOOL_MATCHER_EVENTS.has(event) ? { matcher: "*", hooks: [entry] } : { hooks: [entry] }];
5591
+ }
5592
+ return { hooks };
5593
+ }
5594
+ function injectClaudeSettingsFlag(command, settingsPath) {
5595
+ if (!SAFE_PATH_RE.test(settingsPath) || !isInjectableClaudeCommand(command)) return command;
5596
+ const m = command.match(/^(\s*)claude/);
5597
+ return `${m[1]}claude --settings '${settingsPath}'${command.slice(m[0].length)}`;
5598
+ }
5599
+ function isInjectableClaudeCommand(command) {
5600
+ return /^\s*claude(?=\s|$)/.test(command) && !/(^|\s)--settings(=|\s|$)/.test(command);
5601
+ }
5602
+ const str = (v, max = 256) => typeof v === "string" && v.length > 0 && v.length <= max ? v : void 0;
5603
+ function parseTerminalAgentHook(body) {
5604
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
5605
+ const raw = body;
5606
+ const event = raw.hook_event_name;
5607
+ if (typeof event !== "string" || !KNOWN_EVENTS.has(event)) return null;
5608
+ const terminalId = str(raw.terminalId, 64);
5609
+ if (!terminalId || !SAFE_ID_RE.test(terminalId)) return null;
5610
+ const claudeSessionId = str(raw.session_id, 128);
5611
+ if (!claudeSessionId) return null;
5612
+ const pid = typeof raw.claudePid === "number" ? raw.claudePid : typeof raw.claudePid === "string" && /^\d{1,10}$/.test(raw.claudePid) ? Number(raw.claudePid) : void 0;
5613
+ const turnId = str(raw.prompt_id, 128) ?? str(raw.promptHash, 64);
5614
+ return {
5615
+ event,
5616
+ terminalId,
5617
+ claudeSessionId,
5618
+ ...pid !== void 0 && Number.isInteger(pid) && pid > 1 ? { claudePid: pid } : {},
5619
+ ...turnId ? { turnId } : {},
5620
+ ...str(raw.agent_id, 128) ? { agentId: str(raw.agent_id, 128) } : {},
5621
+ ...str(raw.notification_type, 64) ? { notificationType: str(raw.notification_type, 64) } : {},
5622
+ ...raw.permissionHint === true ? { permissionHint: true } : {},
5623
+ ...str(raw.trigger, 32) ? { trigger: str(raw.trigger, 32) } : {},
5624
+ ...str(raw.source, 32) ? { source: str(raw.source, 32) } : {},
5625
+ ...str(raw.transcript_path, 4096) ? { transcriptPath: str(raw.transcript_path, 4096) } : {}
5626
+ };
5627
+ }
5628
+ const NEEDS_INPUT_NOTIFICATIONS = /* @__PURE__ */ new Set(["permission_prompt", "elicitation_dialog"]);
5629
+ const IDLE_NOTIFICATIONS = /* @__PURE__ */ new Set(["idle_prompt"]);
5630
+ function phaseTo(prev, phase, now, extra = {}) {
5631
+ const { interrupted: _drop, ...rest } = prev;
5632
+ return { ...rest, phase, seq: prev.seq + 1, phaseStartedAt: now, updatedAt: now, ...extra };
5633
+ }
5634
+ function reduceHookRecord(prev, ev, now) {
5635
+ if (ev.event === "SessionStart") {
5636
+ if (prev && prev.claudeSessionId === ev.claudeSessionId && ev.source !== "clear" && ev.source !== "compact") {
5637
+ return { record: { ...prev, updatedAt: now, ...ev.claudePid ? { claudePid: ev.claudePid } : {} }, changed: false };
5638
+ }
5639
+ return {
5640
+ record: {
5641
+ claudeSessionId: ev.claudeSessionId,
5642
+ ...ev.claudePid ? { claudePid: ev.claudePid } : prev?.claudePid ? { claudePid: prev.claudePid } : {},
5643
+ phase: "done",
5644
+ seq: (prev?.seq ?? 0) + 1,
5645
+ phaseStartedAt: now,
5646
+ updatedAt: now,
5647
+ confirmedTurn: false,
5648
+ ...ev.transcriptPath ? { transcriptPath: ev.transcriptPath } : {}
5649
+ },
5650
+ changed: prev?.phase !== "done"
5651
+ };
5652
+ }
5653
+ if (prev && prev.claudeSessionId !== ev.claudeSessionId) return { record: prev, changed: false };
5654
+ const base = prev ?? {
5655
+ claudeSessionId: ev.claudeSessionId,
5656
+ ...ev.claudePid ? { claudePid: ev.claudePid } : {},
5657
+ phase: "done",
5658
+ seq: 0,
5659
+ phaseStartedAt: now,
5660
+ updatedAt: now,
5661
+ confirmedTurn: false
5662
+ };
5663
+ const withPid = ev.claudePid && !base.claudePid ? { ...base, claudePid: ev.claudePid } : base;
5664
+ const fresh = !prev;
5665
+ const renew = () => ({ record: { ...withPid, updatedAt: now }, changed: fresh });
5666
+ switch (ev.event) {
5667
+ case "SessionEnd":
5668
+ return { record: void 0, changed: !!prev };
5669
+ case "UserPromptSubmit":
5670
+ return { record: phaseTo(withPid, "working", now, { confirmedTurn: true, turnId: ev.turnId }), changed: true };
5671
+ case "PostToolUse":
5672
+ if (withPid.phase === "working") return renew();
5673
+ return { record: phaseTo(withPid, "working", now), changed: true };
5674
+ case "PermissionRequest":
5675
+ return { record: phaseTo(withPid, "needs_input", now), changed: withPid.phase !== "needs_input" || fresh };
5676
+ case "Notification": {
5677
+ if (ev.notificationType && IDLE_NOTIFICATIONS.has(ev.notificationType)) {
5678
+ if (withPid.phase === "done") return renew();
5679
+ return { record: phaseTo(withPid, "done", now), changed: true };
5680
+ }
5681
+ const needsInput = ev.notificationType ? NEEDS_INPUT_NOTIFICATIONS.has(ev.notificationType) : ev.permissionHint === true;
5682
+ if (!needsInput) return renew();
5683
+ return { record: phaseTo(withPid, "needs_input", now), changed: withPid.phase !== "needs_input" || fresh };
5684
+ }
5685
+ case "Stop":
5686
+ case "StopFailure":
5687
+ if (ev.agentId) return renew();
5688
+ return { record: phaseTo(withPid, "done", now), changed: withPid.phase !== "done" || fresh };
5689
+ case "PostCompact":
5690
+ if (ev.trigger !== "manual") return renew();
5691
+ return { record: phaseTo(withPid, "done", now), changed: withPid.phase !== "done" || fresh };
5692
+ default:
5693
+ return renew();
5694
+ }
5695
+ }
5696
+ const HOOK_WORKING_STALE_MS = 10 * 6e4;
5697
+ function fuseAgentState(screen, record, now) {
5698
+ const screenOnly = { ...screen ?? {}, source: "screen" };
5699
+ if (!record) return screenOnly;
5700
+ const s = screen?.agentState;
5701
+ if (s === "shell") return screenOnly;
5702
+ if (screen?.agentKind && screen.agentKind !== "claude") return screenOnly;
5703
+ const hook = (agentState) => ({ agentKind: "claude", agentState, source: "hook" });
5704
+ if (s === "needs_input" || s === "working") return { agentKind: "claude", agentState: s, source: "screen" };
5705
+ switch (record.phase) {
5706
+ case "working":
5707
+ case "needs_input":
5708
+ if (now - record.updatedAt > HOOK_WORKING_STALE_MS) return screenOnly;
5709
+ return hook(record.phase);
5710
+ case "done":
5711
+ return hook("idle");
5712
+ }
5713
+ }
5714
+ const INTERRUPT_CHECK_DELAYS_MS = [600, 2e3, 5e3];
5715
+ const INTERRUPT_CONFIRM_WINDOW_MS = INTERRUPT_CHECK_DELAYS_MS[INTERRUPT_CHECK_DELAYS_MS.length - 1] + 1500;
5716
+ const KITTY_ESCAPE_PRESS_RE = /^\x1b\[27(?:;1(?::1)?)?u$/;
5717
+ function isInterruptInput(text) {
5718
+ return text === "\x1B" || text === "" || KITTY_ESCAPE_PRESS_RE.test(text);
5719
+ }
5720
+ function isDialogAnswerInput(text) {
5721
+ return text === "\r" || text === "\n" || text === "\r\n";
5722
+ }
5723
+ function armsInterruptCheck(text, phase) {
5724
+ if (phase === "working") return isInterruptInput(text);
5725
+ if (phase === "needs_input") return isInterruptInput(text) || isDialogAnswerInput(text);
5726
+ return false;
5727
+ }
5728
+ function pendingInterruptFor(record, now) {
5729
+ if (!record || !record.confirmedTurn) return null;
5730
+ if (record.phase !== "working" && record.phase !== "needs_input") return null;
5731
+ return {
5732
+ claudeSessionId: record.claudeSessionId,
5733
+ seq: record.seq,
5734
+ phaseStartedAt: record.phaseStartedAt,
5735
+ ...record.turnId ? { turnId: record.turnId } : {},
5736
+ at: now
5737
+ };
5738
+ }
5739
+ function decideInterrupt(record, pending, screen, now) {
5740
+ if (!record || !record.confirmedTurn) return "drop";
5741
+ if (record.phase !== "working" && record.phase !== "needs_input") return "drop";
5742
+ if (record.claudeSessionId !== pending.claudeSessionId || record.seq !== pending.seq || record.phaseStartedAt !== pending.phaseStartedAt || record.turnId !== pending.turnId) return "drop";
5743
+ if (now - record.updatedAt > HOOK_WORKING_STALE_MS) return "drop";
5744
+ if (now - pending.at > INTERRUPT_CONFIRM_WINDOW_MS) return "drop";
5745
+ if (screen?.agentKind === "claude" && screen.agentState === "idle") return "synthesize";
5746
+ return "wait";
5747
+ }
5748
+ function synthesizeInterruptDone(record, now) {
5749
+ return { ...phaseTo(record, "done", now), interrupted: true };
5750
+ }
5751
+
5752
+ function isPidAlive(pid) {
5753
+ try {
5754
+ process.kill(pid, 0);
5755
+ return true;
5756
+ } catch (error) {
5757
+ return error?.code === "EPERM";
5758
+ }
5759
+ }
5760
+ class TerminalAgentHookStore {
5761
+ constructor(pidAlive = isPidAlive) {
5762
+ this.pidAlive = pidAlive;
5763
+ }
5764
+ records = /* @__PURE__ */ new Map();
5765
+ pending = /* @__PURE__ */ new Map();
5766
+ /** Apply one forwarded hook body. Returns the terminal whose list-relevant
5767
+ * state changed (caller kicks a list refresh), or null. Never throws. */
5768
+ applyHook(body, now) {
5769
+ const ev = parseTerminalAgentHook(body);
5770
+ if (!ev) return null;
5771
+ const prev = this.valid(ev.terminalId);
5772
+ const { record, changed } = reduceHookRecord(prev, ev, now);
5773
+ if (record) this.records.set(ev.terminalId, record);
5774
+ else this.records.delete(ev.terminalId);
5775
+ if (record?.seq !== prev?.seq) this.pending.delete(ev.terminalId);
5776
+ return { terminalId: ev.terminalId, changed };
5777
+ }
5778
+ /** The record if its claude process is still alive; dead ones are dropped. */
5779
+ valid(terminalId) {
5780
+ const record = this.records.get(terminalId);
5781
+ if (!record) return void 0;
5782
+ if (record.claudePid !== void 0 && !this.pidAlive(record.claudePid)) {
5783
+ this.records.delete(terminalId);
5784
+ this.pending.delete(terminalId);
5785
+ return void 0;
5786
+ }
5787
+ return record;
5788
+ }
5789
+ /** Screen observation (undefined = probe failed) → fused state. */
5790
+ fuse(terminalId, screen, now) {
5791
+ return fuseAgentState(screen, this.valid(terminalId), now);
5792
+ }
5793
+ /** A web keystroke reached the pane. True when it armed an inference
5794
+ * check (Esc/Ctrl+C during a turn; also Enter on a dialog). */
5795
+ noteInput(terminalId, text, now) {
5796
+ const record = this.records.get(terminalId);
5797
+ if (!record || !armsInterruptCheck(text, record.phase)) return false;
5798
+ const pending = pendingInterruptFor(this.valid(terminalId), now);
5799
+ if (!pending) return false;
5800
+ this.pending.set(terminalId, pending);
5801
+ return true;
5802
+ }
5803
+ /** Re-check an armed interrupt against a fresh screen observation. */
5804
+ checkInterrupt(terminalId, screen, now) {
5805
+ const pending = this.pending.get(terminalId);
5806
+ if (!pending) return "none";
5807
+ const record = this.valid(terminalId);
5808
+ const verdict = decideInterrupt(record, pending, screen, now);
5809
+ if (verdict === "wait") return "wait";
5810
+ this.pending.delete(terminalId);
5811
+ if (verdict === "drop" || !record) return "drop";
5812
+ this.records.set(terminalId, synthesizeInterruptDone(record, now));
5813
+ return "synthesized";
5814
+ }
5815
+ has(terminalId) {
5816
+ return this.records.has(terminalId);
5817
+ }
5818
+ forget(terminalId) {
5819
+ this.records.delete(terminalId);
5820
+ this.pending.delete(terminalId);
5821
+ }
5822
+ /** Drop records of terminals that no longer exist. */
5823
+ prune(liveIds) {
5824
+ const keep = new Set(liveIds);
5825
+ for (const id of this.records.keys()) if (!keep.has(id)) this.forget(id);
5826
+ for (const id of this.pending.keys()) if (!keep.has(id)) this.pending.delete(id);
5827
+ }
5828
+ }
5829
+
5830
+ const TERMINAL_STATUS_FORWARDER_BASENAME = "terminal_status_forwarder.cjs";
5831
+ const VERSION_TTL_MS = 6 * 60 * 6e4;
5832
+ const VERSION_RETRY_MS = 5 * 6e4;
5833
+ function hooksDir() {
5834
+ return path.join(configuration.configuration.happyHomeDir, "tmp", "terminal-hooks");
5835
+ }
5836
+ function terminalHookSettingsPath(terminalId) {
5837
+ return path.join(hooksDir(), `${terminalId}.json`);
5838
+ }
5839
+ function probeClaudeVersion(env) {
5840
+ return new Promise((resolveVersion) => {
5841
+ try {
5842
+ node_child_process.execFile("claude", ["--version"], { timeout: 5e3, maxBuffer: 32 * 1024, env }, (error, stdout) => {
5843
+ resolveVersion(error ? null : mirrorProtocol.stableVersion(String(stdout)));
5844
+ });
5845
+ } catch {
5846
+ resolveVersion(null);
5847
+ }
5848
+ });
5849
+ }
5850
+ class ClaudeVersionCache {
5851
+ constructor(env, probe = probeClaudeVersion) {
5852
+ this.env = env;
5853
+ this.probe = probe;
5854
+ }
5855
+ version = null;
5856
+ checkedAt = 0;
5857
+ inFlight = null;
5858
+ started = false;
5859
+ start(now = Date.now()) {
5860
+ this.started = true;
5861
+ if (this.version === null || now - this.checkedAt > VERSION_TTL_MS) this.refresh(now);
5862
+ }
5863
+ current(now = Date.now()) {
5864
+ if (!this.started) return null;
5865
+ const ttl = this.version ? VERSION_TTL_MS : VERSION_RETRY_MS;
5866
+ if (now - this.checkedAt > ttl) this.refresh(now);
5867
+ return this.version;
5868
+ }
5869
+ /** current(), but waits (bounded) for a probe already in flight —
5870
+ * for paths that are async anyway (auto-restore). */
5871
+ async settled(timeoutMs) {
5872
+ const pending = this.inFlight;
5873
+ if (pending) {
5874
+ let timer;
5875
+ await Promise.race([pending, new Promise((r) => {
5876
+ timer = setTimeout(r, timeoutMs);
5877
+ })]);
5878
+ if (timer) clearTimeout(timer);
5879
+ }
5880
+ return this.current();
5881
+ }
5882
+ refresh(now) {
5883
+ if (this.inFlight) return;
5884
+ this.checkedAt = now;
5885
+ this.inFlight = this.probe(this.env()).then((parsed) => {
5886
+ this.checkedAt = Date.now();
5887
+ if (parsed) this.version = parsed;
5888
+ logger.logger.debug(`[TERMINAL HOOKS] claude --version \u2192 ${parsed ?? "unresolved"}`);
5889
+ }, () => void 0).finally(() => {
5890
+ this.inFlight = null;
5891
+ });
5892
+ }
5893
+ }
5894
+ function withTerminalHooks(command, terminalId, claudeVersion) {
5895
+ if (!isInjectableClaudeCommand(command)) return command;
5896
+ const events = terminalHookEventPlan(claudeVersion);
5897
+ if (!events) return command;
5898
+ const hookCommand = terminalHookCommand({
5899
+ forwarderPath: path.resolve(projectPath.projectPath(), "scripts", TERMINAL_STATUS_FORWARDER_BASENAME),
5900
+ terminalId,
5901
+ happyHome: configuration.configuration.happyHomeDir
5902
+ });
5903
+ if (!hookCommand) return command;
5904
+ const path$1 = terminalHookSettingsPath(terminalId);
5905
+ try {
5906
+ node_fs.mkdirSync(hooksDir(), { recursive: true });
5907
+ node_fs.writeFileSync(path$1, JSON.stringify(buildTerminalHookSettings(events, hookCommand), null, 2));
5908
+ } catch (error) {
5909
+ logger.logger.debug(`[TERMINAL HOOKS] settings write failed for ${terminalId}: ${error}`);
5910
+ return command;
5911
+ }
5912
+ const injected = injectClaudeSettingsFlag(command, path$1);
5913
+ if (injected !== command) logger.logger.debug(`[TERMINAL HOOKS] hooks injected for ${terminalId} (claude ${claudeVersion}, ${events.length} events)`);
5914
+ return injected;
5915
+ }
5916
+ function removeTerminalHookSettings(terminalId) {
5917
+ try {
5918
+ const path = terminalHookSettingsPath(terminalId);
5919
+ if (node_fs.existsSync(path)) node_fs.unlinkSync(path);
5920
+ } catch {
5921
+ }
5922
+ }
5923
+ function sweepStaleTerminalHookSettings(isLive) {
5924
+ const removed = [];
5925
+ let names;
5926
+ try {
5927
+ names = node_fs.readdirSync(hooksDir());
5928
+ } catch {
5929
+ return removed;
5930
+ }
5931
+ for (const name of names) {
5932
+ const m = name.match(/^([A-Za-z0-9_-]{1,64})\.json$/);
5933
+ if (!m) continue;
5934
+ try {
5935
+ if (isLive(m[1])) continue;
5936
+ node_fs.unlinkSync(path.join(hooksDir(), name));
5937
+ removed.push(m[1]);
5938
+ } catch {
5939
+ }
5940
+ }
5941
+ return removed;
5942
+ }
5943
+
5545
5944
  const AUTO_RESTORE_DEFAULT_MAX = 6;
5546
5945
  const AUTO_RESTORE_DEFAULT_WINDOW_HOURS = 24;
5547
5946
  const AUTO_RESTORE_HARD_MAX = 20;
@@ -5692,6 +6091,626 @@ function directTerminalItems(sessions, tmuxIds) {
5692
6091
  return out.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
5693
6092
  }
5694
6093
 
6094
+ const SCROLLBACK_FILE_MAX_BYTES = 512 * 1024;
6095
+ const SCROLLBACK_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
6096
+ const SCROLLBACK_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
6097
+ const SCROLLBACK_CAPTURE_LINES = 5e3;
6098
+ const CHECKPOINT_QUIET_MS = 3e3;
6099
+ const CHECKPOINT_MIN_INTERVAL_MS = 3e4;
6100
+ const CHECKPOINT_MAX_DIRTY_MS = 12e4;
6101
+ const CHECKPOINTS_PER_TICK = 4;
6102
+ const SCROLLBACK_PERSIST_DEFAULT = true;
6103
+ function resolveScrollbackConfig(raw) {
6104
+ const v = raw?.terminalScrollbackPersist;
6105
+ return { enabled: typeof v === "boolean" ? v : SCROLLBACK_PERSIST_DEFAULT };
6106
+ }
6107
+ const ESC = 27;
6108
+ const SGR_RESET = Buffer.from("\x1B[0m", "ascii");
6109
+ const CRLF = Buffer.from("\r\n", "ascii");
6110
+ function sanitizeArchivedScrollback(input) {
6111
+ const out = Buffer.allocUnsafe(input.length);
6112
+ let o = 0;
6113
+ const n = input.length;
6114
+ let i = 0;
6115
+ while (i < n) {
6116
+ const b = input[i];
6117
+ if (b === ESC) {
6118
+ const next = i + 1 < n ? input[i + 1] : -1;
6119
+ if (next === 91) {
6120
+ let j = i + 2;
6121
+ let paramsOk = true;
6122
+ while (j < n && input[j] >= 48 && input[j] <= 63) {
6123
+ const c = input[j];
6124
+ if (!(c >= 48 && c <= 57 || c === 59 || c === 58)) paramsOk = false;
6125
+ j++;
6126
+ }
6127
+ let intermediates = false;
6128
+ while (j < n && input[j] >= 32 && input[j] <= 47) {
6129
+ intermediates = true;
6130
+ j++;
6131
+ }
6132
+ if (j < n && input[j] >= 64 && input[j] <= 126) {
6133
+ if (input[j] === 109 && paramsOk && !intermediates) {
6134
+ input.copy(out, o, i, j + 1);
6135
+ o += j + 1 - i;
6136
+ }
6137
+ i = j + 1;
6138
+ } else {
6139
+ i = skipToLineEnd(input, j);
6140
+ }
6141
+ continue;
6142
+ }
6143
+ if (next === 93 || next === 80 || next === 95 || next === 94 || next === 88) {
6144
+ let j = i + 2;
6145
+ while (j < n) {
6146
+ if (input[j] === 7 && next === 93) {
6147
+ j += 1;
6148
+ break;
6149
+ }
6150
+ if (input[j] === ESC && j + 1 < n && input[j + 1] === 92) {
6151
+ j += 2;
6152
+ break;
6153
+ }
6154
+ if (input[j] === 10) break;
6155
+ j++;
6156
+ }
6157
+ i = j;
6158
+ continue;
6159
+ }
6160
+ i += next === -1 ? 1 : 2;
6161
+ continue;
6162
+ }
6163
+ if (b < 32 && b !== 9 && b !== 10 && b !== 13) {
6164
+ i++;
6165
+ continue;
6166
+ }
6167
+ if (b === 127) {
6168
+ i++;
6169
+ continue;
6170
+ }
6171
+ if (b === 194 && i + 1 < n && input[i + 1] >= 128 && input[i + 1] <= 159) {
6172
+ i += 2;
6173
+ continue;
6174
+ }
6175
+ out[o++] = b;
6176
+ i++;
6177
+ }
6178
+ return out.subarray(0, o);
6179
+ }
6180
+ function skipToLineEnd(buf, from) {
6181
+ const nl = buf.indexOf(10, from);
6182
+ return nl === -1 ? buf.length : nl;
6183
+ }
6184
+ function splitCrlfLines(blob) {
6185
+ const out = [];
6186
+ let start = 0;
6187
+ for (let i = 0; i < blob.length; i++) {
6188
+ if (blob[i] !== 10) continue;
6189
+ let end = i;
6190
+ if (end > start && blob[end - 1] === 13) end -= 1;
6191
+ out.push(blob.subarray(start, end));
6192
+ start = i + 1;
6193
+ }
6194
+ if (start < blob.length) out.push(blob.subarray(start));
6195
+ return out;
6196
+ }
6197
+ function isBlankRow(row) {
6198
+ return /^(?:\x1b\[[0-9;:]*m| )*$/.test(row.toString("latin1"));
6199
+ }
6200
+ function joinCrlf(lines) {
6201
+ const parts = [];
6202
+ lines.forEach((l, i) => {
6203
+ if (i > 0) parts.push(CRLF);
6204
+ parts.push(l);
6205
+ });
6206
+ return Buffer.concat(parts);
6207
+ }
6208
+ function buildCheckpointBody(parts, budget = SCROLLBACK_FILE_MAX_BYTES) {
6209
+ const cleaned = [];
6210
+ for (const part of parts) {
6211
+ const lines = splitCrlfLines(normalizeCaptureLines(sanitizeArchivedScrollback(part)));
6212
+ while (lines.length > 0 && isBlankRow(lines[lines.length - 1])) lines.pop();
6213
+ if (lines.length > 0) cleaned.push(joinCrlf(lines));
6214
+ }
6215
+ const joined = [];
6216
+ cleaned.forEach((c, i) => {
6217
+ if (i > 0) joined.push(SGR_RESET, CRLF);
6218
+ joined.push(c);
6219
+ });
6220
+ return truncateToBudget(Buffer.concat(joined), budget);
6221
+ }
6222
+ const FILE_MAGIC = "VHSB1";
6223
+ function encodeArchiveFile(file) {
6224
+ return Buffer.concat([Buffer.from(`${FILE_MAGIC} ${Math.floor(file.savedAt)}
6225
+ `, "ascii"), file.body]);
6226
+ }
6227
+ function decodeArchiveFile(raw) {
6228
+ if (!raw || raw.length === 0) return null;
6229
+ const nl = raw.indexOf(10);
6230
+ if (nl <= 0 || nl > 64) return null;
6231
+ const m = /^VHSB1 (\d{1,16})$/.exec(raw.subarray(0, nl).toString("ascii"));
6232
+ if (!m) return null;
6233
+ const savedAt = Number(m[1]);
6234
+ if (!Number.isFinite(savedAt) || savedAt <= 0) return null;
6235
+ const body = truncateToBudget(sanitizeArchivedScrollback(raw.subarray(nl + 1)), SCROLLBACK_FILE_MAX_BYTES);
6236
+ if (body.length === 0) return null;
6237
+ return { savedAt, body };
6238
+ }
6239
+ const DIM = "\x1B[0;2m";
6240
+ const RESET = "\x1B[0m";
6241
+ const REBUILD_SEPARATOR = Buffer.from(`${DIM}\u2500\u2500 \u91CD\u5EFA \xB7 rebuilt \u2500\u2500${RESET}`, "utf8");
6242
+ function chainArchive(prev, live, budget = SCROLLBACK_FILE_MAX_BYTES) {
6243
+ if (!prev) return live ? { savedAt: live.savedAt, body: truncateToBudget(live.body, budget) } : null;
6244
+ if (!live) return { savedAt: prev.savedAt, body: truncateToBudget(prev.body, budget) };
6245
+ const body = Buffer.concat([prev.body, SGR_RESET, CRLF, REBUILD_SEPARATOR, CRLF, live.body]);
6246
+ return { savedAt: Math.max(prev.savedAt, live.savedAt), body: truncateToBudget(body, budget) };
6247
+ }
6248
+ function pad2(n) {
6249
+ return n < 10 ? `0${n}` : String(n);
6250
+ }
6251
+ function formatSavedAt(savedAt) {
6252
+ const d = new Date(savedAt);
6253
+ return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
6254
+ }
6255
+ function archiveHeaderLine(savedAt) {
6256
+ return Buffer.from(`${DIM}\u2500\u2500 \u4EE5\u4E0B\u4E3A\u4E0A\u6B21\u4F1A\u8BDD\u7684\u8F93\u51FA \xB7 previous session (${formatSavedAt(savedAt)}) \u2500\u2500${RESET}`, "utf8");
6257
+ }
6258
+ const ARCHIVE_FOOTER_LINE = Buffer.from(`${DIM}\u2500\u2500 \u4EE5\u4E0A\u4E3A\u4E0A\u6B21\u4F1A\u8BDD\u7684\u8F93\u51FA \xB7 \u7EC8\u7AEF\u5DF2\u91CD\u5EFA\uFF0C\u8FDB\u7A0B\u662F\u65B0\u7684 \u2500\u2500${RESET}`, "utf8");
6259
+ function withArchivedScrollback(payload, archive) {
6260
+ if (!archive || archive.body.length === 0) return payload;
6261
+ const full = Buffer.concat([
6262
+ SGR_RESET,
6263
+ archiveHeaderLine(archive.savedAt),
6264
+ CRLF,
6265
+ archive.body,
6266
+ SGR_RESET,
6267
+ CRLF,
6268
+ ARCHIVE_FOOTER_LINE,
6269
+ CRLF,
6270
+ payload.full
6271
+ ]);
6272
+ return { ...payload, full };
6273
+ }
6274
+ const CHECKPOINT_PROBE_FORMAT = "#{session_name}|#{window_activity}|#{alternate_on}|#{@vh_attach}";
6275
+ const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
6276
+ function parseCheckpointProbe(stdout) {
6277
+ const out = [];
6278
+ for (const line of stdout.split("\n")) {
6279
+ const parts = line.split("|");
6280
+ if (parts.length < 4) continue;
6281
+ const [name, activity, alt] = parts;
6282
+ const attach = parts.slice(3).join("|");
6283
+ if (!name.startsWith("vh-")) continue;
6284
+ const id = name.slice(3);
6285
+ if (!ID_RE.test(id)) continue;
6286
+ if (attach.trim().length > 0) continue;
6287
+ const secs = Number(activity);
6288
+ if (!Number.isFinite(secs) || secs <= 0) continue;
6289
+ out.push({ id, activityAt: secs * 1e3, alternateOn: alt === "1" });
6290
+ }
6291
+ return out;
6292
+ }
6293
+ const ACTIVITY_RESOLUTION_MS = 1e3;
6294
+ function planCheckpoints(terminals, state, now, opts = {}) {
6295
+ const quietMs = opts.quietMs ?? CHECKPOINT_QUIET_MS;
6296
+ const minIntervalMs = opts.minIntervalMs ?? CHECKPOINT_MIN_INTERVAL_MS;
6297
+ const maxDirtyMs = opts.maxDirtyMs ?? CHECKPOINT_MAX_DIRTY_MS;
6298
+ const max = opts.max ?? CHECKPOINTS_PER_TICK;
6299
+ const due = [];
6300
+ for (const t of terminals) {
6301
+ const prev = state.get(t.id);
6302
+ if (prev && t.activityAt + ACTIVITY_RESOLUTION_MS <= prev.at) continue;
6303
+ const lastAt = prev?.at ?? 0;
6304
+ const quiet = now - t.activityAt >= quietMs && now - lastAt >= minIntervalMs;
6305
+ const overdue = now - lastAt >= maxDirtyMs;
6306
+ if (quiet || overdue) due.push({ id: t.id, lastAt });
6307
+ }
6308
+ due.sort((a, b) => a.lastAt - b.lastAt || (a.id < b.id ? -1 : 1));
6309
+ return due.slice(0, Math.max(0, max)).map((d) => d.id);
6310
+ }
6311
+ function checkpointCaptureArgs(id, alternateOn, lines = SCROLLBACK_CAPTURE_LINES) {
6312
+ const t = `=vh-${id}:`;
6313
+ const n = Math.max(1, Math.floor(lines));
6314
+ if (!alternateOn) return [["capture-pane", "-peqJN", "-t", t, "-S", `-${n}`]];
6315
+ return [
6316
+ ["capture-pane", "-peqJN", "-t", t, "-S", `-${n}`, "-E", "-1"],
6317
+ ["capture-pane", "-peqJN", "-t", t, "-a"]
6318
+ ];
6319
+ }
6320
+ const FILE_RE = /^([a-zA-Z0-9_-]{1,64})\.(live|prev)\.vhsb$/;
6321
+ function archiveFileName(id, kind) {
6322
+ if (!ID_RE.test(id)) throw new Error(`invalid terminal id: ${id}`);
6323
+ return `${id}.${kind}.vhsb`;
6324
+ }
6325
+ function parseArchiveFileName(name) {
6326
+ const m = FILE_RE.exec(name);
6327
+ return m ? { id: m[1], kind: m[2] } : null;
6328
+ }
6329
+ function planScrollbackGc(files, keepIds, now, ttlMs = SCROLLBACK_TTL_MS, totalMax = SCROLLBACK_TOTAL_MAX_BYTES) {
6330
+ const doomed = /* @__PURE__ */ new Set();
6331
+ for (const f of files) {
6332
+ if (!f.id || !f.kind) {
6333
+ doomed.add(f.name);
6334
+ continue;
6335
+ }
6336
+ if (now - f.mtimeMs > ttlMs) {
6337
+ doomed.add(f.name);
6338
+ continue;
6339
+ }
6340
+ if (keepIds && !keepIds.has(f.id)) doomed.add(f.name);
6341
+ }
6342
+ const survivors = files.filter((f) => !doomed.has(f.name)).sort((a, b) => a.mtimeMs - b.mtimeMs);
6343
+ let total = survivors.reduce((s, f) => s + f.bytes, 0);
6344
+ for (const f of survivors) {
6345
+ if (total <= totalMax) break;
6346
+ doomed.add(f.name);
6347
+ total -= f.bytes;
6348
+ }
6349
+ return [...doomed];
6350
+ }
6351
+
6352
+ const CONFIG_REFRESH_MS = 6e4;
6353
+ const GC_INTERVAL_MS = 10 * 6e4;
6354
+ const SHUTDOWN_FLUSH_BUDGET_MS = 300;
6355
+ const KILL_CHECKPOINT_BUDGET_MS = 300;
6356
+ const TMUX_TIMEOUT_MS = 5e3;
6357
+ const TMUX_MAX_BUFFER = 32 * 1024 * 1024;
6358
+ function defaultTmux(env) {
6359
+ return {
6360
+ run: (args, timeoutMs) => new Promise((resolve) => {
6361
+ try {
6362
+ node_child_process.execFile(
6363
+ "tmux",
6364
+ tmuxSocket.tmuxArgs(args),
6365
+ { env: env(), encoding: "buffer", timeout: Math.max(1, timeoutMs ?? TMUX_TIMEOUT_MS), maxBuffer: TMUX_MAX_BUFFER },
6366
+ (err, stdout) => resolve(err ? null : stdout)
6367
+ );
6368
+ } catch {
6369
+ resolve(null);
6370
+ }
6371
+ }),
6372
+ runSync: (args, timeoutMs) => {
6373
+ try {
6374
+ const r = node_child_process.spawnSync("tmux", tmuxSocket.tmuxArgs(args), { env: env(), timeout: Math.max(1, timeoutMs), maxBuffer: TMUX_MAX_BUFFER });
6375
+ return r.status === 0 && r.stdout ? r.stdout : null;
6376
+ } catch {
6377
+ return null;
6378
+ }
6379
+ }
6380
+ };
6381
+ }
6382
+ class ScrollbackCheckpointer {
6383
+ opts;
6384
+ tmux;
6385
+ now;
6386
+ readConfig;
6387
+ /** Cached switch; the default until settings were read once. */
6388
+ enabled = SCROLLBACK_PERSIST_DEFAULT;
6389
+ configReadAt = 0;
6390
+ lastGcAt = 0;
6391
+ busy = false;
6392
+ /** Set once a tick ran in this process — the shutdown flush is a no-op
6393
+ * before that (unit tests that only construct+stop never touch tmux). */
6394
+ started = false;
6395
+ stopped = false;
6396
+ state = /* @__PURE__ */ new Map();
6397
+ /** Bumped by promote(): an in-flight capture of an earlier generation must
6398
+ * not overwrite `.live` after the promotion moved it to `.prev`. */
6399
+ generation = /* @__PURE__ */ new Map();
6400
+ constructor(opts) {
6401
+ this.opts = opts;
6402
+ this.tmux = opts.tmux ?? defaultTmux(opts.env);
6403
+ this.now = opts.now ?? Date.now;
6404
+ this.readConfig = opts.readConfig ?? (() => {
6405
+ try {
6406
+ return resolveScrollbackConfig(JSON.parse(node_fs.readFileSync(path.join(this.opts.homeDir(), "settings.json"), "utf8")));
6407
+ } catch {
6408
+ return resolveScrollbackConfig(null);
6409
+ }
6410
+ });
6411
+ }
6412
+ dir() {
6413
+ return path.join(this.opts.homeDir(), "terminal-scrollback");
6414
+ }
6415
+ filePath(id, kind) {
6416
+ return path.join(this.dir(), archiveFileName(id, kind));
6417
+ }
6418
+ isEnabled() {
6419
+ this.refreshConfig();
6420
+ return this.enabled;
6421
+ }
6422
+ // ── tick ────────────────────────────────────────────────────────────────
6423
+ /** Fire-and-forget from the list-track tick. Never throws. */
6424
+ tick(closedIds) {
6425
+ if (this.busy || this.stopped) return;
6426
+ this.busy = true;
6427
+ this.started = true;
6428
+ void this.runTick(closedIds).catch((e) => logger.logger.debug(`[SCROLLBACK] tick failed: ${e}`)).finally(() => {
6429
+ this.busy = false;
6430
+ });
6431
+ }
6432
+ /** One tick, awaitable (tests). */
6433
+ async runTick(closedIds) {
6434
+ this.started = true;
6435
+ this.refreshConfig();
6436
+ if (!this.enabled) return;
6437
+ const out = await this.tmux.run(["list-sessions", "-F", CHECKPOINT_PROBE_FORMAT]);
6438
+ const probed = out ? parseCheckpointProbe(out.toString("utf8")) : null;
6439
+ if (probed) {
6440
+ this.seedState(probed);
6441
+ const now = this.now();
6442
+ for (const id of planCheckpoints(probed, this.state, now)) {
6443
+ if (this.stopped || !this.enabled) break;
6444
+ const t = probed.find((p) => p.id === id);
6445
+ await this.checkpointOne(t);
6446
+ }
6447
+ }
6448
+ this.maybeGc(probed ? /* @__PURE__ */ new Set([...probed.map((p) => p.id), ...closedIds]) : null);
6449
+ }
6450
+ /** Re-read the switch (at most every CONFIG_REFRESH_MS; always on first
6451
+ * use, so a replay right after daemon start already honours `false`). */
6452
+ refreshConfig() {
6453
+ const now = this.now();
6454
+ if (this.configReadAt && now - this.configReadAt < CONFIG_REFRESH_MS) return;
6455
+ this.configReadAt = now;
6456
+ let enabled = SCROLLBACK_PERSIST_DEFAULT;
6457
+ try {
6458
+ enabled = this.readConfig().enabled;
6459
+ } catch {
6460
+ }
6461
+ const turnedOff = this.enabled && !enabled;
6462
+ this.enabled = enabled;
6463
+ if (!enabled) {
6464
+ if (turnedOff) logger.logger.info("[SCROLLBACK] terminalScrollbackPersist=false: deleting saved terminal scrollback");
6465
+ this.purge();
6466
+ }
6467
+ }
6468
+ /** First sight of an id after a daemon start: an existing `.live` file
6469
+ * counts as the last checkpoint (by mtime), so a restart does not re-take
6470
+ * every terminal at once. */
6471
+ seedState(probed) {
6472
+ for (const t of probed) {
6473
+ if (this.state.has(t.id)) continue;
6474
+ try {
6475
+ const mtime = node_fs.statSync(this.filePath(t.id, "live")).mtimeMs;
6476
+ this.state.set(t.id, { at: mtime - ACTIVITY_RESOLUTION_MS });
6477
+ } catch {
6478
+ }
6479
+ }
6480
+ }
6481
+ async checkpointOne(t) {
6482
+ const gen = this.generation.get(t.id) ?? 0;
6483
+ const startedAt = this.now();
6484
+ const parts = [];
6485
+ for (const args of checkpointCaptureArgs(t.id, t.alternateOn)) {
6486
+ const out = await this.tmux.run(args);
6487
+ if (!out) return;
6488
+ parts.push(out);
6489
+ }
6490
+ if ((this.generation.get(t.id) ?? 0) !== gen || this.stopped || !this.enabled) return;
6491
+ this.writeLive(t.id, buildCheckpointBody(parts), this.now());
6492
+ this.state.set(t.id, { at: startedAt });
6493
+ }
6494
+ writeLive(id, body, now) {
6495
+ if (body.length === 0) {
6496
+ this.removeFile(id, "live");
6497
+ return;
6498
+ }
6499
+ this.writeFile(id, "live", { savedAt: now, body });
6500
+ }
6501
+ // ── promote / replay ────────────────────────────────────────────────────
6502
+ /**
6503
+ * Spec D3: called right before a terminal is rebuilt (new tmux session,
6504
+ * same id). `.live` (the life that ended) is chained onto `.prev` and
6505
+ * removed. Synchronous on purpose — the caller creates the session in the
6506
+ * same turn, so no async capture can slip in between.
6507
+ *
6508
+ * Returns an UNDO that puts both files back exactly as they were: the
6509
+ * caller runs it when the rebuild did not happen (new-session failed —
6510
+ * e.g. a timed-out has-session misread a live session as gone), so the
6511
+ * same life is not archived twice and replayed as a duplicate.
6512
+ * `.live` is only removed once the chained `.prev` was written.
6513
+ */
6514
+ promote(id) {
6515
+ this.generation.set(id, (this.generation.get(id) ?? 0) + 1);
6516
+ this.state.delete(id);
6517
+ const noop = () => {
6518
+ };
6519
+ if (!this.isEnabled()) return noop;
6520
+ try {
6521
+ const liveRaw = this.readFile(id, "live");
6522
+ const prevRaw = this.readFile(id, "prev");
6523
+ const live = decodeArchiveFile(liveRaw);
6524
+ const chained = chainArchive(decodeArchiveFile(prevRaw), live);
6525
+ if (!chained || !live || !liveRaw) return noop;
6526
+ if (!this.writeFile(id, "prev", chained)) return noop;
6527
+ this.removeFile(id, "live");
6528
+ return () => {
6529
+ this.generation.set(id, (this.generation.get(id) ?? 0) + 1);
6530
+ if (prevRaw) this.writeRaw(id, "prev", prevRaw);
6531
+ else this.removeFile(id, "prev");
6532
+ this.writeRaw(id, "live", liveRaw);
6533
+ };
6534
+ } catch (e) {
6535
+ logger.logger.debug(`[SCROLLBACK] promote ${id} failed: ${e}`);
6536
+ return noop;
6537
+ }
6538
+ }
6539
+ /** The archive to replay above a capture of `id`, or null. */
6540
+ replayFor(id) {
6541
+ if (!this.isEnabled()) return null;
6542
+ try {
6543
+ return decodeArchiveFile(this.readFile(id, "prev"));
6544
+ } catch {
6545
+ return null;
6546
+ }
6547
+ }
6548
+ // ── shutdown ────────────────────────────────────────────────────────────
6549
+ /**
6550
+ * Daemon exit: capture every terminal with output newer than its last
6551
+ * checkpoint — ASYNC and in PARALLEL, newest activity first, within
6552
+ * `budgetMs`. Never throws and always resolves by the deadline, so the
6553
+ * caller's state/lock cleanup runs regardless. Best-effort: on a machine
6554
+ * shutdown tmux may already be gone; the periodic checkpoint is the real
6555
+ * guarantee.
6556
+ */
6557
+ async flushOnShutdown(budgetMs = SHUTDOWN_FLUSH_BUDGET_MS) {
6558
+ if (this.stopped) return;
6559
+ this.stopped = true;
6560
+ if (!this.started || !this.enabled) return;
6561
+ let timer;
6562
+ const deadline = new Promise((r) => {
6563
+ timer = setTimeout(r, Math.max(0, budgetMs));
6564
+ timer.unref?.();
6565
+ });
6566
+ const startedAt = Date.now();
6567
+ const left = () => Math.max(1, budgetMs - (Date.now() - startedAt));
6568
+ const work = (async () => {
6569
+ const out = await this.tmux.run(["list-sessions", "-F", CHECKPOINT_PROBE_FORMAT], left());
6570
+ if (!out) return;
6571
+ const probed = parseCheckpointProbe(out.toString("utf8"));
6572
+ this.seedState(probed);
6573
+ const dirty = planCheckpoints(probed, this.state, this.now(), {
6574
+ quietMs: 0,
6575
+ minIntervalMs: 0,
6576
+ maxDirtyMs: 0,
6577
+ max: Number.MAX_SAFE_INTEGER
6578
+ });
6579
+ const byRecent = dirty.map((id) => probed.find((p) => p.id === id)).sort((a, b) => b.activityAt - a.activityAt);
6580
+ let written = 0;
6581
+ await Promise.all(byRecent.map(async (t) => {
6582
+ const at = this.now();
6583
+ const parts = await Promise.all(checkpointCaptureArgs(t.id, t.alternateOn).map((a) => this.tmux.run(a, left())));
6584
+ if (parts.some((p) => !p) || Date.now() - startedAt > budgetMs) return;
6585
+ this.writeLive(t.id, buildCheckpointBody(parts), this.now());
6586
+ this.state.set(t.id, { at });
6587
+ written++;
6588
+ }));
6589
+ if (dirty.length > 0) logger.logger.debug(`[SCROLLBACK] shutdown flush: ${written}/${dirty.length} terminal(s)`);
6590
+ })().catch((e) => logger.logger.debug(`[SCROLLBACK] shutdown flush failed: ${e}`));
6591
+ await Promise.race([work, deadline]);
6592
+ if (timer) clearTimeout(timer);
6593
+ }
6594
+ /**
6595
+ * Synchronous single-terminal capture right before the user kills it, so
6596
+ * a ↻ restore of the close record replays its last output. Bounded by
6597
+ * `budgetMs`; never throws; attach terminals are skipped.
6598
+ */
6599
+ checkpointSync(id, budgetMs = KILL_CHECKPOINT_BUDGET_MS) {
6600
+ try {
6601
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id) || !this.isEnabled()) return false;
6602
+ const deadline = Date.now() + budgetMs;
6603
+ const left = () => deadline - Date.now();
6604
+ const probe = this.tmux.runSync(["display-message", "-p", "-t", `=vh-${id}:`, "#{alternate_on}|#{@vh_attach}"], left());
6605
+ if (!probe) return false;
6606
+ const [alt, ...attach] = probe.toString("utf8").replace(/\r?\n$/, "").split("|");
6607
+ if (attach.join("|").trim().length > 0) return false;
6608
+ const at = this.now();
6609
+ const parts = [];
6610
+ for (const args of checkpointCaptureArgs(id, alt === "1")) {
6611
+ const timeout = left();
6612
+ const part = timeout > 0 ? this.tmux.runSync(args, timeout) : null;
6613
+ if (!part) return false;
6614
+ parts.push(part);
6615
+ }
6616
+ this.writeLive(id, buildCheckpointBody(parts), this.now());
6617
+ this.state.set(id, { at });
6618
+ return true;
6619
+ } catch (e) {
6620
+ logger.logger.debug(`[SCROLLBACK] pre-kill checkpoint ${id} failed: ${e}`);
6621
+ return false;
6622
+ }
6623
+ }
6624
+ // ── fs ──────────────────────────────────────────────────────────────────
6625
+ ensureDir() {
6626
+ const dir = this.dir();
6627
+ node_fs.mkdirSync(dir, { recursive: true, mode: 448 });
6628
+ try {
6629
+ node_fs.chmodSync(dir, 448);
6630
+ } catch {
6631
+ }
6632
+ return dir;
6633
+ }
6634
+ readFile(id, kind) {
6635
+ try {
6636
+ const p = this.filePath(id, kind);
6637
+ if (node_fs.statSync(p).size > SCROLLBACK_FILE_MAX_BYTES * 2) return null;
6638
+ return node_fs.readFileSync(p);
6639
+ } catch {
6640
+ return null;
6641
+ }
6642
+ }
6643
+ /** Atomic 0600 write; false on any failure. */
6644
+ writeFile(id, kind, file) {
6645
+ return this.writeRaw(id, kind, encodeArchiveFile(file));
6646
+ }
6647
+ writeRaw(id, kind, bytes) {
6648
+ try {
6649
+ this.ensureDir();
6650
+ const target = this.filePath(id, kind);
6651
+ const tmp = `${target}.${process.pid}.tmp`;
6652
+ node_fs.writeFileSync(tmp, bytes, { mode: 384 });
6653
+ node_fs.chmodSync(tmp, 384);
6654
+ node_fs.renameSync(tmp, target);
6655
+ return true;
6656
+ } catch (e) {
6657
+ logger.logger.debug(`[SCROLLBACK] write ${id}.${kind} failed: ${e}`);
6658
+ return false;
6659
+ }
6660
+ }
6661
+ removeFile(id, kind) {
6662
+ try {
6663
+ node_fs.unlinkSync(this.filePath(id, kind));
6664
+ } catch {
6665
+ }
6666
+ }
6667
+ listFiles() {
6668
+ let names;
6669
+ try {
6670
+ names = node_fs.readdirSync(this.dir());
6671
+ } catch {
6672
+ return [];
6673
+ }
6674
+ const out = [];
6675
+ for (const name of names) {
6676
+ try {
6677
+ const st = node_fs.statSync(path.join(this.dir(), name));
6678
+ if (!st.isFile()) continue;
6679
+ const parsed = parseArchiveFileName(name);
6680
+ out.push({ name, id: parsed?.id ?? null, kind: parsed?.kind ?? null, mtimeMs: st.mtimeMs, bytes: st.size });
6681
+ } catch {
6682
+ }
6683
+ }
6684
+ return out;
6685
+ }
6686
+ maybeGc(keepIds) {
6687
+ const now = this.now();
6688
+ if (this.lastGcAt && now - this.lastGcAt < GC_INTERVAL_MS) return;
6689
+ this.lastGcAt = now;
6690
+ this.gc(keepIds, now);
6691
+ }
6692
+ /** Exposed for tests. */
6693
+ gc(keepIds, now = this.now()) {
6694
+ const doomed = planScrollbackGc(this.listFiles(), keepIds, now);
6695
+ for (const name of doomed) {
6696
+ try {
6697
+ node_fs.unlinkSync(path.join(this.dir(), name));
6698
+ } catch {
6699
+ }
6700
+ }
6701
+ if (doomed.length > 0) logger.logger.debug(`[SCROLLBACK] gc removed ${doomed.length} file(s)`);
6702
+ return doomed;
6703
+ }
6704
+ /** Switch turned off: the saved scrollback goes away entirely. */
6705
+ purge() {
6706
+ try {
6707
+ node_fs.rmSync(this.dir(), { recursive: true, force: true });
6708
+ } catch {
6709
+ }
6710
+ this.state.clear();
6711
+ }
6712
+ }
6713
+
5695
6714
  const TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
5696
6715
  function tmuxKillVerified(kill, probe) {
5697
6716
  return !kill.error && !probe.error && typeof probe.status === "number" && probe.status !== 0;
@@ -5836,6 +6855,10 @@ function terminalListSignature(items) {
5836
6855
  function hostListSignature(items) {
5837
6856
  return `${isTmuxAvailable() ? "tmux" : "no-tmux"}|${terminalListSignature(items)}`;
5838
6857
  }
6858
+ function screenObservation(probe) {
6859
+ if (probe.agentObservedAt === void 0) return void 0;
6860
+ return { agentKind: probe.agentKind, agentState: probe.agentState };
6861
+ }
5839
6862
  function looksLikeClaudeCommand(currentCommand) {
5840
6863
  const cmd = (currentCommand || "").trim().replace(/^-/, "").toLowerCase();
5841
6864
  return cmd === "claude" || cmd === "node" || /^\d+\.\d+(\.\d+)?$/.test(cmd);
@@ -6307,6 +7330,8 @@ class WebTerminalManager {
6307
7330
  * refreshes and notifications into a process that is exiting. */
6308
7331
  shuttingDown = false;
6309
7332
  onAutoRestoreSummaryCb = null;
7333
+ /** B-550: scrollback checkpoints across rebuilds (scrollbackCheckpoint.ts). */
7334
+ scrollback = new ScrollbackCheckpointer({ homeDir: () => configuration.configuration.happyHomeDir, env: ptyEnv });
6310
7335
  /** Report line for the account notification (wired by apiMachine). */
6311
7336
  setOnAutoRestoreSummary(fn) {
6312
7337
  this.onAutoRestoreSummaryCb = fn;
@@ -6345,6 +7370,22 @@ class WebTerminalManager {
6345
7370
  // callback ⇒ tracking is skipped entirely.
6346
7371
  onNotify;
6347
7372
  notifyTracker = new TerminalNotifyTracker();
7373
+ // ── Hook-driven agent status (B-547) ────────────────────────────────────
7374
+ // `claude` commands the daemon types into a terminal get `--settings`
7375
+ // with managed hooks (terminalHookSettings.ts); the forwarded events land
7376
+ // in `agentHooks` and are fused with the screen classifier in
7377
+ // probeAgentState. The screen stays the fallback for everything else.
7378
+ agentHooks = new TerminalAgentHookStore();
7379
+ claudeVersion = new ClaudeVersionCache(ptyEnv);
7380
+ /** Terminals whose LAST probe was decided by a hook record — their
7381
+ * transitions skip the screen debounce in the notification tracker.
7382
+ * Daemon-internal: never pushed (wire shape unchanged). */
7383
+ hookSourced = /* @__PURE__ */ new Set();
7384
+ /** Last `#{pane_current_command}` identity seen per terminal, so an
7385
+ * interrupt re-check can classify the screen without a list read. */
7386
+ lastPolledIdentity = /* @__PURE__ */ new Map();
7387
+ interruptTimers = /* @__PURE__ */ new Map();
7388
+ hookSettingsSwept = false;
6348
7389
  // ── Terminal mirror integration (B-105) ─────────────────────────────────
6349
7390
  /** terminalId → shadow session id (daemon's mirror manager). Feeds the
6350
7391
  * list push (toggle availability) and closed-terminal records. */
@@ -6371,6 +7412,70 @@ class WebTerminalManager {
6371
7412
  requestListRefresh() {
6372
7413
  this.kickListRefresh();
6373
7414
  }
7415
+ /** B-547: one forwarded status hook (control server /terminal-agent-hook).
7416
+ * A list-relevant change pushes within the kick debounce instead of
7417
+ * waiting for the 10 s tick. Never throws. */
7418
+ ingestAgentHook(body) {
7419
+ try {
7420
+ const result = this.agentHooks.applyHook(body, Date.now());
7421
+ if (result?.changed) this.kickListRefresh();
7422
+ } catch (e) {
7423
+ logger.logger.debug(`[WEB TERMINAL] agent hook ingest failed: ${e}`);
7424
+ }
7425
+ }
7426
+ /** B-547: test/diagnostic view — does this terminal hold a hook record? */
7427
+ hasAgentHookRecord(terminalId) {
7428
+ return this.agentHooks.has(terminalId);
7429
+ }
7430
+ /** B-547: once per daemon, drop settings files of terminals whose tmux
7431
+ * session is gone. Per-id `has-session` (not a list read, whose transient
7432
+ * failure would look like "no terminals") decides liveness. */
7433
+ sweepHookSettingsOnce() {
7434
+ if (this.hookSettingsSwept || !isTmuxAvailable()) return;
7435
+ this.hookSettingsSwept = true;
7436
+ try {
7437
+ const env = ptyEnv();
7438
+ const removed = sweepStaleTerminalHookSettings((id) => this.terminals.has(id) || node_child_process.spawnSync("tmux", tmuxSocket.tmuxArgs(["has-session", "-t", `=vh-${id}:`]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }).status !== 1);
7439
+ if (removed.length > 0) logger.logger.debug(`[WEB TERMINAL] removed ${removed.length} stale terminal hook settings file(s)`);
7440
+ } catch (e) {
7441
+ logger.logger.debug(`[WEB TERMINAL] hook settings sweep failed: ${e}`);
7442
+ }
7443
+ }
7444
+ /** B-547: a terminal is gone — drop its hook record, timers and file. */
7445
+ forgetAgentHooks(terminalId) {
7446
+ this.agentHooks.forget(terminalId);
7447
+ this.hookSourced.delete(terminalId);
7448
+ this.lastPolledIdentity.delete(terminalId);
7449
+ for (const t of this.interruptTimers.get(terminalId) ?? []) clearTimeout(t);
7450
+ this.interruptTimers.delete(terminalId);
7451
+ removeTerminalHookSettings(terminalId);
7452
+ }
7453
+ /** B-547: re-check an armed Esc/Ctrl+C at the spec's delays. A later
7454
+ * keypress re-arms (fresh baseline, fresh timers). */
7455
+ scheduleInterruptChecks(terminalId) {
7456
+ for (const t of this.interruptTimers.get(terminalId) ?? []) clearTimeout(t);
7457
+ const timers = INTERRUPT_CHECK_DELAYS_MS.map((delay) => {
7458
+ const t = setTimeout(() => this.runInterruptCheck(terminalId), delay);
7459
+ t.unref?.();
7460
+ return t;
7461
+ });
7462
+ this.interruptTimers.set(terminalId, timers);
7463
+ }
7464
+ runInterruptCheck(terminalId) {
7465
+ try {
7466
+ const screen = this.probeScreenState(`vh-${terminalId}`, this.lastPolledIdentity.get(terminalId));
7467
+ const verdict = this.agentHooks.checkInterrupt(terminalId, screenObservation(screen), Date.now());
7468
+ if (verdict === "wait") return;
7469
+ for (const t of this.interruptTimers.get(terminalId) ?? []) clearTimeout(t);
7470
+ this.interruptTimers.delete(terminalId);
7471
+ if (verdict === "synthesized") {
7472
+ logger.logger.debug(`[WEB TERMINAL] ${terminalId}: inferred interrupt (Esc/Ctrl+C, no Stop hook)`);
7473
+ this.kickListRefresh();
7474
+ }
7475
+ } catch (e) {
7476
+ logger.logger.debug(`[WEB TERMINAL] interrupt check failed: ${e}`);
7477
+ }
7478
+ }
6374
7479
  constructor(emit, onNotify) {
6375
7480
  this.emit = emit;
6376
7481
  this.onNotify = onNotify ?? null;
@@ -6385,6 +7490,8 @@ class WebTerminalManager {
6385
7490
  */
6386
7491
  startListTracking(cb, intervalMs = LIST_TRACK_INTERVAL_MS) {
6387
7492
  this.listChangedCb = cb;
7493
+ this.claudeVersion.start();
7494
+ this.sweepHookSettingsOnce();
6388
7495
  this.lastActivityEmitted = {};
6389
7496
  if (!this.listTrackTimer) {
6390
7497
  this.listTrackTimer = setInterval(() => this.listTrackTick(), intervalMs);
@@ -6407,6 +7514,12 @@ class WebTerminalManager {
6407
7514
  clearTimeout(this.activityFlushTimer);
6408
7515
  this.activityFlushTimer = null;
6409
7516
  }
7517
+ for (const timers of this.interruptTimers.values()) for (const t of timers) clearTimeout(t);
7518
+ this.interruptTimers.clear();
7519
+ }
7520
+ /** B-550: last scrollback checkpoint before the daemon exits (async, ~300ms, never throws). */
7521
+ flushScrollbackForShutdown() {
7522
+ return this.scrollback.flushOnShutdown();
6410
7523
  }
6411
7524
  /**
6412
7525
  * One pty chunk arrived → make sure a realtime activity frame goes out
@@ -6537,6 +7650,8 @@ class WebTerminalManager {
6537
7650
  try {
6538
7651
  const list = this.buildTerminalList();
6539
7652
  this.trackClosures(list);
7653
+ if (list.length > 0) this.agentHooks.prune([...list.map((t) => t.id), ...this.terminals.keys()]);
7654
+ this.scrollback.tick(this.closedTerminals.map((r) => r.id));
6540
7655
  this.trackNotifications(list);
6541
7656
  this.pruneActivityTable(list);
6542
7657
  this.emitActivity(Object.fromEntries(
@@ -6594,6 +7709,7 @@ class WebTerminalManager {
6594
7709
  closedAt: now
6595
7710
  });
6596
7711
  changed = true;
7712
+ this.forgetAgentHooks(id);
6597
7713
  logger.logger.debug(`[WEB TERMINAL] recorded closed terminal ${id}`);
6598
7714
  try {
6599
7715
  this.onTerminalClosedCb?.(id);
@@ -6679,6 +7795,7 @@ class WebTerminalManager {
6679
7795
  if (!isTmuxAvailable()) return;
6680
7796
  const settings = await persistence.readSettings();
6681
7797
  const config = resolveAutoRestoreConfig(settings);
7798
+ await this.claudeVersion.settled(3e3);
6682
7799
  const selection = selectAutoRestore(candidates, {
6683
7800
  now: Date.now(),
6684
7801
  config,
@@ -6787,6 +7904,7 @@ class WebTerminalManager {
6787
7904
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
6788
7905
  ).status === 0;
6789
7906
  if (exists) return false;
7907
+ const undoPromote = plan.attachTmux ? void 0 : this.scrollback.promote(plan.terminalId);
6790
7908
  const envFlags = tmuxSupportsEnvFlag() ? [
6791
7909
  "-e",
6792
7910
  CLAUDE_CLASSIC_RENDERER_ENV,
@@ -6807,6 +7925,7 @@ class WebTerminalManager {
6807
7925
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
6808
7926
  ).status === 0;
6809
7927
  if (!live) {
7928
+ undoPromote?.();
6810
7929
  logger.logger.debug(`[WEB TERMINAL] auto-restore: tmux create failed for ${plan.terminalId}`);
6811
7930
  return false;
6812
7931
  }
@@ -6840,7 +7959,8 @@ class WebTerminalManager {
6840
7959
  );
6841
7960
  }
6842
7961
  if (plan.command) {
6843
- for (const argv of startupInjectionArgs(name, plan.command)) {
7962
+ const command = withTerminalHooks(plan.command, plan.terminalId, this.claudeVersion.current());
7963
+ for (const argv of startupInjectionArgs(name, command)) {
6844
7964
  node_child_process.spawnSync("tmux", tmuxSocket.tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
6845
7965
  }
6846
7966
  }
@@ -6873,7 +7993,13 @@ class WebTerminalManager {
6873
7993
  const now = Date.now();
6874
7994
  this.notifyTracker.prune(list.map((t) => t.id));
6875
7995
  for (const item of list) {
6876
- const event = this.notifyTracker.observe(item.id, item.agentState, now, item.agentKind);
7996
+ const event = this.notifyTracker.observe(
7997
+ item.id,
7998
+ item.agentState,
7999
+ now,
8000
+ item.agentKind,
8001
+ { authoritative: this.hookSourced.has(item.id) }
8002
+ );
6877
8003
  if (!event) continue;
6878
8004
  try {
6879
8005
  this.onNotify({ terminalId: item.id, title: item.title || "Terminal", event });
@@ -7232,7 +8358,8 @@ class WebTerminalManager {
7232
8358
  logger.logger.debug(`[WEB TERMINAL] ${tmuxSession} attaches user tmux session ${attachTarget.id} (${attachTarget.name})`);
7233
8359
  return { id: attachTarget.id, name: attachTarget.name };
7234
8360
  }
7235
- const startup = normalizeStartupCommand(startupCommand);
8361
+ const normalized = normalizeStartupCommand(startupCommand);
8362
+ const startup = normalized && tmuxSession.startsWith("vh-") ? withTerminalHooks(normalized, tmuxSession.slice(3), this.claudeVersion.current()) : normalized;
7236
8363
  if (startup) {
7237
8364
  try {
7238
8365
  for (const a of startupInjectionArgs(tmuxSession, startup)) {
@@ -7348,7 +8475,10 @@ class WebTerminalManager {
7348
8475
  });
7349
8476
  const paneState = parsePaneState((byKey.panes ?? Buffer.alloc(0)).toString("utf8"));
7350
8477
  if (paneState) session.paneId = paneState.paneId;
7351
- const payload = assembleRestore({ ...byKey, tail: unescapeOctal(byKey.tail ?? Buffer.alloc(0)) }, paneState);
8478
+ const payload = withArchivedScrollback(
8479
+ assembleRestore({ ...byKey, tail: unescapeOctal(byKey.tail ?? Buffer.alloc(0)) }, paneState),
8480
+ this.scrollback.replayFor(session.id)
8481
+ );
7352
8482
  if (fresh) {
7353
8483
  session.restoreHeadless(payload.full);
7354
8484
  }
@@ -7407,6 +8537,7 @@ class WebTerminalManager {
7407
8537
  if (!session) return;
7408
8538
  session.lastTouch = Date.now();
7409
8539
  const text = Buffer.from(dataBase64, "base64").toString("utf8");
8540
+ if (this.agentHooks.noteInput(terminalId, text, Date.now())) this.scheduleInterruptChecks(terminalId);
7410
8541
  if (session.transport.kind === "pty") {
7411
8542
  session.transport.pty.write(text);
7412
8543
  return;
@@ -7540,6 +8671,7 @@ class WebTerminalManager {
7540
8671
  this.tombstones[terminalId] = Date.now();
7541
8672
  saveTombstones(this.tombstones);
7542
8673
  this.notifyTracker.remove(terminalId);
8674
+ this.forgetAgentHooks(terminalId);
7543
8675
  logger.logger.debug(`[WEB TERMINAL] killed direct pty ${terminalId}`);
7544
8676
  this.kickListRefresh();
7545
8677
  return true;
@@ -7549,6 +8681,7 @@ class WebTerminalManager {
7549
8681
  return t ? seenInfoOf(t) : void 0;
7550
8682
  })();
7551
8683
  const target = `=vh-${terminalId}:`;
8684
+ this.scrollback.checkpointSync(terminalId);
7552
8685
  const killed = node_child_process.spawnSync("tmux", tmuxSocket.tmuxArgs(["kill-session", "-t", target]), { stdio: "ignore", env: ptyEnv() });
7553
8686
  const verified = node_child_process.spawnSync("tmux", tmuxSocket.tmuxArgs(["has-session", "-t", target]), { stdio: "ignore", env: ptyEnv() });
7554
8687
  if (!tmuxKillVerified(killed, verified)) {
@@ -7590,6 +8723,7 @@ class WebTerminalManager {
7590
8723
  this.tombstones[terminalId] = Date.now();
7591
8724
  saveTombstones(this.tombstones);
7592
8725
  this.notifyTracker.remove(terminalId);
8726
+ this.forgetAgentHooks(terminalId);
7593
8727
  logger.logger.debug(`[WEB TERMINAL] killed session vh-${terminalId}`);
7594
8728
  this.kickListRefresh();
7595
8729
  return true;
@@ -7691,6 +8825,25 @@ class WebTerminalManager {
7691
8825
  * you've opened this daemon lifetime costs nothing. Cold tmux-only sessions
7692
8826
  * (pty reaped / never attached) fall back to the tmux probe. */
7693
8827
  probeAgentState(sessionName, polledCommand) {
8828
+ const id = sessionName.startsWith("vh-") ? sessionName.slice(3) : sessionName;
8829
+ if (polledCommand) this.lastPolledIdentity.set(id, polledCommand);
8830
+ const screen = this.probeScreenState(sessionName, polledCommand);
8831
+ const now = Date.now();
8832
+ const fused = this.agentHooks.fuse(id, screenObservation(screen), now);
8833
+ if (fused.source === "screen") {
8834
+ this.hookSourced.delete(id);
8835
+ return screen;
8836
+ }
8837
+ this.hookSourced.add(id);
8838
+ return {
8839
+ agentState: fused.agentState,
8840
+ agentKind: fused.agentKind,
8841
+ agentObservedAt: screen.agentObservedAt ?? now,
8842
+ claudeConfident: screen.claudeConfident
8843
+ };
8844
+ }
8845
+ /** The screen classifier alone (pre-B-547 probeAgentState). */
8846
+ probeScreenState(sessionName, polledCommand) {
7694
8847
  const id = sessionName.startsWith("vh-") ? sessionName.slice(3) : sessionName;
7695
8848
  const live = this.terminals.get(id);
7696
8849
  if (live) {
@@ -7885,7 +9038,7 @@ class WebTerminalManager {
7885
9038
  }
7886
9039
  }
7887
9040
 
7888
- const UUID_RE$2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9041
+ const UUID_RE$4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7889
9042
  async function listClaudeProjectDirs(projectsRoot) {
7890
9043
  let names;
7891
9044
  try {
@@ -7916,7 +9069,7 @@ async function collectCandidates$1(projectDirs, exclude) {
7916
9069
  for (const name of names) {
7917
9070
  if (!name.endsWith(".jsonl")) continue;
7918
9071
  const id = name.slice(0, -".jsonl".length);
7919
- if (!UUID_RE$2.test(id) || exclude.has(id.toLowerCase())) continue;
9072
+ if (!UUID_RE$4.test(id) || exclude.has(id.toLowerCase())) continue;
7920
9073
  const path$1 = path.join(dir, name);
7921
9074
  try {
7922
9075
  const s = await promises.stat(path$1);
@@ -7945,11 +9098,11 @@ async function readHead$1(path, bytes) {
7945
9098
  await fh.close();
7946
9099
  }
7947
9100
  }
7948
- function oneLine$1(text, max) {
9101
+ function oneLine$2(text, max) {
7949
9102
  const collapsed = text.replace(/\s+/g, " ").trim();
7950
9103
  return collapsed.length > max ? `${collapsed.slice(0, max - 1)}\u2026` : collapsed;
7951
9104
  }
7952
- function parseTimestamp$1(raw) {
9105
+ function parseTimestamp$2(raw) {
7953
9106
  if (typeof raw === "number" && Number.isFinite(raw)) return raw;
7954
9107
  if (typeof raw === "string") {
7955
9108
  const t = Date.parse(raw);
@@ -7976,7 +9129,7 @@ function parseClaudeHistoryHead(head, promptChars) {
7976
9129
  }
7977
9130
  if (!parsed || typeof parsed !== "object") continue;
7978
9131
  if (parsed.type === "summary" && typeof parsed.summary === "string" && !summary) {
7979
- const s = oneLine$1(parsed.summary, promptChars);
9132
+ const s = oneLine$2(parsed.summary, promptChars);
7980
9133
  if (s) summary = s;
7981
9134
  continue;
7982
9135
  }
@@ -7984,7 +9137,7 @@ function parseClaudeHistoryHead(head, promptChars) {
7984
9137
  if (typeof parsed.entrypoint === "string" && parsed.entrypoint && !entrypoint) entrypoint = parsed.entrypoint;
7985
9138
  if (typeof parsed.gitBranch === "string" && parsed.gitBranch && !gitBranch) gitBranch = parsed.gitBranch;
7986
9139
  if (typeof parsed.version === "string" && parsed.version && !version) version = parsed.version;
7987
- if (startedAt === null) startedAt = parseTimestamp$1(parsed.timestamp);
9140
+ if (startedAt === null) startedAt = parseTimestamp$2(parsed.timestamp);
7988
9141
  if (!firstPrompt && parsed.type === "user" && !parsed.isSidechain) {
7989
9142
  const content = parsed.message?.content;
7990
9143
  let text = null;
@@ -7996,7 +9149,7 @@ function parseClaudeHistoryHead(head, promptChars) {
7996
9149
  }
7997
9150
  if (text) {
7998
9151
  if (oneShotPrompts.isVeryHappyOneShotPrompt(text)) return null;
7999
- const cleaned = oneLine$1(stripHarnessTags(text), promptChars);
9152
+ const cleaned = oneLine$2(stripHarnessTags(text), promptChars);
8000
9153
  if (cleaned) firstPrompt = cleaned;
8001
9154
  }
8002
9155
  }
@@ -8050,8 +9203,8 @@ async function listClaudeSessionHistory(options) {
8050
9203
  return { entries, truncated, scanned };
8051
9204
  }
8052
9205
 
8053
- const UUID_RE$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8054
- const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
9206
+ const UUID_RE$3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9207
+ const ROLLOUT_RE$1 = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
8055
9208
  const VERY_HAPPY_CODEX_ORIGINATOR = "happy-codex";
8056
9209
  function getCodexHomeDir() {
8057
9210
  return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
@@ -8073,7 +9226,7 @@ async function collectCandidates(root, exclude) {
8073
9226
  }
8074
9227
  for (const name of names) {
8075
9228
  const full = path.join(dir, name);
8076
- const match = ROLLOUT_RE.exec(name);
9229
+ const match = ROLLOUT_RE$1.exec(name);
8077
9230
  if (match) {
8078
9231
  const id = match[1].toLowerCase();
8079
9232
  if (exclude.has(id)) continue;
@@ -8111,11 +9264,11 @@ async function readHead(path, bytes) {
8111
9264
  await fh.close();
8112
9265
  }
8113
9266
  }
8114
- function oneLine(text, max) {
9267
+ function oneLine$1(text, max) {
8115
9268
  const collapsed = text.replace(/\s+/g, " ").trim();
8116
9269
  return collapsed.length > max ? `${collapsed.slice(0, max - 1)}\u2026` : collapsed;
8117
9270
  }
8118
- function parseTimestamp(raw) {
9271
+ function parseTimestamp$1(raw) {
8119
9272
  if (typeof raw === "number" && Number.isFinite(raw)) return raw;
8120
9273
  if (typeof raw === "string") {
8121
9274
  const t = Date.parse(raw);
@@ -8171,14 +9324,14 @@ function parseCodexHistoryHead(head, promptChars) {
8171
9324
  if (parsed.type === "session_meta" && payload && typeof payload === "object") {
8172
9325
  if (isCodexSubThread(payload)) return null;
8173
9326
  sawMeta = true;
8174
- if (typeof payload.id === "string" && UUID_RE$1.test(payload.id)) id = payload.id.toLowerCase();
9327
+ if (typeof payload.id === "string" && UUID_RE$3.test(payload.id)) id = payload.id.toLowerCase();
8175
9328
  if (typeof payload.cwd === "string" && payload.cwd) cwd = payload.cwd;
8176
9329
  if (typeof payload.source === "string" && payload.source) entrypoint = payload.source;
8177
9330
  if (typeof payload.originator === "string" && payload.originator) originator = payload.originator;
8178
9331
  if (typeof payload.cli_version === "string" && payload.cli_version) version = payload.cli_version;
8179
9332
  const branch = payload.git && typeof payload.git === "object" ? payload.git.branch : void 0;
8180
9333
  if (typeof branch === "string" && branch) gitBranch = branch;
8181
- startedAt = parseTimestamp(payload.timestamp) ?? parseTimestamp(parsed.timestamp);
9334
+ startedAt = parseTimestamp$1(payload.timestamp) ?? parseTimestamp$1(parsed.timestamp);
8182
9335
  continue;
8183
9336
  }
8184
9337
  if (!sawMeta) return null;
@@ -8186,11 +9339,11 @@ function parseCodexHistoryHead(head, promptChars) {
8186
9339
  if (!payload || typeof payload !== "object") continue;
8187
9340
  if (parsed.type === "event_msg") {
8188
9341
  if (payload.type === "user_message" && typeof payload.message === "string") {
8189
- const cleaned = oneLine(stripHarnessBlocks(payload.message), promptChars);
9342
+ const cleaned = oneLine$1(stripHarnessBlocks(payload.message), promptChars);
8190
9343
  if (cleaned) firstPrompt = cleaned;
8191
9344
  } else if (payload.type === "item_completed" && payload.item && payload.item.type === "UserMessage") {
8192
9345
  const text = textFromUserContent(payload.item.content);
8193
- const cleaned = text ? oneLine(stripHarnessBlocks(text), promptChars) : "";
9346
+ const cleaned = text ? oneLine$1(stripHarnessBlocks(text), promptChars) : "";
8194
9347
  if (cleaned) firstPrompt = cleaned;
8195
9348
  }
8196
9349
  continue;
@@ -8198,7 +9351,7 @@ function parseCodexHistoryHead(head, promptChars) {
8198
9351
  if (parsed.type === "response_item" && payload.type === "message" && payload.role === "user" && !fallbackPrompt) {
8199
9352
  const text = textFromUserContent(payload.content);
8200
9353
  if (text && !isHarnessUserText(text)) {
8201
- const cleaned = oneLine(stripHarnessBlocks(text), promptChars);
9354
+ const cleaned = oneLine$1(stripHarnessBlocks(text), promptChars);
8202
9355
  if (cleaned) fallbackPrompt = cleaned;
8203
9356
  }
8204
9357
  }
@@ -8228,7 +9381,7 @@ function parseCodexSessionIndex(text) {
8228
9381
  continue;
8229
9382
  }
8230
9383
  if (!parsed || typeof parsed !== "object") continue;
8231
- if (typeof parsed.id !== "string" || !UUID_RE$1.test(parsed.id)) continue;
9384
+ if (typeof parsed.id !== "string" || !UUID_RE$3.test(parsed.id)) continue;
8232
9385
  if (typeof parsed.thread_name !== "string" || !parsed.thread_name.trim()) continue;
8233
9386
  names.set(parsed.id.toLowerCase(), parsed.thread_name.trim());
8234
9387
  }
@@ -8279,12 +9432,819 @@ async function listCodexSessionHistory(options) {
8279
9432
  updatedAt: Math.round(candidate.updatedAt),
8280
9433
  sizeBytes: candidate.sizeBytes,
8281
9434
  ...rest,
8282
- ...summary ? { summary: oneLine(summary, promptChars) } : {}
9435
+ ...summary ? { summary: oneLine$1(summary, promptChars) } : {}
8283
9436
  });
8284
9437
  }
8285
9438
  return { entries, truncated, scanned };
8286
9439
  }
8287
9440
 
9441
+ const MAX_MESSAGE_CHARS = 4e3;
9442
+ const MAX_SESSION_CHARS = 1e6;
9443
+ const MAX_TITLE_CHARS = 200;
9444
+ const UUID_RE$2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9445
+ function emptyDocState() {
9446
+ return { meta: {}, msgs: [], chars: 0 };
9447
+ }
9448
+ function oneLine(text, max) {
9449
+ const collapsed = text.replace(/\s+/g, " ").trim();
9450
+ return collapsed.length > max ? `${collapsed.slice(0, max - 1)}\u2026` : collapsed;
9451
+ }
9452
+ function parseTimestamp(raw) {
9453
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
9454
+ if (typeof raw === "string") {
9455
+ const t = Date.parse(raw);
9456
+ if (Number.isFinite(t)) return t;
9457
+ }
9458
+ return void 0;
9459
+ }
9460
+ function clip(text) {
9461
+ const trimmed = text.trim();
9462
+ return trimmed.length > MAX_MESSAGE_CHARS ? trimmed.slice(0, MAX_MESSAGE_CHARS) : trimmed;
9463
+ }
9464
+ function push(state, role, text, at) {
9465
+ if (state.full) return;
9466
+ const x = clip(text);
9467
+ if (!x) return;
9468
+ if (state.chars + x.length > MAX_SESSION_CHARS) {
9469
+ state.full = true;
9470
+ return;
9471
+ }
9472
+ state.msgs.push(at === void 0 ? { r: role, x } : { r: role, t: at, x });
9473
+ state.chars += x.length;
9474
+ }
9475
+ const CLAUDE_HARNESS_BLOCK = /<(system-reminder|task-notification|command-message|command-name|local-command-stdout|local-command-stderr|local-command-caveat|bash-input|bash-stdout|bash-stderr|user-prompt-submit-hook)>[\s\S]*?<\/\1>/g;
9476
+ const CLAUDE_HARNESS_TAG = /<\/?(system-reminder|task-notification|command-message|command-name|command-args|local-command-stdout|local-command-stderr|local-command-caveat|bash-input|bash-stdout|bash-stderr|user-prompt-submit-hook)>/g;
9477
+ function stripClaudeHarness(text) {
9478
+ return text.replace(CLAUDE_HARNESS_BLOCK, " ").replace(CLAUDE_HARNESS_TAG, " ");
9479
+ }
9480
+ function claudeText(content) {
9481
+ if (typeof content === "string") return content;
9482
+ if (!Array.isArray(content)) return null;
9483
+ const parts = [];
9484
+ for (const block of content) {
9485
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
9486
+ parts.push(block.text);
9487
+ }
9488
+ }
9489
+ return parts.length > 0 ? parts.join("\n") : null;
9490
+ }
9491
+ function codexText(content) {
9492
+ if (typeof content === "string") return content;
9493
+ if (!Array.isArray(content)) return null;
9494
+ const parts = [];
9495
+ for (const part of content) {
9496
+ if (!part || typeof part !== "object") continue;
9497
+ const type = part.type;
9498
+ const text = part.text;
9499
+ if ((type === "input_text" || type === "output_text" || type === "text") && typeof text === "string") parts.push(text);
9500
+ }
9501
+ return parts.length > 0 ? parts.join("\n") : null;
9502
+ }
9503
+ function lineMayMatter(agent, line) {
9504
+ if (agent === "claude") {
9505
+ return line.includes('"user"') || line.includes('"assistant"') || line.includes('"summary"');
9506
+ }
9507
+ return line.includes('"session_meta"') || line.includes('"message"');
9508
+ }
9509
+ function extractClaudeLine(state, line) {
9510
+ if (state.meta.skip || !line || !lineMayMatter("claude", line)) return;
9511
+ let parsed;
9512
+ try {
9513
+ parsed = JSON.parse(line);
9514
+ } catch {
9515
+ return;
9516
+ }
9517
+ if (!parsed || typeof parsed !== "object") return;
9518
+ const meta = state.meta;
9519
+ if (parsed.type === "summary") {
9520
+ if (typeof parsed.summary === "string" && !meta.summary) {
9521
+ const s = oneLine(parsed.summary, MAX_TITLE_CHARS);
9522
+ if (s) meta.summary = s;
9523
+ }
9524
+ return;
9525
+ }
9526
+ if (parsed.isSidechain) return;
9527
+ if (parsed.isMeta || parsed.isCompactSummary) return;
9528
+ if (typeof parsed.cwd === "string" && parsed.cwd && !meta.cwd) meta.cwd = parsed.cwd;
9529
+ if (typeof parsed.gitBranch === "string" && parsed.gitBranch && !meta.gitBranch) meta.gitBranch = parsed.gitBranch;
9530
+ if (typeof parsed.entrypoint === "string" && parsed.entrypoint && !meta.entrypoint) meta.entrypoint = parsed.entrypoint;
9531
+ if (typeof parsed.version === "string" && parsed.version && !meta.version) meta.version = parsed.version;
9532
+ const at = parseTimestamp(parsed.timestamp);
9533
+ if (meta.startedAt === void 0 && at !== void 0) meta.startedAt = at;
9534
+ if (parsed.type !== "user" && parsed.type !== "assistant") return;
9535
+ const raw = claudeText(parsed.message?.content);
9536
+ if (!raw) return;
9537
+ if (parsed.type === "user") {
9538
+ if (!meta.firstPrompt) {
9539
+ if (oneShotPrompts.isVeryHappyOneShotPrompt(raw)) {
9540
+ meta.skip = "one-shot";
9541
+ state.msgs = [];
9542
+ state.chars = 0;
9543
+ return;
9544
+ }
9545
+ }
9546
+ const text = stripClaudeHarness(raw);
9547
+ if (!meta.firstPrompt) {
9548
+ const first = oneLine(text, MAX_TITLE_CHARS);
9549
+ if (first) meta.firstPrompt = first;
9550
+ }
9551
+ push(state, "u", text, at);
9552
+ return;
9553
+ }
9554
+ push(state, "a", raw, at);
9555
+ }
9556
+ function extractCodexLine(state, line) {
9557
+ if (state.meta.skip || !line) return;
9558
+ const meta = state.meta;
9559
+ if (!meta.sawMeta) {
9560
+ let first;
9561
+ try {
9562
+ first = JSON.parse(line);
9563
+ } catch {
9564
+ meta.skip = "not-rollout";
9565
+ return;
9566
+ }
9567
+ const payload2 = first?.payload;
9568
+ if (first?.type !== "session_meta" || !payload2 || typeof payload2 !== "object") {
9569
+ meta.skip = "not-rollout";
9570
+ return;
9571
+ }
9572
+ if (isCodexSubThread(payload2)) {
9573
+ meta.skip = "subthread";
9574
+ return;
9575
+ }
9576
+ meta.sawMeta = true;
9577
+ if (typeof payload2.id === "string" && UUID_RE$2.test(payload2.id)) meta.id = payload2.id.toLowerCase();
9578
+ if (typeof payload2.cwd === "string" && payload2.cwd) meta.cwd = payload2.cwd;
9579
+ if (typeof payload2.source === "string" && payload2.source) meta.entrypoint = payload2.source;
9580
+ if (typeof payload2.originator === "string" && payload2.originator) meta.originator = payload2.originator;
9581
+ if (typeof payload2.cli_version === "string" && payload2.cli_version) meta.version = payload2.cli_version;
9582
+ const branch = payload2.git && typeof payload2.git === "object" ? payload2.git.branch : void 0;
9583
+ if (typeof branch === "string" && branch) meta.gitBranch = branch;
9584
+ const at2 = parseTimestamp(payload2.timestamp) ?? parseTimestamp(first.timestamp);
9585
+ if (at2 !== void 0) meta.startedAt = at2;
9586
+ return;
9587
+ }
9588
+ if (!lineMayMatter("codex", line)) return;
9589
+ let parsed;
9590
+ try {
9591
+ parsed = JSON.parse(line);
9592
+ } catch {
9593
+ return;
9594
+ }
9595
+ const payload = parsed?.payload;
9596
+ if (parsed?.type !== "response_item" || !payload || payload.type !== "message") return;
9597
+ if (payload.role !== "user" && payload.role !== "assistant") return;
9598
+ const raw = codexText(payload.content);
9599
+ if (!raw) return;
9600
+ const at = parseTimestamp(parsed.timestamp);
9601
+ if (payload.role === "user") {
9602
+ if (isHarnessUserText(raw)) return;
9603
+ const text = stripHarnessBlocks(raw);
9604
+ if (!meta.firstPrompt) {
9605
+ const first = oneLine(text, MAX_TITLE_CHARS);
9606
+ if (first) meta.firstPrompt = first;
9607
+ }
9608
+ push(state, "u", text, at);
9609
+ return;
9610
+ }
9611
+ push(state, "a", raw, at);
9612
+ }
9613
+ function extractLine(agent, state, line) {
9614
+ if (agent === "claude") extractClaudeLine(state, line);
9615
+ else extractCodexLine(state, line);
9616
+ }
9617
+
9618
+ const INDEX_VERSION = 1;
9619
+ const INDEX_FILE_NAME = `index.v${INDEX_VERSION}.jsonl`;
9620
+ const DEFAULT_MAX_TEXT_CHARS = 32e6;
9621
+ function planFile(prev, cur) {
9622
+ if (!prev) return "reread";
9623
+ if (prev.ino !== cur.ino) return "reread";
9624
+ if (cur.size === prev.size && cur.mtimeMs === prev.mtimeMs) return "skip";
9625
+ if (cur.size < prev.offset || cur.size < prev.size) return "reread";
9626
+ if (cur.size === prev.size) return "reread";
9627
+ return prev.evicted ? "reread" : "append";
9628
+ }
9629
+ function totalChars(records) {
9630
+ let sum = 0;
9631
+ for (const r of records) sum += r.chars;
9632
+ return sum;
9633
+ }
9634
+ function enforceTextCap(records, maxChars) {
9635
+ let total = totalChars(records.values());
9636
+ if (total <= maxChars) return 0;
9637
+ const byAge = [...records.values()].filter((r) => r.chars > 0).sort((a, b) => a.mtimeMs - b.mtimeMs);
9638
+ let evicted = 0;
9639
+ for (const r of byAge) {
9640
+ if (total <= maxChars) break;
9641
+ total -= r.chars;
9642
+ r.msgs = [];
9643
+ r.chars = 0;
9644
+ r.evicted = true;
9645
+ evicted += 1;
9646
+ }
9647
+ return evicted;
9648
+ }
9649
+ function serializeIndex(records, savedAt) {
9650
+ const lines = [JSON.stringify({ v: INDEX_VERSION, savedAt })];
9651
+ for (const r of records) lines.push(JSON.stringify(r));
9652
+ return lines.join("\n") + "\n";
9653
+ }
9654
+ function isRecord(value) {
9655
+ return !!value && typeof value === "object" && typeof value.path === "string" && (value.agent === "claude" || value.agent === "codex") && typeof value.id === "string" && typeof value.ino === "number" && typeof value.size === "number" && typeof value.mtimeMs === "number" && typeof value.offset === "number" && !!value.meta && typeof value.meta === "object" && Array.isArray(value.msgs) && typeof value.chars === "number";
9656
+ }
9657
+ function parseIndex(text) {
9658
+ const lines = text.split("\n");
9659
+ let header;
9660
+ try {
9661
+ header = JSON.parse(lines[0] ?? "");
9662
+ } catch {
9663
+ return null;
9664
+ }
9665
+ if (!header || header.v !== INDEX_VERSION || typeof header.savedAt !== "number") return null;
9666
+ const records = /* @__PURE__ */ new Map();
9667
+ for (let i = 1; i < lines.length; i++) {
9668
+ const line = lines[i];
9669
+ if (!line) continue;
9670
+ let parsed;
9671
+ try {
9672
+ parsed = JSON.parse(line);
9673
+ } catch {
9674
+ continue;
9675
+ }
9676
+ if (isRecord(parsed)) records.set(parsed.path, parsed);
9677
+ }
9678
+ return { savedAt: header.savedAt, records };
9679
+ }
9680
+ function indexFilePath(indexDir) {
9681
+ return path.join(indexDir, INDEX_FILE_NAME);
9682
+ }
9683
+ async function loadIndex(indexDir) {
9684
+ let text;
9685
+ try {
9686
+ text = await promises.readFile(indexFilePath(indexDir), "utf-8");
9687
+ } catch {
9688
+ return null;
9689
+ }
9690
+ return parseIndex(text);
9691
+ }
9692
+ async function saveIndex(indexDir, records, savedAt) {
9693
+ await promises.mkdir(indexDir, { recursive: true, mode: 448 });
9694
+ try {
9695
+ await promises.chmod(indexDir, 448);
9696
+ } catch {
9697
+ }
9698
+ const target = indexFilePath(indexDir);
9699
+ const tmp = `${target}.${process.pid}.tmp`;
9700
+ await promises.writeFile(tmp, serializeIndex(records, savedAt), { mode: 384 });
9701
+ await promises.rename(tmp, target);
9702
+ }
9703
+ async function clearIndexDir(indexDir) {
9704
+ await promises.rm(indexDir, { recursive: true, force: true });
9705
+ }
9706
+
9707
+ const UUID_RE$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9708
+ const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
9709
+ const MAX_LINE_BYTES = 2 * 1024 * 1024;
9710
+ const READ_CHUNK_BYTES = 1024 * 1024;
9711
+ async function statFile(path) {
9712
+ try {
9713
+ const s = await promises.stat(path);
9714
+ if (!s.isFile()) return null;
9715
+ return { ino: Number(s.ino) || 0, size: s.size, mtimeMs: s.mtimeMs };
9716
+ } catch {
9717
+ return null;
9718
+ }
9719
+ }
9720
+ async function listDir(path) {
9721
+ try {
9722
+ return await promises.readdir(path);
9723
+ } catch {
9724
+ return [];
9725
+ }
9726
+ }
9727
+ async function isDir(path) {
9728
+ try {
9729
+ return (await promises.stat(path)).isDirectory();
9730
+ } catch {
9731
+ return false;
9732
+ }
9733
+ }
9734
+ async function discoverClaudeFiles(projectsRoot) {
9735
+ const out = [];
9736
+ for (const dirName of await listDir(projectsRoot)) {
9737
+ const dir = path.join(projectsRoot, dirName);
9738
+ if (!await isDir(dir)) continue;
9739
+ for (const name of await listDir(dir)) {
9740
+ if (!name.endsWith(".jsonl")) continue;
9741
+ const id = name.slice(0, -".jsonl".length);
9742
+ if (!UUID_RE$1.test(id)) continue;
9743
+ const path$1 = path.join(dir, name);
9744
+ const s = await statFile(path$1);
9745
+ if (!s || s.size === 0) continue;
9746
+ out.push({ path: path$1, agent: "claude", id: id.toLowerCase(), ...s });
9747
+ }
9748
+ }
9749
+ return out;
9750
+ }
9751
+ async function discoverCodexFiles(sessionsRoot) {
9752
+ const out = [];
9753
+ async function walk(dir, depth) {
9754
+ for (const name of await listDir(dir)) {
9755
+ const full = path.join(dir, name);
9756
+ const match = ROLLOUT_RE.exec(name);
9757
+ if (match) {
9758
+ const s = await statFile(full);
9759
+ if (s && s.size > 0) out.push({ path: full, agent: "codex", id: match[1].toLowerCase(), ...s });
9760
+ continue;
9761
+ }
9762
+ if (name.endsWith(".jsonl") || depth >= 4) continue;
9763
+ if (await isDir(full)) await walk(full, depth + 1);
9764
+ }
9765
+ }
9766
+ await walk(sessionsRoot, 0);
9767
+ return out;
9768
+ }
9769
+ async function readCompleteLines(path, start, onLine, maxLineBytes = MAX_LINE_BYTES) {
9770
+ const fh = await promises.open(path, "r");
9771
+ try {
9772
+ const buf = Buffer.allocUnsafe(READ_CHUNK_BYTES);
9773
+ let pos = start;
9774
+ let committed = start;
9775
+ let pending = [];
9776
+ let pendingLen = 0;
9777
+ let skipping = false;
9778
+ for (; ; ) {
9779
+ const { bytesRead } = await fh.read(buf, 0, buf.length, pos);
9780
+ if (bytesRead === 0) break;
9781
+ let i = 0;
9782
+ while (i < bytesRead) {
9783
+ const nl = buf.indexOf(10, i);
9784
+ if (nl === -1 || nl >= bytesRead) {
9785
+ const rest = bytesRead - i;
9786
+ if (!skipping) {
9787
+ if (pendingLen + rest > maxLineBytes) {
9788
+ skipping = true;
9789
+ pending = [];
9790
+ pendingLen = 0;
9791
+ } else {
9792
+ pending.push(Buffer.from(buf.subarray(i, bytesRead)));
9793
+ pendingLen += rest;
9794
+ }
9795
+ }
9796
+ break;
9797
+ }
9798
+ if (!skipping) {
9799
+ const seg = buf.subarray(i, nl);
9800
+ if (pendingLen + seg.length <= maxLineBytes) {
9801
+ const line = pendingLen > 0 ? Buffer.concat([...pending, seg]).toString("utf-8") : seg.toString("utf-8");
9802
+ onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
9803
+ }
9804
+ }
9805
+ pending = [];
9806
+ pendingLen = 0;
9807
+ skipping = false;
9808
+ committed = pos + nl + 1;
9809
+ i = nl + 1;
9810
+ }
9811
+ pos += bytesRead;
9812
+ }
9813
+ return committed;
9814
+ } finally {
9815
+ await fh.close();
9816
+ }
9817
+ }
9818
+ async function indexFile(file, prev, plan) {
9819
+ const base = plan === "append" && prev ? { meta: { ...prev.meta }, msgs: prev.msgs.slice(), chars: prev.chars, ...prev.full ? { full: true } : {} } : emptyDocState();
9820
+ const start = plan === "append" && prev ? prev.offset : 0;
9821
+ let offset = start;
9822
+ if (!base.meta.skip) {
9823
+ offset = await readCompleteLines(file.path, start, (line) => extractLine(file.agent, base, line));
9824
+ } else {
9825
+ offset = file.size;
9826
+ }
9827
+ if (base.meta.skip) {
9828
+ base.msgs = [];
9829
+ base.chars = 0;
9830
+ }
9831
+ return {
9832
+ path: file.path,
9833
+ agent: file.agent,
9834
+ id: file.agent === "codex" && base.meta.id ? base.meta.id : file.id,
9835
+ ino: file.ino,
9836
+ size: file.size,
9837
+ mtimeMs: file.mtimeMs,
9838
+ offset,
9839
+ meta: base.meta,
9840
+ msgs: base.msgs,
9841
+ chars: base.chars,
9842
+ ...base.full ? { full: true } : {}
9843
+ };
9844
+ }
9845
+ const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve));
9846
+ async function refreshRecords(records, options) {
9847
+ const now = options.now ?? Date.now;
9848
+ const cancelled = () => options.isCancelled?.() === true;
9849
+ const [claude, codex, names] = await Promise.all([
9850
+ discoverClaudeFiles(options.roots.claudeProjectsRoot),
9851
+ discoverCodexFiles(options.roots.codexSessionsRoot),
9852
+ promises.readFile(options.roots.codexIndexPath, "utf-8").then(parseCodexSessionIndex, () => /* @__PURE__ */ new Map())
9853
+ ]);
9854
+ if (cancelled()) return { changed: false, cancelled: true, evicted: 0, filesTotal: 0 };
9855
+ const files = [...claude, ...codex];
9856
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
9857
+ const total = files.length;
9858
+ let changed = false;
9859
+ const present = new Set(files.map((f) => f.path));
9860
+ for (const path of [...records.keys()]) {
9861
+ if (!present.has(path)) {
9862
+ records.delete(path);
9863
+ changed = true;
9864
+ }
9865
+ }
9866
+ let done = 0;
9867
+ let lastCheckpoint = now();
9868
+ options.onProgress?.({ filesTotal: total, filesDone: 0 });
9869
+ for (const file of files) {
9870
+ if (cancelled()) return { changed, cancelled: true, evicted: 0, filesTotal: total };
9871
+ const prev = records.get(file.path);
9872
+ const plan = planFile(prev, file);
9873
+ if (plan !== "skip") {
9874
+ try {
9875
+ const next = await indexFile(file, prev, plan);
9876
+ if (cancelled()) return { changed, cancelled: true, evicted: 0, filesTotal: total };
9877
+ records.set(file.path, next);
9878
+ changed = true;
9879
+ } catch {
9880
+ }
9881
+ await yieldToLoop();
9882
+ }
9883
+ done += 1;
9884
+ if (done % 50 === 0 || done === total) options.onProgress?.({ filesTotal: total, filesDone: done });
9885
+ if (options.checkpoint && changed && now() - lastCheckpoint >= (options.checkpointEveryMs ?? 2e4)) {
9886
+ lastCheckpoint = now();
9887
+ await options.checkpoint();
9888
+ }
9889
+ }
9890
+ for (const record of records.values()) {
9891
+ if (record.agent !== "codex") continue;
9892
+ const name = names.get(record.id);
9893
+ if (name && record.meta.summary !== name) {
9894
+ record.meta = { ...record.meta, summary: name };
9895
+ changed = true;
9896
+ }
9897
+ }
9898
+ const evicted = enforceTextCap(records, options.maxTextChars);
9899
+ if (evicted > 0) changed = true;
9900
+ options.onProgress?.({ filesTotal: total, filesDone: total });
9901
+ return { changed, cancelled: false, evicted, filesTotal: total };
9902
+ }
9903
+
9904
+ const MAX_QUERY_CHARS = 200;
9905
+ const MAX_QUERY_TERMS = 8;
9906
+ const MAX_RESULTS = 50;
9907
+ const DEFAULT_RESULTS = 20;
9908
+ const MAX_SNIPPETS = 3;
9909
+ const SNIPPET_CHARS = 180;
9910
+ const SNIPPET_LEAD = 50;
9911
+ function escapeRegExp(text) {
9912
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9913
+ }
9914
+ function parseQuery(input) {
9915
+ if (typeof input !== "string") return null;
9916
+ const raw = input.replace(/\s+/g, " ").trim().slice(0, MAX_QUERY_CHARS).trim();
9917
+ if (!raw) return null;
9918
+ const words = [...new Set(raw.split(" ").filter(Boolean).map((w) => w.toLowerCase()))].slice(0, MAX_QUERY_TERMS);
9919
+ const terms = words.map((w) => new RegExp(escapeRegExp(w), "i"));
9920
+ const phrase = words.length > 1 ? new RegExp(raw.split(" ").map(escapeRegExp).join("\\s+"), "i") : null;
9921
+ return { terms, phrase, raw };
9922
+ }
9923
+ function matchRanges(text, terms) {
9924
+ const ranges = [];
9925
+ for (const term of terms) {
9926
+ const global = new RegExp(term.source, "gi");
9927
+ let m;
9928
+ while ((m = global.exec(text)) !== null) {
9929
+ if (m[0].length === 0) {
9930
+ global.lastIndex += 1;
9931
+ continue;
9932
+ }
9933
+ ranges.push([m.index, m.index + m[0].length]);
9934
+ if (ranges.length > 64) break;
9935
+ }
9936
+ }
9937
+ ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
9938
+ const merged = [];
9939
+ for (const r of ranges) {
9940
+ const last = merged[merged.length - 1];
9941
+ if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
9942
+ else merged.push([r[0], r[1]]);
9943
+ }
9944
+ return merged;
9945
+ }
9946
+ function buildSnippet(text, terms) {
9947
+ let first = -1;
9948
+ for (const term of terms) {
9949
+ const m = term.exec(text);
9950
+ if (m && (first === -1 || m.index < first)) first = m.index;
9951
+ }
9952
+ const start = Math.max(0, (first === -1 ? 0 : first) - SNIPPET_LEAD);
9953
+ const end = Math.min(text.length, start + SNIPPET_CHARS);
9954
+ let window = text.slice(start, end).replace(/\s+/g, " ");
9955
+ if (start > 0) window = `\u2026${window.trimStart()}`;
9956
+ if (end < text.length) window = `${window.trimEnd()}\u2026`;
9957
+ return { text: window, ranges: matchRanges(window, terms) };
9958
+ }
9959
+ function titleOf(record) {
9960
+ return record.meta.summary || record.meta.firstPrompt || "";
9961
+ }
9962
+ function searchRecords(records, query, options = {}) {
9963
+ const limit = Math.max(1, Math.min(Math.floor(options.limit ?? DEFAULT_RESULTS), MAX_RESULTS));
9964
+ const newest = /* @__PURE__ */ new Map();
9965
+ for (const r of records) {
9966
+ if (r.meta.skip || !r.meta.cwd) continue;
9967
+ if (options.agent && r.agent !== options.agent) continue;
9968
+ if (!r.meta.firstPrompt && !r.meta.summary) continue;
9969
+ const key = `${r.agent}:${r.id}`;
9970
+ const prev = newest.get(key);
9971
+ if (!prev || r.mtimeMs > prev.mtimeMs) newest.set(key, r);
9972
+ }
9973
+ const scored = [];
9974
+ for (const record of newest.values()) {
9975
+ const title = titleOf(record);
9976
+ const head = `${title}
9977
+ ${record.meta.firstPrompt ?? ""}
9978
+ ${record.meta.cwd ?? ""}`;
9979
+ const found = new Array(query.terms.length).fill(false);
9980
+ query.terms.forEach((t, i) => {
9981
+ if (t.test(head)) found[i] = true;
9982
+ });
9983
+ const matched = [];
9984
+ let phrase = query.phrase ? query.phrase.test(head) : true;
9985
+ record.msgs.forEach((msg, index) => {
9986
+ let n = 0;
9987
+ query.terms.forEach((t, i) => {
9988
+ if (t.test(msg.x)) {
9989
+ n += 1;
9990
+ found[i] = true;
9991
+ }
9992
+ });
9993
+ if (n > 0) {
9994
+ matched.push({ msg, hits: n, index });
9995
+ if (!phrase && query.phrase && query.phrase.test(msg.x)) phrase = true;
9996
+ }
9997
+ });
9998
+ if (!found.every(Boolean)) continue;
9999
+ scored.push({ record, phrase, matched });
10000
+ }
10001
+ scored.sort((a, b) => Number(b.phrase) - Number(a.phrase) || b.record.mtimeMs - a.record.mtimeMs);
10002
+ const results = scored.slice(0, limit).map(({ record, matched }) => {
10003
+ const picks = matched.slice().sort((a, b) => b.hits - a.hits || a.index - b.index).slice(0, MAX_SNIPPETS).sort((a, b) => a.index - b.index);
10004
+ const meta = record.meta;
10005
+ return {
10006
+ agent: record.agent,
10007
+ id: record.id,
10008
+ title: titleOf(record),
10009
+ firstPrompt: meta.firstPrompt ?? meta.summary ?? "",
10010
+ ...meta.summary ? { summary: meta.summary } : {},
10011
+ cwd: meta.cwd ?? "",
10012
+ startedAt: meta.startedAt ?? 0,
10013
+ updatedAt: Math.round(record.mtimeMs),
10014
+ sizeBytes: record.size,
10015
+ ...meta.gitBranch ? { gitBranch: meta.gitBranch } : {},
10016
+ ...meta.entrypoint ? { entrypoint: meta.entrypoint } : {},
10017
+ ...meta.originator ? { originator: meta.originator } : {},
10018
+ matchCount: matched.length,
10019
+ snippets: picks.map(({ msg }) => {
10020
+ const s = buildSnippet(msg.x, query.terms);
10021
+ return {
10022
+ role: msg.r === "u" ? "user" : "assistant",
10023
+ text: s.text,
10024
+ ...msg.t !== void 0 ? { at: msg.t } : {},
10025
+ ranges: s.ranges
10026
+ };
10027
+ })
10028
+ };
10029
+ });
10030
+ return { results, truncated: scored.length > limit, total: scored.length };
10031
+ }
10032
+
10033
+ class HistorySearchService {
10034
+ records = null;
10035
+ loading = null;
10036
+ refreshing = null;
10037
+ progress = { filesTotal: 0, filesDone: 0 };
10038
+ lastRefreshStartedAt = 0;
10039
+ lastIndexedAt;
10040
+ error;
10041
+ /** Bumped by `clear`; a pass started under an older generation stops. */
10042
+ generation = 0;
10043
+ /** A `clear` in progress. Searches arriving meanwhile neither load the
10044
+ * old file back nor start a pass that would recreate the directory. */
10045
+ clearing = null;
10046
+ idleTimer = null;
10047
+ opts;
10048
+ constructor(options) {
10049
+ this.opts = {
10050
+ maxTextChars: DEFAULT_MAX_TEXT_CHARS,
10051
+ refreshIntervalMs: 3e4,
10052
+ idleUnloadMs: 10 * 6e4,
10053
+ loadWaitMs: 5e3,
10054
+ now: Date.now,
10055
+ ...options
10056
+ };
10057
+ }
10058
+ /** RPC entry point. Validates loosely; unknown fields are ignored. */
10059
+ async handle(params) {
10060
+ const p = params && typeof params === "object" ? params : {};
10061
+ const op = p.op === "status" || p.op === "clear" ? p.op : "search";
10062
+ if (op === "clear") {
10063
+ await this.clear();
10064
+ return { type: "success", status: this.status(), results: [], truncated: false };
10065
+ }
10066
+ if (op === "status") {
10067
+ return { type: "success", status: this.status(), results: [], truncated: false };
10068
+ }
10069
+ const agent = p.agent === "claude" || p.agent === "codex" ? p.agent : void 0;
10070
+ const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : DEFAULT_RESULTS;
10071
+ return this.search(p.query, { agent, limit });
10072
+ }
10073
+ async search(query, options = {}) {
10074
+ if (this.clearing) {
10075
+ await this.clearing;
10076
+ return { type: "success", status: this.status(), results: [], truncated: false };
10077
+ }
10078
+ this.touch();
10079
+ await this.ensureLoaded();
10080
+ this.maybeRefresh();
10081
+ const parsed = parseQuery(query);
10082
+ if (!parsed || !this.records) {
10083
+ return { type: "success", status: this.status(), results: [], truncated: false };
10084
+ }
10085
+ const started = this.opts.now();
10086
+ const out = searchRecords(this.records.values(), parsed, options);
10087
+ logger.logger.debug(`[HISTORY SEARCH] query len=${parsed.raw.length} terms=${parsed.terms.length} \u2192 ${out.total} sessions (${this.opts.now() - started}ms)`);
10088
+ return { type: "success", status: this.status(), results: out.results, truncated: out.truncated };
10089
+ }
10090
+ status() {
10091
+ const records = this.records;
10092
+ let sessions = 0;
10093
+ let evicted = 0;
10094
+ if (records) {
10095
+ for (const r of records.values()) {
10096
+ if (r.meta.skip) continue;
10097
+ sessions += 1;
10098
+ if (r.evicted) evicted += 1;
10099
+ }
10100
+ }
10101
+ const state = this.refreshing || this.loading ? "indexing" : this.error ? "error" : this.lastIndexedAt !== void 0 ? "ready" : "idle";
10102
+ return {
10103
+ state,
10104
+ filesTotal: this.progress.filesTotal,
10105
+ filesDone: this.progress.filesDone,
10106
+ sessions,
10107
+ textChars: records ? totalChars(records.values()) : 0,
10108
+ maxTextChars: this.opts.maxTextChars,
10109
+ evicted,
10110
+ ...this.lastIndexedAt !== void 0 ? { lastIndexedAt: this.lastIndexedAt } : {},
10111
+ ...this.error ? { error: this.error } : {}
10112
+ };
10113
+ }
10114
+ async clear() {
10115
+ if (this.clearing) return this.clearing;
10116
+ const run = this.doClear().finally(() => {
10117
+ if (this.clearing === run) this.clearing = null;
10118
+ });
10119
+ this.clearing = run;
10120
+ return run;
10121
+ }
10122
+ async doClear() {
10123
+ const running = this.refreshing;
10124
+ this.generation += 1;
10125
+ this.records = null;
10126
+ this.loading = null;
10127
+ this.refreshing = null;
10128
+ this.progress = { filesTotal: 0, filesDone: 0 };
10129
+ this.lastRefreshStartedAt = 0;
10130
+ this.lastIndexedAt = void 0;
10131
+ this.error = void 0;
10132
+ if (this.idleTimer) {
10133
+ clearTimeout(this.idleTimer);
10134
+ this.idleTimer = null;
10135
+ }
10136
+ await running?.catch(() => void 0);
10137
+ await clearIndexDir(this.opts.indexDir);
10138
+ logger.logger.debug("[HISTORY SEARCH] index cleared");
10139
+ }
10140
+ /** Resolves when the running refresh (if any) is done — tests and scripts. */
10141
+ async whenIdle() {
10142
+ while (this.loading || this.refreshing) {
10143
+ await (this.loading ?? this.refreshing);
10144
+ }
10145
+ }
10146
+ touch() {
10147
+ if (this.idleTimer) clearTimeout(this.idleTimer);
10148
+ this.idleTimer = setTimeout(() => this.unload(), this.opts.idleUnloadMs);
10149
+ this.idleTimer.unref?.();
10150
+ }
10151
+ unload() {
10152
+ this.idleTimer = null;
10153
+ if (this.refreshing || this.loading) {
10154
+ this.touch();
10155
+ return;
10156
+ }
10157
+ this.records = null;
10158
+ logger.logger.debug("[HISTORY SEARCH] in-memory index unloaded (idle)");
10159
+ }
10160
+ async ensureLoaded() {
10161
+ if (this.clearing) await this.clearing;
10162
+ if (this.records) return;
10163
+ if (!this.loading) {
10164
+ const gen = this.generation;
10165
+ const run = (async () => {
10166
+ const loaded = await loadIndex(this.opts.indexDir);
10167
+ if (gen !== this.generation) return;
10168
+ this.records = loaded?.records ?? /* @__PURE__ */ new Map();
10169
+ if (loaded && this.lastIndexedAt === void 0) this.lastIndexedAt = loaded.savedAt;
10170
+ })().catch((error) => {
10171
+ if (gen !== this.generation) return;
10172
+ logger.logger.debug("[HISTORY SEARCH] index load failed; starting empty", error);
10173
+ this.records = /* @__PURE__ */ new Map();
10174
+ }).finally(() => {
10175
+ if (this.loading === run) this.loading = null;
10176
+ });
10177
+ this.loading = run;
10178
+ }
10179
+ const pending = this.loading;
10180
+ let timer;
10181
+ await Promise.race([
10182
+ pending,
10183
+ new Promise((resolve) => {
10184
+ timer = setTimeout(resolve, this.opts.loadWaitMs);
10185
+ timer.unref?.();
10186
+ })
10187
+ ]);
10188
+ if (timer) clearTimeout(timer);
10189
+ }
10190
+ maybeRefresh() {
10191
+ if (this.clearing || this.refreshing || !this.records) return;
10192
+ const now = this.opts.now();
10193
+ if (this.lastRefreshStartedAt && now - this.lastRefreshStartedAt < this.opts.refreshIntervalMs) return;
10194
+ this.lastRefreshStartedAt = now;
10195
+ const gen = this.generation;
10196
+ const records = this.records;
10197
+ const save = async () => {
10198
+ if (gen !== this.generation || this.clearing) return;
10199
+ await saveIndex(this.opts.indexDir, records.values(), this.opts.now());
10200
+ };
10201
+ const run = (async () => {
10202
+ const roots = await this.opts.resolveRoots();
10203
+ if (gen !== this.generation) return;
10204
+ const started = this.opts.now();
10205
+ const result = await refreshRecords(records, {
10206
+ roots,
10207
+ maxTextChars: this.opts.maxTextChars,
10208
+ isCancelled: () => gen !== this.generation,
10209
+ onProgress: (progress) => {
10210
+ if (gen === this.generation) this.progress = progress;
10211
+ },
10212
+ checkpoint: async () => {
10213
+ if (gen === this.generation) await save();
10214
+ },
10215
+ now: this.opts.now
10216
+ });
10217
+ if (result.cancelled || gen !== this.generation) return;
10218
+ if (result.changed) await save();
10219
+ this.lastIndexedAt = this.opts.now();
10220
+ this.error = void 0;
10221
+ logger.logger.debug(`[HISTORY SEARCH] refresh: ${result.filesTotal} files, changed=${result.changed}, evicted=${result.evicted} (${this.opts.now() - started}ms)`);
10222
+ })().catch((error) => {
10223
+ if (gen !== this.generation) return;
10224
+ this.error = error instanceof Error ? error.message : String(error);
10225
+ logger.logger.debug("[HISTORY SEARCH] refresh failed", error);
10226
+ }).finally(() => {
10227
+ if (this.refreshing === run) this.refreshing = null;
10228
+ });
10229
+ this.refreshing = run;
10230
+ }
10231
+ }
10232
+
10233
+ async function resolveHistoryRoots() {
10234
+ const homes = await index.refreshAgentHomes(Date.now(), { allowStale: true });
10235
+ return {
10236
+ claudeProjectsRoot: path.join(homes.claudeConfigDir.path, "projects"),
10237
+ codexSessionsRoot: path.join(homes.codexHome.path, "sessions"),
10238
+ codexIndexPath: path.join(homes.codexHome.path, "session_index.jsonl")
10239
+ };
10240
+ }
10241
+ function historySearchIndexDir() {
10242
+ return path.join(configuration.configuration.happyHomeDir, "history-search");
10243
+ }
10244
+ function createHistorySearchService() {
10245
+ return new HistorySearchService({ indexDir: historySearchIndexDir(), resolveRoots: resolveHistoryRoots });
10246
+ }
10247
+
8288
10248
  function selectLowestLatencyRelay(candidates, probes) {
8289
10249
  const rttById = new Map(probes.map((probe) => [probe.relayId, probe.rttMs]));
8290
10250
  let selected = null;
@@ -8403,6 +10363,74 @@ async function discoverAndClaimRelay(input) {
8403
10363
  }
8404
10364
  }
8405
10365
 
10366
+ const RATE_LIMIT_MIN_WRITE_INTERVAL_MS = 6e4;
10367
+ const MAX_PENDING_REPORTS = 16;
10368
+ class RateLimitPublisher {
10369
+ constructor(deps) {
10370
+ this.deps = deps;
10371
+ this.now = deps.now ?? Date.now;
10372
+ this.minIntervalMs = deps.minIntervalMs ?? RATE_LIMIT_MIN_WRITE_INTERVAL_MS;
10373
+ this.setTimer = deps.setTimer ?? ((fn, ms) => {
10374
+ const handle = setTimeout(fn, ms);
10375
+ handle.unref?.();
10376
+ return handle;
10377
+ });
10378
+ this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
10379
+ }
10380
+ pending = [];
10381
+ timer = null;
10382
+ writing = false;
10383
+ lastWriteAt = 0;
10384
+ stopped = false;
10385
+ now;
10386
+ minIntervalMs;
10387
+ setTimer;
10388
+ clearTimer;
10389
+ report(report) {
10390
+ if (this.stopped) return;
10391
+ this.pending.push(report);
10392
+ if (this.pending.length > MAX_PENDING_REPORTS) {
10393
+ this.pending.splice(0, this.pending.length - MAX_PENDING_REPORTS);
10394
+ }
10395
+ this.schedule();
10396
+ }
10397
+ stop() {
10398
+ this.stopped = true;
10399
+ this.pending = [];
10400
+ if (this.timer) this.clearTimer(this.timer);
10401
+ this.timer = null;
10402
+ }
10403
+ schedule() {
10404
+ if (this.timer || this.writing || this.stopped || this.pending.length === 0) return;
10405
+ const wait = this.lastWriteAt === 0 ? 0 : Math.max(0, this.lastWriteAt + this.minIntervalMs - this.now());
10406
+ this.timer = this.setTimer(() => {
10407
+ this.timer = null;
10408
+ void this.flush();
10409
+ }, wait);
10410
+ }
10411
+ /** Exposed for tests; normally driven by the timer. */
10412
+ async flush() {
10413
+ if (this.writing || this.stopped) return;
10414
+ const reports = this.pending;
10415
+ this.pending = [];
10416
+ if (!rateLimitWindows.planRateLimitWrite(this.deps.getCurrent(), reports, this.now())) return;
10417
+ this.writing = true;
10418
+ this.lastWriteAt = this.now();
10419
+ let written = false;
10420
+ try {
10421
+ written = await this.deps.write(reports);
10422
+ } catch {
10423
+ written = false;
10424
+ } finally {
10425
+ this.writing = false;
10426
+ }
10427
+ if (!written && !this.stopped) {
10428
+ this.pending = [...reports, ...this.pending].slice(-MAX_PENDING_REPORTS);
10429
+ }
10430
+ this.schedule();
10431
+ }
10432
+ }
10433
+
8406
10434
  const guardOptions = { log: (message) => logger.logger.debug(message) };
8407
10435
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8408
10436
  function requireNonEmptyString(value, name) {
@@ -8444,6 +10472,8 @@ class ApiMachineClient {
8444
10472
  keepAliveInterval = null;
8445
10473
  lastKnownCLIAvailability = null;
8446
10474
  lastKnownResumeSupport = null;
10475
+ /** B-549: created on the first `history-search` call. */
10476
+ historySearch = null;
8447
10477
  /** Wall-clock of the last CLI availability probe. Initialised as "already
8448
10478
  * stale" so the FIRST keep-alive tick always probes and populates the
8449
10479
  * machine metadata that a connecting/reconnecting web client reads. */
@@ -8453,6 +10483,11 @@ class ApiMachineClient {
8453
10483
  cliUpdateState = null;
8454
10484
  cliUpdatePushChain = Promise.resolve();
8455
10485
  claudeAuthState = null;
10486
+ rateLimitPushChain = Promise.resolve();
10487
+ rateLimitPublisher = new RateLimitPublisher({
10488
+ getCurrent: () => this.machine.daemonState?.rateLimits,
10489
+ write: (reports) => this.writeRateLimits(reports)
10490
+ });
8456
10491
  rpcHandlerManager;
8457
10492
  resumeSessionHandler = null;
8458
10493
  restartSessionHandler = null;
@@ -8513,6 +10548,11 @@ class ApiMachineClient {
8513
10548
  setTerminalTitle(terminalId, title, ifAbsent) {
8514
10549
  return this.webTerminal.setTitle(terminalId, title, ifAbsent);
8515
10550
  }
10551
+ /** B-547: a status hook forwarded from a daemon-launched claude inside a
10552
+ * web terminal (control server /terminal-agent-hook → here). */
10553
+ ingestTerminalAgentHook(body) {
10554
+ this.webTerminal.ingestAgentHook(body);
10555
+ }
8516
10556
  /**
8517
10557
  * Push text to the clipboard of every web client the user has open
8518
10558
  * (terminal-path claude → `very-happy mcp` → daemon /clipboard → here).
@@ -8536,13 +10576,14 @@ class ApiMachineClient {
8536
10576
  return { delivered: true, truncated: prepared.truncated, totalBytes: prepared.totalBytes };
8537
10577
  }
8538
10578
  /** Queue a terminal preview with the machine key. Delivery does not prove a browser opened it. */
8539
- pushFilePreview(terminalId, path, mode = "file") {
10579
+ pushFilePreview(terminalId, path, mode = "file", intent) {
8540
10580
  if (!this.socket?.connected) return { delivered: false, error: "daemon is not connected to the server" };
8541
10581
  this.socket.emit("file-preview-push", {
8542
10582
  terminalId,
8543
10583
  payload: authRelay.encodeBase64(authRelay.encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, path)),
8544
10584
  enc: true,
8545
- mode
10585
+ mode,
10586
+ ...intent ? { intent } : {}
8546
10587
  });
8547
10588
  return { delivered: true };
8548
10589
  }
@@ -8565,6 +10606,26 @@ class ApiMachineClient {
8565
10606
  if (!this.socket?.connected) return;
8566
10607
  this.cliUpdatePushChain = this.cliUpdatePushChain.then(() => this.updateDaemonState((current) => cliUpdate.withCurrentCliUpdateState(current, this.cliUpdateState))).catch((error) => logger.logger.debug("[API MACHINE] Failed to publish CLI update policy:", error));
8567
10608
  }
10609
+ /** B-555: a wrapper's rate-limit observation; written throttled by the publisher. */
10610
+ reportRateLimits(report) {
10611
+ this.rateLimitPublisher.report(report);
10612
+ }
10613
+ writeRateLimits(reports) {
10614
+ if (!this.socket?.connected) return Promise.resolve(false);
10615
+ const attempt = this.rateLimitPushChain.then(() => this.updateDaemonState((current) => {
10616
+ const merged = rateLimitWindows.mergeRateLimitReports(current?.rateLimits, reports, Date.now());
10617
+ return {
10618
+ ...current ?? {},
10619
+ status: current?.status ?? "running",
10620
+ ...merged ? { rateLimits: merged } : {}
10621
+ };
10622
+ })).then(() => true).catch((error) => {
10623
+ logger.logger.debug("[API MACHINE] Failed to publish rate limits:", error);
10624
+ return false;
10625
+ });
10626
+ this.rateLimitPushChain = attempt.then(() => void 0);
10627
+ return attempt;
10628
+ }
8568
10629
  /**
8569
10630
  * B-276: publish the daemon-context Claude auth preflight into daemonState
8570
10631
  * (same serialized push chain as the CLI update policy). Resolves true when
@@ -8915,6 +10976,10 @@ class ApiMachineClient {
8915
10976
  });
8916
10977
  return { type: "success", ...result };
8917
10978
  });
10979
+ this.rpcHandlerManager.registerHandler("history-search", async (params) => {
10980
+ this.historySearch ??= createHistorySearchService();
10981
+ return this.historySearch.handle(params);
10982
+ });
8918
10983
  this.rpcHandlerManager.registerHandler("codex-import-session", async (params) => {
8919
10984
  const { directory, codexThreadId, approvedNewDirectoryCreation, permissionMode, title } = params || {};
8920
10985
  if (typeof directory !== "string" || directory.length === 0) {
@@ -9293,6 +11358,8 @@ class ApiMachineClient {
9293
11358
  claudeHistory: { rpcAvailable: true, detectedAt: now },
9294
11359
  // B-464 capability flag: `codex-list-history` / `codex-import-session`.
9295
11360
  codexHistory: { rpcAvailable: true, detectedAt: now },
11361
+ // B-549 capability flag: `history-search` (same restamp discipline).
11362
+ historySearch: { rpcAvailable: true, detectedAt: now },
9296
11363
  // B-084: closed records survive daemon restarts (persisted
9297
11364
  // in closed-terminals.json), so the connect snapshot ships
9298
11365
  // them too — not just the incremental pushes.
@@ -9595,9 +11662,14 @@ class ApiMachineClient {
9595
11662
  logger.logger.debug("[API MACHINE] Keep-alive stopped");
9596
11663
  }
9597
11664
  }
11665
+ /** B-550: final terminal scrollback checkpoint; resolves within ~300ms, never rejects. */
11666
+ flushTerminalScrollback() {
11667
+ return this.webTerminal.flushScrollbackForShutdown().catch(() => void 0);
11668
+ }
9598
11669
  shutdown() {
9599
11670
  logger.logger.debug("[API MACHINE] Shutting down");
9600
11671
  this.webTerminal.stopListTracking();
11672
+ this.rateLimitPublisher.stop();
9601
11673
  this.stopKeepAlive();
9602
11674
  if (this.reconnectInterval) {
9603
11675
  clearInterval(this.reconnectInterval);