very-happy-cli 0.2.165 → 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.
- package/README.md +1 -1
- package/dist/{AcpBackend-DCfNt9bD.cjs → AcpBackend-20VHOXwb.cjs} +3 -3
- package/dist/{AcpBackend-C11FnVw5.mjs → AcpBackend-tRWk7yi7.mjs} +3 -3
- package/dist/{AcpSessionManager-BQnDJvct.mjs → AcpSessionManager-B7FIrsys.mjs} +1 -1
- package/dist/{AcpSessionManager-9yN2iH4b.cjs → AcpSessionManager-Zf00QnlL.cjs} +1 -1
- package/dist/{BasePermissionHandler-CwJ48yiB.mjs → BasePermissionHandler-CDCLIrZJ.mjs} +1 -1
- package/dist/{BasePermissionHandler-BwQHxl6M.cjs → BasePermissionHandler-COKc4pmt.cjs} +1 -1
- package/dist/{BaseReasoningProcessor-ClF2g1qz.mjs → BaseReasoningProcessor-BDVKvooR.mjs} +1 -1
- package/dist/{BaseReasoningProcessor-81lf_M8G.cjs → BaseReasoningProcessor-DAGQdkNp.cjs} +1 -1
- package/dist/{agentGuidance-CQuNlNx8.mjs → agentGuidance-BDYt2Qy2.mjs} +4 -2
- package/dist/{agentGuidance-B5NATuFa.cjs → agentGuidance-BtBx2xxE.cjs} +4 -1
- package/dist/{agentHome-DvR176E5.mjs → agentHome-2IbIjKCa.mjs} +4 -4
- package/dist/{agentHome-D3Nn82kI.cjs → agentHome-KVWj6nCl.cjs} +4 -4
- package/dist/{api-DTVPUE7g.mjs → api-Cmz3XS4P.mjs} +14 -12
- package/dist/{api-AzdzTKFs.cjs → api-DnNL5fLw.cjs} +14 -12
- package/dist/{apiMachine-BQ-b4Lu3.mjs → apiMachine-B6ySCFe0.mjs} +2137 -65
- package/dist/{apiMachine-B_cJldfa.cjs → apiMachine-DeoQ8QbZ.cjs} +2134 -62
- package/dist/{attachmentContent-dzIs-wis.mjs → attachmentContent-CTXapi-_.mjs} +2 -2
- package/dist/{attachmentContent-muDCe4Gy.cjs → attachmentContent-DPWxHgX4.cjs} +2 -2
- package/dist/{attachmentLimits-eJZ4ZBjF.cjs → attachmentLimits-BfgKV5iE.cjs} +28 -2
- package/dist/{attachmentLimits-B8nzhw-i.mjs → attachmentLimits-C1PCCM79.mjs} +28 -2
- package/dist/{auth-Jtmvh1w4.mjs → auth-BMaBjRAj.mjs} +8 -8
- package/dist/{auth-DtP-B4gf.cjs → auth-D4-eQz85.cjs} +8 -8
- package/dist/{auth-CfLd3phE.cjs → auth-ietHtvwu.cjs} +5 -5
- package/dist/{auth-BY5ujhGn.mjs → auth-pzWIFdOD.mjs} +5 -5
- package/dist/{auto-DjWfR1UB.mjs → auto-DL75Dx3I.mjs} +83 -21
- package/dist/{auto-ClNnbSoF.cjs → auto-DOWTQDPa.cjs} +84 -20
- package/dist/{bootstrap-B1dMZA-u.cjs → bootstrap-BpXIc2wn.cjs} +2 -2
- package/dist/{bootstrap-D_ExGrY2.mjs → bootstrap-XE8A7Ut2.mjs} +2 -2
- package/dist/{browser-_gV7tcPs.cjs → browser-CcG8GLRV.cjs} +1 -1
- package/dist/{browser-mwzpOmcw.mjs → browser-DDFi1T8C.mjs} +1 -1
- package/dist/{claudeLocal-G5UmJ81F.cjs → claudeLocal-DXBXwIOG.cjs} +6 -6
- package/dist/{claudeLocal-BeHToEK5.mjs → claudeLocal-DmM5Oyhv.mjs} +6 -6
- package/dist/{claudeLocalLauncher-B4y2BsmO.cjs → claudeLocalLauncher-C41Y6Kp4.cjs} +14 -11
- package/dist/{claudeLocalLauncher-Cq6bqAT4.mjs → claudeLocalLauncher-Cs4eUC3P.mjs} +14 -11
- package/dist/{claudeSessionFork-CokUhS4r.mjs → claudeSessionFork-BdLnc2Hv.mjs} +1 -1
- package/dist/{claudeSessionFork-CDJq_b9r.cjs → claudeSessionFork-Cs0I3eGI.cjs} +1 -1
- package/dist/{client-BwaWdlCm.mjs → client-CbK33y_s.mjs} +3 -3
- package/dist/{client-uN9d6Git.cjs → client-DJ0kX_8M.cjs} +2 -2
- package/dist/{client-CJZKBZCE.mjs → client-GjTnsq6F.mjs} +2 -2
- package/dist/{client-Do7cGPEq.cjs → client-JISJx2f-.cjs} +3 -3
- package/dist/codex/happyMcpStdioBridge.cjs +23 -18
- package/dist/codex/happyMcpStdioBridge.mjs +22 -17
- package/dist/{codexAppServerClient-BuSdeUxT.cjs → codexAppServerClient-BTjn2ZCZ.cjs} +46 -2
- package/dist/{codexAppServerClient-B0QaMQsC.mjs → codexAppServerClient-CldXEojt.mjs} +47 -3
- package/dist/{codexCommand-YdYmykAR.cjs → codexCommand-BpD0N1F7.cjs} +71 -51
- package/dist/{codexCommand-C3gdjMAw.mjs → codexCommand-DjMU-Ijf.mjs} +71 -51
- package/dist/{config-CGCIFRCN.mjs → config-DjSJKatx.mjs} +3 -3
- package/dist/{config-ChoHFnfO.cjs → config-DuLL0Ppe.cjs} +3 -3
- package/dist/{configuration-BE61XNsg.cjs → configuration-B0cb3xA3.cjs} +1 -1
- package/dist/{configuration-L1qcGTFM.mjs → configuration-D2v3puap.mjs} +1 -1
- package/dist/{connect-D5IKfcfd.cjs → connect-B7JWEzyg.cjs} +13 -13
- package/dist/{connect-CFD0Em6a.mjs → connect-CnNNyM9I.mjs} +13 -13
- package/dist/{context-3WVw8qPz.cjs → context-BFUpYT39.cjs} +1 -1
- package/dist/{context-DaCCB4cm.mjs → context-DjfqLWzE.mjs} +1 -1
- package/dist/{controlClient-Ci7HTMII.mjs → controlClient-BMUJfUeF.mjs} +13 -6
- package/dist/{controlClient-RWTlQA4_.cjs → controlClient-CmnHGcyl.cjs} +13 -5
- package/dist/{createSessionMetadata-CWR-AB2A.mjs → createSessionMetadata-DU0bCg2m.mjs} +2 -2
- package/dist/{createSessionMetadata-CPGTztXP.cjs → createSessionMetadata-DbfRBhRu.cjs} +2 -2
- package/dist/{deterministicJson-BBwG-Ase.mjs → deterministicJson-D-WPUrZb.mjs} +36 -52
- package/dist/{deterministicJson-C4Jo2RWy.cjs → deterministicJson-Dp3TVyap.cjs} +41 -59
- package/dist/{doctor-CAC6qY9Z.mjs → doctor-BlXXyQ1L.mjs} +6 -6
- package/dist/{doctor-ZOQLE5AH.cjs → doctor-CkJmdPbi.cjs} +6 -6
- package/dist/{assistantTools-BsrG3PD-.cjs → downloadTarget-CiQqRIc-.cjs} +44 -6
- package/dist/{assistantTools-DPGga5lM.mjs → downloadTarget-DjSwMHun.mjs} +45 -8
- package/dist/{ensureDaemonRunning-b1pvEFcS.mjs → ensureDaemonRunning-CAQag1zE.mjs} +5 -5
- package/dist/{ensureDaemonRunning-mIgX2vnc.cjs → ensureDaemonRunning-C_aIOhgG.cjs} +5 -5
- package/dist/{handleResumeCommand-DGNhbv-3.cjs → handleResumeCommand-6KzBGDnl.cjs} +5 -5
- package/dist/{handleResumeCommand-CTGbZkCL.mjs → handleResumeCommand-Ci7wacT-.mjs} +5 -5
- package/dist/{index-vVWpCoTr.mjs → index-B0HEEYGh.mjs} +22 -5
- package/dist/{index-DqlyNgt4.cjs → index-C9hI8a10.cjs} +26 -4
- package/dist/{index-BzIxE0Eu.cjs → index-CaFCAlX8.cjs} +3 -3
- package/dist/{index-DrSPR59N.cjs → index-DBmNwMX9.cjs} +39 -38
- package/dist/{index-DIodyAqF.cjs → index-DEKIgZUo.cjs} +4 -4
- package/dist/{index-Diu6HdWH.mjs → index-DQplgo0W.mjs} +4 -4
- package/dist/{index-BgzjPv07.mjs → index-HQsigW_J.mjs} +3 -3
- package/dist/{index-DjtBengz.mjs → index-WGCT6SSV.mjs} +40 -39
- package/dist/index.cjs +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{install-BO_W7VhL.mjs → install-BdvKiOQw.mjs} +2 -0
- package/dist/{install-DrUQk2DJ.cjs → install-CejEiRGW.cjs} +3 -3
- package/dist/{install-Cs5qAPrB.cjs → install-Cf6AFSWC.cjs} +2 -0
- package/dist/{install-Cp_aB-NQ.mjs → install-oJUvsFu9.mjs} +3 -3
- package/dist/lib.cjs +12 -12
- package/dist/lib.d.cts +55 -2
- package/dist/lib.d.mts +55 -2
- package/dist/lib.mjs +12 -12
- package/dist/{lidState-BLHLmhwd.mjs → lidState-BfximpjX.mjs} +1 -1
- package/dist/{lidState-z9ekRpZu.cjs → lidState-D8BmnJV1.cjs} +1 -1
- package/dist/{localHappyAgentAuth-BWvmc_Hg.mjs → localHappyAgentAuth-Ct1aXwqd.mjs} +1 -1
- package/dist/{localHappyAgentAuth-BCx0y1qW.cjs → localHappyAgentAuth-d7B6S_o7.cjs} +1 -1
- package/dist/{logger-CknSVkIe.mjs → logger-BpQcwNnt.mjs} +2 -2
- package/dist/{logger-D0hkk7Vo.cjs → logger-JTcJU-uL.cjs} +2 -2
- package/dist/{machineMetadata-BDCYOXQg.cjs → machineMetadata-BBg_Njw3.cjs} +3 -3
- package/dist/{machineMetadata-Cj1DoFot.mjs → machineMetadata-CBdxABeI.mjs} +3 -3
- package/dist/{main-CKUfkVG_.mjs → main-B5b1gA3K.mjs} +44 -44
- package/dist/{main-COAPGIso.cjs → main-C16fOGgn.cjs} +44 -44
- package/dist/{mcp-BILnw4si.cjs → mcp-BnNiBBax.cjs} +24 -19
- package/dist/{mcp-DeYUfpo8.mjs → mcp-DGNWgVdv.mjs} +23 -18
- package/dist/{mirrorProtocol-BUtmg1ws.cjs → mirrorProtocol-BEGk2--0.cjs} +80 -0
- package/dist/{mirrorProtocol-BNLtoWjO.mjs → mirrorProtocol-h5WHpdhh.mjs} +79 -1
- package/dist/{package-VIUXrYZ1.mjs → package-BqwrPxL0.mjs} +7 -7
- package/dist/{package-UkmvSFsg.cjs → package-C1XvUQFy.cjs} +7 -7
- package/dist/{peerTools-SWITnkqz.mjs → peerTools-CeCe-3lk.mjs} +4 -4
- package/dist/{peerTools-CttfOxti.cjs → peerTools-DPvbcs8V.cjs} +4 -4
- package/dist/{persistence-C6ztVW1W.cjs → persistence-2Xp_o6x6.cjs} +3 -3
- package/dist/{persistence-DujtUH94.mjs → persistence-B9LmhAE2.mjs} +3 -3
- package/dist/{piRunnerArgs-BVJHIjlk.mjs → piRunnerArgs-7FaTyNOq.mjs} +1 -1
- package/dist/{piRunnerArgs-tBQg6xDw.cjs → piRunnerArgs-DI29dDNo.cjs} +1 -1
- package/dist/{piRuntime-DKFA8u8t.mjs → piRuntime-0qEK5AqE.mjs} +1 -1
- package/dist/{piRuntime-DZzjF9It.cjs → piRuntime-R8XVChxK.cjs} +1 -1
- package/dist/{piTerminal-t5pQHCPT.cjs → piTerminal-Ccj0KNPr.cjs} +17 -17
- package/dist/{piTerminal-BTSaFlpV.mjs → piTerminal-zcQKc8Sp.mjs} +17 -17
- package/dist/precheckCompat-CWOi5c9L.cjs +29 -0
- package/dist/precheckCompat-DeEbilbr.mjs +26 -0
- package/dist/rateLimitWindows-C2Jk3Ih7.cjs +258 -0
- package/dist/rateLimitWindows-Dhfhbtqu.mjs +248 -0
- package/dist/{registerCommonHandlers--8ds2auE.cjs → registerCommonHandlers-Aha_KfM3.cjs} +2 -2
- package/dist/{registerCommonHandlers-BR0J4g64.mjs → registerCommonHandlers-DdfGsFbM.mjs} +2 -2
- package/dist/{registerKillSessionHandler-CI23l8aJ.cjs → registerKillSessionHandler-CjitOpUA.cjs} +6 -6
- package/dist/{registerKillSessionHandler-Czz0tdki.mjs → registerKillSessionHandler-DboQlGcH.mjs} +6 -6
- package/dist/{remoteSessionClient-DCAUHgPz.mjs → remoteSessionClient-B2DtWuNP.mjs} +5 -5
- package/dist/{remoteSessionClient-BAkIwM2S.cjs → remoteSessionClient-BwI8rpQw.cjs} +5 -5
- package/dist/{tools-mWqsE6ji.cjs → reportProgressTool-B-Rcy0s3.cjs} +73 -9
- package/dist/{tools-BVRoGcQH.mjs → reportProgressTool-DnAD8wOa.mjs} +67 -10
- package/dist/{run-CkKujjn8.cjs → run-Bh66Xs40.cjs} +268 -118
- package/dist/{run-B75qm2I6.mjs → run-DPEwJpQR.mjs} +266 -116
- package/dist/{runClaude-B1T9DLLi.cjs → runClaude-C-lJdAKD.cjs} +25 -20
- package/dist/{runClaude-aBfBweec.mjs → runClaude-DLr_nBzw.mjs} +25 -20
- package/dist/{runClaudeDeps-BOrPP4J1.mjs → runClaudeDeps-C7JV7nXG.mjs} +47 -37
- package/dist/{runClaudeDeps-BGjNGfuZ.cjs → runClaudeDeps-vWWLod7J.cjs} +44 -35
- package/dist/{runGemini-BuAA_OQb.cjs → runGemini-o-CUSayc.cjs} +42 -35
- package/dist/{runGemini-a-V7JE-J.mjs → runGemini-tH6sLw-G.mjs} +42 -35
- package/dist/{runOpenClaw-DUSwXg1A.mjs → runOpenClaw-C5DZvVhn.mjs} +18 -18
- package/dist/{runOpenClaw-BBVffIh9.cjs → runOpenClaw-D24f35l0.cjs} +18 -18
- package/dist/{sandbox-Brb_5wzc.mjs → sandbox-BaL1aslA.mjs} +4 -4
- package/dist/{sandbox-Fd2G5yaq.cjs → sandbox-TJZJ6ngw.cjs} +4 -4
- package/dist/selfCheck-DPi4lqme.mjs +84 -0
- package/dist/{selfCheck-NuJKMInJ.cjs → selfCheck-Dugi0Nk_.cjs} +34 -34
- package/dist/{send-4dgHKC3u.cjs → send-CkWOSYmU.cjs} +8 -8
- package/dist/{send-DEQJiq_x.mjs → send-DDcJ3ayi.mjs} +8 -8
- package/dist/{server-DWGcDmHG.cjs → server-7qFQ8J2u.cjs} +6 -6
- package/dist/{server-C6AlUFkw.mjs → server-CUjRvQSd.mjs} +4 -4
- package/dist/{sessionLock-B1GgaO1q.cjs → sessionLock-DfhNICyS.cjs} +2 -2
- package/dist/{sessionLock-Dm8QyGo4.mjs → sessionLock-DqVyHFAQ.mjs} +2 -2
- package/dist/{sessionModeFile-BvOEJ4Fl.cjs → sessionModeFile-CIznKD7O.cjs} +1 -1
- package/dist/{sessionModeFile-CuNAWCKL.mjs → sessionModeFile-M4OpiyrF.mjs} +1 -1
- package/dist/{sessionOps-DG5MMEp_.mjs → sessionOps-BnaEaOKs.mjs} +4 -4
- package/dist/{sessionOps-JP64uJmc.cjs → sessionOps-DOyy4gkY.cjs} +4 -4
- package/dist/{sessionScanner-DqSk5Ol_.mjs → sessionScanner-CcuOn9uU.mjs} +75 -19
- package/dist/{sessionScanner-BBPEgJ5S.cjs → sessionScanner-Cu1yKlj4.cjs} +75 -19
- package/dist/{sessions-DgJuw_i0.cjs → sessions-BQgk5Kw8.cjs} +9 -9
- package/dist/{sessions-4isbBAWi.mjs → sessions-C9O91rL1.mjs} +9 -9
- package/dist/{setupOfflineReconnection-_MoZtsro.mjs → setupOfflineReconnection-Byy8aeKT.mjs} +2 -2
- package/dist/{setupOfflineReconnection-CKJKVIUS.cjs → setupOfflineReconnection-DFmQ8s6I.cjs} +2 -2
- package/dist/{spawn-CshERWbY.cjs → spawn-BS7LPmSm.cjs} +13 -12
- package/dist/{spawn-BTOG4tTm.mjs → spawn-EZ2yrtWd.mjs} +13 -12
- package/dist/{spawnAgents-Czi2VDzj.mjs → spawnAgents-HDaE3C0-.mjs} +1 -1
- package/dist/{spawnAgents-McixdtV1.cjs → spawnAgents-vglexX9N.cjs} +1 -1
- package/dist/{spawnHappyCLI-Dc4xYm8P.mjs → spawnHappyCLI-BAa49m0q.mjs} +1 -1
- package/dist/{spawnHappyCLI-Dd5_8Vex.cjs → spawnHappyCLI-DyaldIe5.cjs} +1 -1
- package/dist/specialCommands-CCWtwIGZ.mjs +93 -0
- package/dist/specialCommands-DouFaJ_0.cjs +96 -0
- package/dist/{startFileWatcher-1LedLBY-.cjs → startFileWatcher-DQbbypJv.cjs} +1 -1
- package/dist/{startFileWatcher-B0VlG-Fn.mjs → startFileWatcher-Dfs4whzr.mjs} +1 -1
- package/dist/{streamRelay-C0AffuX6.cjs → streamRelay-Ce9sgcOV.cjs} +1 -1
- package/dist/{streamRelay-BzMEEvJU.mjs → streamRelay-Iogsh51a.mjs} +1 -1
- package/dist/{sync-Dfm6K2te.mjs → sync-BLpH13Kb.mjs} +1 -1
- package/dist/{sync-DwUUGGFz.cjs → sync-x_lI_3j_.cjs} +1 -1
- package/dist/{systemPrompt-CB1Ls0AE.cjs → systemPrompt-4erwILTB.cjs} +3 -3
- package/dist/{systemPrompt-D41Zw1a3.mjs → systemPrompt-BanyDihy.mjs} +3 -3
- package/dist/{teams-D8Ly3i6u.mjs → teams-D6BOT3lR.mjs} +7 -7
- package/dist/{teams-NWv2ji16.cjs → teams-DzetlGWC.cjs} +7 -7
- package/dist/{time-v1Y_qEJp.mjs → time-CfDKj6Bx.mjs} +1 -1
- package/dist/{time-BbzJ7PJQ.cjs → time-T0YmeOhS.cjs} +1 -1
- package/dist/{titleGenerator-BhoblAQY.mjs → titleGenerator-ChImQ4q-.mjs} +1 -1
- package/dist/{titleGenerator-cH6IkPRi.cjs → titleGenerator-CoMxqKfP.cjs} +2 -2
- package/dist/{todo-D1hgWV6d.cjs → todo-CZZebKZg.cjs} +6 -6
- package/dist/{todo-CuNct-fw.mjs → todo-SVtYxGON.mjs} +6 -6
- package/dist/{tools-jof0Bu54.mjs → tools-3X2z3YDe.mjs} +1 -1
- package/dist/{tools-c2fb0CF5.cjs → tools-IJ0TAcJs.cjs} +1 -1
- package/dist/transcriptCursor-B7T_gkQ0.mjs +102 -0
- package/dist/transcriptCursor-DcXGLeSc.cjs +106 -0
- package/dist/{uninstall-VPH5GfrA.cjs → uninstall-CqqCWj7w.cjs} +3 -3
- package/dist/{uninstall-BB7WYYAk.mjs → uninstall-CypD-kr1.mjs} +3 -3
- package/package.json +7 -7
- package/scripts/terminal_status_forwarder.cjs +89 -0
- package/dist/selfCheck-BgwJ3NGS.mjs +0 -84
- package/dist/specialCommands-BSqhYg5X.mjs +0 -52
- package/dist/specialCommands-DSsTeRej.cjs +0 -54
|
@@ -1,50 +1,51 @@
|
|
|
1
1
|
import { io } from 'socket.io-client';
|
|
2
|
-
import { l as logger } from './logger-
|
|
3
|
-
import { R as RpcHandlerManager, i as isRateQuotaCode, p as pauseForRateQuota, s as socketConnectGuard, c as connectGuarded, a as answerSocketRequest, e as shouldReconnect, d as describeSocketAckDrop } from './lidState-
|
|
4
|
-
import { i as isClaudeSessionId, s as sanitizeTagList, p as pickMirrorForTerminal, l as liveSnapshotChanged,
|
|
5
|
-
import { c as configuration } from './configuration-
|
|
2
|
+
import { l as logger } from './logger-BpQcwNnt.mjs';
|
|
3
|
+
import { R as RpcHandlerManager, i as isRateQuotaCode, p as pauseForRateQuota, s as socketConnectGuard, c as connectGuarded, a as answerSocketRequest, e as shouldReconnect, d as describeSocketAckDrop } from './lidState-BfximpjX.mjs';
|
|
4
|
+
import { i as isClaudeSessionId, s as stableVersion, a as sanitizeTagList, p as pickMirrorForTerminal, l as liveSnapshotChanged, b as sanitizeLiveSnapshot, c as serializeLiveSnapshot, d as summarizeSpawnSessionForLog, e as parseMirrorSendParams } from './mirrorProtocol-h5WHpdhh.mjs';
|
|
5
|
+
import { c as configuration } from './configuration-D2v3puap.mjs';
|
|
6
6
|
import { w as withCurrentCliUpdateState } from './cliUpdate-Y34VGe9Y.mjs';
|
|
7
|
-
import { r as registerCommonHandlers } from './registerCommonHandlers-
|
|
8
|
-
import { stat, readdir, lstat, open, readFile } from 'node:fs/promises';
|
|
7
|
+
import { r as registerCommonHandlers } from './registerCommonHandlers-DdfGsFbM.mjs';
|
|
8
|
+
import { stat, readdir, lstat, open, readFile, rm, mkdir, chmod, writeFile, rename } from 'node:fs/promises';
|
|
9
9
|
import { join, isAbsolute, resolve } from 'node:path';
|
|
10
10
|
import os, { homedir } from 'node:os';
|
|
11
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
12
|
-
import { readSettings, readPersistedSessions, readTrackedClaudeSessionIds, readTrackedCodexThreadIds } from './persistence-
|
|
11
|
+
import { spawn, execFile, spawnSync } from 'node:child_process';
|
|
12
|
+
import { readSettings, readPersistedSessions, readTrackedClaudeSessionIds, readTrackedCodexThreadIds } from './persistence-B9LmhAE2.mjs';
|
|
13
13
|
import { e as encodeBase64, g as encrypt, h as decrypt, d as decodeBase64 } from './authRelay-DSAvg1uv.mjs';
|
|
14
14
|
import { p as prepareClipboardText, d as prepareClipboardHistoryText } from './limits-DIo7rEBR.mjs';
|
|
15
|
-
import { b as backoff, d as delay } from './time-
|
|
15
|
+
import { b as backoff, d as delay } from './time-CfDKj6Bx.mjs';
|
|
16
16
|
import * as pty from 'node-pty';
|
|
17
17
|
import { a as attachStartupCommand, v as validateTerminalTags, d as deriveAutoTitle, T as TMUX_SESSION_ID_RE, i as isSafeTmuxSessionName, b as isVhSessionName, U as USER_SESSIONS_FORMAT, p as parseUserSessions, L as LIST_SESSIONS_FORMAT, c as parseSessionListLine, e as LIST_FIELD_SEP, s as sendToVhTerminal } from './terminals-Dlg0x3nY.mjs';
|
|
18
18
|
import { t as tmuxArgs, V as VH_TMUX_SOCKET_ENV, a as scrubTmuxClientEnv } from './tmuxSocket-B8Yx20sT.mjs';
|
|
19
|
-
import { locateClaudeConversation } from './index-
|
|
19
|
+
import { locateClaudeConversation, refreshAgentHomes } from './index-DQplgo0W.mjs';
|
|
20
20
|
import { randomBytes } from 'node:crypto';
|
|
21
|
-
import { existsSync,
|
|
21
|
+
import { readdirSync, unlinkSync, existsSync, mkdirSync, writeFileSync, readFileSync, statSync, chmodSync, renameSync, rmSync, accessSync, constants } from 'node:fs';
|
|
22
22
|
import axios from 'axios';
|
|
23
|
+
import { p as projectPath } from './projectPath-BTBIMB57.mjs';
|
|
23
24
|
import { a as isValidTerminalId } from './ids-DU6sLx4C.mjs';
|
|
24
25
|
import { d as detectCLIAvailability } from './detectCLI-CSGMlV51.mjs';
|
|
25
|
-
import { d as detectResumeSupport } from './localHappyAgentAuth-
|
|
26
|
+
import { d as detectResumeSupport } from './localHappyAgentAuth-Ct1aXwqd.mjs';
|
|
26
27
|
import { g as getProjectPath, a as getClaudeProjectsRoot } from './path-D2wdaNSk.mjs';
|
|
27
28
|
import { i as isVeryHappyOneShotPrompt } from './oneShotPrompts-DT4p6RNd.mjs';
|
|
28
|
-
import { f as forkSession, F as ForkSourceMissingError, d as discardForkedSession, l as listClaudeRewindPoints, a as forkAndTruncateSession, b as ForkTruncateUuidNotFoundError, c as forkBeforeUserMessage } from './claudeSessionFork-
|
|
29
|
-
import { C as CodexAppServerClient } from './codexAppServerClient-
|
|
30
|
-
import {
|
|
29
|
+
import { f as forkSession, F as ForkSourceMissingError, d as discardForkedSession, l as listClaudeRewindPoints, a as forkAndTruncateSession, b as ForkTruncateUuidNotFoundError, c as forkBeforeUserMessage } from './claudeSessionFork-BdLnc2Hv.mjs';
|
|
30
|
+
import { C as CodexAppServerClient } from './codexAppServerClient-CldXEojt.mjs';
|
|
31
|
+
import { r as RelayCandidatesResponseSchema, q as RelayAssignmentResponseSchema, R as ReleaseDrainNoticeSchema } from './index-B0HEEYGh.mjs';
|
|
31
32
|
import { f as forkCodexThread, C as CodexForkRewindPointNotFoundError, l as listCodexRewindPoints, a as forkCodexBeforeUserMessage } from './codexThreadFork-R76RU5H0.mjs';
|
|
33
|
+
import { p as planRateLimitWrite, m as mergeRateLimitReports } from './rateLimitWindows-Dhfhbtqu.mjs';
|
|
32
34
|
import 'chalk';
|
|
33
35
|
import 'node:util';
|
|
34
36
|
import 'os';
|
|
35
37
|
import 'child_process';
|
|
36
|
-
import './package-
|
|
38
|
+
import './package-BqwrPxL0.mjs';
|
|
37
39
|
import 'util';
|
|
38
40
|
import 'fs/promises';
|
|
39
41
|
import 'crypto';
|
|
40
42
|
import 'path';
|
|
41
43
|
import 'cross-spawn';
|
|
42
|
-
import './projectPath-BTBIMB57.mjs';
|
|
43
|
-
import 'url';
|
|
44
44
|
import 'zod';
|
|
45
45
|
import 'tweetnacl';
|
|
46
46
|
import './terminalTitleSuggest-CeKsX_V6.mjs';
|
|
47
47
|
import './loginShellEnv-Dw8M7tkI.mjs';
|
|
48
|
+
import 'url';
|
|
48
49
|
import 'fs';
|
|
49
50
|
import 'node:readline';
|
|
50
51
|
import './contentLogMetadata-BlWI0egu.mjs';
|
|
@@ -186,7 +187,7 @@ const TODO_LIST_MAX_ITEMS = 500;
|
|
|
186
187
|
const TODO_TITLE_MAX_CHARS = 500;
|
|
187
188
|
const FIELD_MAX_CHARS = 200;
|
|
188
189
|
const PRIORITIES = ["none", "low", "medium", "high"];
|
|
189
|
-
function str(value, max = FIELD_MAX_CHARS) {
|
|
190
|
+
function str$1(value, max = FIELD_MAX_CHARS) {
|
|
190
191
|
if (typeof value !== "string") return void 0;
|
|
191
192
|
const flat = value.replace(/\s+/g, " ").trim();
|
|
192
193
|
if (!flat) return void 0;
|
|
@@ -217,8 +218,8 @@ function parseTodoList(stdout) {
|
|
|
217
218
|
continue;
|
|
218
219
|
}
|
|
219
220
|
const r = raw;
|
|
220
|
-
const id = str(r.id);
|
|
221
|
-
const title = str(r.title, TODO_TITLE_MAX_CHARS);
|
|
221
|
+
const id = str$1(r.id);
|
|
222
|
+
const title = str$1(r.title, TODO_TITLE_MAX_CHARS);
|
|
222
223
|
if (!id || !title) {
|
|
223
224
|
dropped++;
|
|
224
225
|
continue;
|
|
@@ -228,10 +229,10 @@ function parseTodoList(stdout) {
|
|
|
228
229
|
id,
|
|
229
230
|
title,
|
|
230
231
|
status: r.status === "done" ? "done" : "open",
|
|
231
|
-
...str(r.due) ? { due: str(r.due) } : {},
|
|
232
|
+
...str$1(r.due) ? { due: str$1(r.due) } : {},
|
|
232
233
|
...priority ? { priority } : {},
|
|
233
|
-
...str(r.group) ? { group: str(r.group) } : {},
|
|
234
|
-
...str(r.note) ? { note: str(r.note) } : {}
|
|
234
|
+
...str$1(r.group) ? { group: str$1(r.group) } : {},
|
|
235
|
+
...str$1(r.note) ? { note: str$1(r.note) } : {}
|
|
235
236
|
});
|
|
236
237
|
}
|
|
237
238
|
return { items, dropped, truncated: hitCap };
|
|
@@ -4968,8 +4969,8 @@ function buildCaptureBatch(o) {
|
|
|
4968
4969
|
const CAPTURE_FULL_BUDGET_BYTES = 1024 * 1024;
|
|
4969
4970
|
const ALT_ENTER = Buffer.from("\x1B[?1049h", "ascii");
|
|
4970
4971
|
const CURSOR_HOME = Buffer.from("\x1B[H", "ascii");
|
|
4971
|
-
const CRLF = Buffer.from("\r\n", "ascii");
|
|
4972
|
-
const SGR_RESET = Buffer.from("\x1B[0m", "ascii");
|
|
4972
|
+
const CRLF$1 = Buffer.from("\r\n", "ascii");
|
|
4973
|
+
const SGR_RESET$1 = Buffer.from("\x1B[0m", "ascii");
|
|
4973
4974
|
function normalizeCaptureLines(blob) {
|
|
4974
4975
|
if (blob.length === 0) return blob;
|
|
4975
4976
|
const lines = [];
|
|
@@ -4984,7 +4985,7 @@ function normalizeCaptureLines(blob) {
|
|
|
4984
4985
|
if (start < blob.length) lines.push(blob.subarray(start));
|
|
4985
4986
|
const out = [];
|
|
4986
4987
|
lines.forEach((l, i) => {
|
|
4987
|
-
if (i > 0) out.push(CRLF);
|
|
4988
|
+
if (i > 0) out.push(CRLF$1);
|
|
4988
4989
|
out.push(l);
|
|
4989
4990
|
});
|
|
4990
4991
|
return Buffer.concat(out);
|
|
@@ -5012,31 +5013,31 @@ function assembleRestore(responses, paneState, budget = CAPTURE_FULL_BUDGET_BYTE
|
|
|
5012
5013
|
realCount = lines.length;
|
|
5013
5014
|
while (lines.length < rows) lines.push(EMPTY);
|
|
5014
5015
|
}
|
|
5015
|
-
const realBody = joinCrlf(lines.slice(0, realCount));
|
|
5016
|
+
const realBody = joinCrlf$1(lines.slice(0, realCount));
|
|
5016
5017
|
const padCount = lines.length - realCount;
|
|
5017
|
-
const padBody = padCount > 0 ? Buffer.concat([realCount > 0 ? CRLF : EMPTY, SGR_RESET, joinCrlf(new Array(padCount).fill(EMPTY))]) : EMPTY;
|
|
5018
|
+
const padBody = padCount > 0 ? Buffer.concat([realCount > 0 ? CRLF$1 : EMPTY, SGR_RESET$1, joinCrlf$1(new Array(padCount).fill(EMPTY))]) : EMPTY;
|
|
5018
5019
|
const cursor = paneState ? Buffer.from(`\x1B[${paneState.cursorY + 1};${paneState.cursorX + 1}H`, "ascii") : Buffer.alloc(0);
|
|
5019
|
-
return Buffer.concat([realBody, padBody, SGR_RESET, tail, cursor]);
|
|
5020
|
+
return Buffer.concat([realBody, padBody, SGR_RESET$1, tail, cursor]);
|
|
5020
5021
|
};
|
|
5021
|
-
const wrapFull = (b) => Buffer.concat([SGR_RESET, b, SGR_RESET]);
|
|
5022
|
-
const wrapSmall = (b) => Buffer.concat([b, SGR_RESET]);
|
|
5022
|
+
const wrapFull = (b) => Buffer.concat([SGR_RESET$1, b, SGR_RESET$1]);
|
|
5023
|
+
const wrapSmall = (b) => Buffer.concat([b, SGR_RESET$1]);
|
|
5023
5024
|
if (!alternateOn) {
|
|
5024
5025
|
const history2 = trimTrailingBlankRows(normalizeCaptureLines(get("history")));
|
|
5025
5026
|
const smallHistory = trimTrailingBlankRows(normalizeCaptureLines(get("smallHistory")));
|
|
5026
5027
|
const withHistory = (h) => Buffer.concat(
|
|
5027
|
-
h.length > 0 ? [truncateToBudget(h, budget), SGR_RESET, CRLF, screen()] : [screen()]
|
|
5028
|
+
h.length > 0 ? [truncateToBudget(h, budget), SGR_RESET$1, CRLF$1, screen()] : [screen()]
|
|
5028
5029
|
);
|
|
5029
5030
|
return { full: wrapFull(withHistory(history2)), small: wrapSmall(withHistory(smallHistory)), alternateOn };
|
|
5030
5031
|
}
|
|
5031
5032
|
const history = trimTrailingBlankRows(normalizeCaptureLines(get("history")));
|
|
5032
5033
|
const saved = trimTrailingBlankRows(normalizeCaptureLines(get("altSaved")));
|
|
5033
5034
|
const scrollback = truncateToBudget(
|
|
5034
|
-
Buffer.concat(history.length > 0 && saved.length > 0 ? [history, SGR_RESET, CRLF, saved] : [history, saved]),
|
|
5035
|
+
Buffer.concat(history.length > 0 && saved.length > 0 ? [history, SGR_RESET$1, CRLF$1, saved] : [history, saved]),
|
|
5035
5036
|
budget
|
|
5036
5037
|
);
|
|
5037
|
-
const altScreen = Buffer.concat([ALT_ENTER, CURSOR_HOME, SGR_RESET, screen()]);
|
|
5038
|
+
const altScreen = Buffer.concat([ALT_ENTER, CURSOR_HOME, SGR_RESET$1, screen()]);
|
|
5038
5039
|
return {
|
|
5039
|
-
full: wrapFull(Buffer.concat([scrollback, scrollback.length > 0 ? CRLF : Buffer.alloc(0), altScreen])),
|
|
5040
|
+
full: wrapFull(Buffer.concat([scrollback, scrollback.length > 0 ? CRLF$1 : Buffer.alloc(0), altScreen])),
|
|
5040
5041
|
small: wrapSmall(altScreen),
|
|
5041
5042
|
alternateOn
|
|
5042
5043
|
};
|
|
@@ -5050,7 +5051,7 @@ function stripTrailingNewlines(blob) {
|
|
|
5050
5051
|
function trimTrailingBlankRows(blob) {
|
|
5051
5052
|
const lines = splitLines(blob);
|
|
5052
5053
|
while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop();
|
|
5053
|
-
return joinCrlf(lines);
|
|
5054
|
+
return joinCrlf$1(lines);
|
|
5054
5055
|
}
|
|
5055
5056
|
function splitLines(blob) {
|
|
5056
5057
|
const out = [];
|
|
@@ -5065,10 +5066,10 @@ function splitLines(blob) {
|
|
|
5065
5066
|
if (start < blob.length) out.push(blob.subarray(start));
|
|
5066
5067
|
return out;
|
|
5067
5068
|
}
|
|
5068
|
-
function joinCrlf(lines) {
|
|
5069
|
+
function joinCrlf$1(lines) {
|
|
5069
5070
|
const parts = [];
|
|
5070
5071
|
lines.forEach((l, i) => {
|
|
5071
|
-
if (i > 0) parts.push(CRLF);
|
|
5072
|
+
if (i > 0) parts.push(CRLF$1);
|
|
5072
5073
|
parts.push(l);
|
|
5073
5074
|
});
|
|
5074
5075
|
return Buffer.concat(parts);
|
|
@@ -5434,7 +5435,7 @@ class TerminalNotifyTracker {
|
|
|
5434
5435
|
* Feed one observation for a terminal. Returns the notification event this
|
|
5435
5436
|
* observation triggers, or null. Call once per terminal per tick.
|
|
5436
5437
|
*/
|
|
5437
|
-
observe(terminalId, state, now, agentKind) {
|
|
5438
|
+
observe(terminalId, state, now, agentKind, opts = {}) {
|
|
5438
5439
|
let t = this.tracks.get(terminalId);
|
|
5439
5440
|
if (!t || t.agentKind !== agentKind || now - t.lastObservedAt > 45e3) {
|
|
5440
5441
|
t = {
|
|
@@ -5465,6 +5466,7 @@ class TerminalNotifyTracker {
|
|
|
5465
5466
|
t.candidateCount += 1;
|
|
5466
5467
|
t.lastCountedAt = now;
|
|
5467
5468
|
}
|
|
5469
|
+
if (opts.authoritative) t.candidateCount = Math.max(t.candidateCount, this.stableTicks);
|
|
5468
5470
|
if (t.candidateCount < this.stableTicks) return null;
|
|
5469
5471
|
const prev = t.confirmed;
|
|
5470
5472
|
t.confirmed = t.candidate;
|
|
@@ -5521,6 +5523,403 @@ function sendTerminalNotification(opts) {
|
|
|
5521
5523
|
});
|
|
5522
5524
|
}
|
|
5523
5525
|
|
|
5526
|
+
const MIN_HOOK_CLAUDE_VERSION = "2.0.0";
|
|
5527
|
+
const EVENT_FIRST_VERSIONS = [
|
|
5528
|
+
["SessionStart", "2.0.0"],
|
|
5529
|
+
["UserPromptSubmit", "2.0.0"],
|
|
5530
|
+
["Stop", "2.0.0"],
|
|
5531
|
+
["Notification", "2.0.0"],
|
|
5532
|
+
["PostToolUse", "2.0.0"],
|
|
5533
|
+
["SessionEnd", "2.0.0"],
|
|
5534
|
+
["PermissionRequest", "2.0.45"],
|
|
5535
|
+
["PostCompact", "2.1.76"],
|
|
5536
|
+
["StopFailure", "2.1.78"]
|
|
5537
|
+
];
|
|
5538
|
+
const KNOWN_EVENTS = new Set(EVENT_FIRST_VERSIONS.map(([name]) => name));
|
|
5539
|
+
const TOOL_MATCHER_EVENTS = /* @__PURE__ */ new Set(["PostToolUse", "PermissionRequest"]);
|
|
5540
|
+
function parseVersion(v) {
|
|
5541
|
+
const m = typeof v === "string" ? v.trim().match(/^(\d+)\.(\d+)\.(\d+)$/) : null;
|
|
5542
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
5543
|
+
}
|
|
5544
|
+
function versionAtLeast(version, floor) {
|
|
5545
|
+
const a = parseVersion(version);
|
|
5546
|
+
const b = parseVersion(floor);
|
|
5547
|
+
if (!a || !b) return false;
|
|
5548
|
+
for (let i = 0; i < 3; i++) {
|
|
5549
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
5550
|
+
}
|
|
5551
|
+
return true;
|
|
5552
|
+
}
|
|
5553
|
+
function terminalHookEventPlan(claudeVersion) {
|
|
5554
|
+
if (!versionAtLeast(claudeVersion, MIN_HOOK_CLAUDE_VERSION)) return null;
|
|
5555
|
+
return EVENT_FIRST_VERSIONS.filter(([, first]) => versionAtLeast(claudeVersion, first)).map(([name]) => name);
|
|
5556
|
+
}
|
|
5557
|
+
const TERMINAL_HOOK_TIMEOUT_SECONDS = 10;
|
|
5558
|
+
const SAFE_PATH_RE = /^[A-Za-z0-9_./@:+ -]+$/;
|
|
5559
|
+
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5560
|
+
function terminalHookCommand(opts) {
|
|
5561
|
+
const { forwarderPath, terminalId, happyHome } = opts;
|
|
5562
|
+
if (!SAFE_PATH_RE.test(forwarderPath) || !SAFE_PATH_RE.test(happyHome) || !SAFE_ID_RE.test(terminalId)) return null;
|
|
5563
|
+
return `[ -f '${forwarderPath}' ] && node '${forwarderPath}' '${terminalId}' '${happyHome}' "$PPID" || true`;
|
|
5564
|
+
}
|
|
5565
|
+
function buildTerminalHookSettings(events, command) {
|
|
5566
|
+
const hooks = {};
|
|
5567
|
+
for (const event of events) {
|
|
5568
|
+
const entry = { type: "command", command, timeout: TERMINAL_HOOK_TIMEOUT_SECONDS };
|
|
5569
|
+
hooks[event] = [TOOL_MATCHER_EVENTS.has(event) ? { matcher: "*", hooks: [entry] } : { hooks: [entry] }];
|
|
5570
|
+
}
|
|
5571
|
+
return { hooks };
|
|
5572
|
+
}
|
|
5573
|
+
function injectClaudeSettingsFlag(command, settingsPath) {
|
|
5574
|
+
if (!SAFE_PATH_RE.test(settingsPath) || !isInjectableClaudeCommand(command)) return command;
|
|
5575
|
+
const m = command.match(/^(\s*)claude/);
|
|
5576
|
+
return `${m[1]}claude --settings '${settingsPath}'${command.slice(m[0].length)}`;
|
|
5577
|
+
}
|
|
5578
|
+
function isInjectableClaudeCommand(command) {
|
|
5579
|
+
return /^\s*claude(?=\s|$)/.test(command) && !/(^|\s)--settings(=|\s|$)/.test(command);
|
|
5580
|
+
}
|
|
5581
|
+
const str = (v, max = 256) => typeof v === "string" && v.length > 0 && v.length <= max ? v : void 0;
|
|
5582
|
+
function parseTerminalAgentHook(body) {
|
|
5583
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
5584
|
+
const raw = body;
|
|
5585
|
+
const event = raw.hook_event_name;
|
|
5586
|
+
if (typeof event !== "string" || !KNOWN_EVENTS.has(event)) return null;
|
|
5587
|
+
const terminalId = str(raw.terminalId, 64);
|
|
5588
|
+
if (!terminalId || !SAFE_ID_RE.test(terminalId)) return null;
|
|
5589
|
+
const claudeSessionId = str(raw.session_id, 128);
|
|
5590
|
+
if (!claudeSessionId) return null;
|
|
5591
|
+
const pid = typeof raw.claudePid === "number" ? raw.claudePid : typeof raw.claudePid === "string" && /^\d{1,10}$/.test(raw.claudePid) ? Number(raw.claudePid) : void 0;
|
|
5592
|
+
const turnId = str(raw.prompt_id, 128) ?? str(raw.promptHash, 64);
|
|
5593
|
+
return {
|
|
5594
|
+
event,
|
|
5595
|
+
terminalId,
|
|
5596
|
+
claudeSessionId,
|
|
5597
|
+
...pid !== void 0 && Number.isInteger(pid) && pid > 1 ? { claudePid: pid } : {},
|
|
5598
|
+
...turnId ? { turnId } : {},
|
|
5599
|
+
...str(raw.agent_id, 128) ? { agentId: str(raw.agent_id, 128) } : {},
|
|
5600
|
+
...str(raw.notification_type, 64) ? { notificationType: str(raw.notification_type, 64) } : {},
|
|
5601
|
+
...raw.permissionHint === true ? { permissionHint: true } : {},
|
|
5602
|
+
...str(raw.trigger, 32) ? { trigger: str(raw.trigger, 32) } : {},
|
|
5603
|
+
...str(raw.source, 32) ? { source: str(raw.source, 32) } : {},
|
|
5604
|
+
...str(raw.transcript_path, 4096) ? { transcriptPath: str(raw.transcript_path, 4096) } : {}
|
|
5605
|
+
};
|
|
5606
|
+
}
|
|
5607
|
+
const NEEDS_INPUT_NOTIFICATIONS = /* @__PURE__ */ new Set(["permission_prompt", "elicitation_dialog"]);
|
|
5608
|
+
const IDLE_NOTIFICATIONS = /* @__PURE__ */ new Set(["idle_prompt"]);
|
|
5609
|
+
function phaseTo(prev, phase, now, extra = {}) {
|
|
5610
|
+
const { interrupted: _drop, ...rest } = prev;
|
|
5611
|
+
return { ...rest, phase, seq: prev.seq + 1, phaseStartedAt: now, updatedAt: now, ...extra };
|
|
5612
|
+
}
|
|
5613
|
+
function reduceHookRecord(prev, ev, now) {
|
|
5614
|
+
if (ev.event === "SessionStart") {
|
|
5615
|
+
if (prev && prev.claudeSessionId === ev.claudeSessionId && ev.source !== "clear" && ev.source !== "compact") {
|
|
5616
|
+
return { record: { ...prev, updatedAt: now, ...ev.claudePid ? { claudePid: ev.claudePid } : {} }, changed: false };
|
|
5617
|
+
}
|
|
5618
|
+
return {
|
|
5619
|
+
record: {
|
|
5620
|
+
claudeSessionId: ev.claudeSessionId,
|
|
5621
|
+
...ev.claudePid ? { claudePid: ev.claudePid } : prev?.claudePid ? { claudePid: prev.claudePid } : {},
|
|
5622
|
+
phase: "done",
|
|
5623
|
+
seq: (prev?.seq ?? 0) + 1,
|
|
5624
|
+
phaseStartedAt: now,
|
|
5625
|
+
updatedAt: now,
|
|
5626
|
+
confirmedTurn: false,
|
|
5627
|
+
...ev.transcriptPath ? { transcriptPath: ev.transcriptPath } : {}
|
|
5628
|
+
},
|
|
5629
|
+
changed: prev?.phase !== "done"
|
|
5630
|
+
};
|
|
5631
|
+
}
|
|
5632
|
+
if (prev && prev.claudeSessionId !== ev.claudeSessionId) return { record: prev, changed: false };
|
|
5633
|
+
const base = prev ?? {
|
|
5634
|
+
claudeSessionId: ev.claudeSessionId,
|
|
5635
|
+
...ev.claudePid ? { claudePid: ev.claudePid } : {},
|
|
5636
|
+
phase: "done",
|
|
5637
|
+
seq: 0,
|
|
5638
|
+
phaseStartedAt: now,
|
|
5639
|
+
updatedAt: now,
|
|
5640
|
+
confirmedTurn: false
|
|
5641
|
+
};
|
|
5642
|
+
const withPid = ev.claudePid && !base.claudePid ? { ...base, claudePid: ev.claudePid } : base;
|
|
5643
|
+
const fresh = !prev;
|
|
5644
|
+
const renew = () => ({ record: { ...withPid, updatedAt: now }, changed: fresh });
|
|
5645
|
+
switch (ev.event) {
|
|
5646
|
+
case "SessionEnd":
|
|
5647
|
+
return { record: void 0, changed: !!prev };
|
|
5648
|
+
case "UserPromptSubmit":
|
|
5649
|
+
return { record: phaseTo(withPid, "working", now, { confirmedTurn: true, turnId: ev.turnId }), changed: true };
|
|
5650
|
+
case "PostToolUse":
|
|
5651
|
+
if (withPid.phase === "working") return renew();
|
|
5652
|
+
return { record: phaseTo(withPid, "working", now), changed: true };
|
|
5653
|
+
case "PermissionRequest":
|
|
5654
|
+
return { record: phaseTo(withPid, "needs_input", now), changed: withPid.phase !== "needs_input" || fresh };
|
|
5655
|
+
case "Notification": {
|
|
5656
|
+
if (ev.notificationType && IDLE_NOTIFICATIONS.has(ev.notificationType)) {
|
|
5657
|
+
if (withPid.phase === "done") return renew();
|
|
5658
|
+
return { record: phaseTo(withPid, "done", now), changed: true };
|
|
5659
|
+
}
|
|
5660
|
+
const needsInput = ev.notificationType ? NEEDS_INPUT_NOTIFICATIONS.has(ev.notificationType) : ev.permissionHint === true;
|
|
5661
|
+
if (!needsInput) return renew();
|
|
5662
|
+
return { record: phaseTo(withPid, "needs_input", now), changed: withPid.phase !== "needs_input" || fresh };
|
|
5663
|
+
}
|
|
5664
|
+
case "Stop":
|
|
5665
|
+
case "StopFailure":
|
|
5666
|
+
if (ev.agentId) return renew();
|
|
5667
|
+
return { record: phaseTo(withPid, "done", now), changed: withPid.phase !== "done" || fresh };
|
|
5668
|
+
case "PostCompact":
|
|
5669
|
+
if (ev.trigger !== "manual") return renew();
|
|
5670
|
+
return { record: phaseTo(withPid, "done", now), changed: withPid.phase !== "done" || fresh };
|
|
5671
|
+
default:
|
|
5672
|
+
return renew();
|
|
5673
|
+
}
|
|
5674
|
+
}
|
|
5675
|
+
const HOOK_WORKING_STALE_MS = 10 * 6e4;
|
|
5676
|
+
function fuseAgentState(screen, record, now) {
|
|
5677
|
+
const screenOnly = { ...screen ?? {}, source: "screen" };
|
|
5678
|
+
if (!record) return screenOnly;
|
|
5679
|
+
const s = screen?.agentState;
|
|
5680
|
+
if (s === "shell") return screenOnly;
|
|
5681
|
+
if (screen?.agentKind && screen.agentKind !== "claude") return screenOnly;
|
|
5682
|
+
const hook = (agentState) => ({ agentKind: "claude", agentState, source: "hook" });
|
|
5683
|
+
if (s === "needs_input" || s === "working") return { agentKind: "claude", agentState: s, source: "screen" };
|
|
5684
|
+
switch (record.phase) {
|
|
5685
|
+
case "working":
|
|
5686
|
+
case "needs_input":
|
|
5687
|
+
if (now - record.updatedAt > HOOK_WORKING_STALE_MS) return screenOnly;
|
|
5688
|
+
return hook(record.phase);
|
|
5689
|
+
case "done":
|
|
5690
|
+
return hook("idle");
|
|
5691
|
+
}
|
|
5692
|
+
}
|
|
5693
|
+
const INTERRUPT_CHECK_DELAYS_MS = [600, 2e3, 5e3];
|
|
5694
|
+
const INTERRUPT_CONFIRM_WINDOW_MS = INTERRUPT_CHECK_DELAYS_MS[INTERRUPT_CHECK_DELAYS_MS.length - 1] + 1500;
|
|
5695
|
+
const KITTY_ESCAPE_PRESS_RE = /^\x1b\[27(?:;1(?::1)?)?u$/;
|
|
5696
|
+
function isInterruptInput(text) {
|
|
5697
|
+
return text === "\x1B" || text === "" || KITTY_ESCAPE_PRESS_RE.test(text);
|
|
5698
|
+
}
|
|
5699
|
+
function isDialogAnswerInput(text) {
|
|
5700
|
+
return text === "\r" || text === "\n" || text === "\r\n";
|
|
5701
|
+
}
|
|
5702
|
+
function armsInterruptCheck(text, phase) {
|
|
5703
|
+
if (phase === "working") return isInterruptInput(text);
|
|
5704
|
+
if (phase === "needs_input") return isInterruptInput(text) || isDialogAnswerInput(text);
|
|
5705
|
+
return false;
|
|
5706
|
+
}
|
|
5707
|
+
function pendingInterruptFor(record, now) {
|
|
5708
|
+
if (!record || !record.confirmedTurn) return null;
|
|
5709
|
+
if (record.phase !== "working" && record.phase !== "needs_input") return null;
|
|
5710
|
+
return {
|
|
5711
|
+
claudeSessionId: record.claudeSessionId,
|
|
5712
|
+
seq: record.seq,
|
|
5713
|
+
phaseStartedAt: record.phaseStartedAt,
|
|
5714
|
+
...record.turnId ? { turnId: record.turnId } : {},
|
|
5715
|
+
at: now
|
|
5716
|
+
};
|
|
5717
|
+
}
|
|
5718
|
+
function decideInterrupt(record, pending, screen, now) {
|
|
5719
|
+
if (!record || !record.confirmedTurn) return "drop";
|
|
5720
|
+
if (record.phase !== "working" && record.phase !== "needs_input") return "drop";
|
|
5721
|
+
if (record.claudeSessionId !== pending.claudeSessionId || record.seq !== pending.seq || record.phaseStartedAt !== pending.phaseStartedAt || record.turnId !== pending.turnId) return "drop";
|
|
5722
|
+
if (now - record.updatedAt > HOOK_WORKING_STALE_MS) return "drop";
|
|
5723
|
+
if (now - pending.at > INTERRUPT_CONFIRM_WINDOW_MS) return "drop";
|
|
5724
|
+
if (screen?.agentKind === "claude" && screen.agentState === "idle") return "synthesize";
|
|
5725
|
+
return "wait";
|
|
5726
|
+
}
|
|
5727
|
+
function synthesizeInterruptDone(record, now) {
|
|
5728
|
+
return { ...phaseTo(record, "done", now), interrupted: true };
|
|
5729
|
+
}
|
|
5730
|
+
|
|
5731
|
+
function isPidAlive(pid) {
|
|
5732
|
+
try {
|
|
5733
|
+
process.kill(pid, 0);
|
|
5734
|
+
return true;
|
|
5735
|
+
} catch (error) {
|
|
5736
|
+
return error?.code === "EPERM";
|
|
5737
|
+
}
|
|
5738
|
+
}
|
|
5739
|
+
class TerminalAgentHookStore {
|
|
5740
|
+
constructor(pidAlive = isPidAlive) {
|
|
5741
|
+
this.pidAlive = pidAlive;
|
|
5742
|
+
}
|
|
5743
|
+
records = /* @__PURE__ */ new Map();
|
|
5744
|
+
pending = /* @__PURE__ */ new Map();
|
|
5745
|
+
/** Apply one forwarded hook body. Returns the terminal whose list-relevant
|
|
5746
|
+
* state changed (caller kicks a list refresh), or null. Never throws. */
|
|
5747
|
+
applyHook(body, now) {
|
|
5748
|
+
const ev = parseTerminalAgentHook(body);
|
|
5749
|
+
if (!ev) return null;
|
|
5750
|
+
const prev = this.valid(ev.terminalId);
|
|
5751
|
+
const { record, changed } = reduceHookRecord(prev, ev, now);
|
|
5752
|
+
if (record) this.records.set(ev.terminalId, record);
|
|
5753
|
+
else this.records.delete(ev.terminalId);
|
|
5754
|
+
if (record?.seq !== prev?.seq) this.pending.delete(ev.terminalId);
|
|
5755
|
+
return { terminalId: ev.terminalId, changed };
|
|
5756
|
+
}
|
|
5757
|
+
/** The record if its claude process is still alive; dead ones are dropped. */
|
|
5758
|
+
valid(terminalId) {
|
|
5759
|
+
const record = this.records.get(terminalId);
|
|
5760
|
+
if (!record) return void 0;
|
|
5761
|
+
if (record.claudePid !== void 0 && !this.pidAlive(record.claudePid)) {
|
|
5762
|
+
this.records.delete(terminalId);
|
|
5763
|
+
this.pending.delete(terminalId);
|
|
5764
|
+
return void 0;
|
|
5765
|
+
}
|
|
5766
|
+
return record;
|
|
5767
|
+
}
|
|
5768
|
+
/** Screen observation (undefined = probe failed) → fused state. */
|
|
5769
|
+
fuse(terminalId, screen, now) {
|
|
5770
|
+
return fuseAgentState(screen, this.valid(terminalId), now);
|
|
5771
|
+
}
|
|
5772
|
+
/** A web keystroke reached the pane. True when it armed an inference
|
|
5773
|
+
* check (Esc/Ctrl+C during a turn; also Enter on a dialog). */
|
|
5774
|
+
noteInput(terminalId, text, now) {
|
|
5775
|
+
const record = this.records.get(terminalId);
|
|
5776
|
+
if (!record || !armsInterruptCheck(text, record.phase)) return false;
|
|
5777
|
+
const pending = pendingInterruptFor(this.valid(terminalId), now);
|
|
5778
|
+
if (!pending) return false;
|
|
5779
|
+
this.pending.set(terminalId, pending);
|
|
5780
|
+
return true;
|
|
5781
|
+
}
|
|
5782
|
+
/** Re-check an armed interrupt against a fresh screen observation. */
|
|
5783
|
+
checkInterrupt(terminalId, screen, now) {
|
|
5784
|
+
const pending = this.pending.get(terminalId);
|
|
5785
|
+
if (!pending) return "none";
|
|
5786
|
+
const record = this.valid(terminalId);
|
|
5787
|
+
const verdict = decideInterrupt(record, pending, screen, now);
|
|
5788
|
+
if (verdict === "wait") return "wait";
|
|
5789
|
+
this.pending.delete(terminalId);
|
|
5790
|
+
if (verdict === "drop" || !record) return "drop";
|
|
5791
|
+
this.records.set(terminalId, synthesizeInterruptDone(record, now));
|
|
5792
|
+
return "synthesized";
|
|
5793
|
+
}
|
|
5794
|
+
has(terminalId) {
|
|
5795
|
+
return this.records.has(terminalId);
|
|
5796
|
+
}
|
|
5797
|
+
forget(terminalId) {
|
|
5798
|
+
this.records.delete(terminalId);
|
|
5799
|
+
this.pending.delete(terminalId);
|
|
5800
|
+
}
|
|
5801
|
+
/** Drop records of terminals that no longer exist. */
|
|
5802
|
+
prune(liveIds) {
|
|
5803
|
+
const keep = new Set(liveIds);
|
|
5804
|
+
for (const id of this.records.keys()) if (!keep.has(id)) this.forget(id);
|
|
5805
|
+
for (const id of this.pending.keys()) if (!keep.has(id)) this.pending.delete(id);
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
5808
|
+
|
|
5809
|
+
const TERMINAL_STATUS_FORWARDER_BASENAME = "terminal_status_forwarder.cjs";
|
|
5810
|
+
const VERSION_TTL_MS = 6 * 60 * 6e4;
|
|
5811
|
+
const VERSION_RETRY_MS = 5 * 6e4;
|
|
5812
|
+
function hooksDir() {
|
|
5813
|
+
return join(configuration.happyHomeDir, "tmp", "terminal-hooks");
|
|
5814
|
+
}
|
|
5815
|
+
function terminalHookSettingsPath(terminalId) {
|
|
5816
|
+
return join(hooksDir(), `${terminalId}.json`);
|
|
5817
|
+
}
|
|
5818
|
+
function probeClaudeVersion(env) {
|
|
5819
|
+
return new Promise((resolveVersion) => {
|
|
5820
|
+
try {
|
|
5821
|
+
execFile("claude", ["--version"], { timeout: 5e3, maxBuffer: 32 * 1024, env }, (error, stdout) => {
|
|
5822
|
+
resolveVersion(error ? null : stableVersion(String(stdout)));
|
|
5823
|
+
});
|
|
5824
|
+
} catch {
|
|
5825
|
+
resolveVersion(null);
|
|
5826
|
+
}
|
|
5827
|
+
});
|
|
5828
|
+
}
|
|
5829
|
+
class ClaudeVersionCache {
|
|
5830
|
+
constructor(env, probe = probeClaudeVersion) {
|
|
5831
|
+
this.env = env;
|
|
5832
|
+
this.probe = probe;
|
|
5833
|
+
}
|
|
5834
|
+
version = null;
|
|
5835
|
+
checkedAt = 0;
|
|
5836
|
+
inFlight = null;
|
|
5837
|
+
started = false;
|
|
5838
|
+
start(now = Date.now()) {
|
|
5839
|
+
this.started = true;
|
|
5840
|
+
if (this.version === null || now - this.checkedAt > VERSION_TTL_MS) this.refresh(now);
|
|
5841
|
+
}
|
|
5842
|
+
current(now = Date.now()) {
|
|
5843
|
+
if (!this.started) return null;
|
|
5844
|
+
const ttl = this.version ? VERSION_TTL_MS : VERSION_RETRY_MS;
|
|
5845
|
+
if (now - this.checkedAt > ttl) this.refresh(now);
|
|
5846
|
+
return this.version;
|
|
5847
|
+
}
|
|
5848
|
+
/** current(), but waits (bounded) for a probe already in flight —
|
|
5849
|
+
* for paths that are async anyway (auto-restore). */
|
|
5850
|
+
async settled(timeoutMs) {
|
|
5851
|
+
const pending = this.inFlight;
|
|
5852
|
+
if (pending) {
|
|
5853
|
+
let timer;
|
|
5854
|
+
await Promise.race([pending, new Promise((r) => {
|
|
5855
|
+
timer = setTimeout(r, timeoutMs);
|
|
5856
|
+
})]);
|
|
5857
|
+
if (timer) clearTimeout(timer);
|
|
5858
|
+
}
|
|
5859
|
+
return this.current();
|
|
5860
|
+
}
|
|
5861
|
+
refresh(now) {
|
|
5862
|
+
if (this.inFlight) return;
|
|
5863
|
+
this.checkedAt = now;
|
|
5864
|
+
this.inFlight = this.probe(this.env()).then((parsed) => {
|
|
5865
|
+
this.checkedAt = Date.now();
|
|
5866
|
+
if (parsed) this.version = parsed;
|
|
5867
|
+
logger.debug(`[TERMINAL HOOKS] claude --version \u2192 ${parsed ?? "unresolved"}`);
|
|
5868
|
+
}, () => void 0).finally(() => {
|
|
5869
|
+
this.inFlight = null;
|
|
5870
|
+
});
|
|
5871
|
+
}
|
|
5872
|
+
}
|
|
5873
|
+
function withTerminalHooks(command, terminalId, claudeVersion) {
|
|
5874
|
+
if (!isInjectableClaudeCommand(command)) return command;
|
|
5875
|
+
const events = terminalHookEventPlan(claudeVersion);
|
|
5876
|
+
if (!events) return command;
|
|
5877
|
+
const hookCommand = terminalHookCommand({
|
|
5878
|
+
forwarderPath: resolve(projectPath(), "scripts", TERMINAL_STATUS_FORWARDER_BASENAME),
|
|
5879
|
+
terminalId,
|
|
5880
|
+
happyHome: configuration.happyHomeDir
|
|
5881
|
+
});
|
|
5882
|
+
if (!hookCommand) return command;
|
|
5883
|
+
const path = terminalHookSettingsPath(terminalId);
|
|
5884
|
+
try {
|
|
5885
|
+
mkdirSync(hooksDir(), { recursive: true });
|
|
5886
|
+
writeFileSync(path, JSON.stringify(buildTerminalHookSettings(events, hookCommand), null, 2));
|
|
5887
|
+
} catch (error) {
|
|
5888
|
+
logger.debug(`[TERMINAL HOOKS] settings write failed for ${terminalId}: ${error}`);
|
|
5889
|
+
return command;
|
|
5890
|
+
}
|
|
5891
|
+
const injected = injectClaudeSettingsFlag(command, path);
|
|
5892
|
+
if (injected !== command) logger.debug(`[TERMINAL HOOKS] hooks injected for ${terminalId} (claude ${claudeVersion}, ${events.length} events)`);
|
|
5893
|
+
return injected;
|
|
5894
|
+
}
|
|
5895
|
+
function removeTerminalHookSettings(terminalId) {
|
|
5896
|
+
try {
|
|
5897
|
+
const path = terminalHookSettingsPath(terminalId);
|
|
5898
|
+
if (existsSync(path)) unlinkSync(path);
|
|
5899
|
+
} catch {
|
|
5900
|
+
}
|
|
5901
|
+
}
|
|
5902
|
+
function sweepStaleTerminalHookSettings(isLive) {
|
|
5903
|
+
const removed = [];
|
|
5904
|
+
let names;
|
|
5905
|
+
try {
|
|
5906
|
+
names = readdirSync(hooksDir());
|
|
5907
|
+
} catch {
|
|
5908
|
+
return removed;
|
|
5909
|
+
}
|
|
5910
|
+
for (const name of names) {
|
|
5911
|
+
const m = name.match(/^([A-Za-z0-9_-]{1,64})\.json$/);
|
|
5912
|
+
if (!m) continue;
|
|
5913
|
+
try {
|
|
5914
|
+
if (isLive(m[1])) continue;
|
|
5915
|
+
unlinkSync(join(hooksDir(), name));
|
|
5916
|
+
removed.push(m[1]);
|
|
5917
|
+
} catch {
|
|
5918
|
+
}
|
|
5919
|
+
}
|
|
5920
|
+
return removed;
|
|
5921
|
+
}
|
|
5922
|
+
|
|
5524
5923
|
const AUTO_RESTORE_DEFAULT_MAX = 6;
|
|
5525
5924
|
const AUTO_RESTORE_DEFAULT_WINDOW_HOURS = 24;
|
|
5526
5925
|
const AUTO_RESTORE_HARD_MAX = 20;
|
|
@@ -5671,6 +6070,626 @@ function directTerminalItems(sessions, tmuxIds) {
|
|
|
5671
6070
|
return out.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
5672
6071
|
}
|
|
5673
6072
|
|
|
6073
|
+
const SCROLLBACK_FILE_MAX_BYTES = 512 * 1024;
|
|
6074
|
+
const SCROLLBACK_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
|
|
6075
|
+
const SCROLLBACK_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
6076
|
+
const SCROLLBACK_CAPTURE_LINES = 5e3;
|
|
6077
|
+
const CHECKPOINT_QUIET_MS = 3e3;
|
|
6078
|
+
const CHECKPOINT_MIN_INTERVAL_MS = 3e4;
|
|
6079
|
+
const CHECKPOINT_MAX_DIRTY_MS = 12e4;
|
|
6080
|
+
const CHECKPOINTS_PER_TICK = 4;
|
|
6081
|
+
const SCROLLBACK_PERSIST_DEFAULT = true;
|
|
6082
|
+
function resolveScrollbackConfig(raw) {
|
|
6083
|
+
const v = raw?.terminalScrollbackPersist;
|
|
6084
|
+
return { enabled: typeof v === "boolean" ? v : SCROLLBACK_PERSIST_DEFAULT };
|
|
6085
|
+
}
|
|
6086
|
+
const ESC = 27;
|
|
6087
|
+
const SGR_RESET = Buffer.from("\x1B[0m", "ascii");
|
|
6088
|
+
const CRLF = Buffer.from("\r\n", "ascii");
|
|
6089
|
+
function sanitizeArchivedScrollback(input) {
|
|
6090
|
+
const out = Buffer.allocUnsafe(input.length);
|
|
6091
|
+
let o = 0;
|
|
6092
|
+
const n = input.length;
|
|
6093
|
+
let i = 0;
|
|
6094
|
+
while (i < n) {
|
|
6095
|
+
const b = input[i];
|
|
6096
|
+
if (b === ESC) {
|
|
6097
|
+
const next = i + 1 < n ? input[i + 1] : -1;
|
|
6098
|
+
if (next === 91) {
|
|
6099
|
+
let j = i + 2;
|
|
6100
|
+
let paramsOk = true;
|
|
6101
|
+
while (j < n && input[j] >= 48 && input[j] <= 63) {
|
|
6102
|
+
const c = input[j];
|
|
6103
|
+
if (!(c >= 48 && c <= 57 || c === 59 || c === 58)) paramsOk = false;
|
|
6104
|
+
j++;
|
|
6105
|
+
}
|
|
6106
|
+
let intermediates = false;
|
|
6107
|
+
while (j < n && input[j] >= 32 && input[j] <= 47) {
|
|
6108
|
+
intermediates = true;
|
|
6109
|
+
j++;
|
|
6110
|
+
}
|
|
6111
|
+
if (j < n && input[j] >= 64 && input[j] <= 126) {
|
|
6112
|
+
if (input[j] === 109 && paramsOk && !intermediates) {
|
|
6113
|
+
input.copy(out, o, i, j + 1);
|
|
6114
|
+
o += j + 1 - i;
|
|
6115
|
+
}
|
|
6116
|
+
i = j + 1;
|
|
6117
|
+
} else {
|
|
6118
|
+
i = skipToLineEnd(input, j);
|
|
6119
|
+
}
|
|
6120
|
+
continue;
|
|
6121
|
+
}
|
|
6122
|
+
if (next === 93 || next === 80 || next === 95 || next === 94 || next === 88) {
|
|
6123
|
+
let j = i + 2;
|
|
6124
|
+
while (j < n) {
|
|
6125
|
+
if (input[j] === 7 && next === 93) {
|
|
6126
|
+
j += 1;
|
|
6127
|
+
break;
|
|
6128
|
+
}
|
|
6129
|
+
if (input[j] === ESC && j + 1 < n && input[j + 1] === 92) {
|
|
6130
|
+
j += 2;
|
|
6131
|
+
break;
|
|
6132
|
+
}
|
|
6133
|
+
if (input[j] === 10) break;
|
|
6134
|
+
j++;
|
|
6135
|
+
}
|
|
6136
|
+
i = j;
|
|
6137
|
+
continue;
|
|
6138
|
+
}
|
|
6139
|
+
i += next === -1 ? 1 : 2;
|
|
6140
|
+
continue;
|
|
6141
|
+
}
|
|
6142
|
+
if (b < 32 && b !== 9 && b !== 10 && b !== 13) {
|
|
6143
|
+
i++;
|
|
6144
|
+
continue;
|
|
6145
|
+
}
|
|
6146
|
+
if (b === 127) {
|
|
6147
|
+
i++;
|
|
6148
|
+
continue;
|
|
6149
|
+
}
|
|
6150
|
+
if (b === 194 && i + 1 < n && input[i + 1] >= 128 && input[i + 1] <= 159) {
|
|
6151
|
+
i += 2;
|
|
6152
|
+
continue;
|
|
6153
|
+
}
|
|
6154
|
+
out[o++] = b;
|
|
6155
|
+
i++;
|
|
6156
|
+
}
|
|
6157
|
+
return out.subarray(0, o);
|
|
6158
|
+
}
|
|
6159
|
+
function skipToLineEnd(buf, from) {
|
|
6160
|
+
const nl = buf.indexOf(10, from);
|
|
6161
|
+
return nl === -1 ? buf.length : nl;
|
|
6162
|
+
}
|
|
6163
|
+
function splitCrlfLines(blob) {
|
|
6164
|
+
const out = [];
|
|
6165
|
+
let start = 0;
|
|
6166
|
+
for (let i = 0; i < blob.length; i++) {
|
|
6167
|
+
if (blob[i] !== 10) continue;
|
|
6168
|
+
let end = i;
|
|
6169
|
+
if (end > start && blob[end - 1] === 13) end -= 1;
|
|
6170
|
+
out.push(blob.subarray(start, end));
|
|
6171
|
+
start = i + 1;
|
|
6172
|
+
}
|
|
6173
|
+
if (start < blob.length) out.push(blob.subarray(start));
|
|
6174
|
+
return out;
|
|
6175
|
+
}
|
|
6176
|
+
function isBlankRow(row) {
|
|
6177
|
+
return /^(?:\x1b\[[0-9;:]*m| )*$/.test(row.toString("latin1"));
|
|
6178
|
+
}
|
|
6179
|
+
function joinCrlf(lines) {
|
|
6180
|
+
const parts = [];
|
|
6181
|
+
lines.forEach((l, i) => {
|
|
6182
|
+
if (i > 0) parts.push(CRLF);
|
|
6183
|
+
parts.push(l);
|
|
6184
|
+
});
|
|
6185
|
+
return Buffer.concat(parts);
|
|
6186
|
+
}
|
|
6187
|
+
function buildCheckpointBody(parts, budget = SCROLLBACK_FILE_MAX_BYTES) {
|
|
6188
|
+
const cleaned = [];
|
|
6189
|
+
for (const part of parts) {
|
|
6190
|
+
const lines = splitCrlfLines(normalizeCaptureLines(sanitizeArchivedScrollback(part)));
|
|
6191
|
+
while (lines.length > 0 && isBlankRow(lines[lines.length - 1])) lines.pop();
|
|
6192
|
+
if (lines.length > 0) cleaned.push(joinCrlf(lines));
|
|
6193
|
+
}
|
|
6194
|
+
const joined = [];
|
|
6195
|
+
cleaned.forEach((c, i) => {
|
|
6196
|
+
if (i > 0) joined.push(SGR_RESET, CRLF);
|
|
6197
|
+
joined.push(c);
|
|
6198
|
+
});
|
|
6199
|
+
return truncateToBudget(Buffer.concat(joined), budget);
|
|
6200
|
+
}
|
|
6201
|
+
const FILE_MAGIC = "VHSB1";
|
|
6202
|
+
function encodeArchiveFile(file) {
|
|
6203
|
+
return Buffer.concat([Buffer.from(`${FILE_MAGIC} ${Math.floor(file.savedAt)}
|
|
6204
|
+
`, "ascii"), file.body]);
|
|
6205
|
+
}
|
|
6206
|
+
function decodeArchiveFile(raw) {
|
|
6207
|
+
if (!raw || raw.length === 0) return null;
|
|
6208
|
+
const nl = raw.indexOf(10);
|
|
6209
|
+
if (nl <= 0 || nl > 64) return null;
|
|
6210
|
+
const m = /^VHSB1 (\d{1,16})$/.exec(raw.subarray(0, nl).toString("ascii"));
|
|
6211
|
+
if (!m) return null;
|
|
6212
|
+
const savedAt = Number(m[1]);
|
|
6213
|
+
if (!Number.isFinite(savedAt) || savedAt <= 0) return null;
|
|
6214
|
+
const body = truncateToBudget(sanitizeArchivedScrollback(raw.subarray(nl + 1)), SCROLLBACK_FILE_MAX_BYTES);
|
|
6215
|
+
if (body.length === 0) return null;
|
|
6216
|
+
return { savedAt, body };
|
|
6217
|
+
}
|
|
6218
|
+
const DIM = "\x1B[0;2m";
|
|
6219
|
+
const RESET = "\x1B[0m";
|
|
6220
|
+
const REBUILD_SEPARATOR = Buffer.from(`${DIM}\u2500\u2500 \u91CD\u5EFA \xB7 rebuilt \u2500\u2500${RESET}`, "utf8");
|
|
6221
|
+
function chainArchive(prev, live, budget = SCROLLBACK_FILE_MAX_BYTES) {
|
|
6222
|
+
if (!prev) return live ? { savedAt: live.savedAt, body: truncateToBudget(live.body, budget) } : null;
|
|
6223
|
+
if (!live) return { savedAt: prev.savedAt, body: truncateToBudget(prev.body, budget) };
|
|
6224
|
+
const body = Buffer.concat([prev.body, SGR_RESET, CRLF, REBUILD_SEPARATOR, CRLF, live.body]);
|
|
6225
|
+
return { savedAt: Math.max(prev.savedAt, live.savedAt), body: truncateToBudget(body, budget) };
|
|
6226
|
+
}
|
|
6227
|
+
function pad2(n) {
|
|
6228
|
+
return n < 10 ? `0${n}` : String(n);
|
|
6229
|
+
}
|
|
6230
|
+
function formatSavedAt(savedAt) {
|
|
6231
|
+
const d = new Date(savedAt);
|
|
6232
|
+
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
6233
|
+
}
|
|
6234
|
+
function archiveHeaderLine(savedAt) {
|
|
6235
|
+
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");
|
|
6236
|
+
}
|
|
6237
|
+
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");
|
|
6238
|
+
function withArchivedScrollback(payload, archive) {
|
|
6239
|
+
if (!archive || archive.body.length === 0) return payload;
|
|
6240
|
+
const full = Buffer.concat([
|
|
6241
|
+
SGR_RESET,
|
|
6242
|
+
archiveHeaderLine(archive.savedAt),
|
|
6243
|
+
CRLF,
|
|
6244
|
+
archive.body,
|
|
6245
|
+
SGR_RESET,
|
|
6246
|
+
CRLF,
|
|
6247
|
+
ARCHIVE_FOOTER_LINE,
|
|
6248
|
+
CRLF,
|
|
6249
|
+
payload.full
|
|
6250
|
+
]);
|
|
6251
|
+
return { ...payload, full };
|
|
6252
|
+
}
|
|
6253
|
+
const CHECKPOINT_PROBE_FORMAT = "#{session_name}|#{window_activity}|#{alternate_on}|#{@vh_attach}";
|
|
6254
|
+
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
6255
|
+
function parseCheckpointProbe(stdout) {
|
|
6256
|
+
const out = [];
|
|
6257
|
+
for (const line of stdout.split("\n")) {
|
|
6258
|
+
const parts = line.split("|");
|
|
6259
|
+
if (parts.length < 4) continue;
|
|
6260
|
+
const [name, activity, alt] = parts;
|
|
6261
|
+
const attach = parts.slice(3).join("|");
|
|
6262
|
+
if (!name.startsWith("vh-")) continue;
|
|
6263
|
+
const id = name.slice(3);
|
|
6264
|
+
if (!ID_RE.test(id)) continue;
|
|
6265
|
+
if (attach.trim().length > 0) continue;
|
|
6266
|
+
const secs = Number(activity);
|
|
6267
|
+
if (!Number.isFinite(secs) || secs <= 0) continue;
|
|
6268
|
+
out.push({ id, activityAt: secs * 1e3, alternateOn: alt === "1" });
|
|
6269
|
+
}
|
|
6270
|
+
return out;
|
|
6271
|
+
}
|
|
6272
|
+
const ACTIVITY_RESOLUTION_MS = 1e3;
|
|
6273
|
+
function planCheckpoints(terminals, state, now, opts = {}) {
|
|
6274
|
+
const quietMs = opts.quietMs ?? CHECKPOINT_QUIET_MS;
|
|
6275
|
+
const minIntervalMs = opts.minIntervalMs ?? CHECKPOINT_MIN_INTERVAL_MS;
|
|
6276
|
+
const maxDirtyMs = opts.maxDirtyMs ?? CHECKPOINT_MAX_DIRTY_MS;
|
|
6277
|
+
const max = opts.max ?? CHECKPOINTS_PER_TICK;
|
|
6278
|
+
const due = [];
|
|
6279
|
+
for (const t of terminals) {
|
|
6280
|
+
const prev = state.get(t.id);
|
|
6281
|
+
if (prev && t.activityAt + ACTIVITY_RESOLUTION_MS <= prev.at) continue;
|
|
6282
|
+
const lastAt = prev?.at ?? 0;
|
|
6283
|
+
const quiet = now - t.activityAt >= quietMs && now - lastAt >= minIntervalMs;
|
|
6284
|
+
const overdue = now - lastAt >= maxDirtyMs;
|
|
6285
|
+
if (quiet || overdue) due.push({ id: t.id, lastAt });
|
|
6286
|
+
}
|
|
6287
|
+
due.sort((a, b) => a.lastAt - b.lastAt || (a.id < b.id ? -1 : 1));
|
|
6288
|
+
return due.slice(0, Math.max(0, max)).map((d) => d.id);
|
|
6289
|
+
}
|
|
6290
|
+
function checkpointCaptureArgs(id, alternateOn, lines = SCROLLBACK_CAPTURE_LINES) {
|
|
6291
|
+
const t = `=vh-${id}:`;
|
|
6292
|
+
const n = Math.max(1, Math.floor(lines));
|
|
6293
|
+
if (!alternateOn) return [["capture-pane", "-peqJN", "-t", t, "-S", `-${n}`]];
|
|
6294
|
+
return [
|
|
6295
|
+
["capture-pane", "-peqJN", "-t", t, "-S", `-${n}`, "-E", "-1"],
|
|
6296
|
+
["capture-pane", "-peqJN", "-t", t, "-a"]
|
|
6297
|
+
];
|
|
6298
|
+
}
|
|
6299
|
+
const FILE_RE = /^([a-zA-Z0-9_-]{1,64})\.(live|prev)\.vhsb$/;
|
|
6300
|
+
function archiveFileName(id, kind) {
|
|
6301
|
+
if (!ID_RE.test(id)) throw new Error(`invalid terminal id: ${id}`);
|
|
6302
|
+
return `${id}.${kind}.vhsb`;
|
|
6303
|
+
}
|
|
6304
|
+
function parseArchiveFileName(name) {
|
|
6305
|
+
const m = FILE_RE.exec(name);
|
|
6306
|
+
return m ? { id: m[1], kind: m[2] } : null;
|
|
6307
|
+
}
|
|
6308
|
+
function planScrollbackGc(files, keepIds, now, ttlMs = SCROLLBACK_TTL_MS, totalMax = SCROLLBACK_TOTAL_MAX_BYTES) {
|
|
6309
|
+
const doomed = /* @__PURE__ */ new Set();
|
|
6310
|
+
for (const f of files) {
|
|
6311
|
+
if (!f.id || !f.kind) {
|
|
6312
|
+
doomed.add(f.name);
|
|
6313
|
+
continue;
|
|
6314
|
+
}
|
|
6315
|
+
if (now - f.mtimeMs > ttlMs) {
|
|
6316
|
+
doomed.add(f.name);
|
|
6317
|
+
continue;
|
|
6318
|
+
}
|
|
6319
|
+
if (keepIds && !keepIds.has(f.id)) doomed.add(f.name);
|
|
6320
|
+
}
|
|
6321
|
+
const survivors = files.filter((f) => !doomed.has(f.name)).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
6322
|
+
let total = survivors.reduce((s, f) => s + f.bytes, 0);
|
|
6323
|
+
for (const f of survivors) {
|
|
6324
|
+
if (total <= totalMax) break;
|
|
6325
|
+
doomed.add(f.name);
|
|
6326
|
+
total -= f.bytes;
|
|
6327
|
+
}
|
|
6328
|
+
return [...doomed];
|
|
6329
|
+
}
|
|
6330
|
+
|
|
6331
|
+
const CONFIG_REFRESH_MS = 6e4;
|
|
6332
|
+
const GC_INTERVAL_MS = 10 * 6e4;
|
|
6333
|
+
const SHUTDOWN_FLUSH_BUDGET_MS = 300;
|
|
6334
|
+
const KILL_CHECKPOINT_BUDGET_MS = 300;
|
|
6335
|
+
const TMUX_TIMEOUT_MS = 5e3;
|
|
6336
|
+
const TMUX_MAX_BUFFER = 32 * 1024 * 1024;
|
|
6337
|
+
function defaultTmux(env) {
|
|
6338
|
+
return {
|
|
6339
|
+
run: (args, timeoutMs) => new Promise((resolve) => {
|
|
6340
|
+
try {
|
|
6341
|
+
execFile(
|
|
6342
|
+
"tmux",
|
|
6343
|
+
tmuxArgs(args),
|
|
6344
|
+
{ env: env(), encoding: "buffer", timeout: Math.max(1, timeoutMs ?? TMUX_TIMEOUT_MS), maxBuffer: TMUX_MAX_BUFFER },
|
|
6345
|
+
(err, stdout) => resolve(err ? null : stdout)
|
|
6346
|
+
);
|
|
6347
|
+
} catch {
|
|
6348
|
+
resolve(null);
|
|
6349
|
+
}
|
|
6350
|
+
}),
|
|
6351
|
+
runSync: (args, timeoutMs) => {
|
|
6352
|
+
try {
|
|
6353
|
+
const r = spawnSync("tmux", tmuxArgs(args), { env: env(), timeout: Math.max(1, timeoutMs), maxBuffer: TMUX_MAX_BUFFER });
|
|
6354
|
+
return r.status === 0 && r.stdout ? r.stdout : null;
|
|
6355
|
+
} catch {
|
|
6356
|
+
return null;
|
|
6357
|
+
}
|
|
6358
|
+
}
|
|
6359
|
+
};
|
|
6360
|
+
}
|
|
6361
|
+
class ScrollbackCheckpointer {
|
|
6362
|
+
opts;
|
|
6363
|
+
tmux;
|
|
6364
|
+
now;
|
|
6365
|
+
readConfig;
|
|
6366
|
+
/** Cached switch; the default until settings were read once. */
|
|
6367
|
+
enabled = SCROLLBACK_PERSIST_DEFAULT;
|
|
6368
|
+
configReadAt = 0;
|
|
6369
|
+
lastGcAt = 0;
|
|
6370
|
+
busy = false;
|
|
6371
|
+
/** Set once a tick ran in this process — the shutdown flush is a no-op
|
|
6372
|
+
* before that (unit tests that only construct+stop never touch tmux). */
|
|
6373
|
+
started = false;
|
|
6374
|
+
stopped = false;
|
|
6375
|
+
state = /* @__PURE__ */ new Map();
|
|
6376
|
+
/** Bumped by promote(): an in-flight capture of an earlier generation must
|
|
6377
|
+
* not overwrite `.live` after the promotion moved it to `.prev`. */
|
|
6378
|
+
generation = /* @__PURE__ */ new Map();
|
|
6379
|
+
constructor(opts) {
|
|
6380
|
+
this.opts = opts;
|
|
6381
|
+
this.tmux = opts.tmux ?? defaultTmux(opts.env);
|
|
6382
|
+
this.now = opts.now ?? Date.now;
|
|
6383
|
+
this.readConfig = opts.readConfig ?? (() => {
|
|
6384
|
+
try {
|
|
6385
|
+
return resolveScrollbackConfig(JSON.parse(readFileSync(join(this.opts.homeDir(), "settings.json"), "utf8")));
|
|
6386
|
+
} catch {
|
|
6387
|
+
return resolveScrollbackConfig(null);
|
|
6388
|
+
}
|
|
6389
|
+
});
|
|
6390
|
+
}
|
|
6391
|
+
dir() {
|
|
6392
|
+
return join(this.opts.homeDir(), "terminal-scrollback");
|
|
6393
|
+
}
|
|
6394
|
+
filePath(id, kind) {
|
|
6395
|
+
return join(this.dir(), archiveFileName(id, kind));
|
|
6396
|
+
}
|
|
6397
|
+
isEnabled() {
|
|
6398
|
+
this.refreshConfig();
|
|
6399
|
+
return this.enabled;
|
|
6400
|
+
}
|
|
6401
|
+
// ── tick ────────────────────────────────────────────────────────────────
|
|
6402
|
+
/** Fire-and-forget from the list-track tick. Never throws. */
|
|
6403
|
+
tick(closedIds) {
|
|
6404
|
+
if (this.busy || this.stopped) return;
|
|
6405
|
+
this.busy = true;
|
|
6406
|
+
this.started = true;
|
|
6407
|
+
void this.runTick(closedIds).catch((e) => logger.debug(`[SCROLLBACK] tick failed: ${e}`)).finally(() => {
|
|
6408
|
+
this.busy = false;
|
|
6409
|
+
});
|
|
6410
|
+
}
|
|
6411
|
+
/** One tick, awaitable (tests). */
|
|
6412
|
+
async runTick(closedIds) {
|
|
6413
|
+
this.started = true;
|
|
6414
|
+
this.refreshConfig();
|
|
6415
|
+
if (!this.enabled) return;
|
|
6416
|
+
const out = await this.tmux.run(["list-sessions", "-F", CHECKPOINT_PROBE_FORMAT]);
|
|
6417
|
+
const probed = out ? parseCheckpointProbe(out.toString("utf8")) : null;
|
|
6418
|
+
if (probed) {
|
|
6419
|
+
this.seedState(probed);
|
|
6420
|
+
const now = this.now();
|
|
6421
|
+
for (const id of planCheckpoints(probed, this.state, now)) {
|
|
6422
|
+
if (this.stopped || !this.enabled) break;
|
|
6423
|
+
const t = probed.find((p) => p.id === id);
|
|
6424
|
+
await this.checkpointOne(t);
|
|
6425
|
+
}
|
|
6426
|
+
}
|
|
6427
|
+
this.maybeGc(probed ? /* @__PURE__ */ new Set([...probed.map((p) => p.id), ...closedIds]) : null);
|
|
6428
|
+
}
|
|
6429
|
+
/** Re-read the switch (at most every CONFIG_REFRESH_MS; always on first
|
|
6430
|
+
* use, so a replay right after daemon start already honours `false`). */
|
|
6431
|
+
refreshConfig() {
|
|
6432
|
+
const now = this.now();
|
|
6433
|
+
if (this.configReadAt && now - this.configReadAt < CONFIG_REFRESH_MS) return;
|
|
6434
|
+
this.configReadAt = now;
|
|
6435
|
+
let enabled = SCROLLBACK_PERSIST_DEFAULT;
|
|
6436
|
+
try {
|
|
6437
|
+
enabled = this.readConfig().enabled;
|
|
6438
|
+
} catch {
|
|
6439
|
+
}
|
|
6440
|
+
const turnedOff = this.enabled && !enabled;
|
|
6441
|
+
this.enabled = enabled;
|
|
6442
|
+
if (!enabled) {
|
|
6443
|
+
if (turnedOff) logger.info("[SCROLLBACK] terminalScrollbackPersist=false: deleting saved terminal scrollback");
|
|
6444
|
+
this.purge();
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
/** First sight of an id after a daemon start: an existing `.live` file
|
|
6448
|
+
* counts as the last checkpoint (by mtime), so a restart does not re-take
|
|
6449
|
+
* every terminal at once. */
|
|
6450
|
+
seedState(probed) {
|
|
6451
|
+
for (const t of probed) {
|
|
6452
|
+
if (this.state.has(t.id)) continue;
|
|
6453
|
+
try {
|
|
6454
|
+
const mtime = statSync(this.filePath(t.id, "live")).mtimeMs;
|
|
6455
|
+
this.state.set(t.id, { at: mtime - ACTIVITY_RESOLUTION_MS });
|
|
6456
|
+
} catch {
|
|
6457
|
+
}
|
|
6458
|
+
}
|
|
6459
|
+
}
|
|
6460
|
+
async checkpointOne(t) {
|
|
6461
|
+
const gen = this.generation.get(t.id) ?? 0;
|
|
6462
|
+
const startedAt = this.now();
|
|
6463
|
+
const parts = [];
|
|
6464
|
+
for (const args of checkpointCaptureArgs(t.id, t.alternateOn)) {
|
|
6465
|
+
const out = await this.tmux.run(args);
|
|
6466
|
+
if (!out) return;
|
|
6467
|
+
parts.push(out);
|
|
6468
|
+
}
|
|
6469
|
+
if ((this.generation.get(t.id) ?? 0) !== gen || this.stopped || !this.enabled) return;
|
|
6470
|
+
this.writeLive(t.id, buildCheckpointBody(parts), this.now());
|
|
6471
|
+
this.state.set(t.id, { at: startedAt });
|
|
6472
|
+
}
|
|
6473
|
+
writeLive(id, body, now) {
|
|
6474
|
+
if (body.length === 0) {
|
|
6475
|
+
this.removeFile(id, "live");
|
|
6476
|
+
return;
|
|
6477
|
+
}
|
|
6478
|
+
this.writeFile(id, "live", { savedAt: now, body });
|
|
6479
|
+
}
|
|
6480
|
+
// ── promote / replay ────────────────────────────────────────────────────
|
|
6481
|
+
/**
|
|
6482
|
+
* Spec D3: called right before a terminal is rebuilt (new tmux session,
|
|
6483
|
+
* same id). `.live` (the life that ended) is chained onto `.prev` and
|
|
6484
|
+
* removed. Synchronous on purpose — the caller creates the session in the
|
|
6485
|
+
* same turn, so no async capture can slip in between.
|
|
6486
|
+
*
|
|
6487
|
+
* Returns an UNDO that puts both files back exactly as they were: the
|
|
6488
|
+
* caller runs it when the rebuild did not happen (new-session failed —
|
|
6489
|
+
* e.g. a timed-out has-session misread a live session as gone), so the
|
|
6490
|
+
* same life is not archived twice and replayed as a duplicate.
|
|
6491
|
+
* `.live` is only removed once the chained `.prev` was written.
|
|
6492
|
+
*/
|
|
6493
|
+
promote(id) {
|
|
6494
|
+
this.generation.set(id, (this.generation.get(id) ?? 0) + 1);
|
|
6495
|
+
this.state.delete(id);
|
|
6496
|
+
const noop = () => {
|
|
6497
|
+
};
|
|
6498
|
+
if (!this.isEnabled()) return noop;
|
|
6499
|
+
try {
|
|
6500
|
+
const liveRaw = this.readFile(id, "live");
|
|
6501
|
+
const prevRaw = this.readFile(id, "prev");
|
|
6502
|
+
const live = decodeArchiveFile(liveRaw);
|
|
6503
|
+
const chained = chainArchive(decodeArchiveFile(prevRaw), live);
|
|
6504
|
+
if (!chained || !live || !liveRaw) return noop;
|
|
6505
|
+
if (!this.writeFile(id, "prev", chained)) return noop;
|
|
6506
|
+
this.removeFile(id, "live");
|
|
6507
|
+
return () => {
|
|
6508
|
+
this.generation.set(id, (this.generation.get(id) ?? 0) + 1);
|
|
6509
|
+
if (prevRaw) this.writeRaw(id, "prev", prevRaw);
|
|
6510
|
+
else this.removeFile(id, "prev");
|
|
6511
|
+
this.writeRaw(id, "live", liveRaw);
|
|
6512
|
+
};
|
|
6513
|
+
} catch (e) {
|
|
6514
|
+
logger.debug(`[SCROLLBACK] promote ${id} failed: ${e}`);
|
|
6515
|
+
return noop;
|
|
6516
|
+
}
|
|
6517
|
+
}
|
|
6518
|
+
/** The archive to replay above a capture of `id`, or null. */
|
|
6519
|
+
replayFor(id) {
|
|
6520
|
+
if (!this.isEnabled()) return null;
|
|
6521
|
+
try {
|
|
6522
|
+
return decodeArchiveFile(this.readFile(id, "prev"));
|
|
6523
|
+
} catch {
|
|
6524
|
+
return null;
|
|
6525
|
+
}
|
|
6526
|
+
}
|
|
6527
|
+
// ── shutdown ────────────────────────────────────────────────────────────
|
|
6528
|
+
/**
|
|
6529
|
+
* Daemon exit: capture every terminal with output newer than its last
|
|
6530
|
+
* checkpoint — ASYNC and in PARALLEL, newest activity first, within
|
|
6531
|
+
* `budgetMs`. Never throws and always resolves by the deadline, so the
|
|
6532
|
+
* caller's state/lock cleanup runs regardless. Best-effort: on a machine
|
|
6533
|
+
* shutdown tmux may already be gone; the periodic checkpoint is the real
|
|
6534
|
+
* guarantee.
|
|
6535
|
+
*/
|
|
6536
|
+
async flushOnShutdown(budgetMs = SHUTDOWN_FLUSH_BUDGET_MS) {
|
|
6537
|
+
if (this.stopped) return;
|
|
6538
|
+
this.stopped = true;
|
|
6539
|
+
if (!this.started || !this.enabled) return;
|
|
6540
|
+
let timer;
|
|
6541
|
+
const deadline = new Promise((r) => {
|
|
6542
|
+
timer = setTimeout(r, Math.max(0, budgetMs));
|
|
6543
|
+
timer.unref?.();
|
|
6544
|
+
});
|
|
6545
|
+
const startedAt = Date.now();
|
|
6546
|
+
const left = () => Math.max(1, budgetMs - (Date.now() - startedAt));
|
|
6547
|
+
const work = (async () => {
|
|
6548
|
+
const out = await this.tmux.run(["list-sessions", "-F", CHECKPOINT_PROBE_FORMAT], left());
|
|
6549
|
+
if (!out) return;
|
|
6550
|
+
const probed = parseCheckpointProbe(out.toString("utf8"));
|
|
6551
|
+
this.seedState(probed);
|
|
6552
|
+
const dirty = planCheckpoints(probed, this.state, this.now(), {
|
|
6553
|
+
quietMs: 0,
|
|
6554
|
+
minIntervalMs: 0,
|
|
6555
|
+
maxDirtyMs: 0,
|
|
6556
|
+
max: Number.MAX_SAFE_INTEGER
|
|
6557
|
+
});
|
|
6558
|
+
const byRecent = dirty.map((id) => probed.find((p) => p.id === id)).sort((a, b) => b.activityAt - a.activityAt);
|
|
6559
|
+
let written = 0;
|
|
6560
|
+
await Promise.all(byRecent.map(async (t) => {
|
|
6561
|
+
const at = this.now();
|
|
6562
|
+
const parts = await Promise.all(checkpointCaptureArgs(t.id, t.alternateOn).map((a) => this.tmux.run(a, left())));
|
|
6563
|
+
if (parts.some((p) => !p) || Date.now() - startedAt > budgetMs) return;
|
|
6564
|
+
this.writeLive(t.id, buildCheckpointBody(parts), this.now());
|
|
6565
|
+
this.state.set(t.id, { at });
|
|
6566
|
+
written++;
|
|
6567
|
+
}));
|
|
6568
|
+
if (dirty.length > 0) logger.debug(`[SCROLLBACK] shutdown flush: ${written}/${dirty.length} terminal(s)`);
|
|
6569
|
+
})().catch((e) => logger.debug(`[SCROLLBACK] shutdown flush failed: ${e}`));
|
|
6570
|
+
await Promise.race([work, deadline]);
|
|
6571
|
+
if (timer) clearTimeout(timer);
|
|
6572
|
+
}
|
|
6573
|
+
/**
|
|
6574
|
+
* Synchronous single-terminal capture right before the user kills it, so
|
|
6575
|
+
* a ↻ restore of the close record replays its last output. Bounded by
|
|
6576
|
+
* `budgetMs`; never throws; attach terminals are skipped.
|
|
6577
|
+
*/
|
|
6578
|
+
checkpointSync(id, budgetMs = KILL_CHECKPOINT_BUDGET_MS) {
|
|
6579
|
+
try {
|
|
6580
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(id) || !this.isEnabled()) return false;
|
|
6581
|
+
const deadline = Date.now() + budgetMs;
|
|
6582
|
+
const left = () => deadline - Date.now();
|
|
6583
|
+
const probe = this.tmux.runSync(["display-message", "-p", "-t", `=vh-${id}:`, "#{alternate_on}|#{@vh_attach}"], left());
|
|
6584
|
+
if (!probe) return false;
|
|
6585
|
+
const [alt, ...attach] = probe.toString("utf8").replace(/\r?\n$/, "").split("|");
|
|
6586
|
+
if (attach.join("|").trim().length > 0) return false;
|
|
6587
|
+
const at = this.now();
|
|
6588
|
+
const parts = [];
|
|
6589
|
+
for (const args of checkpointCaptureArgs(id, alt === "1")) {
|
|
6590
|
+
const timeout = left();
|
|
6591
|
+
const part = timeout > 0 ? this.tmux.runSync(args, timeout) : null;
|
|
6592
|
+
if (!part) return false;
|
|
6593
|
+
parts.push(part);
|
|
6594
|
+
}
|
|
6595
|
+
this.writeLive(id, buildCheckpointBody(parts), this.now());
|
|
6596
|
+
this.state.set(id, { at });
|
|
6597
|
+
return true;
|
|
6598
|
+
} catch (e) {
|
|
6599
|
+
logger.debug(`[SCROLLBACK] pre-kill checkpoint ${id} failed: ${e}`);
|
|
6600
|
+
return false;
|
|
6601
|
+
}
|
|
6602
|
+
}
|
|
6603
|
+
// ── fs ──────────────────────────────────────────────────────────────────
|
|
6604
|
+
ensureDir() {
|
|
6605
|
+
const dir = this.dir();
|
|
6606
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
6607
|
+
try {
|
|
6608
|
+
chmodSync(dir, 448);
|
|
6609
|
+
} catch {
|
|
6610
|
+
}
|
|
6611
|
+
return dir;
|
|
6612
|
+
}
|
|
6613
|
+
readFile(id, kind) {
|
|
6614
|
+
try {
|
|
6615
|
+
const p = this.filePath(id, kind);
|
|
6616
|
+
if (statSync(p).size > SCROLLBACK_FILE_MAX_BYTES * 2) return null;
|
|
6617
|
+
return readFileSync(p);
|
|
6618
|
+
} catch {
|
|
6619
|
+
return null;
|
|
6620
|
+
}
|
|
6621
|
+
}
|
|
6622
|
+
/** Atomic 0600 write; false on any failure. */
|
|
6623
|
+
writeFile(id, kind, file) {
|
|
6624
|
+
return this.writeRaw(id, kind, encodeArchiveFile(file));
|
|
6625
|
+
}
|
|
6626
|
+
writeRaw(id, kind, bytes) {
|
|
6627
|
+
try {
|
|
6628
|
+
this.ensureDir();
|
|
6629
|
+
const target = this.filePath(id, kind);
|
|
6630
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
6631
|
+
writeFileSync(tmp, bytes, { mode: 384 });
|
|
6632
|
+
chmodSync(tmp, 384);
|
|
6633
|
+
renameSync(tmp, target);
|
|
6634
|
+
return true;
|
|
6635
|
+
} catch (e) {
|
|
6636
|
+
logger.debug(`[SCROLLBACK] write ${id}.${kind} failed: ${e}`);
|
|
6637
|
+
return false;
|
|
6638
|
+
}
|
|
6639
|
+
}
|
|
6640
|
+
removeFile(id, kind) {
|
|
6641
|
+
try {
|
|
6642
|
+
unlinkSync(this.filePath(id, kind));
|
|
6643
|
+
} catch {
|
|
6644
|
+
}
|
|
6645
|
+
}
|
|
6646
|
+
listFiles() {
|
|
6647
|
+
let names;
|
|
6648
|
+
try {
|
|
6649
|
+
names = readdirSync(this.dir());
|
|
6650
|
+
} catch {
|
|
6651
|
+
return [];
|
|
6652
|
+
}
|
|
6653
|
+
const out = [];
|
|
6654
|
+
for (const name of names) {
|
|
6655
|
+
try {
|
|
6656
|
+
const st = statSync(join(this.dir(), name));
|
|
6657
|
+
if (!st.isFile()) continue;
|
|
6658
|
+
const parsed = parseArchiveFileName(name);
|
|
6659
|
+
out.push({ name, id: parsed?.id ?? null, kind: parsed?.kind ?? null, mtimeMs: st.mtimeMs, bytes: st.size });
|
|
6660
|
+
} catch {
|
|
6661
|
+
}
|
|
6662
|
+
}
|
|
6663
|
+
return out;
|
|
6664
|
+
}
|
|
6665
|
+
maybeGc(keepIds) {
|
|
6666
|
+
const now = this.now();
|
|
6667
|
+
if (this.lastGcAt && now - this.lastGcAt < GC_INTERVAL_MS) return;
|
|
6668
|
+
this.lastGcAt = now;
|
|
6669
|
+
this.gc(keepIds, now);
|
|
6670
|
+
}
|
|
6671
|
+
/** Exposed for tests. */
|
|
6672
|
+
gc(keepIds, now = this.now()) {
|
|
6673
|
+
const doomed = planScrollbackGc(this.listFiles(), keepIds, now);
|
|
6674
|
+
for (const name of doomed) {
|
|
6675
|
+
try {
|
|
6676
|
+
unlinkSync(join(this.dir(), name));
|
|
6677
|
+
} catch {
|
|
6678
|
+
}
|
|
6679
|
+
}
|
|
6680
|
+
if (doomed.length > 0) logger.debug(`[SCROLLBACK] gc removed ${doomed.length} file(s)`);
|
|
6681
|
+
return doomed;
|
|
6682
|
+
}
|
|
6683
|
+
/** Switch turned off: the saved scrollback goes away entirely. */
|
|
6684
|
+
purge() {
|
|
6685
|
+
try {
|
|
6686
|
+
rmSync(this.dir(), { recursive: true, force: true });
|
|
6687
|
+
} catch {
|
|
6688
|
+
}
|
|
6689
|
+
this.state.clear();
|
|
6690
|
+
}
|
|
6691
|
+
}
|
|
6692
|
+
|
|
5674
6693
|
const TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
5675
6694
|
function tmuxKillVerified(kill, probe) {
|
|
5676
6695
|
return !kill.error && !probe.error && typeof probe.status === "number" && probe.status !== 0;
|
|
@@ -5815,6 +6834,10 @@ function terminalListSignature(items) {
|
|
|
5815
6834
|
function hostListSignature(items) {
|
|
5816
6835
|
return `${isTmuxAvailable() ? "tmux" : "no-tmux"}|${terminalListSignature(items)}`;
|
|
5817
6836
|
}
|
|
6837
|
+
function screenObservation(probe) {
|
|
6838
|
+
if (probe.agentObservedAt === void 0) return void 0;
|
|
6839
|
+
return { agentKind: probe.agentKind, agentState: probe.agentState };
|
|
6840
|
+
}
|
|
5818
6841
|
function looksLikeClaudeCommand(currentCommand) {
|
|
5819
6842
|
const cmd = (currentCommand || "").trim().replace(/^-/, "").toLowerCase();
|
|
5820
6843
|
return cmd === "claude" || cmd === "node" || /^\d+\.\d+(\.\d+)?$/.test(cmd);
|
|
@@ -6286,6 +7309,8 @@ class WebTerminalManager {
|
|
|
6286
7309
|
* refreshes and notifications into a process that is exiting. */
|
|
6287
7310
|
shuttingDown = false;
|
|
6288
7311
|
onAutoRestoreSummaryCb = null;
|
|
7312
|
+
/** B-550: scrollback checkpoints across rebuilds (scrollbackCheckpoint.ts). */
|
|
7313
|
+
scrollback = new ScrollbackCheckpointer({ homeDir: () => configuration.happyHomeDir, env: ptyEnv });
|
|
6289
7314
|
/** Report line for the account notification (wired by apiMachine). */
|
|
6290
7315
|
setOnAutoRestoreSummary(fn) {
|
|
6291
7316
|
this.onAutoRestoreSummaryCb = fn;
|
|
@@ -6324,6 +7349,22 @@ class WebTerminalManager {
|
|
|
6324
7349
|
// callback ⇒ tracking is skipped entirely.
|
|
6325
7350
|
onNotify;
|
|
6326
7351
|
notifyTracker = new TerminalNotifyTracker();
|
|
7352
|
+
// ── Hook-driven agent status (B-547) ────────────────────────────────────
|
|
7353
|
+
// `claude` commands the daemon types into a terminal get `--settings`
|
|
7354
|
+
// with managed hooks (terminalHookSettings.ts); the forwarded events land
|
|
7355
|
+
// in `agentHooks` and are fused with the screen classifier in
|
|
7356
|
+
// probeAgentState. The screen stays the fallback for everything else.
|
|
7357
|
+
agentHooks = new TerminalAgentHookStore();
|
|
7358
|
+
claudeVersion = new ClaudeVersionCache(ptyEnv);
|
|
7359
|
+
/** Terminals whose LAST probe was decided by a hook record — their
|
|
7360
|
+
* transitions skip the screen debounce in the notification tracker.
|
|
7361
|
+
* Daemon-internal: never pushed (wire shape unchanged). */
|
|
7362
|
+
hookSourced = /* @__PURE__ */ new Set();
|
|
7363
|
+
/** Last `#{pane_current_command}` identity seen per terminal, so an
|
|
7364
|
+
* interrupt re-check can classify the screen without a list read. */
|
|
7365
|
+
lastPolledIdentity = /* @__PURE__ */ new Map();
|
|
7366
|
+
interruptTimers = /* @__PURE__ */ new Map();
|
|
7367
|
+
hookSettingsSwept = false;
|
|
6327
7368
|
// ── Terminal mirror integration (B-105) ─────────────────────────────────
|
|
6328
7369
|
/** terminalId → shadow session id (daemon's mirror manager). Feeds the
|
|
6329
7370
|
* list push (toggle availability) and closed-terminal records. */
|
|
@@ -6350,6 +7391,70 @@ class WebTerminalManager {
|
|
|
6350
7391
|
requestListRefresh() {
|
|
6351
7392
|
this.kickListRefresh();
|
|
6352
7393
|
}
|
|
7394
|
+
/** B-547: one forwarded status hook (control server /terminal-agent-hook).
|
|
7395
|
+
* A list-relevant change pushes within the kick debounce instead of
|
|
7396
|
+
* waiting for the 10 s tick. Never throws. */
|
|
7397
|
+
ingestAgentHook(body) {
|
|
7398
|
+
try {
|
|
7399
|
+
const result = this.agentHooks.applyHook(body, Date.now());
|
|
7400
|
+
if (result?.changed) this.kickListRefresh();
|
|
7401
|
+
} catch (e) {
|
|
7402
|
+
logger.debug(`[WEB TERMINAL] agent hook ingest failed: ${e}`);
|
|
7403
|
+
}
|
|
7404
|
+
}
|
|
7405
|
+
/** B-547: test/diagnostic view — does this terminal hold a hook record? */
|
|
7406
|
+
hasAgentHookRecord(terminalId) {
|
|
7407
|
+
return this.agentHooks.has(terminalId);
|
|
7408
|
+
}
|
|
7409
|
+
/** B-547: once per daemon, drop settings files of terminals whose tmux
|
|
7410
|
+
* session is gone. Per-id `has-session` (not a list read, whose transient
|
|
7411
|
+
* failure would look like "no terminals") decides liveness. */
|
|
7412
|
+
sweepHookSettingsOnce() {
|
|
7413
|
+
if (this.hookSettingsSwept || !isTmuxAvailable()) return;
|
|
7414
|
+
this.hookSettingsSwept = true;
|
|
7415
|
+
try {
|
|
7416
|
+
const env = ptyEnv();
|
|
7417
|
+
const removed = sweepStaleTerminalHookSettings((id) => this.terminals.has(id) || spawnSync("tmux", tmuxArgs(["has-session", "-t", `=vh-${id}:`]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }).status !== 1);
|
|
7418
|
+
if (removed.length > 0) logger.debug(`[WEB TERMINAL] removed ${removed.length} stale terminal hook settings file(s)`);
|
|
7419
|
+
} catch (e) {
|
|
7420
|
+
logger.debug(`[WEB TERMINAL] hook settings sweep failed: ${e}`);
|
|
7421
|
+
}
|
|
7422
|
+
}
|
|
7423
|
+
/** B-547: a terminal is gone — drop its hook record, timers and file. */
|
|
7424
|
+
forgetAgentHooks(terminalId) {
|
|
7425
|
+
this.agentHooks.forget(terminalId);
|
|
7426
|
+
this.hookSourced.delete(terminalId);
|
|
7427
|
+
this.lastPolledIdentity.delete(terminalId);
|
|
7428
|
+
for (const t of this.interruptTimers.get(terminalId) ?? []) clearTimeout(t);
|
|
7429
|
+
this.interruptTimers.delete(terminalId);
|
|
7430
|
+
removeTerminalHookSettings(terminalId);
|
|
7431
|
+
}
|
|
7432
|
+
/** B-547: re-check an armed Esc/Ctrl+C at the spec's delays. A later
|
|
7433
|
+
* keypress re-arms (fresh baseline, fresh timers). */
|
|
7434
|
+
scheduleInterruptChecks(terminalId) {
|
|
7435
|
+
for (const t of this.interruptTimers.get(terminalId) ?? []) clearTimeout(t);
|
|
7436
|
+
const timers = INTERRUPT_CHECK_DELAYS_MS.map((delay) => {
|
|
7437
|
+
const t = setTimeout(() => this.runInterruptCheck(terminalId), delay);
|
|
7438
|
+
t.unref?.();
|
|
7439
|
+
return t;
|
|
7440
|
+
});
|
|
7441
|
+
this.interruptTimers.set(terminalId, timers);
|
|
7442
|
+
}
|
|
7443
|
+
runInterruptCheck(terminalId) {
|
|
7444
|
+
try {
|
|
7445
|
+
const screen = this.probeScreenState(`vh-${terminalId}`, this.lastPolledIdentity.get(terminalId));
|
|
7446
|
+
const verdict = this.agentHooks.checkInterrupt(terminalId, screenObservation(screen), Date.now());
|
|
7447
|
+
if (verdict === "wait") return;
|
|
7448
|
+
for (const t of this.interruptTimers.get(terminalId) ?? []) clearTimeout(t);
|
|
7449
|
+
this.interruptTimers.delete(terminalId);
|
|
7450
|
+
if (verdict === "synthesized") {
|
|
7451
|
+
logger.debug(`[WEB TERMINAL] ${terminalId}: inferred interrupt (Esc/Ctrl+C, no Stop hook)`);
|
|
7452
|
+
this.kickListRefresh();
|
|
7453
|
+
}
|
|
7454
|
+
} catch (e) {
|
|
7455
|
+
logger.debug(`[WEB TERMINAL] interrupt check failed: ${e}`);
|
|
7456
|
+
}
|
|
7457
|
+
}
|
|
6353
7458
|
constructor(emit, onNotify) {
|
|
6354
7459
|
this.emit = emit;
|
|
6355
7460
|
this.onNotify = onNotify ?? null;
|
|
@@ -6364,6 +7469,8 @@ class WebTerminalManager {
|
|
|
6364
7469
|
*/
|
|
6365
7470
|
startListTracking(cb, intervalMs = LIST_TRACK_INTERVAL_MS) {
|
|
6366
7471
|
this.listChangedCb = cb;
|
|
7472
|
+
this.claudeVersion.start();
|
|
7473
|
+
this.sweepHookSettingsOnce();
|
|
6367
7474
|
this.lastActivityEmitted = {};
|
|
6368
7475
|
if (!this.listTrackTimer) {
|
|
6369
7476
|
this.listTrackTimer = setInterval(() => this.listTrackTick(), intervalMs);
|
|
@@ -6386,6 +7493,12 @@ class WebTerminalManager {
|
|
|
6386
7493
|
clearTimeout(this.activityFlushTimer);
|
|
6387
7494
|
this.activityFlushTimer = null;
|
|
6388
7495
|
}
|
|
7496
|
+
for (const timers of this.interruptTimers.values()) for (const t of timers) clearTimeout(t);
|
|
7497
|
+
this.interruptTimers.clear();
|
|
7498
|
+
}
|
|
7499
|
+
/** B-550: last scrollback checkpoint before the daemon exits (async, ~300ms, never throws). */
|
|
7500
|
+
flushScrollbackForShutdown() {
|
|
7501
|
+
return this.scrollback.flushOnShutdown();
|
|
6389
7502
|
}
|
|
6390
7503
|
/**
|
|
6391
7504
|
* One pty chunk arrived → make sure a realtime activity frame goes out
|
|
@@ -6516,6 +7629,8 @@ class WebTerminalManager {
|
|
|
6516
7629
|
try {
|
|
6517
7630
|
const list = this.buildTerminalList();
|
|
6518
7631
|
this.trackClosures(list);
|
|
7632
|
+
if (list.length > 0) this.agentHooks.prune([...list.map((t) => t.id), ...this.terminals.keys()]);
|
|
7633
|
+
this.scrollback.tick(this.closedTerminals.map((r) => r.id));
|
|
6519
7634
|
this.trackNotifications(list);
|
|
6520
7635
|
this.pruneActivityTable(list);
|
|
6521
7636
|
this.emitActivity(Object.fromEntries(
|
|
@@ -6573,6 +7688,7 @@ class WebTerminalManager {
|
|
|
6573
7688
|
closedAt: now
|
|
6574
7689
|
});
|
|
6575
7690
|
changed = true;
|
|
7691
|
+
this.forgetAgentHooks(id);
|
|
6576
7692
|
logger.debug(`[WEB TERMINAL] recorded closed terminal ${id}`);
|
|
6577
7693
|
try {
|
|
6578
7694
|
this.onTerminalClosedCb?.(id);
|
|
@@ -6658,6 +7774,7 @@ class WebTerminalManager {
|
|
|
6658
7774
|
if (!isTmuxAvailable()) return;
|
|
6659
7775
|
const settings = await readSettings();
|
|
6660
7776
|
const config = resolveAutoRestoreConfig(settings);
|
|
7777
|
+
await this.claudeVersion.settled(3e3);
|
|
6661
7778
|
const selection = selectAutoRestore(candidates, {
|
|
6662
7779
|
now: Date.now(),
|
|
6663
7780
|
config,
|
|
@@ -6766,6 +7883,7 @@ class WebTerminalManager {
|
|
|
6766
7883
|
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
6767
7884
|
).status === 0;
|
|
6768
7885
|
if (exists) return false;
|
|
7886
|
+
const undoPromote = plan.attachTmux ? void 0 : this.scrollback.promote(plan.terminalId);
|
|
6769
7887
|
const envFlags = tmuxSupportsEnvFlag() ? [
|
|
6770
7888
|
"-e",
|
|
6771
7889
|
CLAUDE_CLASSIC_RENDERER_ENV,
|
|
@@ -6786,6 +7904,7 @@ class WebTerminalManager {
|
|
|
6786
7904
|
{ stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
|
|
6787
7905
|
).status === 0;
|
|
6788
7906
|
if (!live) {
|
|
7907
|
+
undoPromote?.();
|
|
6789
7908
|
logger.debug(`[WEB TERMINAL] auto-restore: tmux create failed for ${plan.terminalId}`);
|
|
6790
7909
|
return false;
|
|
6791
7910
|
}
|
|
@@ -6819,7 +7938,8 @@ class WebTerminalManager {
|
|
|
6819
7938
|
);
|
|
6820
7939
|
}
|
|
6821
7940
|
if (plan.command) {
|
|
6822
|
-
|
|
7941
|
+
const command = withTerminalHooks(plan.command, plan.terminalId, this.claudeVersion.current());
|
|
7942
|
+
for (const argv of startupInjectionArgs(name, command)) {
|
|
6823
7943
|
spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
|
|
6824
7944
|
}
|
|
6825
7945
|
}
|
|
@@ -6852,7 +7972,13 @@ class WebTerminalManager {
|
|
|
6852
7972
|
const now = Date.now();
|
|
6853
7973
|
this.notifyTracker.prune(list.map((t) => t.id));
|
|
6854
7974
|
for (const item of list) {
|
|
6855
|
-
const event = this.notifyTracker.observe(
|
|
7975
|
+
const event = this.notifyTracker.observe(
|
|
7976
|
+
item.id,
|
|
7977
|
+
item.agentState,
|
|
7978
|
+
now,
|
|
7979
|
+
item.agentKind,
|
|
7980
|
+
{ authoritative: this.hookSourced.has(item.id) }
|
|
7981
|
+
);
|
|
6856
7982
|
if (!event) continue;
|
|
6857
7983
|
try {
|
|
6858
7984
|
this.onNotify({ terminalId: item.id, title: item.title || "Terminal", event });
|
|
@@ -7211,7 +8337,8 @@ class WebTerminalManager {
|
|
|
7211
8337
|
logger.debug(`[WEB TERMINAL] ${tmuxSession} attaches user tmux session ${attachTarget.id} (${attachTarget.name})`);
|
|
7212
8338
|
return { id: attachTarget.id, name: attachTarget.name };
|
|
7213
8339
|
}
|
|
7214
|
-
const
|
|
8340
|
+
const normalized = normalizeStartupCommand(startupCommand);
|
|
8341
|
+
const startup = normalized && tmuxSession.startsWith("vh-") ? withTerminalHooks(normalized, tmuxSession.slice(3), this.claudeVersion.current()) : normalized;
|
|
7215
8342
|
if (startup) {
|
|
7216
8343
|
try {
|
|
7217
8344
|
for (const a of startupInjectionArgs(tmuxSession, startup)) {
|
|
@@ -7327,7 +8454,10 @@ class WebTerminalManager {
|
|
|
7327
8454
|
});
|
|
7328
8455
|
const paneState = parsePaneState((byKey.panes ?? Buffer.alloc(0)).toString("utf8"));
|
|
7329
8456
|
if (paneState) session.paneId = paneState.paneId;
|
|
7330
|
-
const payload =
|
|
8457
|
+
const payload = withArchivedScrollback(
|
|
8458
|
+
assembleRestore({ ...byKey, tail: unescapeOctal(byKey.tail ?? Buffer.alloc(0)) }, paneState),
|
|
8459
|
+
this.scrollback.replayFor(session.id)
|
|
8460
|
+
);
|
|
7331
8461
|
if (fresh) {
|
|
7332
8462
|
session.restoreHeadless(payload.full);
|
|
7333
8463
|
}
|
|
@@ -7386,6 +8516,7 @@ class WebTerminalManager {
|
|
|
7386
8516
|
if (!session) return;
|
|
7387
8517
|
session.lastTouch = Date.now();
|
|
7388
8518
|
const text = Buffer.from(dataBase64, "base64").toString("utf8");
|
|
8519
|
+
if (this.agentHooks.noteInput(terminalId, text, Date.now())) this.scheduleInterruptChecks(terminalId);
|
|
7389
8520
|
if (session.transport.kind === "pty") {
|
|
7390
8521
|
session.transport.pty.write(text);
|
|
7391
8522
|
return;
|
|
@@ -7519,6 +8650,7 @@ class WebTerminalManager {
|
|
|
7519
8650
|
this.tombstones[terminalId] = Date.now();
|
|
7520
8651
|
saveTombstones(this.tombstones);
|
|
7521
8652
|
this.notifyTracker.remove(terminalId);
|
|
8653
|
+
this.forgetAgentHooks(terminalId);
|
|
7522
8654
|
logger.debug(`[WEB TERMINAL] killed direct pty ${terminalId}`);
|
|
7523
8655
|
this.kickListRefresh();
|
|
7524
8656
|
return true;
|
|
@@ -7528,6 +8660,7 @@ class WebTerminalManager {
|
|
|
7528
8660
|
return t ? seenInfoOf(t) : void 0;
|
|
7529
8661
|
})();
|
|
7530
8662
|
const target = `=vh-${terminalId}:`;
|
|
8663
|
+
this.scrollback.checkpointSync(terminalId);
|
|
7531
8664
|
const killed = spawnSync("tmux", tmuxArgs(["kill-session", "-t", target]), { stdio: "ignore", env: ptyEnv() });
|
|
7532
8665
|
const verified = spawnSync("tmux", tmuxArgs(["has-session", "-t", target]), { stdio: "ignore", env: ptyEnv() });
|
|
7533
8666
|
if (!tmuxKillVerified(killed, verified)) {
|
|
@@ -7569,6 +8702,7 @@ class WebTerminalManager {
|
|
|
7569
8702
|
this.tombstones[terminalId] = Date.now();
|
|
7570
8703
|
saveTombstones(this.tombstones);
|
|
7571
8704
|
this.notifyTracker.remove(terminalId);
|
|
8705
|
+
this.forgetAgentHooks(terminalId);
|
|
7572
8706
|
logger.debug(`[WEB TERMINAL] killed session vh-${terminalId}`);
|
|
7573
8707
|
this.kickListRefresh();
|
|
7574
8708
|
return true;
|
|
@@ -7670,6 +8804,25 @@ class WebTerminalManager {
|
|
|
7670
8804
|
* you've opened this daemon lifetime costs nothing. Cold tmux-only sessions
|
|
7671
8805
|
* (pty reaped / never attached) fall back to the tmux probe. */
|
|
7672
8806
|
probeAgentState(sessionName, polledCommand) {
|
|
8807
|
+
const id = sessionName.startsWith("vh-") ? sessionName.slice(3) : sessionName;
|
|
8808
|
+
if (polledCommand) this.lastPolledIdentity.set(id, polledCommand);
|
|
8809
|
+
const screen = this.probeScreenState(sessionName, polledCommand);
|
|
8810
|
+
const now = Date.now();
|
|
8811
|
+
const fused = this.agentHooks.fuse(id, screenObservation(screen), now);
|
|
8812
|
+
if (fused.source === "screen") {
|
|
8813
|
+
this.hookSourced.delete(id);
|
|
8814
|
+
return screen;
|
|
8815
|
+
}
|
|
8816
|
+
this.hookSourced.add(id);
|
|
8817
|
+
return {
|
|
8818
|
+
agentState: fused.agentState,
|
|
8819
|
+
agentKind: fused.agentKind,
|
|
8820
|
+
agentObservedAt: screen.agentObservedAt ?? now,
|
|
8821
|
+
claudeConfident: screen.claudeConfident
|
|
8822
|
+
};
|
|
8823
|
+
}
|
|
8824
|
+
/** The screen classifier alone (pre-B-547 probeAgentState). */
|
|
8825
|
+
probeScreenState(sessionName, polledCommand) {
|
|
7673
8826
|
const id = sessionName.startsWith("vh-") ? sessionName.slice(3) : sessionName;
|
|
7674
8827
|
const live = this.terminals.get(id);
|
|
7675
8828
|
if (live) {
|
|
@@ -7864,7 +9017,7 @@ class WebTerminalManager {
|
|
|
7864
9017
|
}
|
|
7865
9018
|
}
|
|
7866
9019
|
|
|
7867
|
-
const UUID_RE$
|
|
9020
|
+
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;
|
|
7868
9021
|
async function listClaudeProjectDirs(projectsRoot) {
|
|
7869
9022
|
let names;
|
|
7870
9023
|
try {
|
|
@@ -7895,7 +9048,7 @@ async function collectCandidates$1(projectDirs, exclude) {
|
|
|
7895
9048
|
for (const name of names) {
|
|
7896
9049
|
if (!name.endsWith(".jsonl")) continue;
|
|
7897
9050
|
const id = name.slice(0, -".jsonl".length);
|
|
7898
|
-
if (!UUID_RE$
|
|
9051
|
+
if (!UUID_RE$4.test(id) || exclude.has(id.toLowerCase())) continue;
|
|
7899
9052
|
const path = join(dir, name);
|
|
7900
9053
|
try {
|
|
7901
9054
|
const s = await stat(path);
|
|
@@ -7924,11 +9077,11 @@ async function readHead$1(path, bytes) {
|
|
|
7924
9077
|
await fh.close();
|
|
7925
9078
|
}
|
|
7926
9079
|
}
|
|
7927
|
-
function oneLine$
|
|
9080
|
+
function oneLine$2(text, max) {
|
|
7928
9081
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
7929
9082
|
return collapsed.length > max ? `${collapsed.slice(0, max - 1)}\u2026` : collapsed;
|
|
7930
9083
|
}
|
|
7931
|
-
function parseTimestamp$
|
|
9084
|
+
function parseTimestamp$2(raw) {
|
|
7932
9085
|
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
7933
9086
|
if (typeof raw === "string") {
|
|
7934
9087
|
const t = Date.parse(raw);
|
|
@@ -7955,7 +9108,7 @@ function parseClaudeHistoryHead(head, promptChars) {
|
|
|
7955
9108
|
}
|
|
7956
9109
|
if (!parsed || typeof parsed !== "object") continue;
|
|
7957
9110
|
if (parsed.type === "summary" && typeof parsed.summary === "string" && !summary) {
|
|
7958
|
-
const s = oneLine$
|
|
9111
|
+
const s = oneLine$2(parsed.summary, promptChars);
|
|
7959
9112
|
if (s) summary = s;
|
|
7960
9113
|
continue;
|
|
7961
9114
|
}
|
|
@@ -7963,7 +9116,7 @@ function parseClaudeHistoryHead(head, promptChars) {
|
|
|
7963
9116
|
if (typeof parsed.entrypoint === "string" && parsed.entrypoint && !entrypoint) entrypoint = parsed.entrypoint;
|
|
7964
9117
|
if (typeof parsed.gitBranch === "string" && parsed.gitBranch && !gitBranch) gitBranch = parsed.gitBranch;
|
|
7965
9118
|
if (typeof parsed.version === "string" && parsed.version && !version) version = parsed.version;
|
|
7966
|
-
if (startedAt === null) startedAt = parseTimestamp$
|
|
9119
|
+
if (startedAt === null) startedAt = parseTimestamp$2(parsed.timestamp);
|
|
7967
9120
|
if (!firstPrompt && parsed.type === "user" && !parsed.isSidechain) {
|
|
7968
9121
|
const content = parsed.message?.content;
|
|
7969
9122
|
let text = null;
|
|
@@ -7975,7 +9128,7 @@ function parseClaudeHistoryHead(head, promptChars) {
|
|
|
7975
9128
|
}
|
|
7976
9129
|
if (text) {
|
|
7977
9130
|
if (isVeryHappyOneShotPrompt(text)) return null;
|
|
7978
|
-
const cleaned = oneLine$
|
|
9131
|
+
const cleaned = oneLine$2(stripHarnessTags(text), promptChars);
|
|
7979
9132
|
if (cleaned) firstPrompt = cleaned;
|
|
7980
9133
|
}
|
|
7981
9134
|
}
|
|
@@ -8029,8 +9182,8 @@ async function listClaudeSessionHistory(options) {
|
|
|
8029
9182
|
return { entries, truncated, scanned };
|
|
8030
9183
|
}
|
|
8031
9184
|
|
|
8032
|
-
const UUID_RE$
|
|
8033
|
-
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;
|
|
9185
|
+
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;
|
|
9186
|
+
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;
|
|
8034
9187
|
const VERY_HAPPY_CODEX_ORIGINATOR = "happy-codex";
|
|
8035
9188
|
function getCodexHomeDir() {
|
|
8036
9189
|
return process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
@@ -8052,7 +9205,7 @@ async function collectCandidates(root, exclude) {
|
|
|
8052
9205
|
}
|
|
8053
9206
|
for (const name of names) {
|
|
8054
9207
|
const full = join(dir, name);
|
|
8055
|
-
const match = ROLLOUT_RE.exec(name);
|
|
9208
|
+
const match = ROLLOUT_RE$1.exec(name);
|
|
8056
9209
|
if (match) {
|
|
8057
9210
|
const id = match[1].toLowerCase();
|
|
8058
9211
|
if (exclude.has(id)) continue;
|
|
@@ -8090,11 +9243,11 @@ async function readHead(path, bytes) {
|
|
|
8090
9243
|
await fh.close();
|
|
8091
9244
|
}
|
|
8092
9245
|
}
|
|
8093
|
-
function oneLine(text, max) {
|
|
9246
|
+
function oneLine$1(text, max) {
|
|
8094
9247
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
8095
9248
|
return collapsed.length > max ? `${collapsed.slice(0, max - 1)}\u2026` : collapsed;
|
|
8096
9249
|
}
|
|
8097
|
-
function parseTimestamp(raw) {
|
|
9250
|
+
function parseTimestamp$1(raw) {
|
|
8098
9251
|
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
8099
9252
|
if (typeof raw === "string") {
|
|
8100
9253
|
const t = Date.parse(raw);
|
|
@@ -8150,14 +9303,14 @@ function parseCodexHistoryHead(head, promptChars) {
|
|
|
8150
9303
|
if (parsed.type === "session_meta" && payload && typeof payload === "object") {
|
|
8151
9304
|
if (isCodexSubThread(payload)) return null;
|
|
8152
9305
|
sawMeta = true;
|
|
8153
|
-
if (typeof payload.id === "string" && UUID_RE$
|
|
9306
|
+
if (typeof payload.id === "string" && UUID_RE$3.test(payload.id)) id = payload.id.toLowerCase();
|
|
8154
9307
|
if (typeof payload.cwd === "string" && payload.cwd) cwd = payload.cwd;
|
|
8155
9308
|
if (typeof payload.source === "string" && payload.source) entrypoint = payload.source;
|
|
8156
9309
|
if (typeof payload.originator === "string" && payload.originator) originator = payload.originator;
|
|
8157
9310
|
if (typeof payload.cli_version === "string" && payload.cli_version) version = payload.cli_version;
|
|
8158
9311
|
const branch = payload.git && typeof payload.git === "object" ? payload.git.branch : void 0;
|
|
8159
9312
|
if (typeof branch === "string" && branch) gitBranch = branch;
|
|
8160
|
-
startedAt = parseTimestamp(payload.timestamp) ?? parseTimestamp(parsed.timestamp);
|
|
9313
|
+
startedAt = parseTimestamp$1(payload.timestamp) ?? parseTimestamp$1(parsed.timestamp);
|
|
8161
9314
|
continue;
|
|
8162
9315
|
}
|
|
8163
9316
|
if (!sawMeta) return null;
|
|
@@ -8165,11 +9318,11 @@ function parseCodexHistoryHead(head, promptChars) {
|
|
|
8165
9318
|
if (!payload || typeof payload !== "object") continue;
|
|
8166
9319
|
if (parsed.type === "event_msg") {
|
|
8167
9320
|
if (payload.type === "user_message" && typeof payload.message === "string") {
|
|
8168
|
-
const cleaned = oneLine(stripHarnessBlocks(payload.message), promptChars);
|
|
9321
|
+
const cleaned = oneLine$1(stripHarnessBlocks(payload.message), promptChars);
|
|
8169
9322
|
if (cleaned) firstPrompt = cleaned;
|
|
8170
9323
|
} else if (payload.type === "item_completed" && payload.item && payload.item.type === "UserMessage") {
|
|
8171
9324
|
const text = textFromUserContent(payload.item.content);
|
|
8172
|
-
const cleaned = text ? oneLine(stripHarnessBlocks(text), promptChars) : "";
|
|
9325
|
+
const cleaned = text ? oneLine$1(stripHarnessBlocks(text), promptChars) : "";
|
|
8173
9326
|
if (cleaned) firstPrompt = cleaned;
|
|
8174
9327
|
}
|
|
8175
9328
|
continue;
|
|
@@ -8177,7 +9330,7 @@ function parseCodexHistoryHead(head, promptChars) {
|
|
|
8177
9330
|
if (parsed.type === "response_item" && payload.type === "message" && payload.role === "user" && !fallbackPrompt) {
|
|
8178
9331
|
const text = textFromUserContent(payload.content);
|
|
8179
9332
|
if (text && !isHarnessUserText(text)) {
|
|
8180
|
-
const cleaned = oneLine(stripHarnessBlocks(text), promptChars);
|
|
9333
|
+
const cleaned = oneLine$1(stripHarnessBlocks(text), promptChars);
|
|
8181
9334
|
if (cleaned) fallbackPrompt = cleaned;
|
|
8182
9335
|
}
|
|
8183
9336
|
}
|
|
@@ -8207,7 +9360,7 @@ function parseCodexSessionIndex(text) {
|
|
|
8207
9360
|
continue;
|
|
8208
9361
|
}
|
|
8209
9362
|
if (!parsed || typeof parsed !== "object") continue;
|
|
8210
|
-
if (typeof parsed.id !== "string" || !UUID_RE$
|
|
9363
|
+
if (typeof parsed.id !== "string" || !UUID_RE$3.test(parsed.id)) continue;
|
|
8211
9364
|
if (typeof parsed.thread_name !== "string" || !parsed.thread_name.trim()) continue;
|
|
8212
9365
|
names.set(parsed.id.toLowerCase(), parsed.thread_name.trim());
|
|
8213
9366
|
}
|
|
@@ -8258,12 +9411,819 @@ async function listCodexSessionHistory(options) {
|
|
|
8258
9411
|
updatedAt: Math.round(candidate.updatedAt),
|
|
8259
9412
|
sizeBytes: candidate.sizeBytes,
|
|
8260
9413
|
...rest,
|
|
8261
|
-
...summary ? { summary: oneLine(summary, promptChars) } : {}
|
|
9414
|
+
...summary ? { summary: oneLine$1(summary, promptChars) } : {}
|
|
8262
9415
|
});
|
|
8263
9416
|
}
|
|
8264
9417
|
return { entries, truncated, scanned };
|
|
8265
9418
|
}
|
|
8266
9419
|
|
|
9420
|
+
const MAX_MESSAGE_CHARS = 4e3;
|
|
9421
|
+
const MAX_SESSION_CHARS = 1e6;
|
|
9422
|
+
const MAX_TITLE_CHARS = 200;
|
|
9423
|
+
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;
|
|
9424
|
+
function emptyDocState() {
|
|
9425
|
+
return { meta: {}, msgs: [], chars: 0 };
|
|
9426
|
+
}
|
|
9427
|
+
function oneLine(text, max) {
|
|
9428
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
9429
|
+
return collapsed.length > max ? `${collapsed.slice(0, max - 1)}\u2026` : collapsed;
|
|
9430
|
+
}
|
|
9431
|
+
function parseTimestamp(raw) {
|
|
9432
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
9433
|
+
if (typeof raw === "string") {
|
|
9434
|
+
const t = Date.parse(raw);
|
|
9435
|
+
if (Number.isFinite(t)) return t;
|
|
9436
|
+
}
|
|
9437
|
+
return void 0;
|
|
9438
|
+
}
|
|
9439
|
+
function clip(text) {
|
|
9440
|
+
const trimmed = text.trim();
|
|
9441
|
+
return trimmed.length > MAX_MESSAGE_CHARS ? trimmed.slice(0, MAX_MESSAGE_CHARS) : trimmed;
|
|
9442
|
+
}
|
|
9443
|
+
function push(state, role, text, at) {
|
|
9444
|
+
if (state.full) return;
|
|
9445
|
+
const x = clip(text);
|
|
9446
|
+
if (!x) return;
|
|
9447
|
+
if (state.chars + x.length > MAX_SESSION_CHARS) {
|
|
9448
|
+
state.full = true;
|
|
9449
|
+
return;
|
|
9450
|
+
}
|
|
9451
|
+
state.msgs.push(at === void 0 ? { r: role, x } : { r: role, t: at, x });
|
|
9452
|
+
state.chars += x.length;
|
|
9453
|
+
}
|
|
9454
|
+
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;
|
|
9455
|
+
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;
|
|
9456
|
+
function stripClaudeHarness(text) {
|
|
9457
|
+
return text.replace(CLAUDE_HARNESS_BLOCK, " ").replace(CLAUDE_HARNESS_TAG, " ");
|
|
9458
|
+
}
|
|
9459
|
+
function claudeText(content) {
|
|
9460
|
+
if (typeof content === "string") return content;
|
|
9461
|
+
if (!Array.isArray(content)) return null;
|
|
9462
|
+
const parts = [];
|
|
9463
|
+
for (const block of content) {
|
|
9464
|
+
if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
|
|
9465
|
+
parts.push(block.text);
|
|
9466
|
+
}
|
|
9467
|
+
}
|
|
9468
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
9469
|
+
}
|
|
9470
|
+
function codexText(content) {
|
|
9471
|
+
if (typeof content === "string") return content;
|
|
9472
|
+
if (!Array.isArray(content)) return null;
|
|
9473
|
+
const parts = [];
|
|
9474
|
+
for (const part of content) {
|
|
9475
|
+
if (!part || typeof part !== "object") continue;
|
|
9476
|
+
const type = part.type;
|
|
9477
|
+
const text = part.text;
|
|
9478
|
+
if ((type === "input_text" || type === "output_text" || type === "text") && typeof text === "string") parts.push(text);
|
|
9479
|
+
}
|
|
9480
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
9481
|
+
}
|
|
9482
|
+
function lineMayMatter(agent, line) {
|
|
9483
|
+
if (agent === "claude") {
|
|
9484
|
+
return line.includes('"user"') || line.includes('"assistant"') || line.includes('"summary"');
|
|
9485
|
+
}
|
|
9486
|
+
return line.includes('"session_meta"') || line.includes('"message"');
|
|
9487
|
+
}
|
|
9488
|
+
function extractClaudeLine(state, line) {
|
|
9489
|
+
if (state.meta.skip || !line || !lineMayMatter("claude", line)) return;
|
|
9490
|
+
let parsed;
|
|
9491
|
+
try {
|
|
9492
|
+
parsed = JSON.parse(line);
|
|
9493
|
+
} catch {
|
|
9494
|
+
return;
|
|
9495
|
+
}
|
|
9496
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
9497
|
+
const meta = state.meta;
|
|
9498
|
+
if (parsed.type === "summary") {
|
|
9499
|
+
if (typeof parsed.summary === "string" && !meta.summary) {
|
|
9500
|
+
const s = oneLine(parsed.summary, MAX_TITLE_CHARS);
|
|
9501
|
+
if (s) meta.summary = s;
|
|
9502
|
+
}
|
|
9503
|
+
return;
|
|
9504
|
+
}
|
|
9505
|
+
if (parsed.isSidechain) return;
|
|
9506
|
+
if (parsed.isMeta || parsed.isCompactSummary) return;
|
|
9507
|
+
if (typeof parsed.cwd === "string" && parsed.cwd && !meta.cwd) meta.cwd = parsed.cwd;
|
|
9508
|
+
if (typeof parsed.gitBranch === "string" && parsed.gitBranch && !meta.gitBranch) meta.gitBranch = parsed.gitBranch;
|
|
9509
|
+
if (typeof parsed.entrypoint === "string" && parsed.entrypoint && !meta.entrypoint) meta.entrypoint = parsed.entrypoint;
|
|
9510
|
+
if (typeof parsed.version === "string" && parsed.version && !meta.version) meta.version = parsed.version;
|
|
9511
|
+
const at = parseTimestamp(parsed.timestamp);
|
|
9512
|
+
if (meta.startedAt === void 0 && at !== void 0) meta.startedAt = at;
|
|
9513
|
+
if (parsed.type !== "user" && parsed.type !== "assistant") return;
|
|
9514
|
+
const raw = claudeText(parsed.message?.content);
|
|
9515
|
+
if (!raw) return;
|
|
9516
|
+
if (parsed.type === "user") {
|
|
9517
|
+
if (!meta.firstPrompt) {
|
|
9518
|
+
if (isVeryHappyOneShotPrompt(raw)) {
|
|
9519
|
+
meta.skip = "one-shot";
|
|
9520
|
+
state.msgs = [];
|
|
9521
|
+
state.chars = 0;
|
|
9522
|
+
return;
|
|
9523
|
+
}
|
|
9524
|
+
}
|
|
9525
|
+
const text = stripClaudeHarness(raw);
|
|
9526
|
+
if (!meta.firstPrompt) {
|
|
9527
|
+
const first = oneLine(text, MAX_TITLE_CHARS);
|
|
9528
|
+
if (first) meta.firstPrompt = first;
|
|
9529
|
+
}
|
|
9530
|
+
push(state, "u", text, at);
|
|
9531
|
+
return;
|
|
9532
|
+
}
|
|
9533
|
+
push(state, "a", raw, at);
|
|
9534
|
+
}
|
|
9535
|
+
function extractCodexLine(state, line) {
|
|
9536
|
+
if (state.meta.skip || !line) return;
|
|
9537
|
+
const meta = state.meta;
|
|
9538
|
+
if (!meta.sawMeta) {
|
|
9539
|
+
let first;
|
|
9540
|
+
try {
|
|
9541
|
+
first = JSON.parse(line);
|
|
9542
|
+
} catch {
|
|
9543
|
+
meta.skip = "not-rollout";
|
|
9544
|
+
return;
|
|
9545
|
+
}
|
|
9546
|
+
const payload2 = first?.payload;
|
|
9547
|
+
if (first?.type !== "session_meta" || !payload2 || typeof payload2 !== "object") {
|
|
9548
|
+
meta.skip = "not-rollout";
|
|
9549
|
+
return;
|
|
9550
|
+
}
|
|
9551
|
+
if (isCodexSubThread(payload2)) {
|
|
9552
|
+
meta.skip = "subthread";
|
|
9553
|
+
return;
|
|
9554
|
+
}
|
|
9555
|
+
meta.sawMeta = true;
|
|
9556
|
+
if (typeof payload2.id === "string" && UUID_RE$2.test(payload2.id)) meta.id = payload2.id.toLowerCase();
|
|
9557
|
+
if (typeof payload2.cwd === "string" && payload2.cwd) meta.cwd = payload2.cwd;
|
|
9558
|
+
if (typeof payload2.source === "string" && payload2.source) meta.entrypoint = payload2.source;
|
|
9559
|
+
if (typeof payload2.originator === "string" && payload2.originator) meta.originator = payload2.originator;
|
|
9560
|
+
if (typeof payload2.cli_version === "string" && payload2.cli_version) meta.version = payload2.cli_version;
|
|
9561
|
+
const branch = payload2.git && typeof payload2.git === "object" ? payload2.git.branch : void 0;
|
|
9562
|
+
if (typeof branch === "string" && branch) meta.gitBranch = branch;
|
|
9563
|
+
const at2 = parseTimestamp(payload2.timestamp) ?? parseTimestamp(first.timestamp);
|
|
9564
|
+
if (at2 !== void 0) meta.startedAt = at2;
|
|
9565
|
+
return;
|
|
9566
|
+
}
|
|
9567
|
+
if (!lineMayMatter("codex", line)) return;
|
|
9568
|
+
let parsed;
|
|
9569
|
+
try {
|
|
9570
|
+
parsed = JSON.parse(line);
|
|
9571
|
+
} catch {
|
|
9572
|
+
return;
|
|
9573
|
+
}
|
|
9574
|
+
const payload = parsed?.payload;
|
|
9575
|
+
if (parsed?.type !== "response_item" || !payload || payload.type !== "message") return;
|
|
9576
|
+
if (payload.role !== "user" && payload.role !== "assistant") return;
|
|
9577
|
+
const raw = codexText(payload.content);
|
|
9578
|
+
if (!raw) return;
|
|
9579
|
+
const at = parseTimestamp(parsed.timestamp);
|
|
9580
|
+
if (payload.role === "user") {
|
|
9581
|
+
if (isHarnessUserText(raw)) return;
|
|
9582
|
+
const text = stripHarnessBlocks(raw);
|
|
9583
|
+
if (!meta.firstPrompt) {
|
|
9584
|
+
const first = oneLine(text, MAX_TITLE_CHARS);
|
|
9585
|
+
if (first) meta.firstPrompt = first;
|
|
9586
|
+
}
|
|
9587
|
+
push(state, "u", text, at);
|
|
9588
|
+
return;
|
|
9589
|
+
}
|
|
9590
|
+
push(state, "a", raw, at);
|
|
9591
|
+
}
|
|
9592
|
+
function extractLine(agent, state, line) {
|
|
9593
|
+
if (agent === "claude") extractClaudeLine(state, line);
|
|
9594
|
+
else extractCodexLine(state, line);
|
|
9595
|
+
}
|
|
9596
|
+
|
|
9597
|
+
const INDEX_VERSION = 1;
|
|
9598
|
+
const INDEX_FILE_NAME = `index.v${INDEX_VERSION}.jsonl`;
|
|
9599
|
+
const DEFAULT_MAX_TEXT_CHARS = 32e6;
|
|
9600
|
+
function planFile(prev, cur) {
|
|
9601
|
+
if (!prev) return "reread";
|
|
9602
|
+
if (prev.ino !== cur.ino) return "reread";
|
|
9603
|
+
if (cur.size === prev.size && cur.mtimeMs === prev.mtimeMs) return "skip";
|
|
9604
|
+
if (cur.size < prev.offset || cur.size < prev.size) return "reread";
|
|
9605
|
+
if (cur.size === prev.size) return "reread";
|
|
9606
|
+
return prev.evicted ? "reread" : "append";
|
|
9607
|
+
}
|
|
9608
|
+
function totalChars(records) {
|
|
9609
|
+
let sum = 0;
|
|
9610
|
+
for (const r of records) sum += r.chars;
|
|
9611
|
+
return sum;
|
|
9612
|
+
}
|
|
9613
|
+
function enforceTextCap(records, maxChars) {
|
|
9614
|
+
let total = totalChars(records.values());
|
|
9615
|
+
if (total <= maxChars) return 0;
|
|
9616
|
+
const byAge = [...records.values()].filter((r) => r.chars > 0).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
9617
|
+
let evicted = 0;
|
|
9618
|
+
for (const r of byAge) {
|
|
9619
|
+
if (total <= maxChars) break;
|
|
9620
|
+
total -= r.chars;
|
|
9621
|
+
r.msgs = [];
|
|
9622
|
+
r.chars = 0;
|
|
9623
|
+
r.evicted = true;
|
|
9624
|
+
evicted += 1;
|
|
9625
|
+
}
|
|
9626
|
+
return evicted;
|
|
9627
|
+
}
|
|
9628
|
+
function serializeIndex(records, savedAt) {
|
|
9629
|
+
const lines = [JSON.stringify({ v: INDEX_VERSION, savedAt })];
|
|
9630
|
+
for (const r of records) lines.push(JSON.stringify(r));
|
|
9631
|
+
return lines.join("\n") + "\n";
|
|
9632
|
+
}
|
|
9633
|
+
function isRecord(value) {
|
|
9634
|
+
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";
|
|
9635
|
+
}
|
|
9636
|
+
function parseIndex(text) {
|
|
9637
|
+
const lines = text.split("\n");
|
|
9638
|
+
let header;
|
|
9639
|
+
try {
|
|
9640
|
+
header = JSON.parse(lines[0] ?? "");
|
|
9641
|
+
} catch {
|
|
9642
|
+
return null;
|
|
9643
|
+
}
|
|
9644
|
+
if (!header || header.v !== INDEX_VERSION || typeof header.savedAt !== "number") return null;
|
|
9645
|
+
const records = /* @__PURE__ */ new Map();
|
|
9646
|
+
for (let i = 1; i < lines.length; i++) {
|
|
9647
|
+
const line = lines[i];
|
|
9648
|
+
if (!line) continue;
|
|
9649
|
+
let parsed;
|
|
9650
|
+
try {
|
|
9651
|
+
parsed = JSON.parse(line);
|
|
9652
|
+
} catch {
|
|
9653
|
+
continue;
|
|
9654
|
+
}
|
|
9655
|
+
if (isRecord(parsed)) records.set(parsed.path, parsed);
|
|
9656
|
+
}
|
|
9657
|
+
return { savedAt: header.savedAt, records };
|
|
9658
|
+
}
|
|
9659
|
+
function indexFilePath(indexDir) {
|
|
9660
|
+
return join(indexDir, INDEX_FILE_NAME);
|
|
9661
|
+
}
|
|
9662
|
+
async function loadIndex(indexDir) {
|
|
9663
|
+
let text;
|
|
9664
|
+
try {
|
|
9665
|
+
text = await readFile(indexFilePath(indexDir), "utf-8");
|
|
9666
|
+
} catch {
|
|
9667
|
+
return null;
|
|
9668
|
+
}
|
|
9669
|
+
return parseIndex(text);
|
|
9670
|
+
}
|
|
9671
|
+
async function saveIndex(indexDir, records, savedAt) {
|
|
9672
|
+
await mkdir(indexDir, { recursive: true, mode: 448 });
|
|
9673
|
+
try {
|
|
9674
|
+
await chmod(indexDir, 448);
|
|
9675
|
+
} catch {
|
|
9676
|
+
}
|
|
9677
|
+
const target = indexFilePath(indexDir);
|
|
9678
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
9679
|
+
await writeFile(tmp, serializeIndex(records, savedAt), { mode: 384 });
|
|
9680
|
+
await rename(tmp, target);
|
|
9681
|
+
}
|
|
9682
|
+
async function clearIndexDir(indexDir) {
|
|
9683
|
+
await rm(indexDir, { recursive: true, force: true });
|
|
9684
|
+
}
|
|
9685
|
+
|
|
9686
|
+
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;
|
|
9687
|
+
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;
|
|
9688
|
+
const MAX_LINE_BYTES = 2 * 1024 * 1024;
|
|
9689
|
+
const READ_CHUNK_BYTES = 1024 * 1024;
|
|
9690
|
+
async function statFile(path) {
|
|
9691
|
+
try {
|
|
9692
|
+
const s = await stat(path);
|
|
9693
|
+
if (!s.isFile()) return null;
|
|
9694
|
+
return { ino: Number(s.ino) || 0, size: s.size, mtimeMs: s.mtimeMs };
|
|
9695
|
+
} catch {
|
|
9696
|
+
return null;
|
|
9697
|
+
}
|
|
9698
|
+
}
|
|
9699
|
+
async function listDir(path) {
|
|
9700
|
+
try {
|
|
9701
|
+
return await readdir(path);
|
|
9702
|
+
} catch {
|
|
9703
|
+
return [];
|
|
9704
|
+
}
|
|
9705
|
+
}
|
|
9706
|
+
async function isDir(path) {
|
|
9707
|
+
try {
|
|
9708
|
+
return (await stat(path)).isDirectory();
|
|
9709
|
+
} catch {
|
|
9710
|
+
return false;
|
|
9711
|
+
}
|
|
9712
|
+
}
|
|
9713
|
+
async function discoverClaudeFiles(projectsRoot) {
|
|
9714
|
+
const out = [];
|
|
9715
|
+
for (const dirName of await listDir(projectsRoot)) {
|
|
9716
|
+
const dir = join(projectsRoot, dirName);
|
|
9717
|
+
if (!await isDir(dir)) continue;
|
|
9718
|
+
for (const name of await listDir(dir)) {
|
|
9719
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
9720
|
+
const id = name.slice(0, -".jsonl".length);
|
|
9721
|
+
if (!UUID_RE$1.test(id)) continue;
|
|
9722
|
+
const path = join(dir, name);
|
|
9723
|
+
const s = await statFile(path);
|
|
9724
|
+
if (!s || s.size === 0) continue;
|
|
9725
|
+
out.push({ path, agent: "claude", id: id.toLowerCase(), ...s });
|
|
9726
|
+
}
|
|
9727
|
+
}
|
|
9728
|
+
return out;
|
|
9729
|
+
}
|
|
9730
|
+
async function discoverCodexFiles(sessionsRoot) {
|
|
9731
|
+
const out = [];
|
|
9732
|
+
async function walk(dir, depth) {
|
|
9733
|
+
for (const name of await listDir(dir)) {
|
|
9734
|
+
const full = join(dir, name);
|
|
9735
|
+
const match = ROLLOUT_RE.exec(name);
|
|
9736
|
+
if (match) {
|
|
9737
|
+
const s = await statFile(full);
|
|
9738
|
+
if (s && s.size > 0) out.push({ path: full, agent: "codex", id: match[1].toLowerCase(), ...s });
|
|
9739
|
+
continue;
|
|
9740
|
+
}
|
|
9741
|
+
if (name.endsWith(".jsonl") || depth >= 4) continue;
|
|
9742
|
+
if (await isDir(full)) await walk(full, depth + 1);
|
|
9743
|
+
}
|
|
9744
|
+
}
|
|
9745
|
+
await walk(sessionsRoot, 0);
|
|
9746
|
+
return out;
|
|
9747
|
+
}
|
|
9748
|
+
async function readCompleteLines(path, start, onLine, maxLineBytes = MAX_LINE_BYTES) {
|
|
9749
|
+
const fh = await open(path, "r");
|
|
9750
|
+
try {
|
|
9751
|
+
const buf = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
9752
|
+
let pos = start;
|
|
9753
|
+
let committed = start;
|
|
9754
|
+
let pending = [];
|
|
9755
|
+
let pendingLen = 0;
|
|
9756
|
+
let skipping = false;
|
|
9757
|
+
for (; ; ) {
|
|
9758
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, pos);
|
|
9759
|
+
if (bytesRead === 0) break;
|
|
9760
|
+
let i = 0;
|
|
9761
|
+
while (i < bytesRead) {
|
|
9762
|
+
const nl = buf.indexOf(10, i);
|
|
9763
|
+
if (nl === -1 || nl >= bytesRead) {
|
|
9764
|
+
const rest = bytesRead - i;
|
|
9765
|
+
if (!skipping) {
|
|
9766
|
+
if (pendingLen + rest > maxLineBytes) {
|
|
9767
|
+
skipping = true;
|
|
9768
|
+
pending = [];
|
|
9769
|
+
pendingLen = 0;
|
|
9770
|
+
} else {
|
|
9771
|
+
pending.push(Buffer.from(buf.subarray(i, bytesRead)));
|
|
9772
|
+
pendingLen += rest;
|
|
9773
|
+
}
|
|
9774
|
+
}
|
|
9775
|
+
break;
|
|
9776
|
+
}
|
|
9777
|
+
if (!skipping) {
|
|
9778
|
+
const seg = buf.subarray(i, nl);
|
|
9779
|
+
if (pendingLen + seg.length <= maxLineBytes) {
|
|
9780
|
+
const line = pendingLen > 0 ? Buffer.concat([...pending, seg]).toString("utf-8") : seg.toString("utf-8");
|
|
9781
|
+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
9782
|
+
}
|
|
9783
|
+
}
|
|
9784
|
+
pending = [];
|
|
9785
|
+
pendingLen = 0;
|
|
9786
|
+
skipping = false;
|
|
9787
|
+
committed = pos + nl + 1;
|
|
9788
|
+
i = nl + 1;
|
|
9789
|
+
}
|
|
9790
|
+
pos += bytesRead;
|
|
9791
|
+
}
|
|
9792
|
+
return committed;
|
|
9793
|
+
} finally {
|
|
9794
|
+
await fh.close();
|
|
9795
|
+
}
|
|
9796
|
+
}
|
|
9797
|
+
async function indexFile(file, prev, plan) {
|
|
9798
|
+
const base = plan === "append" && prev ? { meta: { ...prev.meta }, msgs: prev.msgs.slice(), chars: prev.chars, ...prev.full ? { full: true } : {} } : emptyDocState();
|
|
9799
|
+
const start = plan === "append" && prev ? prev.offset : 0;
|
|
9800
|
+
let offset = start;
|
|
9801
|
+
if (!base.meta.skip) {
|
|
9802
|
+
offset = await readCompleteLines(file.path, start, (line) => extractLine(file.agent, base, line));
|
|
9803
|
+
} else {
|
|
9804
|
+
offset = file.size;
|
|
9805
|
+
}
|
|
9806
|
+
if (base.meta.skip) {
|
|
9807
|
+
base.msgs = [];
|
|
9808
|
+
base.chars = 0;
|
|
9809
|
+
}
|
|
9810
|
+
return {
|
|
9811
|
+
path: file.path,
|
|
9812
|
+
agent: file.agent,
|
|
9813
|
+
id: file.agent === "codex" && base.meta.id ? base.meta.id : file.id,
|
|
9814
|
+
ino: file.ino,
|
|
9815
|
+
size: file.size,
|
|
9816
|
+
mtimeMs: file.mtimeMs,
|
|
9817
|
+
offset,
|
|
9818
|
+
meta: base.meta,
|
|
9819
|
+
msgs: base.msgs,
|
|
9820
|
+
chars: base.chars,
|
|
9821
|
+
...base.full ? { full: true } : {}
|
|
9822
|
+
};
|
|
9823
|
+
}
|
|
9824
|
+
const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
9825
|
+
async function refreshRecords(records, options) {
|
|
9826
|
+
const now = options.now ?? Date.now;
|
|
9827
|
+
const cancelled = () => options.isCancelled?.() === true;
|
|
9828
|
+
const [claude, codex, names] = await Promise.all([
|
|
9829
|
+
discoverClaudeFiles(options.roots.claudeProjectsRoot),
|
|
9830
|
+
discoverCodexFiles(options.roots.codexSessionsRoot),
|
|
9831
|
+
readFile(options.roots.codexIndexPath, "utf-8").then(parseCodexSessionIndex, () => /* @__PURE__ */ new Map())
|
|
9832
|
+
]);
|
|
9833
|
+
if (cancelled()) return { changed: false, cancelled: true, evicted: 0, filesTotal: 0 };
|
|
9834
|
+
const files = [...claude, ...codex];
|
|
9835
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
9836
|
+
const total = files.length;
|
|
9837
|
+
let changed = false;
|
|
9838
|
+
const present = new Set(files.map((f) => f.path));
|
|
9839
|
+
for (const path of [...records.keys()]) {
|
|
9840
|
+
if (!present.has(path)) {
|
|
9841
|
+
records.delete(path);
|
|
9842
|
+
changed = true;
|
|
9843
|
+
}
|
|
9844
|
+
}
|
|
9845
|
+
let done = 0;
|
|
9846
|
+
let lastCheckpoint = now();
|
|
9847
|
+
options.onProgress?.({ filesTotal: total, filesDone: 0 });
|
|
9848
|
+
for (const file of files) {
|
|
9849
|
+
if (cancelled()) return { changed, cancelled: true, evicted: 0, filesTotal: total };
|
|
9850
|
+
const prev = records.get(file.path);
|
|
9851
|
+
const plan = planFile(prev, file);
|
|
9852
|
+
if (plan !== "skip") {
|
|
9853
|
+
try {
|
|
9854
|
+
const next = await indexFile(file, prev, plan);
|
|
9855
|
+
if (cancelled()) return { changed, cancelled: true, evicted: 0, filesTotal: total };
|
|
9856
|
+
records.set(file.path, next);
|
|
9857
|
+
changed = true;
|
|
9858
|
+
} catch {
|
|
9859
|
+
}
|
|
9860
|
+
await yieldToLoop();
|
|
9861
|
+
}
|
|
9862
|
+
done += 1;
|
|
9863
|
+
if (done % 50 === 0 || done === total) options.onProgress?.({ filesTotal: total, filesDone: done });
|
|
9864
|
+
if (options.checkpoint && changed && now() - lastCheckpoint >= (options.checkpointEveryMs ?? 2e4)) {
|
|
9865
|
+
lastCheckpoint = now();
|
|
9866
|
+
await options.checkpoint();
|
|
9867
|
+
}
|
|
9868
|
+
}
|
|
9869
|
+
for (const record of records.values()) {
|
|
9870
|
+
if (record.agent !== "codex") continue;
|
|
9871
|
+
const name = names.get(record.id);
|
|
9872
|
+
if (name && record.meta.summary !== name) {
|
|
9873
|
+
record.meta = { ...record.meta, summary: name };
|
|
9874
|
+
changed = true;
|
|
9875
|
+
}
|
|
9876
|
+
}
|
|
9877
|
+
const evicted = enforceTextCap(records, options.maxTextChars);
|
|
9878
|
+
if (evicted > 0) changed = true;
|
|
9879
|
+
options.onProgress?.({ filesTotal: total, filesDone: total });
|
|
9880
|
+
return { changed, cancelled: false, evicted, filesTotal: total };
|
|
9881
|
+
}
|
|
9882
|
+
|
|
9883
|
+
const MAX_QUERY_CHARS = 200;
|
|
9884
|
+
const MAX_QUERY_TERMS = 8;
|
|
9885
|
+
const MAX_RESULTS = 50;
|
|
9886
|
+
const DEFAULT_RESULTS = 20;
|
|
9887
|
+
const MAX_SNIPPETS = 3;
|
|
9888
|
+
const SNIPPET_CHARS = 180;
|
|
9889
|
+
const SNIPPET_LEAD = 50;
|
|
9890
|
+
function escapeRegExp(text) {
|
|
9891
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9892
|
+
}
|
|
9893
|
+
function parseQuery(input) {
|
|
9894
|
+
if (typeof input !== "string") return null;
|
|
9895
|
+
const raw = input.replace(/\s+/g, " ").trim().slice(0, MAX_QUERY_CHARS).trim();
|
|
9896
|
+
if (!raw) return null;
|
|
9897
|
+
const words = [...new Set(raw.split(" ").filter(Boolean).map((w) => w.toLowerCase()))].slice(0, MAX_QUERY_TERMS);
|
|
9898
|
+
const terms = words.map((w) => new RegExp(escapeRegExp(w), "i"));
|
|
9899
|
+
const phrase = words.length > 1 ? new RegExp(raw.split(" ").map(escapeRegExp).join("\\s+"), "i") : null;
|
|
9900
|
+
return { terms, phrase, raw };
|
|
9901
|
+
}
|
|
9902
|
+
function matchRanges(text, terms) {
|
|
9903
|
+
const ranges = [];
|
|
9904
|
+
for (const term of terms) {
|
|
9905
|
+
const global = new RegExp(term.source, "gi");
|
|
9906
|
+
let m;
|
|
9907
|
+
while ((m = global.exec(text)) !== null) {
|
|
9908
|
+
if (m[0].length === 0) {
|
|
9909
|
+
global.lastIndex += 1;
|
|
9910
|
+
continue;
|
|
9911
|
+
}
|
|
9912
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
9913
|
+
if (ranges.length > 64) break;
|
|
9914
|
+
}
|
|
9915
|
+
}
|
|
9916
|
+
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
9917
|
+
const merged = [];
|
|
9918
|
+
for (const r of ranges) {
|
|
9919
|
+
const last = merged[merged.length - 1];
|
|
9920
|
+
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
|
|
9921
|
+
else merged.push([r[0], r[1]]);
|
|
9922
|
+
}
|
|
9923
|
+
return merged;
|
|
9924
|
+
}
|
|
9925
|
+
function buildSnippet(text, terms) {
|
|
9926
|
+
let first = -1;
|
|
9927
|
+
for (const term of terms) {
|
|
9928
|
+
const m = term.exec(text);
|
|
9929
|
+
if (m && (first === -1 || m.index < first)) first = m.index;
|
|
9930
|
+
}
|
|
9931
|
+
const start = Math.max(0, (first === -1 ? 0 : first) - SNIPPET_LEAD);
|
|
9932
|
+
const end = Math.min(text.length, start + SNIPPET_CHARS);
|
|
9933
|
+
let window = text.slice(start, end).replace(/\s+/g, " ");
|
|
9934
|
+
if (start > 0) window = `\u2026${window.trimStart()}`;
|
|
9935
|
+
if (end < text.length) window = `${window.trimEnd()}\u2026`;
|
|
9936
|
+
return { text: window, ranges: matchRanges(window, terms) };
|
|
9937
|
+
}
|
|
9938
|
+
function titleOf(record) {
|
|
9939
|
+
return record.meta.summary || record.meta.firstPrompt || "";
|
|
9940
|
+
}
|
|
9941
|
+
function searchRecords(records, query, options = {}) {
|
|
9942
|
+
const limit = Math.max(1, Math.min(Math.floor(options.limit ?? DEFAULT_RESULTS), MAX_RESULTS));
|
|
9943
|
+
const newest = /* @__PURE__ */ new Map();
|
|
9944
|
+
for (const r of records) {
|
|
9945
|
+
if (r.meta.skip || !r.meta.cwd) continue;
|
|
9946
|
+
if (options.agent && r.agent !== options.agent) continue;
|
|
9947
|
+
if (!r.meta.firstPrompt && !r.meta.summary) continue;
|
|
9948
|
+
const key = `${r.agent}:${r.id}`;
|
|
9949
|
+
const prev = newest.get(key);
|
|
9950
|
+
if (!prev || r.mtimeMs > prev.mtimeMs) newest.set(key, r);
|
|
9951
|
+
}
|
|
9952
|
+
const scored = [];
|
|
9953
|
+
for (const record of newest.values()) {
|
|
9954
|
+
const title = titleOf(record);
|
|
9955
|
+
const head = `${title}
|
|
9956
|
+
${record.meta.firstPrompt ?? ""}
|
|
9957
|
+
${record.meta.cwd ?? ""}`;
|
|
9958
|
+
const found = new Array(query.terms.length).fill(false);
|
|
9959
|
+
query.terms.forEach((t, i) => {
|
|
9960
|
+
if (t.test(head)) found[i] = true;
|
|
9961
|
+
});
|
|
9962
|
+
const matched = [];
|
|
9963
|
+
let phrase = query.phrase ? query.phrase.test(head) : true;
|
|
9964
|
+
record.msgs.forEach((msg, index) => {
|
|
9965
|
+
let n = 0;
|
|
9966
|
+
query.terms.forEach((t, i) => {
|
|
9967
|
+
if (t.test(msg.x)) {
|
|
9968
|
+
n += 1;
|
|
9969
|
+
found[i] = true;
|
|
9970
|
+
}
|
|
9971
|
+
});
|
|
9972
|
+
if (n > 0) {
|
|
9973
|
+
matched.push({ msg, hits: n, index });
|
|
9974
|
+
if (!phrase && query.phrase && query.phrase.test(msg.x)) phrase = true;
|
|
9975
|
+
}
|
|
9976
|
+
});
|
|
9977
|
+
if (!found.every(Boolean)) continue;
|
|
9978
|
+
scored.push({ record, phrase, matched });
|
|
9979
|
+
}
|
|
9980
|
+
scored.sort((a, b) => Number(b.phrase) - Number(a.phrase) || b.record.mtimeMs - a.record.mtimeMs);
|
|
9981
|
+
const results = scored.slice(0, limit).map(({ record, matched }) => {
|
|
9982
|
+
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);
|
|
9983
|
+
const meta = record.meta;
|
|
9984
|
+
return {
|
|
9985
|
+
agent: record.agent,
|
|
9986
|
+
id: record.id,
|
|
9987
|
+
title: titleOf(record),
|
|
9988
|
+
firstPrompt: meta.firstPrompt ?? meta.summary ?? "",
|
|
9989
|
+
...meta.summary ? { summary: meta.summary } : {},
|
|
9990
|
+
cwd: meta.cwd ?? "",
|
|
9991
|
+
startedAt: meta.startedAt ?? 0,
|
|
9992
|
+
updatedAt: Math.round(record.mtimeMs),
|
|
9993
|
+
sizeBytes: record.size,
|
|
9994
|
+
...meta.gitBranch ? { gitBranch: meta.gitBranch } : {},
|
|
9995
|
+
...meta.entrypoint ? { entrypoint: meta.entrypoint } : {},
|
|
9996
|
+
...meta.originator ? { originator: meta.originator } : {},
|
|
9997
|
+
matchCount: matched.length,
|
|
9998
|
+
snippets: picks.map(({ msg }) => {
|
|
9999
|
+
const s = buildSnippet(msg.x, query.terms);
|
|
10000
|
+
return {
|
|
10001
|
+
role: msg.r === "u" ? "user" : "assistant",
|
|
10002
|
+
text: s.text,
|
|
10003
|
+
...msg.t !== void 0 ? { at: msg.t } : {},
|
|
10004
|
+
ranges: s.ranges
|
|
10005
|
+
};
|
|
10006
|
+
})
|
|
10007
|
+
};
|
|
10008
|
+
});
|
|
10009
|
+
return { results, truncated: scored.length > limit, total: scored.length };
|
|
10010
|
+
}
|
|
10011
|
+
|
|
10012
|
+
class HistorySearchService {
|
|
10013
|
+
records = null;
|
|
10014
|
+
loading = null;
|
|
10015
|
+
refreshing = null;
|
|
10016
|
+
progress = { filesTotal: 0, filesDone: 0 };
|
|
10017
|
+
lastRefreshStartedAt = 0;
|
|
10018
|
+
lastIndexedAt;
|
|
10019
|
+
error;
|
|
10020
|
+
/** Bumped by `clear`; a pass started under an older generation stops. */
|
|
10021
|
+
generation = 0;
|
|
10022
|
+
/** A `clear` in progress. Searches arriving meanwhile neither load the
|
|
10023
|
+
* old file back nor start a pass that would recreate the directory. */
|
|
10024
|
+
clearing = null;
|
|
10025
|
+
idleTimer = null;
|
|
10026
|
+
opts;
|
|
10027
|
+
constructor(options) {
|
|
10028
|
+
this.opts = {
|
|
10029
|
+
maxTextChars: DEFAULT_MAX_TEXT_CHARS,
|
|
10030
|
+
refreshIntervalMs: 3e4,
|
|
10031
|
+
idleUnloadMs: 10 * 6e4,
|
|
10032
|
+
loadWaitMs: 5e3,
|
|
10033
|
+
now: Date.now,
|
|
10034
|
+
...options
|
|
10035
|
+
};
|
|
10036
|
+
}
|
|
10037
|
+
/** RPC entry point. Validates loosely; unknown fields are ignored. */
|
|
10038
|
+
async handle(params) {
|
|
10039
|
+
const p = params && typeof params === "object" ? params : {};
|
|
10040
|
+
const op = p.op === "status" || p.op === "clear" ? p.op : "search";
|
|
10041
|
+
if (op === "clear") {
|
|
10042
|
+
await this.clear();
|
|
10043
|
+
return { type: "success", status: this.status(), results: [], truncated: false };
|
|
10044
|
+
}
|
|
10045
|
+
if (op === "status") {
|
|
10046
|
+
return { type: "success", status: this.status(), results: [], truncated: false };
|
|
10047
|
+
}
|
|
10048
|
+
const agent = p.agent === "claude" || p.agent === "codex" ? p.agent : void 0;
|
|
10049
|
+
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : DEFAULT_RESULTS;
|
|
10050
|
+
return this.search(p.query, { agent, limit });
|
|
10051
|
+
}
|
|
10052
|
+
async search(query, options = {}) {
|
|
10053
|
+
if (this.clearing) {
|
|
10054
|
+
await this.clearing;
|
|
10055
|
+
return { type: "success", status: this.status(), results: [], truncated: false };
|
|
10056
|
+
}
|
|
10057
|
+
this.touch();
|
|
10058
|
+
await this.ensureLoaded();
|
|
10059
|
+
this.maybeRefresh();
|
|
10060
|
+
const parsed = parseQuery(query);
|
|
10061
|
+
if (!parsed || !this.records) {
|
|
10062
|
+
return { type: "success", status: this.status(), results: [], truncated: false };
|
|
10063
|
+
}
|
|
10064
|
+
const started = this.opts.now();
|
|
10065
|
+
const out = searchRecords(this.records.values(), parsed, options);
|
|
10066
|
+
logger.debug(`[HISTORY SEARCH] query len=${parsed.raw.length} terms=${parsed.terms.length} \u2192 ${out.total} sessions (${this.opts.now() - started}ms)`);
|
|
10067
|
+
return { type: "success", status: this.status(), results: out.results, truncated: out.truncated };
|
|
10068
|
+
}
|
|
10069
|
+
status() {
|
|
10070
|
+
const records = this.records;
|
|
10071
|
+
let sessions = 0;
|
|
10072
|
+
let evicted = 0;
|
|
10073
|
+
if (records) {
|
|
10074
|
+
for (const r of records.values()) {
|
|
10075
|
+
if (r.meta.skip) continue;
|
|
10076
|
+
sessions += 1;
|
|
10077
|
+
if (r.evicted) evicted += 1;
|
|
10078
|
+
}
|
|
10079
|
+
}
|
|
10080
|
+
const state = this.refreshing || this.loading ? "indexing" : this.error ? "error" : this.lastIndexedAt !== void 0 ? "ready" : "idle";
|
|
10081
|
+
return {
|
|
10082
|
+
state,
|
|
10083
|
+
filesTotal: this.progress.filesTotal,
|
|
10084
|
+
filesDone: this.progress.filesDone,
|
|
10085
|
+
sessions,
|
|
10086
|
+
textChars: records ? totalChars(records.values()) : 0,
|
|
10087
|
+
maxTextChars: this.opts.maxTextChars,
|
|
10088
|
+
evicted,
|
|
10089
|
+
...this.lastIndexedAt !== void 0 ? { lastIndexedAt: this.lastIndexedAt } : {},
|
|
10090
|
+
...this.error ? { error: this.error } : {}
|
|
10091
|
+
};
|
|
10092
|
+
}
|
|
10093
|
+
async clear() {
|
|
10094
|
+
if (this.clearing) return this.clearing;
|
|
10095
|
+
const run = this.doClear().finally(() => {
|
|
10096
|
+
if (this.clearing === run) this.clearing = null;
|
|
10097
|
+
});
|
|
10098
|
+
this.clearing = run;
|
|
10099
|
+
return run;
|
|
10100
|
+
}
|
|
10101
|
+
async doClear() {
|
|
10102
|
+
const running = this.refreshing;
|
|
10103
|
+
this.generation += 1;
|
|
10104
|
+
this.records = null;
|
|
10105
|
+
this.loading = null;
|
|
10106
|
+
this.refreshing = null;
|
|
10107
|
+
this.progress = { filesTotal: 0, filesDone: 0 };
|
|
10108
|
+
this.lastRefreshStartedAt = 0;
|
|
10109
|
+
this.lastIndexedAt = void 0;
|
|
10110
|
+
this.error = void 0;
|
|
10111
|
+
if (this.idleTimer) {
|
|
10112
|
+
clearTimeout(this.idleTimer);
|
|
10113
|
+
this.idleTimer = null;
|
|
10114
|
+
}
|
|
10115
|
+
await running?.catch(() => void 0);
|
|
10116
|
+
await clearIndexDir(this.opts.indexDir);
|
|
10117
|
+
logger.debug("[HISTORY SEARCH] index cleared");
|
|
10118
|
+
}
|
|
10119
|
+
/** Resolves when the running refresh (if any) is done — tests and scripts. */
|
|
10120
|
+
async whenIdle() {
|
|
10121
|
+
while (this.loading || this.refreshing) {
|
|
10122
|
+
await (this.loading ?? this.refreshing);
|
|
10123
|
+
}
|
|
10124
|
+
}
|
|
10125
|
+
touch() {
|
|
10126
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
10127
|
+
this.idleTimer = setTimeout(() => this.unload(), this.opts.idleUnloadMs);
|
|
10128
|
+
this.idleTimer.unref?.();
|
|
10129
|
+
}
|
|
10130
|
+
unload() {
|
|
10131
|
+
this.idleTimer = null;
|
|
10132
|
+
if (this.refreshing || this.loading) {
|
|
10133
|
+
this.touch();
|
|
10134
|
+
return;
|
|
10135
|
+
}
|
|
10136
|
+
this.records = null;
|
|
10137
|
+
logger.debug("[HISTORY SEARCH] in-memory index unloaded (idle)");
|
|
10138
|
+
}
|
|
10139
|
+
async ensureLoaded() {
|
|
10140
|
+
if (this.clearing) await this.clearing;
|
|
10141
|
+
if (this.records) return;
|
|
10142
|
+
if (!this.loading) {
|
|
10143
|
+
const gen = this.generation;
|
|
10144
|
+
const run = (async () => {
|
|
10145
|
+
const loaded = await loadIndex(this.opts.indexDir);
|
|
10146
|
+
if (gen !== this.generation) return;
|
|
10147
|
+
this.records = loaded?.records ?? /* @__PURE__ */ new Map();
|
|
10148
|
+
if (loaded && this.lastIndexedAt === void 0) this.lastIndexedAt = loaded.savedAt;
|
|
10149
|
+
})().catch((error) => {
|
|
10150
|
+
if (gen !== this.generation) return;
|
|
10151
|
+
logger.debug("[HISTORY SEARCH] index load failed; starting empty", error);
|
|
10152
|
+
this.records = /* @__PURE__ */ new Map();
|
|
10153
|
+
}).finally(() => {
|
|
10154
|
+
if (this.loading === run) this.loading = null;
|
|
10155
|
+
});
|
|
10156
|
+
this.loading = run;
|
|
10157
|
+
}
|
|
10158
|
+
const pending = this.loading;
|
|
10159
|
+
let timer;
|
|
10160
|
+
await Promise.race([
|
|
10161
|
+
pending,
|
|
10162
|
+
new Promise((resolve) => {
|
|
10163
|
+
timer = setTimeout(resolve, this.opts.loadWaitMs);
|
|
10164
|
+
timer.unref?.();
|
|
10165
|
+
})
|
|
10166
|
+
]);
|
|
10167
|
+
if (timer) clearTimeout(timer);
|
|
10168
|
+
}
|
|
10169
|
+
maybeRefresh() {
|
|
10170
|
+
if (this.clearing || this.refreshing || !this.records) return;
|
|
10171
|
+
const now = this.opts.now();
|
|
10172
|
+
if (this.lastRefreshStartedAt && now - this.lastRefreshStartedAt < this.opts.refreshIntervalMs) return;
|
|
10173
|
+
this.lastRefreshStartedAt = now;
|
|
10174
|
+
const gen = this.generation;
|
|
10175
|
+
const records = this.records;
|
|
10176
|
+
const save = async () => {
|
|
10177
|
+
if (gen !== this.generation || this.clearing) return;
|
|
10178
|
+
await saveIndex(this.opts.indexDir, records.values(), this.opts.now());
|
|
10179
|
+
};
|
|
10180
|
+
const run = (async () => {
|
|
10181
|
+
const roots = await this.opts.resolveRoots();
|
|
10182
|
+
if (gen !== this.generation) return;
|
|
10183
|
+
const started = this.opts.now();
|
|
10184
|
+
const result = await refreshRecords(records, {
|
|
10185
|
+
roots,
|
|
10186
|
+
maxTextChars: this.opts.maxTextChars,
|
|
10187
|
+
isCancelled: () => gen !== this.generation,
|
|
10188
|
+
onProgress: (progress) => {
|
|
10189
|
+
if (gen === this.generation) this.progress = progress;
|
|
10190
|
+
},
|
|
10191
|
+
checkpoint: async () => {
|
|
10192
|
+
if (gen === this.generation) await save();
|
|
10193
|
+
},
|
|
10194
|
+
now: this.opts.now
|
|
10195
|
+
});
|
|
10196
|
+
if (result.cancelled || gen !== this.generation) return;
|
|
10197
|
+
if (result.changed) await save();
|
|
10198
|
+
this.lastIndexedAt = this.opts.now();
|
|
10199
|
+
this.error = void 0;
|
|
10200
|
+
logger.debug(`[HISTORY SEARCH] refresh: ${result.filesTotal} files, changed=${result.changed}, evicted=${result.evicted} (${this.opts.now() - started}ms)`);
|
|
10201
|
+
})().catch((error) => {
|
|
10202
|
+
if (gen !== this.generation) return;
|
|
10203
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
10204
|
+
logger.debug("[HISTORY SEARCH] refresh failed", error);
|
|
10205
|
+
}).finally(() => {
|
|
10206
|
+
if (this.refreshing === run) this.refreshing = null;
|
|
10207
|
+
});
|
|
10208
|
+
this.refreshing = run;
|
|
10209
|
+
}
|
|
10210
|
+
}
|
|
10211
|
+
|
|
10212
|
+
async function resolveHistoryRoots() {
|
|
10213
|
+
const homes = await refreshAgentHomes(Date.now(), { allowStale: true });
|
|
10214
|
+
return {
|
|
10215
|
+
claudeProjectsRoot: join(homes.claudeConfigDir.path, "projects"),
|
|
10216
|
+
codexSessionsRoot: join(homes.codexHome.path, "sessions"),
|
|
10217
|
+
codexIndexPath: join(homes.codexHome.path, "session_index.jsonl")
|
|
10218
|
+
};
|
|
10219
|
+
}
|
|
10220
|
+
function historySearchIndexDir() {
|
|
10221
|
+
return join(configuration.happyHomeDir, "history-search");
|
|
10222
|
+
}
|
|
10223
|
+
function createHistorySearchService() {
|
|
10224
|
+
return new HistorySearchService({ indexDir: historySearchIndexDir(), resolveRoots: resolveHistoryRoots });
|
|
10225
|
+
}
|
|
10226
|
+
|
|
8267
10227
|
function selectLowestLatencyRelay(candidates, probes) {
|
|
8268
10228
|
const rttById = new Map(probes.map((probe) => [probe.relayId, probe.rttMs]));
|
|
8269
10229
|
let selected = null;
|
|
@@ -8382,6 +10342,74 @@ async function discoverAndClaimRelay(input) {
|
|
|
8382
10342
|
}
|
|
8383
10343
|
}
|
|
8384
10344
|
|
|
10345
|
+
const RATE_LIMIT_MIN_WRITE_INTERVAL_MS = 6e4;
|
|
10346
|
+
const MAX_PENDING_REPORTS = 16;
|
|
10347
|
+
class RateLimitPublisher {
|
|
10348
|
+
constructor(deps) {
|
|
10349
|
+
this.deps = deps;
|
|
10350
|
+
this.now = deps.now ?? Date.now;
|
|
10351
|
+
this.minIntervalMs = deps.minIntervalMs ?? RATE_LIMIT_MIN_WRITE_INTERVAL_MS;
|
|
10352
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => {
|
|
10353
|
+
const handle = setTimeout(fn, ms);
|
|
10354
|
+
handle.unref?.();
|
|
10355
|
+
return handle;
|
|
10356
|
+
});
|
|
10357
|
+
this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
10358
|
+
}
|
|
10359
|
+
pending = [];
|
|
10360
|
+
timer = null;
|
|
10361
|
+
writing = false;
|
|
10362
|
+
lastWriteAt = 0;
|
|
10363
|
+
stopped = false;
|
|
10364
|
+
now;
|
|
10365
|
+
minIntervalMs;
|
|
10366
|
+
setTimer;
|
|
10367
|
+
clearTimer;
|
|
10368
|
+
report(report) {
|
|
10369
|
+
if (this.stopped) return;
|
|
10370
|
+
this.pending.push(report);
|
|
10371
|
+
if (this.pending.length > MAX_PENDING_REPORTS) {
|
|
10372
|
+
this.pending.splice(0, this.pending.length - MAX_PENDING_REPORTS);
|
|
10373
|
+
}
|
|
10374
|
+
this.schedule();
|
|
10375
|
+
}
|
|
10376
|
+
stop() {
|
|
10377
|
+
this.stopped = true;
|
|
10378
|
+
this.pending = [];
|
|
10379
|
+
if (this.timer) this.clearTimer(this.timer);
|
|
10380
|
+
this.timer = null;
|
|
10381
|
+
}
|
|
10382
|
+
schedule() {
|
|
10383
|
+
if (this.timer || this.writing || this.stopped || this.pending.length === 0) return;
|
|
10384
|
+
const wait = this.lastWriteAt === 0 ? 0 : Math.max(0, this.lastWriteAt + this.minIntervalMs - this.now());
|
|
10385
|
+
this.timer = this.setTimer(() => {
|
|
10386
|
+
this.timer = null;
|
|
10387
|
+
void this.flush();
|
|
10388
|
+
}, wait);
|
|
10389
|
+
}
|
|
10390
|
+
/** Exposed for tests; normally driven by the timer. */
|
|
10391
|
+
async flush() {
|
|
10392
|
+
if (this.writing || this.stopped) return;
|
|
10393
|
+
const reports = this.pending;
|
|
10394
|
+
this.pending = [];
|
|
10395
|
+
if (!planRateLimitWrite(this.deps.getCurrent(), reports, this.now())) return;
|
|
10396
|
+
this.writing = true;
|
|
10397
|
+
this.lastWriteAt = this.now();
|
|
10398
|
+
let written = false;
|
|
10399
|
+
try {
|
|
10400
|
+
written = await this.deps.write(reports);
|
|
10401
|
+
} catch {
|
|
10402
|
+
written = false;
|
|
10403
|
+
} finally {
|
|
10404
|
+
this.writing = false;
|
|
10405
|
+
}
|
|
10406
|
+
if (!written && !this.stopped) {
|
|
10407
|
+
this.pending = [...reports, ...this.pending].slice(-MAX_PENDING_REPORTS);
|
|
10408
|
+
}
|
|
10409
|
+
this.schedule();
|
|
10410
|
+
}
|
|
10411
|
+
}
|
|
10412
|
+
|
|
8385
10413
|
const guardOptions = { log: (message) => logger.debug(message) };
|
|
8386
10414
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8387
10415
|
function requireNonEmptyString(value, name) {
|
|
@@ -8423,6 +10451,8 @@ class ApiMachineClient {
|
|
|
8423
10451
|
keepAliveInterval = null;
|
|
8424
10452
|
lastKnownCLIAvailability = null;
|
|
8425
10453
|
lastKnownResumeSupport = null;
|
|
10454
|
+
/** B-549: created on the first `history-search` call. */
|
|
10455
|
+
historySearch = null;
|
|
8426
10456
|
/** Wall-clock of the last CLI availability probe. Initialised as "already
|
|
8427
10457
|
* stale" so the FIRST keep-alive tick always probes and populates the
|
|
8428
10458
|
* machine metadata that a connecting/reconnecting web client reads. */
|
|
@@ -8432,6 +10462,11 @@ class ApiMachineClient {
|
|
|
8432
10462
|
cliUpdateState = null;
|
|
8433
10463
|
cliUpdatePushChain = Promise.resolve();
|
|
8434
10464
|
claudeAuthState = null;
|
|
10465
|
+
rateLimitPushChain = Promise.resolve();
|
|
10466
|
+
rateLimitPublisher = new RateLimitPublisher({
|
|
10467
|
+
getCurrent: () => this.machine.daemonState?.rateLimits,
|
|
10468
|
+
write: (reports) => this.writeRateLimits(reports)
|
|
10469
|
+
});
|
|
8435
10470
|
rpcHandlerManager;
|
|
8436
10471
|
resumeSessionHandler = null;
|
|
8437
10472
|
restartSessionHandler = null;
|
|
@@ -8492,6 +10527,11 @@ class ApiMachineClient {
|
|
|
8492
10527
|
setTerminalTitle(terminalId, title, ifAbsent) {
|
|
8493
10528
|
return this.webTerminal.setTitle(terminalId, title, ifAbsent);
|
|
8494
10529
|
}
|
|
10530
|
+
/** B-547: a status hook forwarded from a daemon-launched claude inside a
|
|
10531
|
+
* web terminal (control server /terminal-agent-hook → here). */
|
|
10532
|
+
ingestTerminalAgentHook(body) {
|
|
10533
|
+
this.webTerminal.ingestAgentHook(body);
|
|
10534
|
+
}
|
|
8495
10535
|
/**
|
|
8496
10536
|
* Push text to the clipboard of every web client the user has open
|
|
8497
10537
|
* (terminal-path claude → `very-happy mcp` → daemon /clipboard → here).
|
|
@@ -8515,13 +10555,14 @@ class ApiMachineClient {
|
|
|
8515
10555
|
return { delivered: true, truncated: prepared.truncated, totalBytes: prepared.totalBytes };
|
|
8516
10556
|
}
|
|
8517
10557
|
/** Queue a terminal preview with the machine key. Delivery does not prove a browser opened it. */
|
|
8518
|
-
pushFilePreview(terminalId, path, mode = "file") {
|
|
10558
|
+
pushFilePreview(terminalId, path, mode = "file", intent) {
|
|
8519
10559
|
if (!this.socket?.connected) return { delivered: false, error: "daemon is not connected to the server" };
|
|
8520
10560
|
this.socket.emit("file-preview-push", {
|
|
8521
10561
|
terminalId,
|
|
8522
10562
|
payload: encodeBase64(encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, path)),
|
|
8523
10563
|
enc: true,
|
|
8524
|
-
mode
|
|
10564
|
+
mode,
|
|
10565
|
+
...intent ? { intent } : {}
|
|
8525
10566
|
});
|
|
8526
10567
|
return { delivered: true };
|
|
8527
10568
|
}
|
|
@@ -8544,6 +10585,26 @@ class ApiMachineClient {
|
|
|
8544
10585
|
if (!this.socket?.connected) return;
|
|
8545
10586
|
this.cliUpdatePushChain = this.cliUpdatePushChain.then(() => this.updateDaemonState((current) => withCurrentCliUpdateState(current, this.cliUpdateState))).catch((error) => logger.debug("[API MACHINE] Failed to publish CLI update policy:", error));
|
|
8546
10587
|
}
|
|
10588
|
+
/** B-555: a wrapper's rate-limit observation; written throttled by the publisher. */
|
|
10589
|
+
reportRateLimits(report) {
|
|
10590
|
+
this.rateLimitPublisher.report(report);
|
|
10591
|
+
}
|
|
10592
|
+
writeRateLimits(reports) {
|
|
10593
|
+
if (!this.socket?.connected) return Promise.resolve(false);
|
|
10594
|
+
const attempt = this.rateLimitPushChain.then(() => this.updateDaemonState((current) => {
|
|
10595
|
+
const merged = mergeRateLimitReports(current?.rateLimits, reports, Date.now());
|
|
10596
|
+
return {
|
|
10597
|
+
...current ?? {},
|
|
10598
|
+
status: current?.status ?? "running",
|
|
10599
|
+
...merged ? { rateLimits: merged } : {}
|
|
10600
|
+
};
|
|
10601
|
+
})).then(() => true).catch((error) => {
|
|
10602
|
+
logger.debug("[API MACHINE] Failed to publish rate limits:", error);
|
|
10603
|
+
return false;
|
|
10604
|
+
});
|
|
10605
|
+
this.rateLimitPushChain = attempt.then(() => void 0);
|
|
10606
|
+
return attempt;
|
|
10607
|
+
}
|
|
8547
10608
|
/**
|
|
8548
10609
|
* B-276: publish the daemon-context Claude auth preflight into daemonState
|
|
8549
10610
|
* (same serialized push chain as the CLI update policy). Resolves true when
|
|
@@ -8894,6 +10955,10 @@ class ApiMachineClient {
|
|
|
8894
10955
|
});
|
|
8895
10956
|
return { type: "success", ...result };
|
|
8896
10957
|
});
|
|
10958
|
+
this.rpcHandlerManager.registerHandler("history-search", async (params) => {
|
|
10959
|
+
this.historySearch ??= createHistorySearchService();
|
|
10960
|
+
return this.historySearch.handle(params);
|
|
10961
|
+
});
|
|
8897
10962
|
this.rpcHandlerManager.registerHandler("codex-import-session", async (params) => {
|
|
8898
10963
|
const { directory, codexThreadId, approvedNewDirectoryCreation, permissionMode, title } = params || {};
|
|
8899
10964
|
if (typeof directory !== "string" || directory.length === 0) {
|
|
@@ -9272,6 +11337,8 @@ class ApiMachineClient {
|
|
|
9272
11337
|
claudeHistory: { rpcAvailable: true, detectedAt: now },
|
|
9273
11338
|
// B-464 capability flag: `codex-list-history` / `codex-import-session`.
|
|
9274
11339
|
codexHistory: { rpcAvailable: true, detectedAt: now },
|
|
11340
|
+
// B-549 capability flag: `history-search` (same restamp discipline).
|
|
11341
|
+
historySearch: { rpcAvailable: true, detectedAt: now },
|
|
9275
11342
|
// B-084: closed records survive daemon restarts (persisted
|
|
9276
11343
|
// in closed-terminals.json), so the connect snapshot ships
|
|
9277
11344
|
// them too — not just the incremental pushes.
|
|
@@ -9574,9 +11641,14 @@ class ApiMachineClient {
|
|
|
9574
11641
|
logger.debug("[API MACHINE] Keep-alive stopped");
|
|
9575
11642
|
}
|
|
9576
11643
|
}
|
|
11644
|
+
/** B-550: final terminal scrollback checkpoint; resolves within ~300ms, never rejects. */
|
|
11645
|
+
flushTerminalScrollback() {
|
|
11646
|
+
return this.webTerminal.flushScrollbackForShutdown().catch(() => void 0);
|
|
11647
|
+
}
|
|
9577
11648
|
shutdown() {
|
|
9578
11649
|
logger.debug("[API MACHINE] Shutting down");
|
|
9579
11650
|
this.webTerminal.stopListTracking();
|
|
11651
|
+
this.rateLimitPublisher.stop();
|
|
9580
11652
|
this.stopKeepAlive();
|
|
9581
11653
|
if (this.reconnectInterval) {
|
|
9582
11654
|
clearInterval(this.reconnectInterval);
|