termdock 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +385 -0
  3. package/dist/client/apple-splash-default-1170x2532.png +0 -0
  4. package/dist/client/apple-splash-landscape-1136x640.png +0 -0
  5. package/dist/client/apple-splash-landscape-1334x750.png +0 -0
  6. package/dist/client/apple-splash-landscape-1792x828.png +0 -0
  7. package/dist/client/apple-splash-landscape-2048x1536.png +0 -0
  8. package/dist/client/apple-splash-landscape-2160x1620.png +0 -0
  9. package/dist/client/apple-splash-landscape-2208x1242.png +0 -0
  10. package/dist/client/apple-splash-landscape-2224x1668.png +0 -0
  11. package/dist/client/apple-splash-landscape-2266x1488.png +0 -0
  12. package/dist/client/apple-splash-landscape-2360x1640.png +0 -0
  13. package/dist/client/apple-splash-landscape-2388x1668.png +0 -0
  14. package/dist/client/apple-splash-landscape-2436x1125.png +0 -0
  15. package/dist/client/apple-splash-landscape-2532x1170.png +0 -0
  16. package/dist/client/apple-splash-landscape-2556x1179.png +0 -0
  17. package/dist/client/apple-splash-landscape-2622x1206.png +0 -0
  18. package/dist/client/apple-splash-landscape-2688x1242.png +0 -0
  19. package/dist/client/apple-splash-landscape-2732x2048.png +0 -0
  20. package/dist/client/apple-splash-landscape-2778x1284.png +0 -0
  21. package/dist/client/apple-splash-landscape-2796x1290.png +0 -0
  22. package/dist/client/apple-splash-landscape-2868x1320.png +0 -0
  23. package/dist/client/apple-splash-portrait-1125x2436.png +0 -0
  24. package/dist/client/apple-splash-portrait-1170x2532.png +0 -0
  25. package/dist/client/apple-splash-portrait-1179x2556.png +0 -0
  26. package/dist/client/apple-splash-portrait-1206x2622.png +0 -0
  27. package/dist/client/apple-splash-portrait-1242x2208.png +0 -0
  28. package/dist/client/apple-splash-portrait-1242x2688.png +0 -0
  29. package/dist/client/apple-splash-portrait-1284x2778.png +0 -0
  30. package/dist/client/apple-splash-portrait-1290x2796.png +0 -0
  31. package/dist/client/apple-splash-portrait-1320x2868.png +0 -0
  32. package/dist/client/apple-splash-portrait-1488x2266.png +0 -0
  33. package/dist/client/apple-splash-portrait-1536x2048.png +0 -0
  34. package/dist/client/apple-splash-portrait-1620x2160.png +0 -0
  35. package/dist/client/apple-splash-portrait-1640x2360.png +0 -0
  36. package/dist/client/apple-splash-portrait-1668x2224.png +0 -0
  37. package/dist/client/apple-splash-portrait-1668x2388.png +0 -0
  38. package/dist/client/apple-splash-portrait-2048x2732.png +0 -0
  39. package/dist/client/apple-splash-portrait-640x1136.png +0 -0
  40. package/dist/client/apple-splash-portrait-750x1334.png +0 -0
  41. package/dist/client/apple-splash-portrait-828x1792.png +0 -0
  42. package/dist/client/apple-touch-icon-180x180.png +0 -0
  43. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  44. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  45. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  47. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  48. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  50. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  53. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  54. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  57. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  58. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  60. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  61. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  62. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  63. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  64. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  65. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  66. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  67. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  68. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  69. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  70. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  71. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  72. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  73. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  74. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  75. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  76. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  77. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  78. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  79. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  80. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  81. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  82. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  83. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  84. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  85. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  86. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  87. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  88. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  89. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  90. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  91. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  92. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  93. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  94. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  95. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  96. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  97. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  98. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  99. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  100. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  101. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  102. package/dist/client/assets/abnfDiagram-VRR7QNED-D0NxeSHA.js +1 -0
  103. package/dist/client/assets/arc-B7GoKc6u.js +1 -0
  104. package/dist/client/assets/architectureDiagram-ZJ3FMSHR-BQPW-wUx.js +36 -0
  105. package/dist/client/assets/blockDiagram-677ZJIJ3-CX_hbcgG.js +132 -0
  106. package/dist/client/assets/c4Diagram-LMCZKHZV-BwOMQA4x.js +10 -0
  107. package/dist/client/assets/channel-C2gUFcyU.js +1 -0
  108. package/dist/client/assets/chunk-2Q5K7J3B-BLJzEQXz.js +1 -0
  109. package/dist/client/assets/chunk-32BRIVSS-CjBDr_gz.js +1 -0
  110. package/dist/client/assets/chunk-5VM5RSS4-lddvv0x2.js +15 -0
  111. package/dist/client/assets/chunk-EX3LRPZG-Dtsq1Stg.js +231 -0
  112. package/dist/client/assets/chunk-JWPE2WC7-BG3PZx4j.js +1 -0
  113. package/dist/client/assets/chunk-MOJQB5TN-CMtlCuQg.js +88 -0
  114. package/dist/client/assets/chunk-RYQCIY6F-uplKqw64.js +1 -0
  115. package/dist/client/assets/chunk-V7JOEXUC-CpteLsY3.js +206 -0
  116. package/dist/client/assets/chunk-VR4S4FIN-1zYOJQXL.js +1 -0
  117. package/dist/client/assets/chunk-XXDRQBXY-C0wXH91I.js +1 -0
  118. package/dist/client/assets/classDiagram-OUVF2IWQ-BTOnA9HS.js +1 -0
  119. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BTOnA9HS.js +1 -0
  120. package/dist/client/assets/cose-bilkent-JH36ORCC-BceT5ki2.js +1 -0
  121. package/dist/client/assets/cynefin-VYW2F7L2-BqCIkNeC.js +166 -0
  122. package/dist/client/assets/cynefinDiagram-TSTJHNR4-Dm7y-tsN.js +62 -0
  123. package/dist/client/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
  124. package/dist/client/assets/dagre-VKFMJZFB-Bn8eh0lE.js +4 -0
  125. package/dist/client/assets/defaultLocale-CrowFXzY.js +1 -0
  126. package/dist/client/assets/diagram-FQU43EPY-DmZGMAsX.js +3 -0
  127. package/dist/client/assets/diagram-G47NLZAW-BLWSm_Rn.js +24 -0
  128. package/dist/client/assets/diagram-NH7WQ7WH-C2-HK8pv.js +24 -0
  129. package/dist/client/assets/diagram-OA4YK3LP-8Ex4XIU5.js +30 -0
  130. package/dist/client/assets/diagram-WEI45ONY-MRNIpBXG.js +41 -0
  131. package/dist/client/assets/diffWorker-B6msVyjo.js +18 -0
  132. package/dist/client/assets/dnd-BCFAPC0L.js +1 -0
  133. package/dist/client/assets/ebnfDiagram-CCIWWBDH-D7Z6wZi_.js +1 -0
  134. package/dist/client/assets/erDiagram-Q63AITRT-C96Q5x9y.js +85 -0
  135. package/dist/client/assets/flowDiagram-23GEKE2U-DzQp40QH.js +156 -0
  136. package/dist/client/assets/ganttDiagram-NO4QXBWP-ZzkchZJ8.js +292 -0
  137. package/dist/client/assets/gitGraphDiagram-IHSO6WYX-CFacEf54.js +106 -0
  138. package/dist/client/assets/graph-D2o_JWn5.js +1 -0
  139. package/dist/client/assets/index-Ca-rq7yX.js +6 -0
  140. package/dist/client/assets/index-Cv7q4nZR.css +1 -0
  141. package/dist/client/assets/index-XktPMm51.js +394 -0
  142. package/dist/client/assets/infoDiagram-FWYZ7A6U-CVRXs5rF.js +2 -0
  143. package/dist/client/assets/init-Gi6I4Gst.js +1 -0
  144. package/dist/client/assets/ishikawaDiagram-FXEZZL3T-B8FP_wzM.js +70 -0
  145. package/dist/client/assets/journeyDiagram-5HDEW3XC-BR__4Hbd.js +139 -0
  146. package/dist/client/assets/kanban-definition-HUTT4EX6-v39oZe6X.js +89 -0
  147. package/dist/client/assets/katex-55ZPZeOF.css +1 -0
  148. package/dist/client/assets/katex-DnlPpQZa.js +257 -0
  149. package/dist/client/assets/katex-HP8lGamR.js +257 -0
  150. package/dist/client/assets/layout-1eBIAoSl.js +1 -0
  151. package/dist/client/assets/linear-jlTiHqO6.js +1 -0
  152. package/dist/client/assets/map-BdqsVdb3.js +1 -0
  153. package/dist/client/assets/mermaid.core-DiiIN0EC.js +306 -0
  154. package/dist/client/assets/mindmap-definition-LN4V7U3C-AeUxAZeq.js +96 -0
  155. package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
  156. package/dist/client/assets/pegDiagram-2B236MQR-Dh-bgAbv.js +1 -0
  157. package/dist/client/assets/pieDiagram-ENE6RG2P-De7Si7Tm.js +39 -0
  158. package/dist/client/assets/purify.es-CC4Brkr_.js +3 -0
  159. package/dist/client/assets/quadrantDiagram-ABIIQ3AL-CgWfHqy0.js +7 -0
  160. package/dist/client/assets/railroadDiagram-RFXS5EU6-BXnQW-pI.js +1 -0
  161. package/dist/client/assets/react-XDCjal2T.js +8 -0
  162. package/dist/client/assets/requirementDiagram-TGXJPOKE-CU1nnpUM.js +84 -0
  163. package/dist/client/assets/sankeyDiagram-HTMAVEWB-DFK-AXY6.js +40 -0
  164. package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D_bVDWgq.js +162 -0
  165. package/dist/client/assets/sizeCapture-X5ZJPWSS-nh89ExHT.js +1 -0
  166. package/dist/client/assets/stateDiagram-2N3HPSRC-DUvT6Ruk.js +1 -0
  167. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dz-3r-Yq.js +1 -0
  168. package/dist/client/assets/swimlanes-5IMT3BWC-ZYa52gjG.js +2 -0
  169. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DXR_9lf9.js +8 -0
  170. package/dist/client/assets/terminal-C7X98Amf.js +10 -0
  171. package/dist/client/assets/timeline-definition-FHXFAJF6-Bp6A49KQ.js +120 -0
  172. package/dist/client/assets/vennDiagram-L72KCM5P-CDDUmLOI.js +34 -0
  173. package/dist/client/assets/wardleyDiagram-EHGQE667-CRc2ptu7.js +78 -0
  174. package/dist/client/assets/xychartDiagram-FW5EYKEG-ht_S0LUA.js +7 -0
  175. package/dist/client/favicon.ico +0 -0
  176. package/dist/client/fonts/JetBrainsMonoNL-Bold.woff2 +0 -0
  177. package/dist/client/fonts/JetBrainsMonoNL-BoldItalic.woff2 +0 -0
  178. package/dist/client/fonts/JetBrainsMonoNL-Italic.woff2 +0 -0
  179. package/dist/client/fonts/JetBrainsMonoNL-Regular.woff2 +0 -0
  180. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf +0 -0
  181. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf +0 -0
  182. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf +0 -0
  183. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf +0 -0
  184. package/dist/client/fonts/NotoSansMonoCJKsc-Bold-Subset.woff2 +0 -0
  185. package/dist/client/fonts/NotoSansMonoCJKsc-Subset.woff2 +0 -0
  186. package/dist/client/fonts/NotoSansSymbols2-Subset.woff2 +0 -0
  187. package/dist/client/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
  188. package/dist/client/fonts/TermdockStarSymbol-Subset.woff2 +0 -0
  189. package/dist/client/index.html +255 -0
  190. package/dist/client/manifest.webmanifest +1 -0
  191. package/dist/client/maskable-icon-512x512.png +0 -0
  192. package/dist/client/pwa-192x192.png +0 -0
  193. package/dist/client/pwa-192x192.svg +5 -0
  194. package/dist/client/pwa-512x512.png +0 -0
  195. package/dist/client/pwa-512x512.svg +5 -0
  196. package/dist/client/pwa-64x64.png +0 -0
  197. package/dist/client/registerSW.js +1 -0
  198. package/dist/client/robots.txt +4 -0
  199. package/dist/client/shell-integration/termdock.bash +107 -0
  200. package/dist/client/shell-integration/termdock.fish +60 -0
  201. package/dist/client/shell-integration/termdock.zsh +164 -0
  202. package/dist/client/sw-notifications.js +42 -0
  203. package/dist/client/sw.js +1 -0
  204. package/dist/client/workbox-e4022e15.js +1 -0
  205. package/dist/server/certificateWatcher.js +111 -0
  206. package/dist/server/cli.js +2534 -0
  207. package/dist/server/config.js +54 -0
  208. package/dist/server/entry.js +571 -0
  209. package/dist/server/onboardingServer.js +54 -0
  210. package/dist/server/routes/auth.js +48 -0
  211. package/dist/server/routes/filesystem.js +3749 -0
  212. package/dist/server/routes/local.js +32 -0
  213. package/dist/server/routes/onboarding.js +134 -0
  214. package/dist/server/routes/terminal.js +4879 -0
  215. package/dist/server/utils/authProtection.js +338 -0
  216. package/dist/server/utils/caffeinate.js +124 -0
  217. package/dist/server/utils/changeAuditStore.js +521 -0
  218. package/dist/server/utils/cookieSecurity.js +13 -0
  219. package/dist/server/utils/csrfProtection.js +112 -0
  220. package/dist/server/utils/localAccess.js +211 -0
  221. package/dist/server/utils/pathValidator.js +272 -0
  222. package/dist/server/utils/requestSecurity.js +103 -0
  223. package/dist/server/utils/serverLogger.js +57 -0
  224. package/dist/server/utils/settings.js +157 -0
  225. package/dist/server/utils/tmuxFocus.js +45 -0
  226. package/dist/server/utils/tmuxProgramDetection.js +148 -0
  227. package/package.json +125 -0
@@ -0,0 +1,4879 @@
1
+ import express from 'express';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { createRequire } from 'module';
6
+ import { fileURLToPath } from 'url';
7
+ import QRCode from 'qrcode';
8
+ import { execFile, spawn } from 'child_process';
9
+ import { randomUUID } from 'crypto';
10
+ import { promisify } from 'util';
11
+ import { caffeinateManager } from '../utils/caffeinate.js';
12
+ import { pathValidator } from '../utils/pathValidator.js';
13
+ import { localAccessManager } from '../utils/localAccess.js';
14
+ import { normalizeLocalAccessName } from '../utils/settings.js';
15
+ import { getOnboardingServerUrl } from '../onboardingServer.js';
16
+ import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
17
+ import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
18
+ const router = express.Router();
19
+ const execFileAsync = promisify(execFile);
20
+ const TERMDOCK_DIR = `${os.homedir()}/.termdock`;
21
+ async function readJsonFileIfExists(filePath) {
22
+ try {
23
+ const raw = await fs.promises.readFile(filePath, 'utf-8');
24
+ return JSON.parse(raw);
25
+ }
26
+ catch (error) {
27
+ if (error.code === 'ENOENT')
28
+ return null;
29
+ throw error;
30
+ }
31
+ }
32
+ async function writeJsonFile(filePath, value) {
33
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
34
+ await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8');
35
+ }
36
+ // Termdock metadata constants used to populate tmux user options
37
+ // (`@termdock-*`) so external tools (e.g. `termdock --tls`) can identify
38
+ // and describe termdock-managed tmux sessions without contacting the server.
39
+ const TERMDOCK_VERSION = (() => {
40
+ try {
41
+ const require_ = createRequire(import.meta.url);
42
+ // dist/server/routes/terminal.js → ../../../package.json
43
+ const pkg = require_(path.join(__dirname || '', '..', '..', '..', 'package.json'));
44
+ if (typeof pkg?.version === 'string')
45
+ return pkg.version;
46
+ }
47
+ catch { /* fall through */ }
48
+ try {
49
+ const require_ = createRequire(import.meta.url);
50
+ const pkg = require_('../../../package.json');
51
+ if (typeof pkg?.version === 'string')
52
+ return pkg.version;
53
+ }
54
+ catch { /* ignore */ }
55
+ return '0.0.0';
56
+ })();
57
+ const TERMDOCK_HOST = os.hostname();
58
+ const TERMDOCK_PID = String(process.pid);
59
+ const TERMDOCK_GUI_DETACHED_AT_OPTION = '@termdock-gui-detached-at';
60
+ // WebSocket clients per session (separate from SSE clients).
61
+ const wsClients = new Map();
62
+ // Sessions where copy-mode -e just auto-exited at the bottom.
63
+ // Prevents immediate re-entry on subsequent scroll-down commands.
64
+ const exitedAtBottom = new Set();
65
+ class HttpStatusError extends Error {
66
+ statusCode;
67
+ code;
68
+ constructor(statusCode, message, code) {
69
+ super(message);
70
+ this.statusCode = statusCode;
71
+ this.code = code;
72
+ this.name = 'HttpStatusError';
73
+ }
74
+ }
75
+ const terminalSessions = new Map();
76
+ let globalSessionState = { sessions: [], updatedAt: Date.now() };
77
+ // ── 持久化 globalSessionState 到磁盘,防止服务重启后丢失 ──
78
+ const GLOBAL_SESSION_STATE_FILE = `${TERMDOCK_DIR}/global-session-state.json`;
79
+ const CLIENT_STATES_FILE = `${TERMDOCK_DIR}/client-states.json`; // 保留用于迁移
80
+ let persistGlobalStateTimer = null;
81
+ let globalSessionStateWatcher = null;
82
+ let globalSessionStateReloadTimer = null;
83
+ // ── Control WebSocket: pushes the canonical client-state to every connected
84
+ // browser in real time. Each client gets a fresh snapshot on connect, then
85
+ // receives deltas on every mutation (PUT/DELETE client-state, dead-session
86
+ // reconciliation, etc.). Replaces the 5-second poll on the front-end. ──
87
+ const controlClients = new Map();
88
+ let latestSessionInventory = null;
89
+ let latestSessionInventoryAt = 0;
90
+ let sessionInventoryBuildPromise = null;
91
+ let broadcastInventorySeq = 0;
92
+ let broadcastClientStateTimer = null;
93
+ let broadcastClientStateInFlight = false;
94
+ let broadcastClientStateNeedsRerun = false;
95
+ let lastBroadcastClientStateSignature = null;
96
+ const inventoryOpenLocks = new Map();
97
+ const CONTROL_BROADCAST_COALESCE_MS = 50;
98
+ const SESSION_INVENTORY_CACHE_TTL_MS = 1500;
99
+ async function getSessionInventorySnapshot(options = {}) {
100
+ const now = Date.now();
101
+ if (!options.refresh && latestSessionInventory && now - latestSessionInventoryAt < SESSION_INVENTORY_CACHE_TTL_MS) {
102
+ return latestSessionInventory;
103
+ }
104
+ if (!options.refresh && sessionInventoryBuildPromise) {
105
+ return sessionInventoryBuildPromise;
106
+ }
107
+ const promise = buildSessionInventory()
108
+ .then((inventory) => {
109
+ latestSessionInventory = inventory;
110
+ latestSessionInventoryAt = Date.now();
111
+ return inventory;
112
+ })
113
+ .finally(() => {
114
+ if (sessionInventoryBuildPromise === promise)
115
+ sessionInventoryBuildPromise = null;
116
+ });
117
+ sessionInventoryBuildPromise = promise;
118
+ return promise;
119
+ }
120
+ function getClientStateSemanticSignature(state, inventory) {
121
+ return JSON.stringify({
122
+ sessions: state.sessions.map((session) => ({
123
+ sessionId: session.sessionId,
124
+ name: session.name,
125
+ customName: session.customName === true,
126
+ backendSessionId: session.backendSessionId,
127
+ mode: session.mode,
128
+ tmuxSessionName: session.tmuxSessionName,
129
+ createdAt: session.createdAt,
130
+ lastActivity: session.lastActivity,
131
+ })),
132
+ inventory: inventory ? {
133
+ clientSessions: inventory.clientSessions.map((session) => ({
134
+ sessionId: session.sessionId,
135
+ frontendSessionId: session.frontendSessionId,
136
+ name: session.name,
137
+ customName: session.customName === true,
138
+ backendSessionId: session.backendSessionId,
139
+ mode: session.mode,
140
+ tmuxSessionName: session.tmuxSessionName,
141
+ createdAt: session.createdAt,
142
+ lastActivity: session.lastActivity,
143
+ connected: session.connected,
144
+ live: session.live,
145
+ restorable: session.restorable,
146
+ })),
147
+ tmuxSessions: inventory.tmuxSessions.map((session) => ({
148
+ name: session.name,
149
+ windows: session.windows,
150
+ attached: session.attached,
151
+ attachedCount: session.attachedCount,
152
+ createdAt: session.createdAt,
153
+ boundFrontendSessionId: session.boundFrontendSessionId,
154
+ connected: session.connected,
155
+ live: session.live,
156
+ restorable: session.restorable,
157
+ friendlyName: session.friendlyName,
158
+ label: session.label,
159
+ program: session.program,
160
+ cwd: session.cwd,
161
+ clientCount: session.clientCount,
162
+ })),
163
+ tmuxStatus: inventory.tmuxStatus,
164
+ } : null,
165
+ });
166
+ }
167
+ function sendClientStatePayload(payload) {
168
+ for (const [clientId, ws] of controlClients) {
169
+ if (ws.readyState !== ws.OPEN) {
170
+ controlClients.delete(clientId);
171
+ continue;
172
+ }
173
+ try {
174
+ ws.send(payload);
175
+ }
176
+ catch {
177
+ controlClients.delete(clientId);
178
+ }
179
+ }
180
+ }
181
+ function broadcastControlEvent(payload) {
182
+ sendClientStatePayload(JSON.stringify(payload));
183
+ }
184
+ async function flushClientStateBroadcast() {
185
+ if (broadcastClientStateInFlight) {
186
+ broadcastClientStateNeedsRerun = true;
187
+ return;
188
+ }
189
+ broadcastClientStateInFlight = true;
190
+ try {
191
+ const inventory = await getSessionInventorySnapshot().catch((error) => {
192
+ console.warn('[session-inventory] failed to build snapshot for broadcast:', getErrorMessage(error));
193
+ return latestSessionInventory;
194
+ });
195
+ const effectiveInventory = inventory ?? latestSessionInventory;
196
+ const signature = getClientStateSemanticSignature(globalSessionState, effectiveInventory);
197
+ if (signature === lastBroadcastClientStateSignature) {
198
+ return;
199
+ }
200
+ lastBroadcastClientStateSignature = signature;
201
+ const seq = ++broadcastInventorySeq;
202
+ sendClientStatePayload(JSON.stringify({
203
+ type: 'client-state',
204
+ seq,
205
+ state: globalSessionState,
206
+ inventory: effectiveInventory,
207
+ }));
208
+ }
209
+ finally {
210
+ broadcastClientStateInFlight = false;
211
+ if (broadcastClientStateNeedsRerun) {
212
+ broadcastClientStateNeedsRerun = false;
213
+ broadcastClientState();
214
+ }
215
+ }
216
+ }
217
+ function broadcastClientState() {
218
+ if (broadcastClientStateTimer) {
219
+ return;
220
+ }
221
+ broadcastClientStateTimer = setTimeout(() => {
222
+ broadcastClientStateTimer = null;
223
+ void flushClientStateBroadcast();
224
+ }, CONTROL_BROADCAST_COALESCE_MS);
225
+ broadcastClientStateTimer.unref?.();
226
+ }
227
+ function deduplicateGlobalSessions(sessions) {
228
+ const seen = new Map();
229
+ let hasDuplicates = false;
230
+ const keep = new Array(sessions.length).fill(true);
231
+ for (let i = sessions.length - 1; i >= 0; i--) {
232
+ const s = sessions[i];
233
+ if (seen.has(s.sessionId)) {
234
+ keep[i] = false;
235
+ hasDuplicates = true;
236
+ continue;
237
+ }
238
+ seen.set(s.sessionId, i);
239
+ if (s.mode === 'tmux' && s.tmuxSessionName) {
240
+ const dupKey = `tmux:${s.tmuxSessionName}`;
241
+ if (seen.has(dupKey)) {
242
+ keep[i] = false;
243
+ hasDuplicates = true;
244
+ continue;
245
+ }
246
+ seen.set(dupKey, i);
247
+ }
248
+ }
249
+ if (!hasDuplicates)
250
+ return sessions;
251
+ return sessions.filter((_, i) => keep[i]);
252
+ }
253
+ async function migrateFromClientStatesFile() {
254
+ try {
255
+ const data = await readJsonFileIfExists(CLIENT_STATES_FILE);
256
+ if (!data)
257
+ return null;
258
+ const allSessions = [];
259
+ for (const state of Object.values(data)) {
260
+ for (const s of (state.sessions || [])) {
261
+ const normalized = normalizePersistedClientSession(s);
262
+ if (normalized)
263
+ allSessions.push(normalized);
264
+ }
265
+ }
266
+ return {
267
+ sessions: deduplicateGlobalSessions(allSessions),
268
+ updatedAt: Date.now(),
269
+ };
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
275
+ async function loadGlobalSessionStateFromDisk() {
276
+ try {
277
+ const data = await readJsonFileIfExists(GLOBAL_SESSION_STATE_FILE);
278
+ if (data) {
279
+ globalSessionState = {
280
+ sessions: deduplicateGlobalSessions((data.sessions || []).map(s => normalizePersistedClientSession(s)).filter((s) => s !== null)),
281
+ updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : Date.now(),
282
+ };
283
+ console.log(`[session-persist] Loaded ${globalSessionState.sessions.length} sessions from global state`);
284
+ return;
285
+ }
286
+ const migrated = await migrateFromClientStatesFile();
287
+ if (migrated) {
288
+ globalSessionState = migrated;
289
+ schedulePersistGlobalState();
290
+ console.log(`[session-persist] Migrated ${globalSessionState.sessions.length} sessions from legacy client-states`);
291
+ return;
292
+ }
293
+ }
294
+ catch (error) {
295
+ console.warn('[session-persist] Failed to load global state:', getErrorMessage(error));
296
+ }
297
+ }
298
+ async function reloadGlobalSessionStateFromDisk(source) {
299
+ try {
300
+ const previous = JSON.stringify(globalSessionState);
301
+ await loadGlobalSessionStateFromDisk();
302
+ const next = JSON.stringify(globalSessionState);
303
+ if (previous === next) {
304
+ return;
305
+ }
306
+ console.log(`[session-persist] Reloaded global state from disk via ${source}`);
307
+ broadcastClientState();
308
+ }
309
+ catch (error) {
310
+ console.warn('[session-persist] Failed to reload global state:', getErrorMessage(error));
311
+ }
312
+ }
313
+ function scheduleReloadGlobalSessionState() {
314
+ if (globalSessionStateReloadTimer) {
315
+ clearTimeout(globalSessionStateReloadTimer);
316
+ }
317
+ globalSessionStateReloadTimer = setTimeout(() => {
318
+ globalSessionStateReloadTimer = null;
319
+ void reloadGlobalSessionStateFromDisk('watch');
320
+ }, 120);
321
+ globalSessionStateReloadTimer.unref?.();
322
+ }
323
+ async function watchGlobalSessionStateFile() {
324
+ try {
325
+ const dir = path.dirname(GLOBAL_SESSION_STATE_FILE);
326
+ await fs.promises.mkdir(dir, { recursive: true });
327
+ globalSessionStateWatcher?.close();
328
+ globalSessionStateWatcher = fs.watch(dir, (_eventType, filename) => {
329
+ if (filename !== path.basename(GLOBAL_SESSION_STATE_FILE)) {
330
+ return;
331
+ }
332
+ scheduleReloadGlobalSessionState();
333
+ });
334
+ globalSessionStateWatcher.on('error', (error) => {
335
+ console.warn('[session-persist] Global state watcher failed:', getErrorMessage(error));
336
+ });
337
+ }
338
+ catch (error) {
339
+ console.warn('[session-persist] Failed to watch global state file:', getErrorMessage(error));
340
+ }
341
+ }
342
+ function schedulePersistGlobalState() {
343
+ if (persistGlobalStateTimer)
344
+ clearTimeout(persistGlobalStateTimer);
345
+ persistGlobalStateTimer = setTimeout(() => {
346
+ try {
347
+ void writeJsonFile(GLOBAL_SESSION_STATE_FILE, globalSessionState).catch((error) => {
348
+ console.warn('[session-persist] Failed to persist global state:', getErrorMessage(error));
349
+ });
350
+ }
351
+ catch (error) {
352
+ console.warn('[session-persist] Failed to persist global state:', getErrorMessage(error));
353
+ }
354
+ }, 200);
355
+ }
356
+ async function persistGlobalStateNow() {
357
+ if (persistGlobalStateTimer) {
358
+ clearTimeout(persistGlobalStateTimer);
359
+ persistGlobalStateTimer = null;
360
+ }
361
+ try {
362
+ await writeJsonFile(GLOBAL_SESSION_STATE_FILE, globalSessionState);
363
+ }
364
+ catch (error) {
365
+ console.warn('[session-persist] Failed to persist global state:', getErrorMessage(error));
366
+ }
367
+ }
368
+ // 进程退出前立即刷盘,避免 tsx watch 重启导致状态丢失
369
+ function flushPersistAndExit() {
370
+ if (persistGlobalStateTimer) {
371
+ clearTimeout(persistGlobalStateTimer);
372
+ persistGlobalStateTimer = null;
373
+ }
374
+ try {
375
+ fs.mkdirSync(TERMDOCK_DIR, { recursive: true });
376
+ fs.writeFileSync(GLOBAL_SESSION_STATE_FILE, JSON.stringify(globalSessionState, null, 2), 'utf-8');
377
+ }
378
+ catch { /* best effort */ }
379
+ }
380
+ process.on('SIGTERM', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
381
+ process.on('SIGINT', () => { flushPersistAndExit(); void persistToolbarPresetsNow(); caffeinateManager.shutdown(); process.exit(0); });
382
+ // 服务启动时从磁盘加载(带去重,防止历史累积的重复条目复活)
383
+ void (async () => {
384
+ await loadGlobalSessionStateFromDisk();
385
+ await watchGlobalSessionStateFile();
386
+ pruneOrphanSessions();
387
+ await backfillPersistedTmuxMetadata();
388
+ })();
389
+ caffeinateManager.startNetworkMonitor();
390
+ // 清理磁盘恢复后后端已不存在的 session 引用。
391
+ // 服务重启时 terminalSessions 是空的,持久化的 global state
392
+ // 全部指向已销毁的 session。shell session 的 PTY 已死无法复用,直接删除;
393
+ // tmux session 的 tmux 进程独立于 termdock,保留条目但清空 backendSessionId。
394
+ function pruneOrphanSessions() {
395
+ let changed = false;
396
+ const cleaned = globalSessionState.sessions.filter((s) => {
397
+ // Shell sessions with no live backend: PTY is dead, can't be reattached — remove entirely
398
+ if (s.mode !== 'tmux') {
399
+ if (s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
400
+ changed = true;
401
+ return false;
402
+ }
403
+ // backendSessionId already null but no live backend either — also dead
404
+ if (s.backendSessionId == null) {
405
+ changed = true;
406
+ return false;
407
+ }
408
+ return true;
409
+ }
410
+ // Tmux sessions: tmux process may still be alive, keep but clear backend ref
411
+ if (s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
412
+ changed = true;
413
+ }
414
+ return true;
415
+ }).map((s) => {
416
+ if (s.mode === 'tmux' && s.backendSessionId != null && !terminalSessions.has(s.backendSessionId)) {
417
+ return { ...s, backendSessionId: null };
418
+ }
419
+ return s;
420
+ });
421
+ if (!changed)
422
+ return;
423
+ globalSessionState = {
424
+ sessions: cleaned,
425
+ updatedAt: Date.now(),
426
+ };
427
+ schedulePersistGlobalState();
428
+ broadcastClientState();
429
+ }
430
+ // On boot, backfill termdock metadata onto every tmux session referenced by
431
+ // the persisted client states. Lets `termdock --tls` work the first time
432
+ // after upgrading from a version that didn't write `@termdock-*`.
433
+ // Dynamic fields (label/program/cwd/last-active-at) are intentionally left
434
+ // for the per-session polling to fill in lazily.
435
+ async function backfillPersistedTmuxMetadata() {
436
+ const seen = new Set();
437
+ for (const s of globalSessionState.sessions) {
438
+ if (s.mode !== 'tmux' || !s.tmuxSessionName)
439
+ continue;
440
+ if (seen.has(s.tmuxSessionName))
441
+ continue;
442
+ seen.add(s.tmuxSessionName);
443
+ try {
444
+ if (!(await tmuxSessionExists(s.tmuxSessionName)))
445
+ continue;
446
+ const baseOptions = {
447
+ '@termdock-version': TERMDOCK_VERSION,
448
+ '@termdock-host': TERMDOCK_HOST,
449
+ '@termdock-pid': TERMDOCK_PID,
450
+ };
451
+ const existingCreatedAt = await getTmuxOption(s.tmuxSessionName, '@termdock-created-at');
452
+ if (!existingCreatedAt) {
453
+ baseOptions['@termdock-created-at'] = String(Date.now());
454
+ }
455
+ await setTmuxOptions(s.tmuxSessionName, baseOptions);
456
+ if (s.customName === true && typeof s.name === 'string' && s.name.trim().length > 0) {
457
+ await setTmuxOption(s.tmuxSessionName, '@termdock-friendly-name', s.name);
458
+ }
459
+ }
460
+ catch (error) {
461
+ console.warn(`[tmux] failed to backfill metadata on ${s.tmuxSessionName}: ${getErrorMessage(error)}`);
462
+ }
463
+ }
464
+ }
465
+ // ── end persistence ──
466
+ // ── Toolbar presets persistence (shared across all clients) ──
467
+ // Stored as a single JSON document at ~/.termdock/toolbar-presets.json.
468
+ // The schema is intentionally opaque to the server: it just round-trips
469
+ // `presets` (array) and `version` (number) so the client owns all merge /
470
+ // upgrade logic. The whole document is global (not keyed by clientId) so
471
+ // every browser pointing at this server sees the same toolbar config.
472
+ const TOOLBAR_PRESETS_FILE = `${TERMDOCK_DIR}/toolbar-presets.json`;
473
+ let toolbarPresetsDoc = null;
474
+ let persistToolbarPresetsTimer = null;
475
+ async function loadToolbarPresetsFromDisk() {
476
+ try {
477
+ const parsed = await readJsonFileIfExists(TOOLBAR_PRESETS_FILE);
478
+ if (parsed) {
479
+ toolbarPresetsDoc = {
480
+ version: typeof parsed.version === 'number' ? parsed.version : 0,
481
+ presets: Array.isArray(parsed.presets) ? parsed.presets : [],
482
+ updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(),
483
+ };
484
+ }
485
+ }
486
+ catch (error) {
487
+ console.warn('[toolbar-presets] Failed to load from disk:', getErrorMessage(error));
488
+ }
489
+ }
490
+ async function persistToolbarPresetsNow() {
491
+ if (!toolbarPresetsDoc)
492
+ return;
493
+ try {
494
+ await writeJsonFile(TOOLBAR_PRESETS_FILE, toolbarPresetsDoc);
495
+ }
496
+ catch (error) {
497
+ console.warn('[toolbar-presets] Failed to persist:', getErrorMessage(error));
498
+ }
499
+ }
500
+ function schedulePersistToolbarPresets() {
501
+ if (persistToolbarPresetsTimer)
502
+ clearTimeout(persistToolbarPresetsTimer);
503
+ persistToolbarPresetsTimer = setTimeout(() => { void persistToolbarPresetsNow(); }, 200);
504
+ }
505
+ void loadToolbarPresetsFromDisk();
506
+ // ── end toolbar presets persistence ──
507
+ // 开发模式下使用更激进的清理策略
508
+ const isDevelopment = process.env.NODE_ENV === 'development';
509
+ // idle 超时:手机锁屏后客户端 JS 被 OS 暂停 → 无法发心跳维持活动。
510
+ // DEV 原本是 5 分钟,太短,手机后台一会儿就被清掉触发 auto-recreate;
511
+ // 调到 30 分钟覆盖大多数日常使用。生产 6 小时保持不变。
512
+ const TERMINAL_IDLE_TIMEOUT = parseInt(process.env.TERMINAL_IDLE_TIMEOUT || (isDevelopment ? '1800000' : '21600000'), 10);
513
+ const CLEANUP_INTERVAL = isDevelopment ? 60 * 1000 : 5 * 60 * 1000;
514
+ const RECONNECT_SCROLLBACK = parseInt(process.env.TERMINAL_RECONNECT_SCROLLBACK || '200', 10);
515
+ const TMUX_POLL_INTERVAL = parseInt(process.env.TMUX_POLL_INTERVAL || '500', 10);
516
+ const ACTIVE_PROGRAM_POLL_INTERVAL = parseInt(process.env.TERMINAL_ACTIVE_PROGRAM_POLL_INTERVAL || '1200', 10);
517
+ const FLOW_CONTROL_PAUSE_LEASE_MS = parseInt(process.env.TERMINAL_FLOW_CONTROL_PAUSE_LEASE_MS || '15000', 10);
518
+ const TMUX_DELIMITER = '\x1f';
519
+ const parsedTmuxHistoryLimit = Number.parseInt(process.env.TERMDOCK_TMUX_HISTORY_LIMIT || '10000', 10);
520
+ const TERMDOCK_TMUX_HISTORY_LIMIT = Number.isFinite(parsedTmuxHistoryLimit) && parsedTmuxHistoryLimit > 0
521
+ ? parsedTmuxHistoryLimit
522
+ : 10000;
523
+ // 输出历史缓冲区(限制大小)
524
+ const MAX_HISTORY_SIZE = 100 * 1024; // 100KB per session
525
+ const sessionHistory = new Map();
526
+ function addToHistory(sessionId, data) {
527
+ let history = sessionHistory.get(sessionId);
528
+ if (!history) {
529
+ history = { chunks: [], size: 0, nextSeq: 1 };
530
+ sessionHistory.set(sessionId, history);
531
+ }
532
+ const seq = history.nextSeq++;
533
+ history.chunks.push({ seq, data });
534
+ history.size += data.length;
535
+ // 超出限制时移除最旧的 chunk
536
+ while (history.size > MAX_HISTORY_SIZE && history.chunks.length > 0) {
537
+ const removed = history.chunks.shift();
538
+ if (removed) {
539
+ history.size -= removed.data.length;
540
+ }
541
+ }
542
+ return seq;
543
+ }
544
+ function getHistory(sessionId) {
545
+ const history = sessionHistory.get(sessionId);
546
+ return history ? history.chunks.map((c) => c.data) : [];
547
+ }
548
+ function clearHistory(sessionId) {
549
+ sessionHistory.delete(sessionId);
550
+ }
551
+ function getReconnectionHistory(sessionId) {
552
+ const history = getHistory(sessionId);
553
+ if (RECONNECT_SCROLLBACK <= 0 || history.length <= RECONNECT_SCROLLBACK) {
554
+ return history;
555
+ }
556
+ return history.slice(-RECONNECT_SCROLLBACK);
557
+ }
558
+ // 取当前 history 的最大 seq;用于客户端首次 attach 后记录基线。
559
+ function getHistoryLastSeq(sessionId) {
560
+ const history = sessionHistory.get(sessionId);
561
+ return history ? history.nextSeq - 1 : 0;
562
+ }
563
+ // 短线重连:返回 sinceSeq 之后的所有 chunks(含 seq)。
564
+ // 若 sinceSeq 落在已淘汰窗口之外,则需要发"超出窗口"标志,让前端走全量恢复。
565
+ function getHistorySince(sessionId, sinceSeq) {
566
+ const history = sessionHistory.get(sessionId);
567
+ if (!history) {
568
+ return { chunks: [], lastSeq: 0, outOfWindow: false };
569
+ }
570
+ const lastSeq = history.nextSeq - 1;
571
+ if (sinceSeq <= 0) {
572
+ // 客户端没有基线,按 RECONNECT_SCROLLBACK 截断。
573
+ const chunks = RECONNECT_SCROLLBACK > 0 && history.chunks.length > RECONNECT_SCROLLBACK
574
+ ? history.chunks.slice(-RECONNECT_SCROLLBACK)
575
+ : history.chunks.slice();
576
+ return { chunks, lastSeq, outOfWindow: false };
577
+ }
578
+ if (sinceSeq >= lastSeq) {
579
+ return { chunks: [], lastSeq, outOfWindow: false };
580
+ }
581
+ const oldestSeq = history.chunks.length > 0 ? history.chunks[0].seq : history.nextSeq;
582
+ if (sinceSeq < oldestSeq - 1) {
583
+ // 客户端基线已被淘汰,需要全量重放可见窗口。
584
+ const chunks = RECONNECT_SCROLLBACK > 0 && history.chunks.length > RECONNECT_SCROLLBACK
585
+ ? history.chunks.slice(-RECONNECT_SCROLLBACK)
586
+ : history.chunks.slice();
587
+ return { chunks, lastSeq, outOfWindow: true };
588
+ }
589
+ // 正常增量:返回 seq > sinceSeq 的部分。
590
+ // 由于 chunks 按 seq 递增,可以二分;这里数据量有限直接 filter。
591
+ const chunks = history.chunks.filter((c) => c.seq > sinceSeq);
592
+ return { chunks, lastSeq, outOfWindow: false };
593
+ }
594
+ function normalizeMode(input) {
595
+ return input === 'tmux' ? 'tmux' : 'shell';
596
+ }
597
+ function normalizePersistedClientSession(input) {
598
+ if (!input || typeof input !== 'object') {
599
+ return null;
600
+ }
601
+ const candidate = input;
602
+ if (typeof candidate.sessionId !== 'string' || typeof candidate.name !== 'string') {
603
+ return null;
604
+ }
605
+ return {
606
+ sessionId: candidate.sessionId,
607
+ name: candidate.name,
608
+ customName: candidate.customName === true ? true : undefined,
609
+ backendSessionId: typeof candidate.backendSessionId === 'string' && candidate.backendSessionId.trim().length > 0
610
+ ? candidate.backendSessionId
611
+ : null,
612
+ mode: normalizeMode(candidate.mode),
613
+ tmuxSessionName: typeof candidate.tmuxSessionName === 'string' && candidate.tmuxSessionName.trim().length > 0
614
+ ? candidate.tmuxSessionName
615
+ : null,
616
+ createdAt: typeof candidate.createdAt === 'number' && Number.isFinite(candidate.createdAt)
617
+ ? Math.floor(candidate.createdAt)
618
+ : Date.now(),
619
+ lastActivity: typeof candidate.lastActivity === 'number' && Number.isFinite(candidate.lastActivity)
620
+ ? Math.floor(candidate.lastActivity)
621
+ : Date.now(),
622
+ cwd: typeof candidate.cwd === 'string' && candidate.cwd.trim().length > 0
623
+ ? candidate.cwd
624
+ : null,
625
+ };
626
+ }
627
+ function generateTmuxSessionName() {
628
+ const timePart = Date.now().toString(36);
629
+ const randomPart = Math.random().toString(36).slice(2, 8);
630
+ return `wt-${timePart}${randomPart}`;
631
+ }
632
+ function normalizeTmuxSessionName(input) {
633
+ if (typeof input !== 'string') {
634
+ return generateTmuxSessionName();
635
+ }
636
+ const normalized = input.trim();
637
+ return normalized.length > 0 ? normalized : generateTmuxSessionName();
638
+ }
639
+ function parseNumberOption(raw) {
640
+ if (!raw)
641
+ return null;
642
+ const value = Number(raw);
643
+ return Number.isFinite(value) ? value : null;
644
+ }
645
+ function isTermdockManagedTmuxSession(session) {
646
+ return !!(session.version || session.host || session.pid || session.createdAt || session.lastActiveAt || session.label || session.program || session.cwd || session.guiDetachedAt);
647
+ }
648
+ function normalizeMetadataProgram(program) {
649
+ return normalizeTmuxMetadataProgram(program, { shellNames: shellNamesBackend });
650
+ }
651
+ function getActivePaneFromLayout(layout) {
652
+ const activeWindow = layout.windows.find((window) => window.id === layout.activeWindowId);
653
+ return activeWindow?.panes.find((pane) => pane.id === layout.activePaneId) ?? null;
654
+ }
655
+ function buildRuntimeTmuxMetadata(input) {
656
+ const program = normalizeMetadataProgram(input.program);
657
+ const cwd = input.cwd ?? null;
658
+ const friendlyName = findFriendlyNameForTmuxSession(input.tmuxSessionName);
659
+ const label = buildTermdockLabel({
660
+ friendlyName,
661
+ program,
662
+ cwd,
663
+ sessionName: input.tmuxSessionName,
664
+ });
665
+ return { program, cwd, label };
666
+ }
667
+ function maybeRepairTmuxOptions(sessionName, current, next) {
668
+ const currentSnapshot = {
669
+ program: current.program ?? null,
670
+ cwd: current.cwd ?? null,
671
+ label: current.label ?? '',
672
+ };
673
+ if (!tmuxMetadataChanged(currentSnapshot, next)) {
674
+ return;
675
+ }
676
+ void setTmuxOptions(sessionName, {
677
+ '@termdock-label': next.label,
678
+ '@termdock-program': next.program ?? '',
679
+ '@termdock-cwd': next.cwd ?? '',
680
+ '@termdock-last-active-at': String(Date.now()),
681
+ });
682
+ }
683
+ function makeTerminalSessionPayload(backendSessionId, session, cols = 80, rows = 24) {
684
+ return {
685
+ sessionId: backendSessionId,
686
+ cols,
687
+ rows,
688
+ mode: session.mode,
689
+ tmuxSessionName: session.tmuxSessionName,
690
+ activeProgram: session.activeProgram?.command ?? null,
691
+ activeProgramRaw: session.activeProgram?.rawArgs ?? null,
692
+ activeProgramSource: session.activeProgram?.source ?? null,
693
+ cwd: session.cwd ?? null,
694
+ };
695
+ }
696
+ function findBackendSessionForTmux(tmuxSessionName) {
697
+ for (const entry of terminalSessions.entries()) {
698
+ const [, session] = entry;
699
+ if (session.mode === 'tmux' && session.tmuxSessionName === tmuxSessionName) {
700
+ return entry;
701
+ }
702
+ }
703
+ return null;
704
+ }
705
+ function persistAndBroadcastGlobalState() {
706
+ globalSessionState = {
707
+ sessions: deduplicateGlobalSessions(globalSessionState.sessions),
708
+ updatedAt: Date.now(),
709
+ };
710
+ schedulePersistGlobalState();
711
+ broadcastClientState();
712
+ }
713
+ function makeInventoryOpenLockKey(input) {
714
+ const preferredFrontendSessionId = typeof input.preferredFrontendSessionId === 'string'
715
+ ? input.preferredFrontendSessionId.trim()
716
+ : '';
717
+ if (preferredFrontendSessionId) {
718
+ return `frontend:${preferredFrontendSessionId}`;
719
+ }
720
+ if (input.createIfEmpty === true) {
721
+ return 'default-if-empty';
722
+ }
723
+ const mode = normalizeMode(input.mode);
724
+ if (mode === 'tmux') {
725
+ const rawTmuxName = typeof input.tmuxSessionName === 'string'
726
+ ? input.tmuxSessionName.trim()
727
+ : '';
728
+ if (rawTmuxName) {
729
+ return `tmux:${rawTmuxName}`;
730
+ }
731
+ }
732
+ return `new:${randomUUID()}`;
733
+ }
734
+ async function withInventoryOpenLock(key, task) {
735
+ const previous = inventoryOpenLocks.get(key);
736
+ if (previous) {
737
+ return previous;
738
+ }
739
+ let pending;
740
+ pending = task().finally(() => {
741
+ if (inventoryOpenLocks.get(key) === pending) {
742
+ inventoryOpenLocks.delete(key);
743
+ }
744
+ });
745
+ inventoryOpenLocks.set(key, pending);
746
+ return pending;
747
+ }
748
+ function upsertGlobalSessionRecord(record) {
749
+ const normalized = normalizePersistedClientSession(record);
750
+ if (!normalized) {
751
+ throw new Error('invalid session record');
752
+ }
753
+ const next = [];
754
+ let replaced = false;
755
+ for (const existing of globalSessionState.sessions) {
756
+ if (existing.sessionId === normalized.sessionId) {
757
+ next.push(normalized);
758
+ replaced = true;
759
+ continue;
760
+ }
761
+ if (normalized.mode === 'tmux' &&
762
+ normalized.tmuxSessionName &&
763
+ existing.mode === 'tmux' &&
764
+ existing.tmuxSessionName === normalized.tmuxSessionName) {
765
+ continue;
766
+ }
767
+ next.push(existing);
768
+ }
769
+ if (!replaced) {
770
+ next.push(normalized);
771
+ }
772
+ globalSessionState = {
773
+ sessions: deduplicateGlobalSessions(next),
774
+ updatedAt: Date.now(),
775
+ };
776
+ return normalized;
777
+ }
778
+ function removeGlobalSessionRecord(frontendSessionId) {
779
+ const before = globalSessionState.sessions.length;
780
+ globalSessionState = {
781
+ sessions: globalSessionState.sessions.filter((session) => session.sessionId !== frontendSessionId),
782
+ updatedAt: Date.now(),
783
+ };
784
+ return globalSessionState.sessions.length !== before;
785
+ }
786
+ function getTrustedCwdFromRecord(record) {
787
+ return typeof record.cwd === 'string' && record.cwd.trim().length > 0
788
+ ? record.cwd
789
+ : undefined;
790
+ }
791
+ async function markAllPersistedTmuxSessionsDetached() {
792
+ const detachedAt = String(Date.now());
793
+ const tmuxSessionNames = new Set(globalSessionState.sessions
794
+ .map((session) => session.mode === 'tmux' ? session.tmuxSessionName : null)
795
+ .filter((name) => typeof name === 'string' && name.length > 0));
796
+ try {
797
+ const liveTmuxSessions = await listLiveTmuxInventorySessions();
798
+ for (const tmux of liveTmuxSessions) {
799
+ if (isTermdockManagedTmuxSession(tmux)) {
800
+ tmuxSessionNames.add(tmux.name);
801
+ }
802
+ }
803
+ }
804
+ catch (error) {
805
+ const errorMessage = getErrorMessage(error);
806
+ if (!isTmuxServerMissingMessage(errorMessage)) {
807
+ console.warn('[session-inventory] failed to list tmux sessions for clear-all:', errorMessage);
808
+ }
809
+ }
810
+ await Promise.all(Array.from(tmuxSessionNames).map((tmuxSessionName) => setTmuxOption(tmuxSessionName, TERMDOCK_GUI_DETACHED_AT_OPTION, detachedAt)));
811
+ }
812
+ function getClientSessionView(inventory, frontendSessionId) {
813
+ const session = inventory.clientSessions.find((entry) => entry.sessionId === frontendSessionId);
814
+ if (!session) {
815
+ throw new Error(`session inventory entry missing after mutation: ${frontendSessionId}`);
816
+ }
817
+ return session;
818
+ }
819
+ async function ensureBackendSessionForRecord(req, record, options = {}) {
820
+ if (record.backendSessionId) {
821
+ const existing = terminalSessions.get(record.backendSessionId);
822
+ if (existing) {
823
+ record.cwd = existing.cwd ?? record.cwd ?? null;
824
+ return {
825
+ backendSessionId: record.backendSessionId,
826
+ session: existing,
827
+ cols: options.cols ?? 80,
828
+ rows: options.rows ?? 24,
829
+ changed: false,
830
+ };
831
+ }
832
+ }
833
+ if (record.mode === 'tmux' && record.tmuxSessionName) {
834
+ await unsetTmuxOption(record.tmuxSessionName, TERMDOCK_GUI_DETACHED_AT_OPTION);
835
+ const existingTmuxBackend = findBackendSessionForTmux(record.tmuxSessionName);
836
+ if (existingTmuxBackend) {
837
+ const [backendSessionId, session] = existingTmuxBackend;
838
+ await prepareManagedTmuxSession(record.tmuxSessionName, options.cwd);
839
+ record.backendSessionId = backendSessionId;
840
+ record.cwd = session.cwd ?? record.cwd ?? null;
841
+ record.lastActivity = Date.now();
842
+ return {
843
+ backendSessionId,
844
+ session,
845
+ cols: options.cols ?? 80,
846
+ rows: options.rows ?? 24,
847
+ changed: true,
848
+ };
849
+ }
850
+ }
851
+ let spawnCwd = options.cwd ?? getTrustedCwdFromRecord(record);
852
+ if (!spawnCwd && record.mode === 'tmux' && record.tmuxSessionName) {
853
+ try {
854
+ const metadata = await resolveLiveTmuxMetadata(record.tmuxSessionName);
855
+ spawnCwd = metadata?.cwd ?? undefined;
856
+ if (spawnCwd) {
857
+ record.cwd = spawnCwd;
858
+ }
859
+ }
860
+ catch {
861
+ // If the tmux target is gone and no cwd was remembered, the persisted
862
+ // record is stale. Avoid silently recreating it in the user's home dir.
863
+ }
864
+ }
865
+ if (!spawnCwd && options.allowDefaultCwd !== true) {
866
+ if (removeGlobalSessionRecord(record.sessionId)) {
867
+ persistAndBroadcastGlobalState();
868
+ }
869
+ throw new HttpStatusError(410, 'session can no longer be restored without a working directory', 'STALE_SESSION_RESTORE_REJECTED');
870
+ }
871
+ // 记录里的 cwd 是服务端自己从 OSC 跟踪写入的(受信任)。服务重启后
872
+ // 白名单会重置为默认值,恢复一个 cwd 在白名单外的会话(例如 Windows
873
+ // 其它盘符)会被 resolveWorkingDirectory 拒绝——先放行再 spawn。
874
+ if (spawnCwd) {
875
+ await pathValidator.allowSessionCwd(spawnCwd);
876
+ }
877
+ const spawned = await spawnTerminalSession(req, {
878
+ cwd: spawnCwd,
879
+ cols: options.cols,
880
+ rows: options.rows,
881
+ mode: record.mode,
882
+ tmuxSessionName: record.tmuxSessionName ?? undefined,
883
+ termType: options.termType,
884
+ });
885
+ record.backendSessionId = spawned.sessionId;
886
+ record.mode = spawned.session.mode;
887
+ record.tmuxSessionName = spawned.session.tmuxSessionName;
888
+ record.cwd = spawned.session.cwd ?? spawnCwd ?? record.cwd ?? null;
889
+ record.lastActivity = Date.now();
890
+ return {
891
+ backendSessionId: spawned.sessionId,
892
+ session: spawned.session,
893
+ cols: spawned.cols,
894
+ rows: spawned.rows,
895
+ changed: true,
896
+ };
897
+ }
898
+ function updateGlobalBindingForBackendSession(backendSessionId, patch) {
899
+ const idx = globalSessionState.sessions.findIndex((session) => session.backendSessionId === backendSessionId);
900
+ if (idx < 0)
901
+ return false;
902
+ const current = globalSessionState.sessions[idx];
903
+ const updated = {
904
+ ...current,
905
+ ...patch,
906
+ backendSessionId: patch.backendSessionId === undefined ? current.backendSessionId : patch.backendSessionId,
907
+ tmuxSessionName: patch.tmuxSessionName === undefined ? current.tmuxSessionName : patch.tmuxSessionName,
908
+ mode: patch.mode ?? current.mode,
909
+ cwd: patch.cwd === undefined ? current.cwd : patch.cwd,
910
+ lastActivity: patch.lastActivity ?? Date.now(),
911
+ };
912
+ upsertGlobalSessionRecord(updated);
913
+ return true;
914
+ }
915
+ async function openInventorySession(req, input) {
916
+ const normalizedMode = normalizeMode(input.mode);
917
+ const normalizedTmuxName = normalizedMode === 'tmux'
918
+ ? normalizeTmuxSessionName(input.tmuxSessionName)
919
+ : null;
920
+ const preferredFrontendSessionId = typeof input.preferredFrontendSessionId === 'string'
921
+ ? input.preferredFrontendSessionId.trim()
922
+ : '';
923
+ const createIfEmpty = input.createIfEmpty === true;
924
+ const requireExisting = input.requireExisting === true;
925
+ const now = Date.now();
926
+ let record = preferredFrontendSessionId
927
+ ? globalSessionState.sessions.find((session) => session.sessionId === preferredFrontendSessionId) ?? null
928
+ : null;
929
+ let reused = !!record;
930
+ if (!record && normalizedMode === 'tmux' && normalizedTmuxName) {
931
+ record = globalSessionState.sessions.find((session) => session.mode === 'tmux' && session.tmuxSessionName === normalizedTmuxName) ?? null;
932
+ reused = !!record;
933
+ }
934
+ if (!record && createIfEmpty && globalSessionState.sessions.length === 0) {
935
+ await getSessionInventorySnapshot({ refresh: true });
936
+ if (globalSessionState.sessions.length > 0) {
937
+ record = [...globalSessionState.sessions]
938
+ .sort((a, b) => b.lastActivity - a.lastActivity)[0] ?? null;
939
+ reused = !!record;
940
+ }
941
+ }
942
+ if (!record && createIfEmpty && globalSessionState.sessions.length > 0) {
943
+ record = [...globalSessionState.sessions]
944
+ .sort((a, b) => b.lastActivity - a.lastActivity)[0] ?? null;
945
+ reused = !!record;
946
+ }
947
+ if (!record && requireExisting) {
948
+ throw new HttpStatusError(404, 'session not found');
949
+ }
950
+ if (!record) {
951
+ const sessionId = preferredFrontendSessionId || randomUUID();
952
+ const defaultName = normalizedMode === 'tmux' && normalizedTmuxName
953
+ ? `tmux:${normalizedTmuxName}`
954
+ : `terminal-${now.toString(36)}`;
955
+ const name = typeof input.name === 'string' && input.name.trim().length > 0
956
+ ? input.name.trim()
957
+ : defaultName;
958
+ record = {
959
+ sessionId,
960
+ name,
961
+ customName: input.customName === true ? true : undefined,
962
+ backendSessionId: null,
963
+ mode: normalizedMode,
964
+ tmuxSessionName: normalizedTmuxName,
965
+ createdAt: now,
966
+ lastActivity: now,
967
+ };
968
+ }
969
+ else {
970
+ record = { ...record };
971
+ record.lastActivity = now;
972
+ if (record.mode === 'tmux' && !record.tmuxSessionName && normalizedTmuxName) {
973
+ record.tmuxSessionName = normalizedTmuxName;
974
+ }
975
+ }
976
+ const requestedCwd = typeof input.cwd === 'string' && input.cwd.trim().length > 0
977
+ ? input.cwd
978
+ : undefined;
979
+ const cols = typeof input.cols === 'number' && Number.isFinite(input.cols) ? Math.floor(input.cols) : undefined;
980
+ const rows = typeof input.rows === 'number' && Number.isFinite(input.rows) ? Math.floor(input.rows) : undefined;
981
+ const termType = typeof input.termType === 'string' ? input.termType : undefined;
982
+ const allowDefaultCwd = !reused && preferredFrontendSessionId.length === 0;
983
+ const backend = await ensureBackendSessionForRecord(req, record, { cwd: requestedCwd, cols, rows, termType, allowDefaultCwd });
984
+ const savedRecord = upsertGlobalSessionRecord(record);
985
+ persistAndBroadcastGlobalState();
986
+ if (savedRecord.mode === 'tmux' && savedRecord.tmuxSessionName) {
987
+ void setTmuxOptions(savedRecord.tmuxSessionName, {
988
+ '@termdock-version': TERMDOCK_VERSION,
989
+ '@termdock-host': TERMDOCK_HOST,
990
+ '@termdock-pid': TERMDOCK_PID,
991
+ });
992
+ }
993
+ const inventory = await getSessionInventorySnapshot({ refresh: true });
994
+ return {
995
+ session: getClientSessionView(inventory, savedRecord.sessionId),
996
+ terminalSession: makeTerminalSessionPayload(backend.backendSessionId, backend.session, backend.cols, backend.rows),
997
+ inventory,
998
+ reused,
999
+ };
1000
+ }
1001
+ function getTmuxBinary() {
1002
+ return process.env.TMUX_BIN || 'tmux';
1003
+ }
1004
+ async function runTmux(args) {
1005
+ const { stdout } = await execFileAsync(getTmuxBinary(), args, {
1006
+ timeout: 5000,
1007
+ maxBuffer: 2 * 1024 * 1024,
1008
+ });
1009
+ return stdout;
1010
+ }
1011
+ // ---- Persistent tmux control-mode connection ----
1012
+ // Instead of spawning a new `tmux` process per command (execFile overhead),
1013
+ // maintain a single `tmux -C attach` child process per session. Commands
1014
+ // are written to stdin and responses are parsed from stdout using tmux's
1015
+ // control-mode protocol (%begin / %end / %exit).
1016
+ const TMUX_CONTROL_ENABLED = false;
1017
+ const TMUX_CONTROL_COMMAND_TIMEOUT_MS = 2000;
1018
+ function spawnTmuxControl(sessionName) {
1019
+ const process = spawn(getTmuxBinary(), ['-C', 'attach', '-t', sessionName], {
1020
+ stdio: ['pipe', 'pipe', 'pipe'],
1021
+ });
1022
+ const control = {
1023
+ process,
1024
+ nextSeq: 0,
1025
+ pending: new Map(),
1026
+ buffer: '',
1027
+ dead: false,
1028
+ };
1029
+ process.stdout?.on('data', (chunk) => {
1030
+ control.buffer += chunk.toString();
1031
+ const lines = control.buffer.split('\n');
1032
+ control.buffer = lines.pop() || '';
1033
+ for (const line of lines) {
1034
+ // %begin <seq> — start of command output block
1035
+ const beginMatch = line.match(/^%begin\s+(\d+)$/);
1036
+ if (beginMatch) {
1037
+ // Subsequent lines (until %end) are the output.
1038
+ continue;
1039
+ }
1040
+ // %end <seq> — end of output block
1041
+ const endMatch = line.match(/^%end\s+(\d+)$/);
1042
+ if (endMatch) {
1043
+ continue;
1044
+ }
1045
+ // %exit <seq> <code> — command finished
1046
+ const exitMatch = line.match(/^%exit\s+(\d+)\s+(\d+)$/);
1047
+ if (exitMatch) {
1048
+ const seq = parseInt(exitMatch[1], 10);
1049
+ const code = parseInt(exitMatch[2], 10);
1050
+ const entry = control.pending.get(seq);
1051
+ if (entry) {
1052
+ control.pending.delete(seq);
1053
+ if (code === 0) {
1054
+ entry.resolve(entry.output);
1055
+ }
1056
+ else {
1057
+ entry.reject(new Error(`tmux command exited with code ${code}`));
1058
+ }
1059
+ }
1060
+ continue;
1061
+ }
1062
+ // Output line between %begin and %end — attach to the most recent
1063
+ // pending entry (the one with the matching sequence).
1064
+ // We don't know which seq this belongs to until %end/%exit,
1065
+ // so stash it on the newest pending entry.
1066
+ if (control.pending.size > 0) {
1067
+ const lastEntry = Array.from(control.pending.values()).pop();
1068
+ if (lastEntry) {
1069
+ lastEntry.output += (lastEntry.output ? '\n' : '') + line;
1070
+ }
1071
+ }
1072
+ }
1073
+ });
1074
+ process.on('error', (err) => {
1075
+ control.dead = true;
1076
+ for (const [, entry] of control.pending) {
1077
+ entry.reject(err);
1078
+ }
1079
+ control.pending.clear();
1080
+ });
1081
+ process.on('exit', () => {
1082
+ control.dead = true;
1083
+ for (const [, entry] of control.pending) {
1084
+ entry.reject(new Error('tmux control process exited'));
1085
+ }
1086
+ control.pending.clear();
1087
+ });
1088
+ process.stderr?.on('data', (chunk) => {
1089
+ console.warn(`[tmux-control ${sessionName}] ${chunk.toString().trim()}`);
1090
+ });
1091
+ return control;
1092
+ }
1093
+ /**
1094
+ * Send a command through the persistent control-mode connection and wait
1095
+ * for the response. Falls back to `execFile` if the control process is
1096
+ * dead, the write fails, or the command times out.
1097
+ */
1098
+ async function sendTmuxCommand(_sessionName, control, args) {
1099
+ if (control && !control.dead) {
1100
+ const seq = control.nextSeq++;
1101
+ const command = args.join(' ');
1102
+ try {
1103
+ return await new Promise((resolve, reject) => {
1104
+ const timeout = setTimeout(() => {
1105
+ control.pending.delete(seq);
1106
+ reject(new Error('tmux control command timed out'));
1107
+ }, TMUX_CONTROL_COMMAND_TIMEOUT_MS);
1108
+ control.pending.set(seq, {
1109
+ resolve: (value) => { clearTimeout(timeout); resolve(value); },
1110
+ reject: (err) => { clearTimeout(timeout); reject(err); },
1111
+ output: '',
1112
+ });
1113
+ control.process.stdin?.write(command + '\n');
1114
+ });
1115
+ }
1116
+ catch {
1117
+ // Control mode failed — mark dead and fall through to execFile.
1118
+ control.dead = true;
1119
+ }
1120
+ }
1121
+ // Fallback: spawn a one-shot tmux process
1122
+ return runTmux(args);
1123
+ }
1124
+ /**
1125
+ * Fire-and-forget variant for scroll commands where we don't need to wait
1126
+ * for a response. Writes through the control process if available;
1127
+ * falls back to a one-shot execFile on any failure.
1128
+ */
1129
+ function sendTmuxCommandFireAndForget(_sessionName, control, args) {
1130
+ if (control && !control.dead) {
1131
+ try {
1132
+ control.process.stdin?.write(args.join(' ') + '\n');
1133
+ return;
1134
+ }
1135
+ catch {
1136
+ control.dead = true;
1137
+ // Fall through to execFile fallback below
1138
+ }
1139
+ }
1140
+ // Fallback: spawn a one-shot process (fire-and-forget)
1141
+ const child = execFile(getTmuxBinary(), args, { timeout: 5000 });
1142
+ child.on('error', () => { });
1143
+ }
1144
+ function destroyTmuxControl(control) {
1145
+ if (!control)
1146
+ return;
1147
+ control.dead = true;
1148
+ for (const [, entry] of control.pending) {
1149
+ entry.reject(new Error('tmux control process destroyed'));
1150
+ }
1151
+ control.pending.clear();
1152
+ try {
1153
+ control.process.kill();
1154
+ }
1155
+ catch {
1156
+ // Process may already be dead
1157
+ }
1158
+ }
1159
+ function isTmuxUnavailableMessage(errorMessage) {
1160
+ return /no such file or directory|not found|enoent/i.test(errorMessage);
1161
+ }
1162
+ function isTmuxServerMissingMessage(errorMessage) {
1163
+ return /no server running|error connecting to .*\(No such file or directory\)/i.test(errorMessage);
1164
+ }
1165
+ function isTmuxSessionMissingMessage(errorMessage) {
1166
+ return /can't find session|session not found/i.test(errorMessage) || isTmuxServerMissingMessage(errorMessage);
1167
+ }
1168
+ async function getTmuxStatus() {
1169
+ try {
1170
+ const raw = await runTmux(['-V']);
1171
+ return {
1172
+ available: true,
1173
+ version: raw.trim() || null,
1174
+ reason: null,
1175
+ };
1176
+ }
1177
+ catch (error) {
1178
+ const errorMessage = getErrorMessage(error);
1179
+ if (isTmuxUnavailableMessage(errorMessage)) {
1180
+ return {
1181
+ available: false,
1182
+ version: null,
1183
+ reason: 'tmux is not installed or not available in PATH.',
1184
+ };
1185
+ }
1186
+ return {
1187
+ available: false,
1188
+ version: null,
1189
+ reason: errorMessage || 'Failed to detect tmux availability',
1190
+ };
1191
+ }
1192
+ }
1193
+ async function enableTmuxMouse(sessionName) {
1194
+ let lastError;
1195
+ // A newly attached session can race briefly with the client startup.
1196
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1197
+ try {
1198
+ await runTmux(['set-option', '-t', sessionName, 'mouse', 'on']);
1199
+ return;
1200
+ }
1201
+ catch (error) {
1202
+ lastError = error;
1203
+ await new Promise((resolve) => setTimeout(resolve, 100));
1204
+ }
1205
+ }
1206
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1207
+ }
1208
+ async function configureTmuxWheelBindings() {
1209
+ // Pass mouse events through to TUI programs when they request mouse
1210
+ // reporting (vim with mouse=a, htop, etc.). When only the alternate
1211
+ // screen is active (less, man without mouse), send arrow keys instead.
1212
+ // Otherwise fall back to tmux copy-mode for scrollback history.
1213
+ //
1214
+ // The command is passed as a single string argument because tmux's
1215
+ // argument parser cannot parse nested { } groups from separate argv
1216
+ // tokens — execFile bypasses the shell, so tmux receives each token
1217
+ // individually and rejects the command with "too many arguments".
1218
+ const upCmd = "if -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' { send -M } { if -F '#{alternate_on}' { send-keys -N 5 Up } { copy-mode -He } }";
1219
+ // WheelDownPane: don't enter copy mode — scrolling down at the live
1220
+ // prompt has nowhere to go and just flash-enters/exits copy mode.
1221
+ const downCmd = "if -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' { send -M } { if -F '#{alternate_on}' { send-keys -N 5 Down } }";
1222
+ await runTmux(['bind-key', '-n', 'WheelUpPane', upCmd]);
1223
+ await runTmux(['bind-key', '-n', 'WheelDownPane', downCmd]);
1224
+ await runTmux([
1225
+ 'bind-key', '-n', 'MouseDown1Pane',
1226
+ "select-pane -t= ; set-option -p -F @termdock-mouse-down-x '#{mouse_x}' ; set-option -p -F @termdock-mouse-down-y '#{mouse_y}' ; if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' 'send-keys -M'",
1227
+ ]);
1228
+ await runTmux([
1229
+ 'bind-key', '-n', 'MouseDrag1Pane',
1230
+ "if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' 'send-keys -M' 'if-shell -F \"#{||:#{e|>=:#{e|-:#{mouse_x},#{@termdock-mouse-down-x}},1},#{e|>=:#{e|-:#{@termdock-mouse-down-x},#{mouse_x}},1},#{e|>=:#{e|-:#{mouse_y},#{@termdock-mouse-down-y}},1},#{e|>=:#{e|-:#{@termdock-mouse-down-y},#{mouse_y}},1}}\" \"copy-mode -M\"'",
1231
+ ]);
1232
+ await runTmux([
1233
+ 'bind-key', '-n', 'DoubleClick1Pane',
1234
+ "select-pane -t= ; if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' 'send-keys -M' 'copy-mode -H ; send-keys -X select-word ; run-shell -d 0.3 ; send-keys -X copy-pipe-no-clear'",
1235
+ ]);
1236
+ await runTmux([
1237
+ 'bind-key', '-n', 'TripleClick1Pane',
1238
+ "select-pane -t= ; if-shell -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' 'send-keys -M' 'copy-mode -H ; send-keys -X select-line ; run-shell -d 0.3 ; send-keys -X copy-pipe-no-clear'",
1239
+ ]);
1240
+ // tmux defaults mouse selection shortcuts to copy-pipe-and-cancel:
1241
+ // selecting text with the mouse copies successfully, then immediately exits
1242
+ // copy-mode on release/double-click. Termdock keeps copy-mode open so users
1243
+ // can continue inspecting scrollback after one selection.
1244
+ for (const table of ['copy-mode', 'copy-mode-vi']) {
1245
+ try {
1246
+ await runTmux(['bind-key', '-T', table, 'MouseDragEnd1Pane', 'send-keys', '-X', 'copy-pipe-no-clear']);
1247
+ }
1248
+ catch {
1249
+ await runTmux(['bind-key', '-T', table, 'MouseDragEnd1Pane', 'send-keys', '-X', 'copy-selection']);
1250
+ }
1251
+ for (const [key, selectionCommand] of [
1252
+ ['DoubleClick1Pane', 'select-word'],
1253
+ ['TripleClick1Pane', 'select-line'],
1254
+ ]) {
1255
+ try {
1256
+ await runTmux([
1257
+ 'bind-key', '-T', table, key,
1258
+ `select-pane ; send-keys -X ${selectionCommand} ; run-shell -d 0.3 ; send-keys -X copy-pipe-no-clear`,
1259
+ ]);
1260
+ }
1261
+ catch {
1262
+ await runTmux([
1263
+ 'bind-key', '-T', table, key,
1264
+ `select-pane ; send-keys -X ${selectionCommand} ; run-shell -d 0.3 ; send-keys -X copy-selection`,
1265
+ ]);
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+ async function applyTmuxScrollbackProfile(sessionName) {
1271
+ const commands = [
1272
+ ['set-option', '-g', 'history-limit', String(TERMDOCK_TMUX_HISTORY_LIMIT)],
1273
+ ['set-option', '-gw', 'scroll-on-clear', 'off'],
1274
+ ];
1275
+ if (sessionName) {
1276
+ commands.push(['set-option', '-t', sessionName, 'history-limit', String(TERMDOCK_TMUX_HISTORY_LIMIT)]);
1277
+ }
1278
+ for (const args of commands) {
1279
+ try {
1280
+ await runTmux(args);
1281
+ }
1282
+ catch (error) {
1283
+ console.warn(`[tmux] failed to apply scrollback profile (${args.join(' ')}): ${getErrorMessage(error)}`);
1284
+ }
1285
+ }
1286
+ if (!sessionName)
1287
+ return;
1288
+ try {
1289
+ const windowsRaw = await runTmux(['list-windows', '-t', sessionName, '-F', '#{window_id}']);
1290
+ const windowIds = windowsRaw.split('\n').map((line) => line.trim()).filter(Boolean);
1291
+ for (const windowId of windowIds) {
1292
+ try {
1293
+ await runTmux(['set-option', '-w', '-t', windowId, 'scroll-on-clear', 'off']);
1294
+ }
1295
+ catch (error) {
1296
+ console.warn(`[tmux] failed to disable scroll-on-clear for ${windowId}: ${getErrorMessage(error)}`);
1297
+ }
1298
+ }
1299
+ }
1300
+ catch (error) {
1301
+ console.warn(`[tmux] failed to list windows for scrollback profile on ${sessionName}: ${getErrorMessage(error)}`);
1302
+ }
1303
+ }
1304
+ async function disableTmuxStatus(sessionName) {
1305
+ let lastError;
1306
+ // A newly attached session can race briefly with the client startup.
1307
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1308
+ try {
1309
+ await runTmux(['set-option', '-t', sessionName, 'status', 'off']);
1310
+ return;
1311
+ }
1312
+ catch (error) {
1313
+ lastError = error;
1314
+ await new Promise((resolve) => setTimeout(resolve, 100));
1315
+ }
1316
+ }
1317
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1318
+ }
1319
+ // ── tmux user-option helpers (`@termdock-*`) ──
1320
+ //
1321
+ // We use tmux user options to attach termdock metadata (program/cwd/label/
1322
+ // friendly-name/client-count/etc.) to each managed session. They live with the
1323
+ // session, propagate across attaches, and never affect session-name addressing.
1324
+ // Failures are non-fatal — we only warn once per call site so a transient tmux
1325
+ // hiccup never blocks layout broadcast or session creation.
1326
+ async function setTmuxOption(sessionName, key, value) {
1327
+ let lastError;
1328
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1329
+ try {
1330
+ await runTmux(['set-option', '-t', sessionName, key, value]);
1331
+ return;
1332
+ }
1333
+ catch (error) {
1334
+ lastError = error;
1335
+ if (attempt === 0) {
1336
+ await new Promise((resolve) => setTimeout(resolve, 50));
1337
+ }
1338
+ }
1339
+ }
1340
+ console.warn(`[tmux] failed to set option ${key} on ${sessionName}: ${getErrorMessage(lastError)}`);
1341
+ }
1342
+ async function setTmuxOptions(sessionName, options) {
1343
+ await Promise.all(Object.entries(options).map(([key, value]) => setTmuxOption(sessionName, key, value)));
1344
+ }
1345
+ async function unsetTmuxOption(sessionName, key) {
1346
+ try {
1347
+ await runTmux(['set-option', '-t', sessionName, '-u', key]);
1348
+ }
1349
+ catch (error) {
1350
+ const errorMessage = getErrorMessage(error);
1351
+ if (isTmuxSessionMissingMessage(errorMessage)) {
1352
+ return;
1353
+ }
1354
+ console.warn(`[tmux] failed to unset option ${key} on ${sessionName}: ${errorMessage}`);
1355
+ }
1356
+ }
1357
+ async function getTmuxOption(sessionName, key) {
1358
+ try {
1359
+ const value = (await runTmux(['show-option', '-vqt', sessionName, key])).trim();
1360
+ return value.length > 0 ? value : null;
1361
+ }
1362
+ catch {
1363
+ return null;
1364
+ }
1365
+ }
1366
+ async function tmuxSessionExists(sessionName) {
1367
+ try {
1368
+ await runTmux(['has-session', '-t', sessionName]);
1369
+ return true;
1370
+ }
1371
+ catch {
1372
+ return false;
1373
+ }
1374
+ }
1375
+ async function ensureTmuxColorEnvironment(sessionName) {
1376
+ const forceColor = process.env.TERMDOCK_FORCE_COLOR === '1';
1377
+ await runTmux(['set-environment', '-g', 'COLORTERM', 'truecolor']);
1378
+ if (forceColor) {
1379
+ await runTmux(['set-environment', '-g', 'FORCE_COLOR', '1']);
1380
+ await runTmux(['set-environment', '-g', '-u', 'NO_COLOR']);
1381
+ }
1382
+ else {
1383
+ // Clear the legacy Termdock override so tmux sessions can respect user color prefs.
1384
+ await runTmux(['set-environment', '-g', '-u', 'FORCE_COLOR']);
1385
+ }
1386
+ if (sessionName) {
1387
+ await runTmux(['set-environment', '-t', sessionName, 'COLORTERM', 'truecolor']);
1388
+ if (forceColor) {
1389
+ await runTmux(['set-environment', '-t', sessionName, 'FORCE_COLOR', '1']);
1390
+ await runTmux(['set-environment', '-t', sessionName, '-u', 'NO_COLOR']);
1391
+ }
1392
+ else {
1393
+ await runTmux(['set-environment', '-t', sessionName, '-u', 'FORCE_COLOR']);
1394
+ }
1395
+ }
1396
+ }
1397
+ async function listLiveTmuxInventorySessions() {
1398
+ const format = [
1399
+ '#{session_name}',
1400
+ '#{session_windows}',
1401
+ '#{session_attached}',
1402
+ '#{@termdock-friendly-name}',
1403
+ '#{@termdock-program}',
1404
+ '#{@termdock-cwd}',
1405
+ '#{@termdock-label}',
1406
+ '#{@termdock-client-count}',
1407
+ '#{@termdock-host}',
1408
+ '#{@termdock-pid}',
1409
+ '#{@termdock-version}',
1410
+ '#{@termdock-created-at}',
1411
+ '#{@termdock-last-active-at}',
1412
+ `#{${TERMDOCK_GUI_DETACHED_AT_OPTION}}`,
1413
+ ].join(TMUX_DELIMITER);
1414
+ try {
1415
+ const raw = await runTmux(['list-sessions', '-F', format]);
1416
+ return raw
1417
+ .split('\n')
1418
+ .map((line) => line.trim())
1419
+ .filter(Boolean)
1420
+ .map((line) => parseDelimitedRow(line, 14))
1421
+ .filter((row) => row !== null)
1422
+ .map(([name, windowsRaw, attachedRaw, friendlyName, program, cwd, label, clientCountRaw, host, pidRaw, version, createdAtRaw, lastActiveAtRaw, guiDetachedAtRaw,]) => ({
1423
+ name,
1424
+ windows: Number.parseInt(windowsRaw || '0', 10) || 0,
1425
+ attachedCount: Number.parseInt(attachedRaw || '0', 10) || 0,
1426
+ friendlyName: friendlyName || null,
1427
+ program: program || null,
1428
+ cwd: cwd || null,
1429
+ label: label || null,
1430
+ clientCount: parseNumberOption(clientCountRaw),
1431
+ host: host || null,
1432
+ pid: parseNumberOption(pidRaw),
1433
+ version: version || null,
1434
+ createdAt: parseNumberOption(createdAtRaw),
1435
+ lastActiveAt: parseNumberOption(lastActiveAtRaw),
1436
+ guiDetachedAt: parseNumberOption(guiDetachedAtRaw),
1437
+ }))
1438
+ .sort((a, b) => {
1439
+ const aCreated = a.createdAt ?? Number.POSITIVE_INFINITY;
1440
+ const bCreated = b.createdAt ?? Number.POSITIVE_INFINITY;
1441
+ if (aCreated !== bCreated)
1442
+ return aCreated - bCreated;
1443
+ return a.name.localeCompare(b.name);
1444
+ });
1445
+ }
1446
+ catch (error) {
1447
+ const errorMessage = getErrorMessage(error);
1448
+ if (isTmuxServerMissingMessage(errorMessage)) {
1449
+ return [];
1450
+ }
1451
+ throw error;
1452
+ }
1453
+ }
1454
+ async function buildSessionInventory() {
1455
+ const tmuxStatus = await getTmuxStatus();
1456
+ let liveTmuxSessions = [];
1457
+ if (tmuxStatus.available) {
1458
+ try {
1459
+ liveTmuxSessions = await listLiveTmuxInventorySessions();
1460
+ }
1461
+ catch (error) {
1462
+ const errorMessage = getErrorMessage(error);
1463
+ if (!isTmuxServerMissingMessage(errorMessage)) {
1464
+ console.warn('[session-inventory] failed to list tmux sessions:', errorMessage);
1465
+ }
1466
+ liveTmuxSessions = [];
1467
+ }
1468
+ }
1469
+ const refreshedTmuxSessions = await Promise.all(liveTmuxSessions.map(async (tmux) => {
1470
+ if (!isTermdockManagedTmuxSession(tmux)) {
1471
+ return tmux;
1472
+ }
1473
+ try {
1474
+ const metadata = await resolveLiveTmuxMetadata(tmux.name);
1475
+ if (!metadata) {
1476
+ return tmux;
1477
+ }
1478
+ maybeRepairTmuxOptions(tmux.name, tmux, metadata);
1479
+ return {
1480
+ ...tmux,
1481
+ program: metadata.program,
1482
+ cwd: metadata.cwd,
1483
+ label: metadata.label,
1484
+ lastActiveAt: Date.now(),
1485
+ };
1486
+ }
1487
+ catch {
1488
+ return tmux;
1489
+ }
1490
+ }));
1491
+ let discoveredManagedTmuxSession = false;
1492
+ for (const tmux of refreshedTmuxSessions) {
1493
+ if (!isTermdockManagedTmuxSession(tmux))
1494
+ continue;
1495
+ if (tmux.guiDetachedAt !== null)
1496
+ continue;
1497
+ const exists = globalSessionState.sessions.some((session) => session.mode === 'tmux' && session.tmuxSessionName === tmux.name);
1498
+ if (exists)
1499
+ continue;
1500
+ const liveBackend = findBackendSessionForTmux(tmux.name);
1501
+ const friendlyName = tmux.friendlyName?.trim() || null;
1502
+ upsertGlobalSessionRecord({
1503
+ sessionId: randomUUID(),
1504
+ name: friendlyName ?? `tmux:${tmux.name}`,
1505
+ customName: friendlyName ? true : undefined,
1506
+ backendSessionId: liveBackend?.[0] ?? null,
1507
+ mode: 'tmux',
1508
+ tmuxSessionName: tmux.name,
1509
+ createdAt: tmux.createdAt ?? Date.now(),
1510
+ lastActivity: tmux.lastActiveAt ?? Date.now(),
1511
+ });
1512
+ discoveredManagedTmuxSession = true;
1513
+ }
1514
+ if (discoveredManagedTmuxSession) {
1515
+ schedulePersistGlobalState();
1516
+ }
1517
+ const liveTmuxByName = new Map(refreshedTmuxSessions.map((session) => [session.name, session]));
1518
+ let synchronizedTmuxFriendlyName = false;
1519
+ globalSessionState = {
1520
+ sessions: globalSessionState.sessions.map((session) => {
1521
+ if (session.mode !== 'tmux' || !session.tmuxSessionName) {
1522
+ return session;
1523
+ }
1524
+ const friendlyName = liveTmuxByName.get(session.tmuxSessionName)?.friendlyName?.trim();
1525
+ if (!friendlyName) {
1526
+ return session;
1527
+ }
1528
+ if (session.customName === true && session.name === friendlyName) {
1529
+ return session;
1530
+ }
1531
+ synchronizedTmuxFriendlyName = true;
1532
+ return {
1533
+ ...session,
1534
+ name: friendlyName,
1535
+ customName: true,
1536
+ };
1537
+ }),
1538
+ updatedAt: synchronizedTmuxFriendlyName ? Date.now() : globalSessionState.updatedAt,
1539
+ };
1540
+ if (synchronizedTmuxFriendlyName) {
1541
+ schedulePersistGlobalState();
1542
+ }
1543
+ const clientSessions = globalSessionState.sessions.map((session) => {
1544
+ const backendLive = !!session.backendSessionId && terminalSessions.has(session.backendSessionId);
1545
+ const tmuxLive = session.mode === 'tmux' && !!session.tmuxSessionName && liveTmuxByName.has(session.tmuxSessionName);
1546
+ const live = session.mode === 'tmux' ? tmuxLive : backendLive;
1547
+ // 展示名提示(activeProgram / cwd):优先取在线 backend session 的实时值,
1548
+ // 其次回退到 tmux 清单里的 program/cwd(tmux 模式即使 backend 未 attach,
1549
+ // tmux 服务端仍能给出当前 pane 的程序与目录)。让前端 hydrate 即可显示。
1550
+ const backend = backendLive ? terminalSessions.get(session.backendSessionId) : undefined;
1551
+ const tmuxMeta = session.tmuxSessionName ? liveTmuxByName.get(session.tmuxSessionName) : undefined;
1552
+ const activeProgram = backend?.activeProgram?.command ?? tmuxMeta?.program ?? null;
1553
+ const cwd = backend?.cwd ?? tmuxMeta?.cwd ?? null;
1554
+ return {
1555
+ ...session,
1556
+ frontendSessionId: session.sessionId,
1557
+ customName: session.customName === true,
1558
+ connected: backendLive,
1559
+ live,
1560
+ restorable: session.mode === 'tmux' && tmuxLive && !backendLive,
1561
+ activeProgram,
1562
+ cwd,
1563
+ };
1564
+ });
1565
+ const clientByTmux = new Map();
1566
+ for (const session of clientSessions) {
1567
+ if (session.mode === 'tmux' && session.tmuxSessionName && !clientByTmux.has(session.tmuxSessionName)) {
1568
+ clientByTmux.set(session.tmuxSessionName, session);
1569
+ }
1570
+ }
1571
+ const tmuxOrder = new Map();
1572
+ globalSessionState.sessions.forEach((session, index) => {
1573
+ if (session.mode === 'tmux' && session.tmuxSessionName && !tmuxOrder.has(session.tmuxSessionName)) {
1574
+ tmuxOrder.set(session.tmuxSessionName, index);
1575
+ }
1576
+ });
1577
+ const tmuxSessions = refreshedTmuxSessions
1578
+ .slice()
1579
+ .sort((a, b) => {
1580
+ const aRank = tmuxOrder.get(a.name) ?? Number.POSITIVE_INFINITY;
1581
+ const bRank = tmuxOrder.get(b.name) ?? Number.POSITIVE_INFINITY;
1582
+ if (aRank !== bRank)
1583
+ return aRank - bRank;
1584
+ const aCreated = a.createdAt ?? Number.POSITIVE_INFINITY;
1585
+ const bCreated = b.createdAt ?? Number.POSITIVE_INFINITY;
1586
+ if (aCreated !== bCreated)
1587
+ return aCreated - bCreated;
1588
+ return a.name.localeCompare(b.name);
1589
+ })
1590
+ .map((tmux) => {
1591
+ const bound = clientByTmux.get(tmux.name) ?? null;
1592
+ return {
1593
+ name: tmux.name,
1594
+ windows: tmux.windows,
1595
+ attached: tmux.attachedCount,
1596
+ attachedCount: tmux.attachedCount,
1597
+ createdAt: tmux.createdAt,
1598
+ boundFrontendSessionId: bound?.sessionId ?? null,
1599
+ connected: bound?.connected === true,
1600
+ live: true,
1601
+ restorable: bound?.restorable === true,
1602
+ friendlyName: tmux.friendlyName,
1603
+ label: tmux.label,
1604
+ program: tmux.program,
1605
+ cwd: tmux.cwd,
1606
+ clientCount: tmux.clientCount,
1607
+ lastActiveAt: tmux.lastActiveAt,
1608
+ };
1609
+ });
1610
+ return {
1611
+ clientSessions,
1612
+ tmuxSessions,
1613
+ tmuxStatus,
1614
+ updatedAt: Date.now(),
1615
+ };
1616
+ }
1617
+ // ── end tmux user-option helpers ──
1618
+ async function captureTmuxPane(sessionName) {
1619
+ let lastError;
1620
+ // An attached session can briefly race with tmux pane availability.
1621
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1622
+ try {
1623
+ const paneId = (await runTmux([
1624
+ 'display-message',
1625
+ '-t',
1626
+ sessionName,
1627
+ '-p',
1628
+ '#{pane_id}',
1629
+ ])).trim();
1630
+ return await runTmux([
1631
+ 'capture-pane',
1632
+ '-p',
1633
+ '-e',
1634
+ '-J',
1635
+ '-t',
1636
+ paneId || sessionName,
1637
+ ]);
1638
+ }
1639
+ catch (error) {
1640
+ lastError = error;
1641
+ await new Promise((resolve) => setTimeout(resolve, 100));
1642
+ }
1643
+ }
1644
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1645
+ }
1646
+ async function isTmuxPaneInMode(target, control) {
1647
+ const paneInModeRaw = (await sendTmuxCommand(target, control, [
1648
+ 'display-message',
1649
+ '-t',
1650
+ target,
1651
+ '-p',
1652
+ '#{pane_in_mode}',
1653
+ ])).trim();
1654
+ return paneInModeRaw === '1';
1655
+ }
1656
+ function getErrorMessage(error) {
1657
+ return error instanceof Error ? error.message : String(error);
1658
+ }
1659
+ function isAlreadyInCopyModeError(message) {
1660
+ return /already in.*mode/i.test(message);
1661
+ }
1662
+ function isNotInCopyModeError(message) {
1663
+ return /not in (a )?mode/i.test(message);
1664
+ }
1665
+ function parseDelimitedRow(line, expected) {
1666
+ let normalizedLine = line;
1667
+ if (normalizedLine.includes('\\037')) {
1668
+ normalizedLine = normalizedLine.split('\\037').join(TMUX_DELIMITER);
1669
+ }
1670
+ if (normalizedLine.includes('\\x1f')) {
1671
+ normalizedLine = normalizedLine.split('\\x1f').join(TMUX_DELIMITER);
1672
+ }
1673
+ const parts = normalizedLine.split(TMUX_DELIMITER);
1674
+ if (parts.length < expected) {
1675
+ return null;
1676
+ }
1677
+ if (parts.length === expected) {
1678
+ return parts;
1679
+ }
1680
+ const merged = parts.slice(0, expected - 1);
1681
+ merged.push(parts.slice(expected - 1).join(TMUX_DELIMITER));
1682
+ return merged;
1683
+ }
1684
+ function getPtyProcessPid(ptyProcess) {
1685
+ if (typeof ptyProcess.pid === 'number' && Number.isFinite(ptyProcess.pid)) {
1686
+ return ptyProcess.pid;
1687
+ }
1688
+ return null;
1689
+ }
1690
+ // ── OSC sniffing for CWD tracking + prompt state + title ──
1691
+ const OSC_SNIFF_CAP = 32768; // 32 KB rolling buffer
1692
+ // Match all OSC sequences we care about:
1693
+ // OSC 0;... / OSC 2;... → title (may contain cwd or command name)
1694
+ // OSC 7;... → cwd report (kitty-shell-cwd://host/path)
1695
+ // OSC 133;A / P → prompt start (idle)
1696
+ // OSC 133;C → command start (running)
1697
+ // OSC 133;D[;exitcode] → command end (idle, with optional exit code)
1698
+ const OSC_ANY_PATTERN = /\x1b\](\d+);([^\x07\x1b]*)(\x07|\x1b\\)/g;
1699
+ function parseTitleCwd(title, home) {
1700
+ const parsePathPart = (pathPart) => {
1701
+ const trimmedPath = pathPart.trim();
1702
+ if (!trimmedPath)
1703
+ return null;
1704
+ if (trimmedPath.startsWith('~/') || trimmedPath.startsWith('~\\'))
1705
+ return home + trimmedPath.slice(1);
1706
+ if (trimmedPath === '~')
1707
+ return home;
1708
+ if (trimmedPath.startsWith('/') || isWindowsAbsolutePath(trimmedPath))
1709
+ return trimmedPath;
1710
+ return null;
1711
+ };
1712
+ // Format: user@host:/path/to/dir
1713
+ const atIdx = title.lastIndexOf('@');
1714
+ if (atIdx >= 0) {
1715
+ const afterAt = title.slice(atIdx + 1);
1716
+ const colonIdx = afterAt.indexOf(':');
1717
+ if (colonIdx >= 0) {
1718
+ const pathPart = afterAt.slice(colonIdx + 1).trim();
1719
+ const parsed = parsePathPart(pathPart);
1720
+ if (parsed)
1721
+ return parsed;
1722
+ return home + '/' + pathPart;
1723
+ }
1724
+ }
1725
+ // Direct path format
1726
+ const directPath = parsePathPart(title);
1727
+ if (directPath)
1728
+ return directPath;
1729
+ return null;
1730
+ }
1731
+ function isWindowsAbsolutePath(value) {
1732
+ return /^[a-zA-Z]:[\\/]/.test(value) || /^[/\\]{2}[^/\\]+[/\\][^/\\]+/.test(value);
1733
+ }
1734
+ // Git-Bash/MSYS 环境上报的 cwd 是 "/c/foo" 形式,Windows 的 fs API 无法使用,
1735
+ // 转换成 "C:/foo"。单字母首段才视为盘符("/usr" 这类不受影响)。
1736
+ function normalizeReportedCwdPath(p) {
1737
+ if (process.platform === 'win32') {
1738
+ const msys = /^\/([a-zA-Z])(?=\/|$)(.*)$/.exec(p);
1739
+ if (msys) {
1740
+ return `${msys[1].toUpperCase()}:${msys[2] || '/'}`;
1741
+ }
1742
+ }
1743
+ return p;
1744
+ }
1745
+ function parseOsc7Cwd(data, home) {
1746
+ // Format: kitty-shell-cwd://hostname/path or file://hostname/path
1747
+ const match = data.match(/^(?:kitty-shell-cwd|file):\/\/[^/]+(.+)/i);
1748
+ if (match) {
1749
+ const p = match[1];
1750
+ if (p.startsWith('~/'))
1751
+ return home + p.slice(1);
1752
+ if (p.startsWith('/'))
1753
+ return p;
1754
+ }
1755
+ return null;
1756
+ }
1757
+ function parseConEmuProgressReport(data) {
1758
+ if (!data.startsWith('4;') || data.length < 3)
1759
+ return null;
1760
+ const stateCode = data[2];
1761
+ const state = stateCode === '0'
1762
+ ? 'remove'
1763
+ : stateCode === '1'
1764
+ ? 'set'
1765
+ : stateCode === '2'
1766
+ ? 'error'
1767
+ : stateCode === '3'
1768
+ ? 'indeterminate'
1769
+ : stateCode === '4'
1770
+ ? 'pause'
1771
+ : null;
1772
+ if (!state)
1773
+ return null;
1774
+ let progress = null;
1775
+ if ((state === 'set' || state === 'error' || state === 'pause') && data[3] === ';') {
1776
+ const value = Number.parseInt(data.slice(4), 10);
1777
+ if (Number.isFinite(value)) {
1778
+ progress = Math.min(100, Math.max(0, value));
1779
+ }
1780
+ }
1781
+ return { state, progress };
1782
+ }
1783
+ function sniffOsc(buf, home) {
1784
+ let match;
1785
+ let lastCwd = null;
1786
+ let lastTitle = null;
1787
+ let promptState = null;
1788
+ let exitCode = null;
1789
+ let tuiProgress = null;
1790
+ let lastMatchEnd = 0;
1791
+ OSC_ANY_PATTERN.lastIndex = 0;
1792
+ while ((match = OSC_ANY_PATTERN.exec(buf)) !== null) {
1793
+ const oscNum = match[1];
1794
+ const oscData = match[2] || '';
1795
+ lastMatchEnd = match.index + match[0].length;
1796
+ if (oscNum === '0' || oscNum === '2') {
1797
+ // Title — could be cwd or command name
1798
+ lastTitle = oscData;
1799
+ const cwd = parseTitleCwd(oscData, home);
1800
+ if (cwd)
1801
+ lastCwd = normalizeReportedCwdPath(cwd);
1802
+ }
1803
+ else if (oscNum === '7') {
1804
+ // CWD report
1805
+ const cwd = parseOsc7Cwd(oscData, home);
1806
+ if (cwd)
1807
+ lastCwd = normalizeReportedCwdPath(cwd);
1808
+ }
1809
+ else if (oscNum === '133') {
1810
+ // Semantic prompt marks
1811
+ if (oscData.startsWith('C')) {
1812
+ promptState = 'running';
1813
+ }
1814
+ else if (oscData.startsWith('D')) {
1815
+ promptState = 'idle';
1816
+ // Parse optional exit code: 133;D;exitcode
1817
+ const parts = oscData.split(';');
1818
+ if (parts.length >= 2) {
1819
+ const code = parseInt(parts[1], 10);
1820
+ if (!isNaN(code))
1821
+ exitCode = code;
1822
+ }
1823
+ }
1824
+ else if (oscData.startsWith('A') || oscData.startsWith('P')) {
1825
+ promptState = 'idle';
1826
+ }
1827
+ }
1828
+ else if (oscNum === '9') {
1829
+ const progress = parseConEmuProgressReport(oscData);
1830
+ if (progress)
1831
+ tuiProgress = progress;
1832
+ }
1833
+ }
1834
+ const remaining = buf.slice(lastMatchEnd).slice(-128);
1835
+ return { cwd: lastCwd, title: lastTitle, promptState, exitCode, tuiProgress, remaining };
1836
+ }
1837
+ const AGENT_INDICATORS = new Set(['spinner', 'pulse', 'dot', 'ring', 'badge', 'terminal', 'question']);
1838
+ const DEFAULT_AGENT_CLEAR_DELAY_MS = 450;
1839
+ const MIN_AGENT_CLEAR_DELAY_MS = 80;
1840
+ const MAX_AGENT_CLEAR_DELAY_MS = 10_000;
1841
+ const COCO_WAITING_RULE_PATTERN = '(?:Tab/Arrow keys to navigate[\\s\\S]{0,200}(?:select ·|Enter))|Coco\\s*等待态采样';
1842
+ // Built-in default rules
1843
+ const BUILTIN_AGENT_RULES = [
1844
+ {
1845
+ program: 'claude',
1846
+ rules: [
1847
+ // 雪花符号(spinner) + 1-25 个中英文字母 + 3 个点(英文 ... 或中文 …)
1848
+ // 严格按"结构"匹配,不依赖具体词,排除 token 数字/输入回显
1849
+ { pattern: '[✢✶✻✽][A-Za-z\\u4E00-\\u9FA5]{1,25}(?:\\.{3}|…)', status: 'running', color: '#4ade80', indicator: 'spinner', clearDelayMs: 700 },
1850
+ ],
1851
+ },
1852
+ {
1853
+ program: 'opencode',
1854
+ rules: [
1855
+ { pattern: 'thinking|working|generating', status: 'running', color: '#4ade80', indicator: 'pulse', clearDelayMs: 900 },
1856
+ { pattern: '~ Asking questions', status: 'waiting', color: '#facc15', indicator: 'question', clearDelayMs: 10000 },
1857
+ ],
1858
+ },
1859
+ {
1860
+ program: 'coco',
1861
+ rules: [
1862
+ { pattern: COCO_WAITING_RULE_PATTERN, status: 'waiting', color: '#facc15', indicator: 'question', clearDelayMs: 10000 },
1863
+ { pattern: '[·✢❋❇✽] (thinking|working|generating)', status: 'running', color: '#4ade80', indicator: 'spinner', clearDelayMs: 700 },
1864
+ ],
1865
+ },
1866
+ ];
1867
+ function isSameAgentRule(a, b) {
1868
+ return a.pattern === b.pattern &&
1869
+ a.status === b.status &&
1870
+ a.color === b.color &&
1871
+ a.indicator === b.indicator &&
1872
+ a.clearDelayMs === b.clearDelayMs;
1873
+ }
1874
+ function isSingleProgramConfig(config, program) {
1875
+ const programs = Array.isArray(config.programs)
1876
+ ? config.programs.filter((name) => typeof name === 'string' && name.trim().length > 0)
1877
+ : (typeof config.program === 'string' && config.program.trim().length > 0 ? [config.program] : []);
1878
+ return programs.length === 1 && programs[0]?.trim().toLowerCase() === program;
1879
+ }
1880
+ function migrateAgentRules(rules) {
1881
+ let changed = false;
1882
+ const deprecatedCocoConfirmRule = {
1883
+ pattern: 'confirm|approve|permission|continue\\?',
1884
+ status: 'waiting',
1885
+ color: '#facc15',
1886
+ indicator: 'question',
1887
+ clearDelayMs: 10000,
1888
+ };
1889
+ const deprecatedCocoBroadWaitingRule = {
1890
+ pattern: 'Tab/Arrow keys to navigate|Esc to|select ·|Coco 等待态采样|AskUserQuestion|User\'s answers',
1891
+ status: 'waiting',
1892
+ color: '#facc15',
1893
+ indicator: 'question',
1894
+ clearDelayMs: 10000,
1895
+ };
1896
+ const cocoWaitingRule = {
1897
+ pattern: COCO_WAITING_RULE_PATTERN,
1898
+ status: 'waiting',
1899
+ color: '#facc15',
1900
+ indicator: 'question',
1901
+ clearDelayMs: 10000,
1902
+ };
1903
+ const nextRules = rules
1904
+ .filter((config) => {
1905
+ const shouldRemove = isSingleProgramConfig(config, 'aider') &&
1906
+ config.rules.length === 1 &&
1907
+ isSameAgentRule(config.rules[0], {
1908
+ pattern: 'Thinking|Generating|Working',
1909
+ status: 'running',
1910
+ color: '#4ade80',
1911
+ indicator: 'pulse',
1912
+ clearDelayMs: 900,
1913
+ });
1914
+ if (shouldRemove)
1915
+ changed = true;
1916
+ return !shouldRemove;
1917
+ })
1918
+ .map((config) => {
1919
+ if (!isSingleProgramConfig(config, 'coco'))
1920
+ return config;
1921
+ let nextRules = config.rules.filter((rule) => !isSameAgentRule(rule, deprecatedCocoConfirmRule));
1922
+ nextRules = nextRules.map((rule) => isSameAgentRule(rule, deprecatedCocoBroadWaitingRule) ? cocoWaitingRule : rule);
1923
+ if (nextRules.length === config.rules.length && nextRules.every((rule, index) => isSameAgentRule(rule, config.rules[index])))
1924
+ return config;
1925
+ changed = true;
1926
+ return { ...config, rules: nextRules };
1927
+ });
1928
+ return { rules: nextRules, changed };
1929
+ }
1930
+ // Runtime cache: program → compiled rules
1931
+ let agentRulesCache = new Map();
1932
+ let agentRulesVersion = 0;
1933
+ function normalizeAgentIndicator(value) {
1934
+ return typeof value === 'string' && AGENT_INDICATORS.has(value)
1935
+ ? value
1936
+ : 'pulse';
1937
+ }
1938
+ function normalizeAgentClearDelay(value) {
1939
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : DEFAULT_AGENT_CLEAR_DELAY_MS;
1940
+ return Math.min(MAX_AGENT_CLEAR_DELAY_MS, Math.max(MIN_AGENT_CLEAR_DELAY_MS, n));
1941
+ }
1942
+ function compileAgentRules(rules) {
1943
+ const map = new Map();
1944
+ for (const config of rules) {
1945
+ const compiled = config.rules.map(r => ({
1946
+ status: r.status,
1947
+ color: r.color,
1948
+ indicator: normalizeAgentIndicator(r.indicator),
1949
+ clearDelayMs: normalizeAgentClearDelay(r.clearDelayMs),
1950
+ regex: new RegExp(r.pattern, 'i'),
1951
+ }));
1952
+ const programNames = Array.isArray(config.programs)
1953
+ ? config.programs
1954
+ : (typeof config.program === 'string' ? [config.program] : []);
1955
+ for (const name of programNames) {
1956
+ const normalized = typeof name === 'string' ? name.trim().toLowerCase() : '';
1957
+ if (!normalized)
1958
+ continue;
1959
+ map.set(normalized, compiled);
1960
+ }
1961
+ }
1962
+ agentRulesCache = map;
1963
+ agentRulesVersion++;
1964
+ return map;
1965
+ }
1966
+ const AGENT_RULES_FILE = `${TERMDOCK_DIR}/agent-rules.json`;
1967
+ async function loadAgentRulesFromDisk() {
1968
+ try {
1969
+ const parsed = await readJsonFileIfExists(AGENT_RULES_FILE);
1970
+ if (Array.isArray(parsed) && parsed.length > 0) {
1971
+ const migrated = migrateAgentRules(parsed);
1972
+ if (migrated.changed) {
1973
+ await writeJsonFile(AGENT_RULES_FILE, migrated.rules);
1974
+ }
1975
+ return migrated.rules;
1976
+ }
1977
+ }
1978
+ catch { /* file doesn't exist or invalid, use builtins */ }
1979
+ return BUILTIN_AGENT_RULES;
1980
+ }
1981
+ async function loadAgentRules() {
1982
+ return compileAgentRules(await loadAgentRulesFromDisk());
1983
+ }
1984
+ async function saveAgentRulesToDisk(rules) {
1985
+ await writeJsonFile(AGENT_RULES_FILE, rules);
1986
+ compileAgentRules(rules);
1987
+ }
1988
+ // Initialize on startup
1989
+ void loadAgentRules();
1990
+ function isAiToolProgram(command) {
1991
+ if (!command)
1992
+ return false;
1993
+ return agentRulesCache.has(command.toLowerCase());
1994
+ }
1995
+ /**
1996
+ * 去除 ANSI 转义序列,保留纯文本内容
1997
+ */
1998
+ function stripAnsi(data) {
1999
+ return data
2000
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
2001
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC sequences
2002
+ .replace(/\x1b[()][AB0-2]/g, '') // Charset
2003
+ .replace(/\x1b[^[\]()0-9]/g, '') // Other escapes
2004
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // Control chars
2005
+ }
2006
+ // Retained for potential future re-enablement of agent status detection.
2007
+ void stripAnsi;
2008
+ /**
2009
+ * 基于可配置规则匹配检测 AI 工具状态
2010
+ * 只看实际输出文本,不受 resize/布局变化影响
2011
+ */
2012
+ function detectAgentStatus(command, buf) {
2013
+ if (!buf || buf.length < 2)
2014
+ return null;
2015
+ const rules = agentRulesCache.get(command.toLowerCase());
2016
+ if (!rules)
2017
+ return null;
2018
+ // 只看最后 1KB,这是最新的输出
2019
+ const tail = buf.slice(-1024);
2020
+ for (const rule of rules) {
2021
+ if (rule.regex.test(tail)) {
2022
+ return { status: rule.status, color: rule.color, indicator: rule.indicator, clearDelayMs: rule.clearDelayMs };
2023
+ }
2024
+ }
2025
+ return null;
2026
+ }
2027
+ function clearAgentStatusTimer(session) {
2028
+ if (session.agentStatusTimer) {
2029
+ clearTimeout(session.agentStatusTimer);
2030
+ session.agentStatusTimer = null;
2031
+ }
2032
+ }
2033
+ function evaluateAgentStatus(sessionId, session, latestChunk) {
2034
+ const previousStatus = session.agentStatus;
2035
+ const command = session.activeProgram?.command;
2036
+ if (!command || !isAiToolProgram(command)) {
2037
+ if (previousStatus !== null) {
2038
+ clearAgentStatusTimer(session);
2039
+ session.agentStatus = null;
2040
+ session.agentColor = null;
2041
+ session.agentIndicator = null;
2042
+ session.agentStatusClearDelayMs = DEFAULT_AGENT_CLEAR_DELAY_MS;
2043
+ broadcastEvent(sessionId, { type: 'agent-status', agentStatus: null, agentColor: null, agentIndicator: null });
2044
+ }
2045
+ return;
2046
+ }
2047
+ const detected = detectAgentStatus(command, latestChunk || session.agentStatusBuf);
2048
+ if (detected) {
2049
+ clearAgentStatusTimer(session);
2050
+ const newStatus = detected.status;
2051
+ const newColor = detected.color ?? null;
2052
+ const newIndicator = detected.indicator;
2053
+ session.agentStatusClearDelayMs = detected.clearDelayMs;
2054
+ if (newStatus !== previousStatus || newColor !== session.agentColor || newIndicator !== session.agentIndicator) {
2055
+ session.agentStatus = newStatus;
2056
+ session.agentColor = newColor;
2057
+ session.agentIndicator = newIndicator;
2058
+ broadcastEvent(sessionId, { type: 'agent-status', agentStatus: newStatus, agentColor: newColor, agentIndicator: newIndicator });
2059
+ }
2060
+ return;
2061
+ }
2062
+ if (previousStatus !== null && !session.agentStatusTimer) {
2063
+ session.agentStatusTimer = setTimeout(() => {
2064
+ session.agentStatusTimer = null;
2065
+ const recheck = detectAgentStatus(command, session.agentStatusBuf.slice(-256));
2066
+ if (!recheck) {
2067
+ session.agentStatus = null;
2068
+ session.agentColor = null;
2069
+ session.agentIndicator = null;
2070
+ session.agentStatusClearDelayMs = DEFAULT_AGENT_CLEAR_DELAY_MS;
2071
+ broadcastEvent(sessionId, { type: 'agent-status', agentStatus: null, agentColor: null, agentIndicator: null });
2072
+ }
2073
+ }, session.agentStatusClearDelayMs);
2074
+ }
2075
+ }
2076
+ // ── end Agent status detection ──
2077
+ /**
2078
+ * Resolve the "real" program name for a tmux pane.
2079
+ *
2080
+ * tmux's `#{pane_current_command}` only returns the kernel comm (e.g. "node"),
2081
+ * which is too coarse — we can't distinguish `aiden x claude` from `node server.js`.
2082
+ * So we use the pane's shell PID + `ps` to find the foreground child process
2083
+ * and extract a meaningful label from its full command line.
2084
+ */
2085
+ // ── Program detection config (persisted to ~/.termdock/program-detection.json) ──
2086
+ const PROGRAM_DETECTION_FILE = `${TERMDOCK_DIR}/program-detection.json`;
2087
+ const DEFAULT_PROGRAM_DETECTION = {
2088
+ genericProgramNames: ['node', 'python', 'python3', 'ruby', 'perl', 'java'],
2089
+ wrapperScriptNames: ['aiden', 'ttadk', 'npx', 'yarn', 'dlx'],
2090
+ shellNames: ['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'nu'],
2091
+ };
2092
+ let genericProgramNames = new Set(DEFAULT_PROGRAM_DETECTION.genericProgramNames);
2093
+ let wrapperScriptNames = new Set(DEFAULT_PROGRAM_DETECTION.wrapperScriptNames);
2094
+ let shellNamesBackend = new Set(DEFAULT_PROGRAM_DETECTION.shellNames);
2095
+ async function loadProgramDetectionFromDisk() {
2096
+ try {
2097
+ const parsed = await readJsonFileIfExists(PROGRAM_DETECTION_FILE);
2098
+ if (!parsed)
2099
+ return { ...DEFAULT_PROGRAM_DETECTION };
2100
+ return {
2101
+ genericProgramNames: Array.isArray(parsed.genericProgramNames) ? parsed.genericProgramNames : DEFAULT_PROGRAM_DETECTION.genericProgramNames,
2102
+ wrapperScriptNames: Array.isArray(parsed.wrapperScriptNames) ? parsed.wrapperScriptNames : DEFAULT_PROGRAM_DETECTION.wrapperScriptNames,
2103
+ shellNames: Array.isArray(parsed.shellNames) ? parsed.shellNames : DEFAULT_PROGRAM_DETECTION.shellNames,
2104
+ };
2105
+ }
2106
+ catch { /* file doesn't exist or invalid, use defaults */ }
2107
+ return { ...DEFAULT_PROGRAM_DETECTION };
2108
+ }
2109
+ function applyProgramDetectionConfig(config) {
2110
+ genericProgramNames = new Set(config.genericProgramNames);
2111
+ wrapperScriptNames = new Set(config.wrapperScriptNames);
2112
+ shellNamesBackend = new Set(config.shellNames);
2113
+ }
2114
+ async function saveProgramDetectionToDisk(config) {
2115
+ await writeJsonFile(PROGRAM_DETECTION_FILE, config);
2116
+ applyProgramDetectionConfig(config);
2117
+ }
2118
+ // Initialize on startup
2119
+ void loadProgramDetectionFromDisk().then(applyProgramDetectionConfig);
2120
+ async function resolveTmuxPaneProgram(pane) {
2121
+ // If pane command is a known shell, try to find a child foreground process
2122
+ const command = normalizeProgramName(pane.command);
2123
+ const commandKey = command?.toLowerCase() ?? null;
2124
+ const isShell = commandKey ? shellNamesBackend.has(commandKey) : false;
2125
+ // If pane command is NOT a shell but also too generic (e.g. "node"), also try
2126
+ const isGeneric = commandKey ? genericProgramNames.has(commandKey) : false;
2127
+ if (!isShell && !isGeneric) {
2128
+ // Non-shell, non-generic command — pane_current_command is good enough
2129
+ return { command, source: 'tmux-pane', rawArgs: null };
2130
+ }
2131
+ if (!pane.pid) {
2132
+ return { command, source: 'tmux-pane', rawArgs: null };
2133
+ }
2134
+ try {
2135
+ const psArgs = pane.tty
2136
+ ? ['-t', pane.tty.replace(/^\/dev\//, ''), '-o', 'pid=,ppid=,pgid=,tpgid=,stat=,comm=,args=']
2137
+ : ['-o', 'pid=,ppid=,pgid=,tpgid=,stat=,comm=,args='];
2138
+ const { stdout } = await execFileAsync('ps', psArgs, { timeout: 3000, maxBuffer: 512 * 1024 });
2139
+ const rows = stdout
2140
+ .split('\n')
2141
+ .map((line) => line.trim())
2142
+ .filter(Boolean)
2143
+ .map((line) => {
2144
+ // ps -o format: PID PPID PGID TPGID STAT COMM ARGS
2145
+ // COMM is a single token; ARGS is the rest of the line
2146
+ const match = line.match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\S+)\s+(.+)$/);
2147
+ if (!match)
2148
+ return null;
2149
+ return {
2150
+ pid: Number.parseInt(match[1] || '0', 10),
2151
+ ppid: Number.parseInt(match[2] || '0', 10),
2152
+ pgid: Number.parseInt(match[3] || '0', 10),
2153
+ tpgid: Number.parseInt(match[4] || '0', 10),
2154
+ stat: match[5] || '',
2155
+ comm: match[6] || '',
2156
+ args: match[7]?.trim() || '',
2157
+ };
2158
+ })
2159
+ .filter((row) => row !== null);
2160
+ const selected = selectTmuxForegroundProgram({
2161
+ panePid: pane.pid,
2162
+ rows,
2163
+ shellNames: shellNamesBackend,
2164
+ genericProgramNames,
2165
+ extractProgramLabel,
2166
+ });
2167
+ if (selected) {
2168
+ return { command: selected.command, source: 'tmux-tty', rawArgs: selected.rawArgs };
2169
+ }
2170
+ }
2171
+ catch {
2172
+ // Fall through to pane_current_command fallback
2173
+ }
2174
+ return { command, source: 'tmux-pane', rawArgs: null };
2175
+ }
2176
+ /**
2177
+ * Extract a human-readable program label from a full command line.
2178
+ *
2179
+ * e.g. "node /path/to/aiden x claude --flag" → "claude"
2180
+ * "node /path/to/ttadk claude --flag" → "claude"
2181
+ * "node /path/to/npm install" → "npm"
2182
+ * "node /path/to/server.js" → "server"
2183
+ * "python3 /path/to/train.py" → "train"
2184
+ * "vim /path/to/file.txt" → "vim"
2185
+ * "/usr/bin/git status" → "git"
2186
+ */
2187
+ function extractProgramLabel(args) {
2188
+ return extractProgramLabelFromArgs(args, { genericProgramNames, wrapperScriptNames });
2189
+ }
2190
+ function getActiveProgramFromTmuxLayout(layout) {
2191
+ const activeWindow = layout.windows.find((window) => window.id === layout.activeWindowId);
2192
+ const activePane = activeWindow?.panes.find((pane) => pane.id === layout.activePaneId);
2193
+ if (!activePane) {
2194
+ return null;
2195
+ }
2196
+ const command = normalizeProgramName(activePane.command);
2197
+ if (!command) {
2198
+ return null;
2199
+ }
2200
+ // For shell/generic programs, we'll resolve asynchronously in the caller
2201
+ // Here we just return the basic info; the caller will call resolveTmuxPaneProgram
2202
+ return {
2203
+ command,
2204
+ source: 'tmux-pane',
2205
+ updatedAt: Date.now(),
2206
+ rawArgs: null,
2207
+ };
2208
+ }
2209
+ function getCwdFromTmuxLayout(layout) {
2210
+ const activeWindow = layout.windows.find((window) => window.id === layout.activeWindowId);
2211
+ const activePane = activeWindow?.panes.find((pane) => pane.id === layout.activePaneId);
2212
+ return activePane?.currentPath || null;
2213
+ }
2214
+ async function resolveLiveTmuxMetadata(tmuxSessionName) {
2215
+ const layout = await getTmuxLayout(tmuxSessionName);
2216
+ const activePane = getActivePaneFromLayout(layout);
2217
+ if (!activePane) {
2218
+ return null;
2219
+ }
2220
+ const resolved = await resolveTmuxPaneProgram(activePane);
2221
+ const fallback = getActiveProgramFromTmuxLayout(layout);
2222
+ const program = resolved?.command ?? fallback?.command ?? null;
2223
+ const cwd = getCwdFromTmuxLayout(layout);
2224
+ return buildRuntimeTmuxMetadata({ tmuxSessionName, program, cwd });
2225
+ }
2226
+ // ── label builder (mirrors the frontend `getSessionDisplayLines` semantics) ──
2227
+ //
2228
+ // Used to populate the `@termdock-label` tmux user option so external tools
2229
+ // (e.g. `termdock --tls`) can show a meaningful one-line summary that matches
2230
+ // what the user sees on the tab in the browser.
2231
+ function getCwdLeafBackend(cwd) {
2232
+ if (!cwd)
2233
+ return null;
2234
+ const trimmed = cwd.trim();
2235
+ if (!trimmed)
2236
+ return null;
2237
+ if (trimmed === '/')
2238
+ return '/';
2239
+ const normalized = trimmed.replace(/[\\/]+$/, '');
2240
+ if (!normalized)
2241
+ return '/';
2242
+ const segments = normalized.split(/[\\/]/).filter(Boolean);
2243
+ const leaf = segments[segments.length - 1];
2244
+ return leaf && leaf.length > 0 ? leaf : trimmed;
2245
+ }
2246
+ function buildTermdockLabel(input) {
2247
+ const friendly = input.friendlyName?.trim();
2248
+ if (friendly)
2249
+ return friendly;
2250
+ const program = normalizeMetadataProgram(input.program);
2251
+ const dir = getCwdLeafBackend(input.cwd);
2252
+ if (program && !shellNamesBackend.has(program)) {
2253
+ return dir ? `${program} · ${dir}` : program;
2254
+ }
2255
+ if (dir)
2256
+ return dir;
2257
+ return input.sessionName;
2258
+ }
2259
+ // Find a friendly (custom) name for a given tmux session from the global state.
2260
+ function findFriendlyNameForTmuxSession(tmuxSessionName) {
2261
+ for (const s of globalSessionState.sessions) {
2262
+ if (s.mode === 'tmux' &&
2263
+ s.tmuxSessionName === tmuxSessionName &&
2264
+ s.customName === true &&
2265
+ typeof s.name === 'string' &&
2266
+ s.name.trim().length > 0) {
2267
+ return s.name;
2268
+ }
2269
+ }
2270
+ return null;
2271
+ }
2272
+ // Push the latest dynamic metadata (program / cwd / label / last-active-at)
2273
+ // onto the tmux session as user options. Caller passes the previous metadata
2274
+ // snapshot and last-active-write timestamp so repeated polls with no change
2275
+ // skip the tmux write entirely. Returns the new snapshot + write timestamp.
2276
+ const TERMDOCK_LAST_ACTIVE_REFRESH_MS = 30_000;
2277
+ function syncDynamicTmuxMetadata(input) {
2278
+ const { tmuxSessionName, program, cwd, previousMetadata, lastActiveWriteAt } = input;
2279
+ const metadata = buildRuntimeTmuxMetadata({ tmuxSessionName, program, cwd });
2280
+ const now = Date.now();
2281
+ if (!tmuxMetadataChanged(previousMetadata, metadata)) {
2282
+ // Cheap path: refresh last-active-at at most every 30 s so external
2283
+ // tools see the session as alive without flooding tmux every 500 ms.
2284
+ if (now - lastActiveWriteAt >= TERMDOCK_LAST_ACTIVE_REFRESH_MS) {
2285
+ void setTmuxOption(tmuxSessionName, '@termdock-last-active-at', String(now));
2286
+ return { ...metadata, lastActiveWriteAt: now };
2287
+ }
2288
+ return { ...metadata, lastActiveWriteAt };
2289
+ }
2290
+ void setTmuxOptions(tmuxSessionName, {
2291
+ '@termdock-label': metadata.label,
2292
+ '@termdock-program': metadata.program ?? '',
2293
+ '@termdock-cwd': metadata.cwd ?? '',
2294
+ '@termdock-last-active-at': String(now),
2295
+ });
2296
+ return { ...metadata, lastActiveWriteAt: now };
2297
+ }
2298
+ async function detectShellActiveProgram(session) {
2299
+ const pid = getPtyProcessPid(session.ptyProcess);
2300
+ if (pid === null) {
2301
+ return null;
2302
+ }
2303
+ try {
2304
+ const ttyPath = await fs.promises.readlink(`/proc/${pid}/fd/0`);
2305
+ const ttyName = ttyPath.startsWith('/dev/') ? ttyPath.slice('/dev/'.length) : ttyPath;
2306
+ const { stdout } = await execFileAsync('ps', [
2307
+ '-t',
2308
+ ttyName,
2309
+ '-o',
2310
+ 'pid=,ppid=,pgid=,tpgid=,stat=,comm=',
2311
+ ], {
2312
+ timeout: 3000,
2313
+ maxBuffer: 512 * 1024,
2314
+ });
2315
+ const rows = stdout
2316
+ .split('\n')
2317
+ .map((line) => line.trim())
2318
+ .filter(Boolean)
2319
+ .map((line) => {
2320
+ const match = line.match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(.+)$/);
2321
+ if (!match) {
2322
+ return null;
2323
+ }
2324
+ return {
2325
+ pid: Number.parseInt(match[1] || '0', 10),
2326
+ pgid: Number.parseInt(match[3] || '0', 10),
2327
+ tpgid: Number.parseInt(match[4] || '0', 10),
2328
+ stat: match[5] || '',
2329
+ command: normalizeProgramName(match[6]),
2330
+ };
2331
+ })
2332
+ .filter((row) => row !== null);
2333
+ if (rows.length > 0) {
2334
+ const foregroundRows = rows.filter((row) => row.command && row.tpgid > 0 && row.pgid === row.tpgid && !row.stat.startsWith('Z'));
2335
+ const preferredForeground = foregroundRows.find((row) => row.pid !== pid) ?? foregroundRows[foregroundRows.length - 1];
2336
+ if (preferredForeground?.command) {
2337
+ return {
2338
+ command: preferredForeground.command,
2339
+ source: 'shell-tty',
2340
+ rawArgs: null,
2341
+ updatedAt: Date.now(),
2342
+ };
2343
+ }
2344
+ const shellRow = rows.find((row) => row.pid === pid && row.command);
2345
+ if (shellRow?.command) {
2346
+ return {
2347
+ command: shellRow.command,
2348
+ source: 'shell-pid',
2349
+ rawArgs: null,
2350
+ updatedAt: Date.now(),
2351
+ };
2352
+ }
2353
+ }
2354
+ }
2355
+ catch {
2356
+ // Fall through to shell fallback.
2357
+ }
2358
+ return {
2359
+ command: normalizeProgramName(process.env.SHELL || '/bin/sh'),
2360
+ source: 'unknown',
2361
+ rawArgs: null,
2362
+ updatedAt: Date.now(),
2363
+ };
2364
+ }
2365
+ async function resolveTmuxClientTty(sessionName, preferredClientPid) {
2366
+ const clientsRaw = await runTmux([
2367
+ 'list-clients',
2368
+ '-t',
2369
+ sessionName,
2370
+ '-F',
2371
+ `#{client_pid}${TMUX_DELIMITER}#{client_tty}`,
2372
+ ]);
2373
+ const rows = clientsRaw
2374
+ .split('\n')
2375
+ .map((line) => line.trim())
2376
+ .filter(Boolean)
2377
+ .map((line) => parseDelimitedRow(line, 2))
2378
+ .filter((row) => row !== null);
2379
+ if (rows.length === 0) {
2380
+ return null;
2381
+ }
2382
+ if (preferredClientPid !== null) {
2383
+ const matched = rows.find(([clientPid]) => clientPid === String(preferredClientPid));
2384
+ if (matched?.[1]) {
2385
+ return matched[1];
2386
+ }
2387
+ }
2388
+ return rows[0][1] || null;
2389
+ }
2390
+ async function ensureTmuxSessionExists(sessionName, cwd) {
2391
+ let serverWasMissing = false;
2392
+ try {
2393
+ await runTmux(['has-session', '-t', sessionName]);
2394
+ await ensureTmuxColorEnvironment(sessionName);
2395
+ return;
2396
+ }
2397
+ catch (error) {
2398
+ const errorMessage = getErrorMessage(error);
2399
+ serverWasMissing = isTmuxServerMissingMessage(errorMessage);
2400
+ if (!isTmuxSessionMissingMessage(errorMessage)) {
2401
+ throw error;
2402
+ }
2403
+ }
2404
+ if (!serverWasMissing) {
2405
+ await ensureTmuxColorEnvironment();
2406
+ }
2407
+ const args = ['new-session', '-d', '-s', sessionName, '-e', 'COLORTERM=truecolor'];
2408
+ if (process.env.TERMDOCK_FORCE_COLOR === '1') {
2409
+ args.push('-e', 'FORCE_COLOR=1');
2410
+ }
2411
+ if (cwd) {
2412
+ args.push('-c', cwd);
2413
+ }
2414
+ await runTmux(args);
2415
+ await ensureTmuxColorEnvironment(sessionName);
2416
+ // Inject shell integration env into the tmux session so inner shells
2417
+ // get the same OSC 133/2/7 marks as direct shell mode.
2418
+ await injectTmuxShellIntegration(sessionName);
2419
+ }
2420
+ /**
2421
+ * Inject shell integration environment variables into a tmux session.
2422
+ *
2423
+ * tmux's `set-environment` makes vars available to processes spawned in
2424
+ * that session (i.e. the inner shell). We detect the shell type from
2425
+ * the tmux session's default-command / default-shell, then inject the
2426
+ * same env vars as injectShellIntegration does for direct shell mode.
2427
+ */
2428
+ async function injectTmuxShellIntegration(sessionName) {
2429
+ // Determine the shell used inside tmux. tmux's default-shell is usually
2430
+ // the user's $SHELL, but can be overridden. We read it.
2431
+ let shellPath = process.env.SHELL || '/bin/bash';
2432
+ try {
2433
+ const tmuxShell = (await runTmux(['show-options', '-t', sessionName, '-v', 'default-shell'])).trim();
2434
+ if (tmuxShell)
2435
+ shellPath = tmuxShell;
2436
+ }
2437
+ catch {
2438
+ // default-shell not set for this session, use global $SHELL
2439
+ }
2440
+ const shellType = detectShellType(shellPath);
2441
+ const integrationDir = await resolveShellIntegrationDir();
2442
+ if (!integrationDir)
2443
+ return;
2444
+ const home = (process.env.HOME || '/root').replace(/\/+$/, '') || '/';
2445
+ if (shellType === 'zsh') {
2446
+ // Create a temporary ZDOTDIR for tmux's inner zsh.
2447
+ // Use session name in the dir to avoid collisions between sessions.
2448
+ const safeName = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_');
2449
+ const zdotdir = '/tmp/termdock-zsh-tmux-' + safeName;
2450
+ try {
2451
+ await fs.promises.mkdir(zdotdir, { recursive: true });
2452
+ await fs.promises.writeFile(zdotdir + '/.zshenv', '[[ -f "' + home + '/.zshenv" ]] && source "' + home + '/.zshenv"\n');
2453
+ await fs.promises.writeFile(zdotdir + '/.zshrc', 'ZDOTDIR=\n' +
2454
+ '[[ -f "' + home + '/.zshrc" ]] && source "' + home + '/.zshrc"\n' +
2455
+ 'source "' + integrationDir + '/termdock.zsh"\n');
2456
+ await runTmux(['set-environment', '-t', sessionName, 'ZDOTDIR', zdotdir]);
2457
+ }
2458
+ catch {
2459
+ // If we can't create the ZDOTDIR, skip integration
2460
+ }
2461
+ }
2462
+ else if (shellType === 'bash') {
2463
+ const scriptPath = path.join(integrationDir, 'termdock.bash');
2464
+ await runTmux(['set-environment', '-t', sessionName, 'BASH_ENV', scriptPath]);
2465
+ // Also set a bootstrap PROMPT_COMMAND for interactive bash
2466
+ const bootstrap = 'source "' + scriptPath + '" 2>/dev/null';
2467
+ await runTmux(['set-environment', '-t', sessionName, 'TERMDOCK_BASH_BOOTSTRAP', bootstrap]);
2468
+ }
2469
+ else if (shellType === 'fish') {
2470
+ const scriptPath = path.join(integrationDir, 'termdock.fish');
2471
+ await runTmux(['set-environment', '-t', sessionName, 'TERMDOCK_FISH_INTEGRATION', scriptPath]);
2472
+ }
2473
+ }
2474
+ async function enableTmuxFocusEvents() {
2475
+ const current = (await runTmux(['show-options', '-gqv', 'focus-events'])).trim();
2476
+ if (current === 'on')
2477
+ return;
2478
+ await runTmux(['set-option', '-g', 'focus-events', 'on']);
2479
+ console.log('[tmux-focus] enabled global focus-events');
2480
+ }
2481
+ async function ensureSharedTmuxServerReady() {
2482
+ await ensureTmuxColorEnvironment();
2483
+ await applyTmuxScrollbackProfile();
2484
+ await enableTmuxFocusEvents();
2485
+ await configureTmuxWheelBindings();
2486
+ }
2487
+ async function stampTmuxMetadata(sessionName) {
2488
+ const baseOptions = {
2489
+ '@termdock-version': TERMDOCK_VERSION,
2490
+ '@termdock-host': TERMDOCK_HOST,
2491
+ '@termdock-pid': TERMDOCK_PID,
2492
+ };
2493
+ const existingCreatedAt = await getTmuxOption(sessionName, '@termdock-created-at');
2494
+ if (!existingCreatedAt) {
2495
+ baseOptions['@termdock-created-at'] = String(Date.now());
2496
+ }
2497
+ await setTmuxOptions(sessionName, baseOptions);
2498
+ }
2499
+ async function ensureManagedTmuxSessionReady(sessionName) {
2500
+ try {
2501
+ await ensureTmuxColorEnvironment(sessionName);
2502
+ }
2503
+ catch (error) {
2504
+ console.warn(`Failed to set tmux color environment for ${sessionName}: ${getErrorMessage(error)}`);
2505
+ }
2506
+ try {
2507
+ await disableTmuxStatus(sessionName);
2508
+ }
2509
+ catch (error) {
2510
+ console.warn(`Failed to disable tmux status for ${sessionName}: ${getErrorMessage(error)}`);
2511
+ }
2512
+ try {
2513
+ await enableTmuxMouse(sessionName);
2514
+ }
2515
+ catch (error) {
2516
+ console.warn(`Failed to enable tmux mouse for ${sessionName}: ${getErrorMessage(error)}`);
2517
+ }
2518
+ await applyTmuxScrollbackProfile(sessionName);
2519
+ await stampTmuxMetadata(sessionName);
2520
+ }
2521
+ async function prepareManagedTmuxSession(sessionName, cwd) {
2522
+ await ensureTmuxSessionExists(sessionName, cwd);
2523
+ await ensureSharedTmuxServerReady();
2524
+ await ensureManagedTmuxSessionReady(sessionName);
2525
+ }
2526
+ async function getTmuxLayout(sessionName) {
2527
+ const sessionInfoRaw = (await runTmux([
2528
+ 'display-message',
2529
+ '-t',
2530
+ sessionName,
2531
+ '-p',
2532
+ `#{session_id}${TMUX_DELIMITER}#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_in_mode}`,
2533
+ ])).trim();
2534
+ const sessionInfo = parseDelimitedRow(sessionInfoRaw, 5);
2535
+ if (!sessionInfo) {
2536
+ throw new Error(`Failed to parse tmux session info: ${sessionInfoRaw}`);
2537
+ }
2538
+ const [sessionId, resolvedSessionName, activeWindowId, activePaneId, paneInMode] = sessionInfo;
2539
+ const windowsRaw = await runTmux([
2540
+ 'list-windows',
2541
+ '-t',
2542
+ sessionName,
2543
+ '-F',
2544
+ `#{window_id}${TMUX_DELIMITER}#{window_name}${TMUX_DELIMITER}#{window_index}${TMUX_DELIMITER}#{window_active}`,
2545
+ ]);
2546
+ const windows = [];
2547
+ for (const line of windowsRaw.trim().split('\n')) {
2548
+ if (!line) {
2549
+ continue;
2550
+ }
2551
+ const row = parseDelimitedRow(line, 4);
2552
+ if (!row) {
2553
+ continue;
2554
+ }
2555
+ const [windowId, windowName, windowIndexRaw, windowActiveRaw] = row;
2556
+ const panesRaw = await runTmux([
2557
+ 'list-panes',
2558
+ '-t',
2559
+ windowId,
2560
+ '-F',
2561
+ `#{pane_id}${TMUX_DELIMITER}#{pane_index}${TMUX_DELIMITER}#{pane_active}${TMUX_DELIMITER}#{pane_width}${TMUX_DELIMITER}#{pane_height}${TMUX_DELIMITER}#{pane_top}${TMUX_DELIMITER}#{pane_left}${TMUX_DELIMITER}#{pane_current_command}${TMUX_DELIMITER}#{pane_pid}${TMUX_DELIMITER}#{pane_tty}${TMUX_DELIMITER}#{pane_title}${TMUX_DELIMITER}#{pane_current_path}`,
2562
+ ]);
2563
+ const panes = panesRaw.trim().split('\n').filter(Boolean).map((paneLine) => {
2564
+ const paneRow = parseDelimitedRow(paneLine, 12);
2565
+ if (!paneRow) {
2566
+ return null;
2567
+ }
2568
+ const [paneId, paneIndexRaw, paneActiveRaw, widthRaw, heightRaw, topRaw, leftRaw, command, pidRaw, tty, title, currentPath] = paneRow;
2569
+ return {
2570
+ id: paneId,
2571
+ index: parseInt(paneIndexRaw || '0', 10),
2572
+ active: paneActiveRaw === '1',
2573
+ width: parseInt(widthRaw || '0', 10),
2574
+ height: parseInt(heightRaw || '0', 10),
2575
+ top: parseInt(topRaw || '0', 10),
2576
+ left: parseInt(leftRaw || '0', 10),
2577
+ command: command || '',
2578
+ pid: parseInt(pidRaw || '0', 10) || 0,
2579
+ tty: tty || '',
2580
+ title: title || '',
2581
+ currentPath: currentPath || '',
2582
+ };
2583
+ }).filter((pane) => pane !== null);
2584
+ windows.push({
2585
+ id: windowId,
2586
+ name: windowName || '',
2587
+ index: parseInt(windowIndexRaw || '0', 10),
2588
+ active: windowActiveRaw === '1',
2589
+ panes,
2590
+ });
2591
+ }
2592
+ return {
2593
+ sessionId,
2594
+ sessionName: resolvedSessionName,
2595
+ windows,
2596
+ activeWindowId,
2597
+ activePaneId,
2598
+ inCopyMode: paneInMode === '1',
2599
+ };
2600
+ }
2601
+ async function getRestoreHistory(sessionId, session) {
2602
+ // In tmux mode, scrollback belongs to tmux itself rather than the app layer,
2603
+ // so capture the active pane to rebuild the visible screen on refresh.
2604
+ if (session.mode === 'tmux') {
2605
+ if (!session.tmuxSessionName) {
2606
+ return [];
2607
+ }
2608
+ try {
2609
+ const snapshot = await captureTmuxPane(session.tmuxSessionName);
2610
+ return snapshot
2611
+ // capture-pane returns a line-oriented snapshot with LF separators.
2612
+ // In tmux mode the frontend intentionally keeps xterm.convertEol=false
2613
+ // so live TUI output preserves exact terminal semantics; feeding bare LF
2614
+ // during restore moves down without carriage return and renders as sparse
2615
+ // diagonal characters after reconnect. Normalize only this synthetic
2616
+ // snapshot to CRLF so each captured tmux line starts at column 0.
2617
+ ? ['\u001b[H\u001b[2J\u001b[3J', snapshot.replace(/\r?\n/g, '\r\n')]
2618
+ : [];
2619
+ }
2620
+ catch (error) {
2621
+ console.warn(`Failed to capture tmux pane for ${session.tmuxSessionName}: ${getErrorMessage(error)}`);
2622
+ return [];
2623
+ }
2624
+ }
2625
+ return getReconnectionHistory(sessionId);
2626
+ }
2627
+ async function resolveWorkingDirectory(req, inputCwd) {
2628
+ const requestedCwd = inputCwd || os.homedir();
2629
+ if (req.pathValidator) {
2630
+ return req.pathValidator.validateAsync(requestedCwd);
2631
+ }
2632
+ try {
2633
+ await fs.promises.access(requestedCwd, fs.constants.R_OK | fs.constants.X_OK);
2634
+ }
2635
+ catch {
2636
+ throw new Error(`Working directory is not accessible: ${requestedCwd}`);
2637
+ }
2638
+ return requestedCwd;
2639
+ }
2640
+ function isExecutable(filePath) {
2641
+ try {
2642
+ fs.accessSync(filePath, fs.constants.X_OK);
2643
+ return true;
2644
+ }
2645
+ catch {
2646
+ return false;
2647
+ }
2648
+ }
2649
+ function resolveShellCandidates() {
2650
+ const candidates = [];
2651
+ const configuredShell = process.env.SHELL;
2652
+ if (configuredShell && isExecutable(configuredShell)) {
2653
+ candidates.push(configuredShell);
2654
+ }
2655
+ const fallbackShells = [
2656
+ '/bin/bash',
2657
+ '/usr/bin/bash',
2658
+ '/bin/zsh',
2659
+ '/usr/bin/zsh',
2660
+ '/bin/sh',
2661
+ '/usr/bin/sh',
2662
+ ];
2663
+ for (const candidate of fallbackShells) {
2664
+ if (isExecutable(candidate) && !candidates.includes(candidate)) {
2665
+ candidates.push(candidate);
2666
+ }
2667
+ }
2668
+ if (candidates.length === 0) {
2669
+ throw new Error('No usable shell found. Set SHELL to an installed shell such as /bin/bash or /bin/sh.');
2670
+ }
2671
+ return candidates;
2672
+ }
2673
+ function shouldRetryShellSpawn(error) {
2674
+ const errorMessage = error instanceof Error ? error.message : String(error);
2675
+ return /posix_spawnp failed|ENOENT|EACCES/i.test(errorMessage);
2676
+ }
2677
+ function writeSse(res, payload) {
2678
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
2679
+ }
2680
+ function getTotalClients(sessionId) {
2681
+ let count = 0;
2682
+ const session = terminalSessions.get(sessionId);
2683
+ if (session)
2684
+ count += session.clients.size;
2685
+ const ws = wsClients.get(sessionId);
2686
+ if (ws)
2687
+ count += ws.size;
2688
+ return count;
2689
+ }
2690
+ // Mirror the current SSE+WS client count onto the session's tmux user
2691
+ // option `@termdock-client-count`. No-op for shell sessions.
2692
+ function syncClientCountToTmux(sessionId) {
2693
+ const session = terminalSessions.get(sessionId);
2694
+ if (!session || session.mode !== 'tmux' || !session.tmuxSessionName)
2695
+ return;
2696
+ void setTmuxOption(session.tmuxSessionName, '@termdock-client-count', String(getTotalClients(sessionId)));
2697
+ }
2698
+ function closeClient(session, sessionId, clientId) {
2699
+ const client = session.clients.get(clientId);
2700
+ if (!client) {
2701
+ return;
2702
+ }
2703
+ session.clients.delete(clientId);
2704
+ removeClientFocus(sessionId, session, clientId);
2705
+ syncClientCountToTmux(sessionId);
2706
+ try {
2707
+ client.end();
2708
+ }
2709
+ catch {
2710
+ // ignore
2711
+ }
2712
+ }
2713
+ function broadcastEvent(sessionId, payload) {
2714
+ // SSE clients
2715
+ const session = terminalSessions.get(sessionId);
2716
+ if (session) {
2717
+ for (const [clientId, client] of session.clients.entries()) {
2718
+ try {
2719
+ writeSse(client, payload);
2720
+ }
2721
+ catch {
2722
+ closeClient(session, sessionId, clientId);
2723
+ }
2724
+ }
2725
+ }
2726
+ // WebSocket clients
2727
+ broadcastJsonWs(sessionId, payload);
2728
+ }
2729
+ function cleanupSession(sessionId, options) {
2730
+ const session = terminalSessions.get(sessionId);
2731
+ if (!session) {
2732
+ return;
2733
+ }
2734
+ if (session.agentStatusTimer) {
2735
+ clearTimeout(session.agentStatusTimer);
2736
+ session.agentStatusTimer = null;
2737
+ }
2738
+ session.dataDisposable?.dispose();
2739
+ session.exitDisposable?.dispose();
2740
+ destroyTmuxControl(session.tmuxControl);
2741
+ session.tmuxControl = undefined;
2742
+ for (const timer of session.flowPausedClientTimers.values()) {
2743
+ clearTimeout(timer);
2744
+ }
2745
+ session.flowPausedClientTimers.clear();
2746
+ session.flowPausedClients.clear();
2747
+ if (session.ptyPausedForFlowControl) {
2748
+ applyPtyFlowControl(sessionId, session, false, 'session-cleanup');
2749
+ }
2750
+ if (session.tmuxSessionName) {
2751
+ exitedAtBottom.delete(session.tmuxSessionName);
2752
+ }
2753
+ if (options.killProcess) {
2754
+ try {
2755
+ session.ptyProcess.kill();
2756
+ }
2757
+ catch {
2758
+ // ignore
2759
+ }
2760
+ }
2761
+ for (const client of session.clients.values()) {
2762
+ try {
2763
+ client.end();
2764
+ }
2765
+ catch {
2766
+ // ignore
2767
+ }
2768
+ }
2769
+ terminalSessions.delete(sessionId);
2770
+ if (options.clearHistoryBuffer !== false) {
2771
+ clearHistory(sessionId);
2772
+ }
2773
+ // Fast-path cleanup for natural shell exits (PTY died on its own, not via
2774
+ // our DELETE /:id path). Drop the persisted client-state entry so every
2775
+ // browser sees the tab vanish within one WS tick, instead of waiting up to
2776
+ // 30s for the reconciler. Only fires for shell mode: a tmux wrapper
2777
+ // exiting is normal (the user can detach/reconnect) and the tmux daemon
2778
+ // itself is independent of the wrapper.
2779
+ if (options.killProcess === false && session.mode === 'shell') {
2780
+ const beforeCount = globalSessionState.sessions.length;
2781
+ globalSessionState = {
2782
+ sessions: globalSessionState.sessions.filter((s) => s.backendSessionId !== sessionId),
2783
+ updatedAt: Date.now(),
2784
+ };
2785
+ if (globalSessionState.sessions.length !== beforeCount) {
2786
+ schedulePersistGlobalState();
2787
+ broadcastClientState();
2788
+ }
2789
+ }
2790
+ }
2791
+ function broadcastToWs(sessionId, data, excludeClientId) {
2792
+ const clients = wsClients.get(sessionId);
2793
+ const session = terminalSessions.get(sessionId);
2794
+ if (!clients)
2795
+ return;
2796
+ for (const [clientId, ws] of clients.entries()) {
2797
+ if (excludeClientId && clientId === excludeClientId)
2798
+ continue;
2799
+ try {
2800
+ ws.send(data);
2801
+ }
2802
+ catch {
2803
+ clients.delete(clientId);
2804
+ if (session)
2805
+ removeClientFlowPaused(sessionId, session, clientId, 'ws-send-failed');
2806
+ }
2807
+ }
2808
+ }
2809
+ function broadcastJsonWs(sessionId, payload, excludeClientId) {
2810
+ broadcastToWs(sessionId, JSON.stringify(payload), excludeClientId);
2811
+ }
2812
+ // 统一的 pty resize 入口:调用 ptyProcess.resize 改变 pty size 之后,把
2813
+ // 真实尺寸广播给除发起方之外的所有 ws client。多端场景下,B 端把 pty
2814
+ // 拉小后,A 端能立即知道 server 真实尺寸跟自己 lastServerSize 不一致,
2815
+ // 下次 fit 才能正确触发 push。发起方自己已经知道这个尺寸(自己刚发的),
2816
+ // 不必再回声一份,避免无意义的 lastServerSize 重复写、pending timer 取消。
2817
+ function applyPtyResize(sessionId, session, cols, rows, source, originClientId) {
2818
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
2819
+ return false;
2820
+ }
2821
+ const cleanCols = Math.floor(cols);
2822
+ const cleanRows = Math.floor(rows);
2823
+ const changed = session.cols !== cleanCols || session.rows !== cleanRows;
2824
+ try {
2825
+ session.ptyProcess.resize(cleanCols, cleanRows);
2826
+ }
2827
+ catch (error) {
2828
+ console.warn(`[pty-resize] failed session=${sessionId} cols=${cleanCols} rows=${cleanRows}: ${getErrorMessage(error)}`);
2829
+ return false;
2830
+ }
2831
+ session.cols = cleanCols;
2832
+ session.rows = cleanRows;
2833
+ session.lastActivity = Date.now();
2834
+ if (changed) {
2835
+ broadcastJsonWs(sessionId, { type: 'pty-size', cols: cleanCols, rows: cleanRows, source }, originClientId);
2836
+ }
2837
+ return true;
2838
+ }
2839
+ function getFlowPausedClients(session) {
2840
+ if (!session.flowPausedClients) {
2841
+ session.flowPausedClients = new Set();
2842
+ }
2843
+ return session.flowPausedClients;
2844
+ }
2845
+ function getFlowPausedClientTimers(session) {
2846
+ if (!session.flowPausedClientTimers) {
2847
+ session.flowPausedClientTimers = new Map();
2848
+ }
2849
+ return session.flowPausedClientTimers;
2850
+ }
2851
+ function clearFlowPauseLease(session, clientId) {
2852
+ const timers = getFlowPausedClientTimers(session);
2853
+ const timer = timers.get(clientId);
2854
+ if (timer) {
2855
+ clearTimeout(timer);
2856
+ timers.delete(clientId);
2857
+ }
2858
+ }
2859
+ function refreshFlowPauseLease(sessionId, session, clientId) {
2860
+ clearFlowPauseLease(session, clientId);
2861
+ const timer = setTimeout(() => {
2862
+ const currentSession = terminalSessions.get(sessionId);
2863
+ if (!currentSession)
2864
+ return;
2865
+ console.warn(`[flow-control] pause lease expired session=${sessionId} client=${clientId}`);
2866
+ removeClientFlowPaused(sessionId, currentSession, clientId, 'pause-lease-expired');
2867
+ }, FLOW_CONTROL_PAUSE_LEASE_MS);
2868
+ timer.unref?.();
2869
+ getFlowPausedClientTimers(session).set(clientId, timer);
2870
+ }
2871
+ function applyPtyFlowControl(sessionId, session, paused, reason) {
2872
+ if (session.ptyPausedForFlowControl === paused)
2873
+ return;
2874
+ session.ptyPausedForFlowControl = paused;
2875
+ const method = paused ? session.ptyProcess.pause : session.ptyProcess.resume;
2876
+ if (typeof method !== 'function') {
2877
+ console.warn(`[flow-control] PTY backend has no ${paused ? 'pause' : 'resume'} method session=${sessionId} reason=${reason}`);
2878
+ return;
2879
+ }
2880
+ try {
2881
+ method.call(session.ptyProcess);
2882
+ }
2883
+ catch (error) {
2884
+ console.warn(`[flow-control] failed to ${paused ? 'pause' : 'resume'} PTY session=${sessionId} reason=${reason}: ${getErrorMessage(error)}`);
2885
+ }
2886
+ }
2887
+ function closeFlowControlledWsClient(sessionId, clientId, reason) {
2888
+ const clients = wsClients.get(sessionId);
2889
+ const ws = clients?.get(clientId);
2890
+ if (!ws)
2891
+ return;
2892
+ try {
2893
+ if (ws.readyState === ws.OPEN) {
2894
+ ws.send(JSON.stringify({
2895
+ type: 'reconnecting',
2896
+ reason: 'flow-control',
2897
+ detail: reason,
2898
+ }));
2899
+ }
2900
+ }
2901
+ catch {
2902
+ // The close path below will clean up the client map.
2903
+ }
2904
+ try {
2905
+ if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
2906
+ ws.close(1013, 'Client output backlog');
2907
+ }
2908
+ }
2909
+ catch {
2910
+ clients?.delete(clientId);
2911
+ const session = terminalSessions.get(sessionId);
2912
+ if (session) {
2913
+ removeClientFocus(sessionId, session, clientId);
2914
+ removeClientFlowPaused(sessionId, session, clientId, 'flow-control-close-failed');
2915
+ }
2916
+ if (clients?.size === 0) {
2917
+ wsClients.delete(sessionId);
2918
+ }
2919
+ syncClientCountToTmux(sessionId);
2920
+ }
2921
+ }
2922
+ function setClientFlowPaused(sessionId, session, clientId, paused, reason) {
2923
+ const clients = getFlowPausedClients(session);
2924
+ const wasPaused = clients.has(clientId);
2925
+ if (paused) {
2926
+ clients.add(clientId);
2927
+ refreshFlowPauseLease(sessionId, session, clientId);
2928
+ console.warn(`[flow-control] disconnecting slow client session=${sessionId} client=${clientId} reason=${reason}`);
2929
+ closeFlowControlledWsClient(sessionId, clientId, reason);
2930
+ }
2931
+ else {
2932
+ clients.delete(clientId);
2933
+ clearFlowPauseLease(session, clientId);
2934
+ }
2935
+ if (wasPaused === paused) {
2936
+ return;
2937
+ }
2938
+ if (!paused && session.ptyPausedForFlowControl && clients.size === 0) {
2939
+ applyPtyFlowControl(sessionId, session, false, reason);
2940
+ }
2941
+ }
2942
+ function removeClientFlowPaused(sessionId, session, clientId, reason) {
2943
+ clearFlowPauseLease(session, clientId);
2944
+ const clients = getFlowPausedClients(session);
2945
+ if (!clients.delete(clientId))
2946
+ return;
2947
+ if (session.ptyPausedForFlowControl && clients.size === 0) {
2948
+ applyPtyFlowControl(sessionId, session, false, reason);
2949
+ }
2950
+ }
2951
+ function emitFocusSequenceIfNeeded(sessionId, session, focused, reason) {
2952
+ if (session.mode !== 'tmux' || !session.focusTrackingRequested)
2953
+ return;
2954
+ try {
2955
+ session.ptyProcess.write(getFocusSequence(focused));
2956
+ console.log(`[tmux-focus] emitted ${focused ? 'focus-in' : 'focus-out'} session=${sessionId} reason=${reason}`);
2957
+ }
2958
+ catch (error) {
2959
+ console.warn(`[tmux-focus] failed to emit focus sequence session=${sessionId}: ${getErrorMessage(error)}`);
2960
+ }
2961
+ }
2962
+ function updateClientFocusState(sessionId, session, clientId, focused, reason) {
2963
+ const result = setClientFocusState(session.focusAggregation, clientId, focused);
2964
+ if (result.changed) {
2965
+ emitFocusSequenceIfNeeded(sessionId, session, result.effectiveFocused, reason);
2966
+ }
2967
+ }
2968
+ function removeClientFocus(sessionId, session, clientId) {
2969
+ const result = removeClientFocusState(session.focusAggregation, clientId);
2970
+ if (result.changed) {
2971
+ emitFocusSequenceIfNeeded(sessionId, session, result.effectiveFocused, 'client-disconnect');
2972
+ }
2973
+ }
2974
+ function updateFocusTrackingFromOutput(sessionId, session, data) {
2975
+ const result = scanFocusTrackingMode(data, {
2976
+ buffer: session.focusModeSniffBuf,
2977
+ requested: session.focusTrackingRequested,
2978
+ });
2979
+ session.focusModeSniffBuf = result.buffer;
2980
+ if (!result.changed)
2981
+ return;
2982
+ session.focusTrackingRequested = result.requested;
2983
+ broadcastJsonWs(sessionId, {
2984
+ type: 'focus-mode',
2985
+ focusTrackingRequested: result.requested,
2986
+ });
2987
+ console.log(`[tmux-focus] mode ${result.requested ? 'enabled' : 'disabled'} session=${sessionId}`);
2988
+ if (result.requested && session.focusAggregation.effectiveFocused) {
2989
+ emitFocusSequenceIfNeeded(sessionId, session, true, 'focus-mode-enabled');
2990
+ }
2991
+ }
2992
+ function setupPtyHandlers(sessionId, session) {
2993
+ const home = (process.env.HOME || '/root').replace(/\/+$/, '') || '/';
2994
+ session.dataDisposable = session.ptyProcess.onData((data) => {
2995
+ session.lastActivity = Date.now();
2996
+ session.hasWrittenData = true;
2997
+ let seq;
2998
+ if (session.mode === 'shell') {
2999
+ seq = addToHistory(sessionId, data);
3000
+ }
3001
+ if (session.mode === 'tmux') {
3002
+ updateFocusTrackingFromOutput(sessionId, session, data);
3003
+ }
3004
+ // Sniff OSC sequences for CWD tracking + prompt state + title
3005
+ try {
3006
+ const buf = session.oscSniffBuf + data;
3007
+ if (buf.length > OSC_SNIFF_CAP) {
3008
+ session.oscSniffBuf = buf.slice(-OSC_SNIFF_CAP / 4); // trim
3009
+ }
3010
+ const result = sniffOsc(buf, home);
3011
+ session.oscSniffBuf = result.remaining;
3012
+ // CWD change
3013
+ if (result.cwd && result.cwd !== session.lastOscCwd) {
3014
+ session.lastOscCwd = result.cwd;
3015
+ session.cwd = result.cwd;
3016
+ // 用户在终端里 cd 到白名单外目录(如 Windows 其它盘符)时,动态放行,
3017
+ // 否则侧边栏目录树/文件 API 会报 "Access denied: path not allowed"。
3018
+ void pathValidator.allowSessionCwd(result.cwd);
3019
+ if (updateGlobalBindingForBackendSession(sessionId, { cwd: result.cwd, lastActivity: session.lastActivity })) {
3020
+ schedulePersistGlobalState();
3021
+ }
3022
+ broadcastEvent(sessionId, { type: 'cwd', cwd: result.cwd });
3023
+ }
3024
+ // Title change — broadcast so the frontend can update tab/sidebar
3025
+ if (result.title !== null && result.title !== session.lastOscTitle) {
3026
+ session.lastOscTitle = result.title;
3027
+ broadcastJsonWs(sessionId, { type: 'shell-title', title: result.title });
3028
+ }
3029
+ // Prompt state change (OSC 133)
3030
+ if (result.promptState !== null && result.promptState !== session.lastPromptState) {
3031
+ session.lastPromptState = result.promptState;
3032
+ broadcastJsonWs(sessionId, {
3033
+ type: 'prompt-state',
3034
+ state: result.promptState,
3035
+ exitCode: result.exitCode,
3036
+ });
3037
+ }
3038
+ if (result.tuiProgress !== null) {
3039
+ session.tuiProgress = result.tuiProgress.state === 'remove' ? null : result.tuiProgress;
3040
+ broadcastJsonWs(sessionId, {
3041
+ type: 'tui-progress',
3042
+ tuiProgress: session.tuiProgress,
3043
+ });
3044
+ }
3045
+ }
3046
+ catch { /* sniff failure should never block data */ }
3047
+ // Agent status content-based detection — DISABLED.
3048
+ // OSC 133 promptState now provides real-time running/idle state for all
3049
+ // programs (not just AI tools). The content-based detection was inaccurate
3050
+ // (false positives from spinner characters in normal output, false negatives
3051
+ // when AI tools changed their output format). Keeping the code for potential
3052
+ // future re-enablement but not calling it.
3053
+ // try {
3054
+ // if (isAiToolProgram(session.activeProgram?.command)) {
3055
+ // ...evaluateAgentStatus...
3056
+ // }
3057
+ // } catch {}
3058
+ broadcastEvent(sessionId, seq !== undefined ? { type: 'data', data, seq } : { type: 'data', data });
3059
+ });
3060
+ session.exitDisposable = session.ptyProcess.onExit(({ exitCode, signal }) => {
3061
+ console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
3062
+ broadcastEvent(sessionId, { type: 'exit', exitCode, signal });
3063
+ cleanupSession(sessionId, { killProcess: false });
3064
+ });
3065
+ }
3066
+ async function spawnTerminalSession(req, input) {
3067
+ const cwd = await resolveWorkingDirectory(req, input.cwd);
3068
+ const cols = input.cols || 80;
3069
+ const rows = input.rows || 24;
3070
+ const sessionId = Math.random().toString(36).substring(2, 15) +
3071
+ Math.random().toString(36).substring(2, 15);
3072
+ const mode = normalizeMode(input.mode);
3073
+ const tmuxSessionName = mode === 'tmux' ? normalizeTmuxSessionName(input.tmuxSessionName) : null;
3074
+ if (mode === 'tmux' && tmuxSessionName) {
3075
+ await prepareManagedTmuxSession(tmuxSessionName, cwd);
3076
+ }
3077
+ const command = mode === 'tmux'
3078
+ ? getTmuxBinary()
3079
+ : (process.platform === 'win32' ? 'powershell.exe' : resolveShellCandidates()[0]);
3080
+ const args = mode === 'tmux' && tmuxSessionName
3081
+ ? ['attach-session', '-t', tmuxSessionName]
3082
+ : (process.platform === 'win32' ? buildPowerShellCwdHookArgs() : []);
3083
+ const envPath = buildAugmentedPath();
3084
+ const resolvedEnv = { ...process.env, PATH: envPath };
3085
+ const pty = await getPtyProvider();
3086
+ const termValue = resolveTerminalTermType(input.termType);
3087
+ const baseEnv = {
3088
+ ...resolvedEnv,
3089
+ TERM: termValue,
3090
+ COLORTERM: 'truecolor',
3091
+ };
3092
+ let ptyProcess = null;
3093
+ if (mode === 'shell' && process.platform !== 'win32') {
3094
+ const shellCandidates = resolveShellCandidates();
3095
+ let lastError = null;
3096
+ for (const shellCandidate of shellCandidates) {
3097
+ try {
3098
+ const env = await injectShellIntegration(shellCandidate, baseEnv);
3099
+ ptyProcess = pty.spawn(shellCandidate, [], {
3100
+ name: termValue,
3101
+ cols,
3102
+ rows,
3103
+ cwd,
3104
+ env,
3105
+ });
3106
+ break;
3107
+ }
3108
+ catch (error) {
3109
+ lastError = error;
3110
+ if (!shouldRetryShellSpawn(error)) {
3111
+ throw error;
3112
+ }
3113
+ }
3114
+ }
3115
+ if (!ptyProcess) {
3116
+ throw lastError ?? new Error('Failed to start shell');
3117
+ }
3118
+ }
3119
+ else {
3120
+ ptyProcess = pty.spawn(command, args, {
3121
+ name: termValue,
3122
+ cols,
3123
+ rows,
3124
+ cwd,
3125
+ env: baseEnv,
3126
+ // Windows: 用 node-pty 自带的新版 conpty.dll(Windows Terminal 同源),
3127
+ // 系统内置 conhost 的 ConPTY 差分在「输出中 resize/宽字符」场景下会与
3128
+ // 终端真实状态分叉,表现为滚动 TUI(如 claude code)时行首 CJK 残影。
3129
+ ...(process.platform === 'win32' ? { useConptyDll: true } : {}),
3130
+ });
3131
+ }
3132
+ if (!ptyProcess) {
3133
+ throw new Error('Failed to start PTY process');
3134
+ }
3135
+ const session = {
3136
+ ptyProcess,
3137
+ ptyBackend: pty.backend,
3138
+ cwd,
3139
+ mode,
3140
+ tmuxSessionName,
3141
+ cols,
3142
+ rows,
3143
+ lastActivity: Date.now(),
3144
+ clients: new Map(),
3145
+ createdAt: Date.now(),
3146
+ hasWrittenData: false,
3147
+ activeProgram: null,
3148
+ oscSniffBuf: '',
3149
+ lastOscCwd: null,
3150
+ lastOscTitle: null,
3151
+ lastPromptState: null,
3152
+ tuiProgress: null,
3153
+ agentStatus: null,
3154
+ agentColor: null,
3155
+ agentIndicator: null,
3156
+ agentStatusBuf: '',
3157
+ agentStatusTimer: null,
3158
+ agentStatusClearDelayMs: DEFAULT_AGENT_CLEAR_DELAY_MS,
3159
+ focusTrackingRequested: false,
3160
+ focusModeSniffBuf: '',
3161
+ focusAggregation: {
3162
+ focusedClients: new Map(),
3163
+ effectiveFocused: false,
3164
+ },
3165
+ flowPausedClients: new Set(),
3166
+ flowPausedClientTimers: new Map(),
3167
+ ptyPausedForFlowControl: false,
3168
+ };
3169
+ terminalSessions.set(sessionId, session);
3170
+ setupPtyHandlers(sessionId, session);
3171
+ if (mode === 'tmux' && tmuxSessionName) {
3172
+ // Session was prepared before attach so the first tmux client sees the
3173
+ // right server/session options immediately.
3174
+ // Spawn a persistent control-mode connection so scroll commands
3175
+ // don't pay execFile process-spawn overhead on every frame.
3176
+ if (TMUX_CONTROL_ENABLED) {
3177
+ let ctrl;
3178
+ try {
3179
+ ctrl = spawnTmuxControl(tmuxSessionName);
3180
+ // Verify the control process is healthy before we rely on it.
3181
+ await sendTmuxCommand(tmuxSessionName, ctrl, ['display-message', '-p', 'ok']);
3182
+ session.tmuxControl = ctrl;
3183
+ }
3184
+ catch (error) {
3185
+ // Control process failed — clean up and fall back to execFile.
3186
+ console.warn(`Failed to start tmux control for ${tmuxSessionName}: ${getErrorMessage(error)}`);
3187
+ if (ctrl)
3188
+ destroyTmuxControl(ctrl);
3189
+ }
3190
+ }
3191
+ }
3192
+ return { sessionId, session, cols, rows };
3193
+ }
3194
+ function detectShellType(shellPath) {
3195
+ const base = shellPath.split('/').pop()?.toLowerCase() || '';
3196
+ if (base.includes('bash'))
3197
+ return 'bash';
3198
+ if (base.includes('zsh'))
3199
+ return 'zsh';
3200
+ if (base.includes('fish'))
3201
+ return 'fish';
3202
+ return 'other';
3203
+ }
3204
+ /**
3205
+ * Resolve the shell-integration script directory.
3206
+ *
3207
+ * In dev (tsx): scripts live at <project-root>/public/shell-integration/
3208
+ * In prod (installed): scripts are copied to <dist>/client/shell-integration/
3209
+ * by the build. We probe both locations.
3210
+ */
3211
+ let cachedIntegrationDir = null;
3212
+ async function resolveShellIntegrationDir() {
3213
+ if (cachedIntegrationDir !== null)
3214
+ return cachedIntegrationDir;
3215
+ // 项目是 ESM (`"type": "module"`),__dirname 不存在;用 import.meta.url 推导。
3216
+ // dev 模式下此文件位于 src/server/routes/,对应 dist 路径为 dist/server/routes/;
3217
+ // 探针覆盖 dev / 安装后 dist 两种位置。
3218
+ let sourceDir = process.cwd();
3219
+ try {
3220
+ sourceDir = path.dirname(fileURLToPath(import.meta.url));
3221
+ }
3222
+ catch { /* fall through to cwd */ }
3223
+ const candidates = [
3224
+ path.join(process.cwd(), 'public', 'shell-integration'),
3225
+ path.join(sourceDir, '..', '..', '..', 'client', 'shell-integration'),
3226
+ path.join(sourceDir, '..', '..', 'client', 'shell-integration'),
3227
+ path.join(process.cwd(), 'dist', 'client', 'shell-integration'),
3228
+ ];
3229
+ for (const dir of candidates) {
3230
+ try {
3231
+ await fs.promises.access(path.join(dir, 'termdock.zsh'), fs.constants.R_OK);
3232
+ cachedIntegrationDir = dir;
3233
+ return dir;
3234
+ }
3235
+ catch { /* access 抛错跳过这个候选 */ }
3236
+ }
3237
+ return null;
3238
+ }
3239
+ /**
3240
+ * Inject termdock shell integration via environment variables.
3241
+ *
3242
+ * This replaces the old injectShellTitleHooks with a full Ghostty-style
3243
+ * integration that emits OSC 133 (prompt marks), OSC 2 (title), and OSC 7 (cwd).
3244
+ *
3245
+ * Works for both shell mode (env passed to PTY spawn) and tmux mode
3246
+ * (env passed to `tmux set-environment`).
3247
+ */
3248
+ async function injectShellIntegration(shellPath, baseEnv) {
3249
+ const shellType = detectShellType(shellPath);
3250
+ const home = (process.env.HOME || '/root').replace(/\/+$/, '') || '/';
3251
+ const integrationDir = await resolveShellIntegrationDir();
3252
+ const env = { ...baseEnv };
3253
+ if (!integrationDir) {
3254
+ // Fallback: no integration scripts found, use minimal title hooks
3255
+ if (shellType === 'bash') {
3256
+ env.PROMPT_COMMAND = 'printf "\\033]0;%s@%s:%s\\007" "${USER}" "${HOSTNAME%%.*}" "${PWD}"';
3257
+ }
3258
+ return env;
3259
+ }
3260
+ if (shellType === 'zsh') {
3261
+ // Create a temporary ZDOTDIR that sources user's zshenv/zshrc then
3262
+ // sources our integration script. Same approach as Ghostty.
3263
+ const zdotdir = '/tmp/termdock-zsh-' + String(process.pid);
3264
+ try {
3265
+ await fs.promises.mkdir(zdotdir, { recursive: true });
3266
+ await fs.promises.writeFile(zdotdir + '/.zshenv', '[[ -f "' + home + '/.zshenv" ]] && source "' + home + '/.zshenv"\n');
3267
+ await fs.promises.writeFile(zdotdir + '/.zshrc', 'ZDOTDIR=\n' +
3268
+ '[[ -f "' + home + '/.zshrc" ]] && source "' + home + '/.zshrc"\n' +
3269
+ 'source "' + integrationDir + '/termdock.zsh"\n');
3270
+ env.ZDOTDIR = zdotdir;
3271
+ }
3272
+ catch {
3273
+ // Fallback: no ZDOTDIR, integration won't load
3274
+ }
3275
+ }
3276
+ else if (shellType === 'bash') {
3277
+ // For bash, we use BASH_ENV to source our script. BASH_ENV is sourced
3278
+ // for non-interactive bash, but since our script checks $- for interactive,
3279
+ // it's safe. For interactive shells, bash sources .bashrc which we can't
3280
+ // easily prepend to. Instead, use PROMPT_COMMAND as a bootstrap.
3281
+ const scriptPath = path.join(integrationDir, 'termdock.bash');
3282
+ // Use ENV alias trick: set BASH_ENV for non-interactive, and for interactive
3283
+ // bash, we prepend a source command via PROMPT_COMMAND bootstrap.
3284
+ // The script itself checks $- and returns early for non-interactive.
3285
+ env.BASH_ENV = scriptPath;
3286
+ // Bootstrap: source our integration on first prompt if not already loaded.
3287
+ // This is a one-shot that self-removes from PROMPT_COMMAND.
3288
+ if (!env.PROMPT_COMMAND || !env.PROMPT_COMMAND.includes('__termdock_hook')) {
3289
+ const existing = env.PROMPT_COMMAND || '';
3290
+ env.PROMPT_COMMAND = 'source "' + scriptPath + '" 2>/dev/null' + (existing ? '; ' + existing : '');
3291
+ }
3292
+ }
3293
+ else if (shellType === 'fish') {
3294
+ // Fish uses vendor_conf.d for automatic sourcing. We can't easily inject
3295
+ // a vendor dir, so we use fish's --init-command equivalent: set
3296
+ // __fish_config_dir to point to a dir that includes our script.
3297
+ // Simpler: just set an env var that the user's fish config can source,
3298
+ // or use fish's XDG_DATA_DIRS to include our vendor conf.
3299
+ const scriptPath = path.join(integrationDir, 'termdock.fish');
3300
+ // Fish doesn't have a clean env-based injection. We set a variable
3301
+ // that the startup can use, but for now, rely on the user's fish
3302
+ // config or fish's native title support. The OSC 133 marks won't
3303
+ // be emitted without explicit sourcing.
3304
+ // TODO: For fish, we could create a temporary XDG_CONFIG_HOME/fish/conf.d/
3305
+ // But that's risky. Leave fish to use its native fish_title for now.
3306
+ env.TERMDOCK_FISH_INTEGRATION = scriptPath;
3307
+ }
3308
+ return env;
3309
+ }
3310
+ function buildPowerShellCwdHookArgs() {
3311
+ const script = `
3312
+ $script:__TermdockOriginalPrompt = if (Test-Path Function:\\prompt) {
3313
+ (Get-Command prompt -CommandType Function).ScriptBlock
3314
+ } else {
3315
+ { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }
3316
+ }
3317
+ function global:prompt {
3318
+ try {
3319
+ $location = Get-Location
3320
+ $path = if ($location.Provider.Name -eq 'FileSystem') { $location.ProviderPath } else { $location.Path }
3321
+ [Console]::Write("$([char]27)]0;$path$([char]7)")
3322
+ } catch {}
3323
+ & $script:__TermdockOriginalPrompt
3324
+ }
3325
+ `.trim();
3326
+ return ['-NoLogo', '-NoExit', '-ExecutionPolicy', 'Bypass', '-Command', script];
3327
+ }
3328
+ function buildAugmentedPath() {
3329
+ const pathEnv = process.env.PATH || '';
3330
+ const extraPaths = ['/usr/local/bin', '/usr/bin', '/bin'];
3331
+ const uniquePaths = new Set([...extraPaths, ...pathEnv.split(':').filter(Boolean)]);
3332
+ return Array.from(uniquePaths).join(':');
3333
+ }
3334
+ let ptyProviderPromise = null;
3335
+ /**
3336
+ * Resolve the requested termType to the TERM value used by PTY processes.
3337
+ * Termdock now exposes only the xterm.js terminal engine, so every path uses
3338
+ * the portable xterm-256color terminfo. Older clients may still send a stale
3339
+ * value; keep accepting the field but do not pass custom TERM names through.
3340
+ */
3341
+ function resolveTerminalTermType(_requested) {
3342
+ return 'xterm-256color';
3343
+ }
3344
+ async function getPtyProvider() {
3345
+ if (ptyProviderPromise) {
3346
+ return ptyProviderPromise;
3347
+ }
3348
+ ptyProviderPromise = (async () => {
3349
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3350
+ const bunRuntime = globalThis.Bun;
3351
+ if (bunRuntime) {
3352
+ try {
3353
+ const bunPty = await import('bun-pty');
3354
+ console.log('Using bun-pty for terminal sessions');
3355
+ return { spawn: bunPty.spawn, backend: 'bun-pty' };
3356
+ }
3357
+ catch (error) {
3358
+ console.warn('bun-pty unavailable, falling back to node-pty');
3359
+ }
3360
+ }
3361
+ try {
3362
+ const nodePty = await import('node-pty');
3363
+ console.log('Using node-pty for terminal sessions');
3364
+ return { spawn: nodePty.spawn, backend: 'node-pty' };
3365
+ }
3366
+ catch (error) {
3367
+ const errorMessage = error instanceof Error ? error.message : String(error);
3368
+ console.error('Failed to load node-pty:', errorMessage);
3369
+ if (bunRuntime) {
3370
+ throw new Error('No PTY backend available. Install bun-pty or node-pty.');
3371
+ }
3372
+ throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
3373
+ }
3374
+ })();
3375
+ return ptyProviderPromise;
3376
+ }
3377
+ setInterval(() => {
3378
+ const now = Date.now();
3379
+ for (const [sessionId, session] of terminalSessions.entries()) {
3380
+ const idleTooLong = now - session.lastActivity > TERMINAL_IDLE_TIMEOUT;
3381
+ if (idleTooLong) {
3382
+ console.log(`Cleaning up terminal session: ${sessionId}, idleTooLong=${idleTooLong}`);
3383
+ cleanupSession(sessionId, { killProcess: true });
3384
+ }
3385
+ }
3386
+ }, CLEANUP_INTERVAL);
3387
+ router.get('/tmux/sessions', async (_req, res) => {
3388
+ try {
3389
+ const inventory = await getSessionInventorySnapshot();
3390
+ const sessions = inventory.tmuxSessions.map((session) => ({
3391
+ name: session.name,
3392
+ windows: session.windows,
3393
+ attached: session.attachedCount,
3394
+ attachedCount: session.attachedCount,
3395
+ createdAt: session.createdAt,
3396
+ boundFrontendSessionId: session.boundFrontendSessionId,
3397
+ connected: session.connected,
3398
+ live: session.live,
3399
+ restorable: session.restorable,
3400
+ friendlyName: session.friendlyName,
3401
+ label: session.label,
3402
+ program: session.program,
3403
+ cwd: session.cwd,
3404
+ clientCount: session.clientCount,
3405
+ lastActiveAt: session.lastActiveAt,
3406
+ }));
3407
+ return res.json({ sessions });
3408
+ }
3409
+ catch (error) {
3410
+ const errorMessage = getErrorMessage(error);
3411
+ if (isTmuxServerMissingMessage(errorMessage)) {
3412
+ return res.json({ sessions: [] });
3413
+ }
3414
+ if (isTmuxUnavailableMessage(errorMessage)) {
3415
+ return res.json({ sessions: [], available: false, reason: 'tmux is not installed or not available in PATH.' });
3416
+ }
3417
+ return res.status(500).json({ error: errorMessage || 'Failed to list tmux sessions' });
3418
+ }
3419
+ });
3420
+ router.get('/tmux/status', async (_req, res) => {
3421
+ const status = await getTmuxStatus();
3422
+ res.json(status);
3423
+ });
3424
+ router.delete('/tmux/sessions/:name', async (req, res) => {
3425
+ const rawName = typeof req.params.name === 'string' ? req.params.name.trim() : '';
3426
+ if (!rawName) {
3427
+ return res.status(400).json({ error: 'tmux session name is required' });
3428
+ }
3429
+ // tmux session names cannot contain ':' or '.'; reject anything that doesn't look right.
3430
+ if (/[:.\s]/.test(rawName)) {
3431
+ return res.status(400).json({ error: 'invalid tmux session name' });
3432
+ }
3433
+ // Detach any local terminal sessions still wired to this tmux session so that
3434
+ // their pty (the tmux client) is cleaned up alongside the kill-session call.
3435
+ const affectedSessionIds = [];
3436
+ for (const [sessionId, session] of terminalSessions.entries()) {
3437
+ if (session.mode === 'tmux' && session.tmuxSessionName === rawName) {
3438
+ affectedSessionIds.push(sessionId);
3439
+ }
3440
+ }
3441
+ try {
3442
+ await runTmux(['kill-session', '-t', rawName]);
3443
+ }
3444
+ catch (error) {
3445
+ const errorMessage = getErrorMessage(error);
3446
+ if (isTmuxSessionMissingMessage(errorMessage)) {
3447
+ // Already gone; treat as success and still clean up any orphan ptys.
3448
+ for (const id of affectedSessionIds) {
3449
+ try {
3450
+ cleanupSession(id, { killProcess: true });
3451
+ }
3452
+ catch { }
3453
+ }
3454
+ return res.json({ success: true, alreadyGone: true, cleanedSessions: affectedSessionIds });
3455
+ }
3456
+ if (isTmuxUnavailableMessage(errorMessage)) {
3457
+ return res.status(503).json({ error: 'tmux is not installed or not available in PATH.' });
3458
+ }
3459
+ return res.status(500).json({ error: errorMessage || 'Failed to kill tmux session' });
3460
+ }
3461
+ for (const id of affectedSessionIds) {
3462
+ try {
3463
+ cleanupSession(id, { killProcess: true });
3464
+ }
3465
+ catch (error) {
3466
+ console.error(`[tmux] cleanup attached session ${id} failed:`, getErrorMessage(error));
3467
+ }
3468
+ }
3469
+ // Also drop any persisted client-state entries that pointed at this tmux
3470
+ // session. Without this, every connected browser would still see a tab
3471
+ // whose backing tmux server is gone, and only the 30s reconciler would
3472
+ // notice. Doing it inline + broadcasting keeps cross-device UX instant.
3473
+ const beforeCount = globalSessionState.sessions.length;
3474
+ globalSessionState = {
3475
+ sessions: globalSessionState.sessions.filter((s) => !(s.mode === 'tmux' && s.tmuxSessionName === rawName)),
3476
+ updatedAt: Date.now(),
3477
+ };
3478
+ if (globalSessionState.sessions.length !== beforeCount) {
3479
+ schedulePersistGlobalState();
3480
+ broadcastClientState();
3481
+ }
3482
+ console.log(`[tmux] killed session: ${rawName} (cleaned ${affectedSessionIds.length} attached pty, dropped ${beforeCount - globalSessionState.sessions.length} client-state entries)`);
3483
+ res.json({ success: true, cleanedSessions: affectedSessionIds });
3484
+ });
3485
+ router.post('/serialize-state', async (req, res) => {
3486
+ const ids = Array.isArray(req.body?.ids)
3487
+ ? new Set(req.body.ids.filter((item) => typeof item === 'string'))
3488
+ : null;
3489
+ const states = await Promise.all(Array.from(terminalSessions.entries())
3490
+ .filter(([sessionId]) => (ids ? ids.has(sessionId) : true))
3491
+ .map(async ([sessionId, session]) => ({
3492
+ sessionId,
3493
+ cwd: session.cwd,
3494
+ createdAt: session.createdAt,
3495
+ lastActivity: session.lastActivity,
3496
+ backend: session.ptyBackend,
3497
+ mode: session.mode,
3498
+ tmuxSessionName: session.tmuxSessionName,
3499
+ history: await getRestoreHistory(sessionId, session),
3500
+ })));
3501
+ res.json({
3502
+ serialized: JSON.stringify({ version: 1, states }),
3503
+ states,
3504
+ });
3505
+ });
3506
+ router.get('/client-state', async (_req, res) => {
3507
+ const inventory = await getSessionInventorySnapshot().catch(() => null);
3508
+ res.json({ ...globalSessionState, inventory: inventory ?? latestSessionInventory });
3509
+ });
3510
+ router.get('/session-inventory', async (_req, res) => {
3511
+ try {
3512
+ const inventory = await getSessionInventorySnapshot();
3513
+ res.json(inventory);
3514
+ }
3515
+ catch (error) {
3516
+ const errorMessage = getErrorMessage(error);
3517
+ res.status(500).json({ error: errorMessage || 'Failed to build session inventory' });
3518
+ }
3519
+ });
3520
+ router.post('/session-inventory/open', async (req, res) => {
3521
+ try {
3522
+ const input = req.body ?? {};
3523
+ const lockKey = makeInventoryOpenLockKey(input);
3524
+ const result = await withInventoryOpenLock(lockKey, () => openInventorySession(req, input));
3525
+ res.json(result);
3526
+ }
3527
+ catch (error) {
3528
+ const errorMessage = getErrorMessage(error);
3529
+ if (error instanceof HttpStatusError) {
3530
+ return res.status(error.statusCode).json({
3531
+ error: errorMessage || 'Failed to open session',
3532
+ code: error.code,
3533
+ });
3534
+ }
3535
+ console.error('[session-inventory] failed to open session:', errorMessage);
3536
+ res.status(500).json({ error: errorMessage || 'Failed to open session' });
3537
+ }
3538
+ });
3539
+ router.patch('/session-inventory/sessions/:frontendSessionId', async (req, res) => {
3540
+ const frontendSessionId = typeof req.params.frontendSessionId === 'string' ? req.params.frontendSessionId.trim() : '';
3541
+ if (!frontendSessionId) {
3542
+ return res.status(400).json({ error: 'frontendSessionId is required' });
3543
+ }
3544
+ const idx = globalSessionState.sessions.findIndex((session) => session.sessionId === frontendSessionId);
3545
+ if (idx < 0) {
3546
+ return res.status(404).json({ error: 'session not found' });
3547
+ }
3548
+ const body = req.body ?? {};
3549
+ const previous = globalSessionState.sessions[idx];
3550
+ const next = { ...previous, lastActivity: Date.now() };
3551
+ if (typeof body.name === 'string' && body.name.trim().length > 0) {
3552
+ next.name = body.name.trim();
3553
+ }
3554
+ if (typeof body.customName === 'boolean') {
3555
+ next.customName = body.customName ? true : undefined;
3556
+ }
3557
+ if (Object.prototype.hasOwnProperty.call(body, 'backendSessionId')) {
3558
+ next.backendSessionId = typeof body.backendSessionId === 'string' && body.backendSessionId.trim().length > 0
3559
+ ? body.backendSessionId.trim()
3560
+ : null;
3561
+ }
3562
+ if (Object.prototype.hasOwnProperty.call(body, 'tmuxSessionName')) {
3563
+ next.tmuxSessionName = typeof body.tmuxSessionName === 'string' && body.tmuxSessionName.trim().length > 0
3564
+ ? body.tmuxSessionName.trim()
3565
+ : null;
3566
+ if (next.tmuxSessionName)
3567
+ next.mode = 'tmux';
3568
+ }
3569
+ if (next.mode === 'tmux' && next.tmuxSessionName) {
3570
+ if (next.customName === true && next.name.trim().length > 0) {
3571
+ await setTmuxOption(next.tmuxSessionName, '@termdock-friendly-name', next.name);
3572
+ }
3573
+ else if (previous.customName === true) {
3574
+ await unsetTmuxOption(next.tmuxSessionName, '@termdock-friendly-name');
3575
+ }
3576
+ }
3577
+ upsertGlobalSessionRecord(next);
3578
+ await persistGlobalStateNow();
3579
+ broadcastClientState();
3580
+ const inventory = await getSessionInventorySnapshot({ refresh: true });
3581
+ res.json(inventory);
3582
+ });
3583
+ router.post('/session-inventory/reorder', async (req, res) => {
3584
+ const sessionIds = Array.isArray(req.body?.sessionIds)
3585
+ ? req.body.sessionIds.filter((id) => typeof id === 'string')
3586
+ : [];
3587
+ const idToSession = new Map(globalSessionState.sessions.map((session) => [session.sessionId, session]));
3588
+ const covered = new Set(sessionIds);
3589
+ const reordered = sessionIds
3590
+ .map((id) => idToSession.get(id))
3591
+ .filter((session) => session !== undefined);
3592
+ const remaining = globalSessionState.sessions.filter((session) => !covered.has(session.sessionId));
3593
+ globalSessionState = {
3594
+ sessions: deduplicateGlobalSessions([...reordered, ...remaining]),
3595
+ updatedAt: Date.now(),
3596
+ };
3597
+ schedulePersistGlobalState();
3598
+ broadcastClientState();
3599
+ const inventory = await getSessionInventorySnapshot({ refresh: true });
3600
+ res.json(inventory);
3601
+ });
3602
+ router.delete('/session-inventory/sessions/:frontendSessionId', async (req, res) => {
3603
+ const frontendSessionId = typeof req.params.frontendSessionId === 'string' ? req.params.frontendSessionId.trim() : '';
3604
+ if (!frontendSessionId) {
3605
+ return res.status(400).json({ error: 'frontendSessionId is required' });
3606
+ }
3607
+ const removedSession = globalSessionState.sessions.find((session) => session.sessionId === frontendSessionId) ?? null;
3608
+ const changed = removeGlobalSessionRecord(frontendSessionId);
3609
+ if (changed) {
3610
+ if (removedSession?.mode === 'tmux' && removedSession.tmuxSessionName) {
3611
+ await setTmuxOption(removedSession.tmuxSessionName, TERMDOCK_GUI_DETACHED_AT_OPTION, String(Date.now()));
3612
+ }
3613
+ await persistGlobalStateNow();
3614
+ broadcastClientState();
3615
+ }
3616
+ res.status(204).send();
3617
+ });
3618
+ router.delete('/session-inventory/sessions', async (_req, res) => {
3619
+ await markAllPersistedTmuxSessionsDetached();
3620
+ globalSessionState = { sessions: [], updatedAt: Date.now() };
3621
+ schedulePersistGlobalState();
3622
+ broadcastClientState();
3623
+ res.status(204).send();
3624
+ });
3625
+ router.put('/client-state', (_req, res) => {
3626
+ res.status(410).json({
3627
+ error: 'client-state replacement is no longer supported; use session-inventory endpoints',
3628
+ code: 'CLIENT_STATE_REPLACE_DISABLED',
3629
+ });
3630
+ });
3631
+ router.delete('/client-state', async (_req, res) => {
3632
+ await markAllPersistedTmuxSessionsDetached();
3633
+ globalSessionState = { sessions: [], updatedAt: Date.now() };
3634
+ schedulePersistGlobalState();
3635
+ broadcastClientState();
3636
+ res.status(204).send();
3637
+ });
3638
+ router.get('/toolbar-presets', (_req, res) => {
3639
+ res.json(toolbarPresetsDoc ?? { version: 0, presets: [], updatedAt: 0 });
3640
+ });
3641
+ router.put('/toolbar-presets', (req, res) => {
3642
+ const body = (req.body ?? {});
3643
+ const baseUpdatedAt = typeof body.baseUpdatedAt === 'number'
3644
+ ? body.baseUpdatedAt
3645
+ : null;
3646
+ const currentUpdatedAt = toolbarPresetsDoc?.updatedAt ?? 0;
3647
+ if (baseUpdatedAt !== null && currentUpdatedAt > 0 && currentUpdatedAt !== baseUpdatedAt) {
3648
+ res.status(409).json({
3649
+ error: 'Toolbar presets changed on another client',
3650
+ code: 'TOOLBAR_PRESETS_CONFLICT',
3651
+ current: toolbarPresetsDoc ?? { version: 0, presets: [], updatedAt: 0 },
3652
+ });
3653
+ return;
3654
+ }
3655
+ const version = typeof body.version === 'number' ? body.version : 0;
3656
+ const presets = Array.isArray(body.presets) ? body.presets : [];
3657
+ toolbarPresetsDoc = { version, presets, updatedAt: Date.now() };
3658
+ schedulePersistToolbarPresets();
3659
+ broadcastControlEvent({
3660
+ type: 'config-updated',
3661
+ key: 'toolbar-presets',
3662
+ updatedAt: toolbarPresetsDoc.updatedAt,
3663
+ });
3664
+ res.json(toolbarPresetsDoc);
3665
+ });
3666
+ async function getSettingsPayload() {
3667
+ const localAccess = localAccessManager.getState();
3668
+ const interfaces = await Promise.all(localAccess.interfaces.map(async (entry) => {
3669
+ const url = `${localAccess.httpsEnabled ? 'https' : 'http'}://${entry.address}:9834`;
3670
+ const qrDataUrl = await QRCode.toDataURL(url, {
3671
+ margin: 1,
3672
+ width: 132,
3673
+ errorCorrectionLevel: 'M',
3674
+ }).catch(() => null);
3675
+ return { ...entry, url, qrDataUrl };
3676
+ }));
3677
+ return {
3678
+ preventSleep: caffeinateManager.getPreventSleep(),
3679
+ caffeinateActive: caffeinateManager.isActive(),
3680
+ networkAvailable: caffeinateManager.isNetworkAvailable(),
3681
+ localAccess: {
3682
+ ...localAccess,
3683
+ interfaces,
3684
+ onboardingUrl: getOnboardingServerUrl() ?? null,
3685
+ },
3686
+ };
3687
+ }
3688
+ // ── Settings (prevent sleep) ──────────────────────────────────────────
3689
+ router.get('/settings', async (_req, res) => {
3690
+ res.json(await getSettingsPayload());
3691
+ });
3692
+ router.put('/settings', async (req, res) => {
3693
+ const body = req.body ?? {};
3694
+ if (typeof body.preventSleep === 'boolean') {
3695
+ caffeinateManager.setPreventSleep(body.preventSleep);
3696
+ }
3697
+ if (body.localAccess && typeof body.localAccess === 'object') {
3698
+ const localAccessBody = body.localAccess;
3699
+ if (localAccessBody.reset === true) {
3700
+ await localAccessManager.resetAutoName();
3701
+ }
3702
+ else if (localAccessBody.name !== undefined) {
3703
+ const normalized = normalizeLocalAccessName(localAccessBody.name);
3704
+ if (!normalized) {
3705
+ res.status(400).json({ error: 'Invalid local access name', code: 'INVALID_LOCAL_ACCESS_NAME' });
3706
+ return;
3707
+ }
3708
+ const state = await localAccessManager.updateName(normalized, 'manual');
3709
+ if (state.status === 'conflict') {
3710
+ res.status(409).json({ error: state.reason ?? 'Local access name is already in use', code: 'LOCAL_ACCESS_CONFLICT', localAccess: state });
3711
+ return;
3712
+ }
3713
+ }
3714
+ }
3715
+ res.json(await getSettingsPayload());
3716
+ });
3717
+ // ── Agent detection rules API ──
3718
+ router.get('/agent-rules', async (_req, res) => {
3719
+ res.json(await loadAgentRulesFromDisk());
3720
+ });
3721
+ router.put('/agent-rules', async (req, res) => {
3722
+ const rules = req.body;
3723
+ if (!Array.isArray(rules)) {
3724
+ res.status(400).json({ error: 'Expected array of program configs' });
3725
+ return;
3726
+ }
3727
+ // Basic validation
3728
+ for (const config of rules) {
3729
+ const hasProgram = typeof config.program === 'string' && config.program.trim().length > 0;
3730
+ const hasPrograms = Array.isArray(config.programs) && config.programs.some((name) => typeof name === 'string' && name.trim().length > 0);
3731
+ if ((!hasProgram && !hasPrograms) || !Array.isArray(config.rules)) {
3732
+ res.status(400).json({ error: 'Each config must have program/programs and rules' });
3733
+ return;
3734
+ }
3735
+ for (const rule of config.rules) {
3736
+ if (!rule.pattern || !rule.status) {
3737
+ res.status(400).json({ error: 'Each rule must have pattern and status' });
3738
+ return;
3739
+ }
3740
+ // Validate regex
3741
+ try {
3742
+ new RegExp(rule.pattern, 'i');
3743
+ }
3744
+ catch {
3745
+ res.status(400).json({ error: `Invalid regex: ${rule.pattern}` });
3746
+ return;
3747
+ }
3748
+ if (rule.indicator !== undefined && !AGENT_INDICATORS.has(rule.indicator)) {
3749
+ res.status(400).json({ error: `Invalid indicator: ${rule.indicator}` });
3750
+ return;
3751
+ }
3752
+ if (rule.clearDelayMs !== undefined) {
3753
+ const delay = Number(rule.clearDelayMs);
3754
+ if (!Number.isFinite(delay) || delay < MIN_AGENT_CLEAR_DELAY_MS || delay > MAX_AGENT_CLEAR_DELAY_MS) {
3755
+ res.status(400).json({ error: `clearDelayMs must be ${MIN_AGENT_CLEAR_DELAY_MS}-${MAX_AGENT_CLEAR_DELAY_MS}` });
3756
+ return;
3757
+ }
3758
+ }
3759
+ }
3760
+ }
3761
+ await saveAgentRulesToDisk(rules);
3762
+ broadcastControlEvent({
3763
+ type: 'config-updated',
3764
+ key: 'agent-rules',
3765
+ updatedAt: Date.now(),
3766
+ });
3767
+ res.json(rules);
3768
+ });
3769
+ router.delete('/agent-rules', async (_req, res) => {
3770
+ // Remove custom rules file so builtins take effect again
3771
+ await fs.promises.unlink(AGENT_RULES_FILE).catch(() => undefined);
3772
+ compileAgentRules(BUILTIN_AGENT_RULES);
3773
+ broadcastControlEvent({
3774
+ type: 'config-updated',
3775
+ key: 'agent-rules',
3776
+ updatedAt: Date.now(),
3777
+ });
3778
+ res.json(BUILTIN_AGENT_RULES);
3779
+ });
3780
+ // ── Program detection config API ──
3781
+ router.get('/program-detection', async (_req, res) => {
3782
+ res.json(await loadProgramDetectionFromDisk());
3783
+ });
3784
+ router.put('/program-detection', async (req, res) => {
3785
+ const config = req.body;
3786
+ if (!config || typeof config !== 'object') {
3787
+ res.status(400).json({ error: 'Expected object with genericProgramNames, wrapperScriptNames, shellNames' });
3788
+ return;
3789
+ }
3790
+ const validated = {
3791
+ genericProgramNames: Array.isArray(config.genericProgramNames)
3792
+ ? config.genericProgramNames.filter((s) => typeof s === 'string' && s.trim())
3793
+ : DEFAULT_PROGRAM_DETECTION.genericProgramNames,
3794
+ wrapperScriptNames: Array.isArray(config.wrapperScriptNames)
3795
+ ? config.wrapperScriptNames.filter((s) => typeof s === 'string' && s.trim())
3796
+ : DEFAULT_PROGRAM_DETECTION.wrapperScriptNames,
3797
+ shellNames: Array.isArray(config.shellNames)
3798
+ ? config.shellNames.filter((s) => typeof s === 'string' && s.trim())
3799
+ : DEFAULT_PROGRAM_DETECTION.shellNames,
3800
+ };
3801
+ await saveProgramDetectionToDisk(validated);
3802
+ broadcastControlEvent({
3803
+ type: 'config-updated',
3804
+ key: 'program-detection',
3805
+ updatedAt: Date.now(),
3806
+ });
3807
+ res.json(validated);
3808
+ });
3809
+ router.delete('/program-detection', async (_req, res) => {
3810
+ await fs.promises.unlink(PROGRAM_DETECTION_FILE).catch(() => undefined);
3811
+ applyProgramDetectionConfig({ ...DEFAULT_PROGRAM_DETECTION });
3812
+ broadcastControlEvent({
3813
+ type: 'config-updated',
3814
+ key: 'program-detection',
3815
+ updatedAt: Date.now(),
3816
+ });
3817
+ res.json(DEFAULT_PROGRAM_DETECTION);
3818
+ });
3819
+ router.post('/create', async (req, res) => {
3820
+ try {
3821
+ const { cwd: inputCwd, cols, rows, mode, tmuxSessionName, termType } = req.body;
3822
+ const normalizedMode = normalizeMode(mode);
3823
+ const normalizedTmuxName = normalizedMode === 'tmux' ? normalizeTmuxSessionName(tmuxSessionName) : null;
3824
+ // Deduplicate: if a TerminalSession for this tmux session already exists,
3825
+ // return it instead of creating a duplicate wrapper. tmux's own
3826
+ // new-session -A already prevents duplicate tmux sessions.
3827
+ if (normalizedMode === 'tmux' && normalizedTmuxName) {
3828
+ for (const [id, s] of terminalSessions.entries()) {
3829
+ if (s.mode === 'tmux' && s.tmuxSessionName === normalizedTmuxName) {
3830
+ console.log(`Reusing existing terminal session ${id} for tmux:${normalizedTmuxName}`);
3831
+ // Heal shared tmux server/session options on reuse so long-lived
3832
+ // wrappers pick up capabilities added after they were created.
3833
+ await prepareManagedTmuxSession(normalizedTmuxName, typeof inputCwd === 'string' ? inputCwd : undefined);
3834
+ return res.json({
3835
+ sessionId: id,
3836
+ cols: cols || 80,
3837
+ rows: rows || 24,
3838
+ mode: s.mode,
3839
+ tmuxSessionName: s.tmuxSessionName,
3840
+ activeProgram: s.activeProgram?.command ?? null,
3841
+ activeProgramRaw: s.activeProgram?.rawArgs ?? null,
3842
+ activeProgramSource: s.activeProgram?.source ?? null,
3843
+ });
3844
+ }
3845
+ }
3846
+ }
3847
+ const spawned = await spawnTerminalSession(req, {
3848
+ cwd: inputCwd,
3849
+ cols,
3850
+ rows,
3851
+ mode,
3852
+ tmuxSessionName,
3853
+ termType,
3854
+ });
3855
+ console.log(`Created terminal session: ${spawned.sessionId} in ${spawned.session.cwd}`);
3856
+ res.json({
3857
+ sessionId: spawned.sessionId,
3858
+ cols: cols || 80,
3859
+ rows: rows || 24,
3860
+ mode: spawned.session.mode,
3861
+ tmuxSessionName: spawned.session.tmuxSessionName,
3862
+ activeProgram: spawned.session.activeProgram?.command ?? null,
3863
+ activeProgramRaw: spawned.session.activeProgram?.rawArgs ?? null,
3864
+ activeProgramSource: spawned.session.activeProgram?.source ?? null,
3865
+ });
3866
+ }
3867
+ catch (error) {
3868
+ const errorMessage = error instanceof Error ? error.message : String(error);
3869
+ console.error('Failed to create terminal session:', errorMessage);
3870
+ res.status(500).json({ error: errorMessage || 'Failed to create terminal session' });
3871
+ }
3872
+ });
3873
+ router.get('/:sessionId/stream', async (req, res) => {
3874
+ const { sessionId } = req.params;
3875
+ const session = terminalSessions.get(sessionId);
3876
+ if (!session) {
3877
+ return res.status(404).json({ error: 'Terminal session not found' });
3878
+ }
3879
+ // 连接时立即检测一次 activeProgram,避免前端首次显示闪烁
3880
+ try {
3881
+ if (session.mode === 'shell') {
3882
+ const ap = await detectShellActiveProgram(session);
3883
+ if (ap)
3884
+ session.activeProgram = ap;
3885
+ }
3886
+ else if (session.mode === 'tmux' && session.tmuxSessionName) {
3887
+ const layout = await getTmuxLayout(session.tmuxSessionName);
3888
+ const activePane = getActivePaneFromLayout(layout);
3889
+ const ap = activePane
3890
+ ? await resolveTmuxPaneProgram(activePane)
3891
+ : getActiveProgramFromTmuxLayout(layout);
3892
+ if (ap)
3893
+ session.activeProgram = { ...ap, updatedAt: Date.now() };
3894
+ }
3895
+ }
3896
+ catch { /* ignore */ }
3897
+ res.setHeader('Content-Type', 'text/event-stream');
3898
+ res.setHeader('Cache-Control', 'no-cache');
3899
+ res.setHeader('Connection', 'keep-alive');
3900
+ res.setHeader('X-Accel-Buffering', 'no');
3901
+ const clientId = Math.random().toString(36).substring(7);
3902
+ session.clients.set(clientId, res);
3903
+ session.lastActivity = Date.now();
3904
+ syncClientCountToTmux(sessionId);
3905
+ if (updateGlobalBindingForBackendSession(sessionId, { cwd: session.cwd, lastActivity: session.lastActivity })) {
3906
+ persistAndBroadcastGlobalState();
3907
+ }
3908
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3909
+ const runtime = globalThis.Bun ? 'bun' : 'node';
3910
+ const ptyBackend = session.ptyBackend || 'unknown';
3911
+ writeSse(res, {
3912
+ type: 'connected',
3913
+ runtime,
3914
+ ptyBackend,
3915
+ mode: session.mode,
3916
+ tmuxSessionName: session.tmuxSessionName,
3917
+ cwd: session.cwd ?? null,
3918
+ activeProgram: session.activeProgram?.command ?? null,
3919
+ activeProgramRaw: session.activeProgram?.rawArgs ?? null,
3920
+ activeProgramSource: session.activeProgram?.source ?? null,
3921
+ agentStatus: session.agentStatus,
3922
+ agentColor: session.agentColor,
3923
+ agentIndicator: session.agentIndicator,
3924
+ tuiProgress: session.tuiProgress,
3925
+ });
3926
+ let tmuxInterval = null;
3927
+ let activeProgramInterval = null;
3928
+ let lastTmuxLayoutSnapshot = '';
3929
+ let lastActiveProgramSnapshot = JSON.stringify(session.activeProgram ?? null);
3930
+ let lastTmuxMetadata = null;
3931
+ let lastTmuxMetaWriteAt = 0;
3932
+ const maybeWriteActiveProgram = (activeProgram) => {
3933
+ const snapshot = JSON.stringify(activeProgram ? { command: activeProgram.command, source: activeProgram.source } : null);
3934
+ if (snapshot === lastActiveProgramSnapshot) {
3935
+ return;
3936
+ }
3937
+ lastActiveProgramSnapshot = snapshot;
3938
+ session.activeProgram = activeProgram;
3939
+ // Agent status: react to AI tool start/exit
3940
+ evaluateAgentStatus(sessionId, session);
3941
+ console.log(`[active-program][shell-sse] session=${sessionId} client=${clientId} cmd=${activeProgram?.command ?? null} source=${activeProgram?.source ?? null}`);
3942
+ writeSse(res, {
3943
+ type: 'active-program',
3944
+ activeProgram: activeProgram?.command ?? null,
3945
+ activeProgramRaw: activeProgram?.rawArgs ?? null,
3946
+ activeProgramSource: activeProgram?.source ?? null,
3947
+ });
3948
+ };
3949
+ const sendTmuxLayout = async () => {
3950
+ if (session.mode !== 'tmux' || !session.tmuxSessionName) {
3951
+ return;
3952
+ }
3953
+ try {
3954
+ const layout = await getTmuxLayout(session.tmuxSessionName);
3955
+ // Resolve the active program — try ps-based detection for generic commands
3956
+ const activePane = getActivePaneFromLayout(layout);
3957
+ if (activePane) {
3958
+ const resolved = await resolveTmuxPaneProgram(activePane);
3959
+ if (resolved) {
3960
+ maybeWriteActiveProgram({
3961
+ command: resolved.command,
3962
+ source: resolved.source,
3963
+ rawArgs: resolved.rawArgs,
3964
+ updatedAt: Date.now(),
3965
+ });
3966
+ }
3967
+ else {
3968
+ maybeWriteActiveProgram(getActiveProgramFromTmuxLayout(layout));
3969
+ }
3970
+ }
3971
+ else {
3972
+ maybeWriteActiveProgram(getActiveProgramFromTmuxLayout(layout));
3973
+ }
3974
+ const newCwd = getCwdFromTmuxLayout(layout);
3975
+ if (newCwd && newCwd !== session.cwd) {
3976
+ session.cwd = newCwd;
3977
+ console.log(`[tmux-cwd][sse] session=${sessionId} cwd=${newCwd}`);
3978
+ if (updateGlobalBindingForBackendSession(sessionId, { cwd: newCwd, lastActivity: session.lastActivity })) {
3979
+ schedulePersistGlobalState();
3980
+ }
3981
+ writeSse(res, { type: 'cwd', cwd: newCwd });
3982
+ }
3983
+ // tmux 消费了 inner shell 发的 OSC 2/133,不透传到外层 PTY。
3984
+ // 从 tmux layout 提取 active pane 的 title 和 command 来推导。
3985
+ if (activePane) {
3986
+ const paneTitle = activePane.title || '';
3987
+ if (paneTitle && paneTitle !== session.lastOscTitle) {
3988
+ session.lastOscTitle = paneTitle;
3989
+ writeSse(res, { type: 'shell-title', title: paneTitle });
3990
+ }
3991
+ const paneCmd = activePane.command || '';
3992
+ const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
3993
+ if (inferredState !== session.lastPromptState) {
3994
+ session.lastPromptState = inferredState;
3995
+ writeSse(res, { type: 'prompt-state', state: inferredState });
3996
+ }
3997
+ }
3998
+ // Mirror dynamic metadata onto tmux user options (cheap when nothing
3999
+ // changed thanks to the full metadata snapshot cache).
4000
+ const meta = syncDynamicTmuxMetadata({
4001
+ tmuxSessionName: session.tmuxSessionName,
4002
+ program: session.activeProgram?.command ?? null,
4003
+ cwd: session.cwd ?? null,
4004
+ previousMetadata: lastTmuxMetadata,
4005
+ lastActiveWriteAt: lastTmuxMetaWriteAt,
4006
+ });
4007
+ lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
4008
+ lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
4009
+ const snapshot = JSON.stringify(layout);
4010
+ if (snapshot === lastTmuxLayoutSnapshot) {
4011
+ return;
4012
+ }
4013
+ lastTmuxLayoutSnapshot = snapshot;
4014
+ writeSse(res, { type: 'tmux-layout', layout });
4015
+ }
4016
+ catch (error) {
4017
+ const errorMessage = error instanceof Error ? error.message : String(error);
4018
+ console.warn(`Failed to fetch tmux layout for ${session.tmuxSessionName}: ${errorMessage}`);
4019
+ }
4020
+ };
4021
+ const sendShellActiveProgram = async () => {
4022
+ if (session.mode !== 'shell') {
4023
+ return;
4024
+ }
4025
+ try {
4026
+ maybeWriteActiveProgram(await detectShellActiveProgram(session));
4027
+ }
4028
+ catch (error) {
4029
+ const errorMessage = error instanceof Error ? error.message : String(error);
4030
+ console.warn(`Failed to detect active shell program for ${sessionId}: ${errorMessage}`);
4031
+ }
4032
+ };
4033
+ if (session.mode === 'tmux' && session.tmuxSessionName) {
4034
+ void sendTmuxLayout();
4035
+ tmuxInterval = setInterval(() => {
4036
+ void sendTmuxLayout();
4037
+ }, TMUX_POLL_INTERVAL);
4038
+ }
4039
+ else {
4040
+ void sendShellActiveProgram();
4041
+ activeProgramInterval = setInterval(() => {
4042
+ void sendShellActiveProgram();
4043
+ }, ACTIVE_PROGRAM_POLL_INTERVAL);
4044
+ }
4045
+ const heartbeatInterval = setInterval(() => {
4046
+ try {
4047
+ res.write(': heartbeat\n\n');
4048
+ }
4049
+ catch (error) {
4050
+ console.error(`Heartbeat failed for client ${clientId}:`, error);
4051
+ clearInterval(heartbeatInterval);
4052
+ }
4053
+ }, 15000);
4054
+ if (req.query.replay === '1') {
4055
+ if (session.mode === 'shell') {
4056
+ const replayChunks = getReconnectionHistory(sessionId);
4057
+ for (const chunk of replayChunks) {
4058
+ writeSse(res, { type: 'data', data: chunk, replay: true });
4059
+ }
4060
+ }
4061
+ }
4062
+ const cleanup = () => {
4063
+ clearInterval(heartbeatInterval);
4064
+ if (tmuxInterval) {
4065
+ clearInterval(tmuxInterval);
4066
+ }
4067
+ if (activeProgramInterval) {
4068
+ clearInterval(activeProgramInterval);
4069
+ }
4070
+ closeClient(session, sessionId, clientId);
4071
+ console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`);
4072
+ };
4073
+ req.on('close', cleanup);
4074
+ req.on('error', cleanup);
4075
+ console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
4076
+ });
4077
+ router.get('/:sessionId/health', (req, res) => {
4078
+ const { sessionId } = req.params;
4079
+ const session = terminalSessions.get(sessionId);
4080
+ if (!session) {
4081
+ console.log(`Health check: session ${sessionId} not found`);
4082
+ return res.status(404).json({ healthy: false, error: 'Session not found' });
4083
+ }
4084
+ console.log(`Health check: session ${sessionId} healthy, cwd=${session.cwd}, clients=${getTotalClients(sessionId)}, lastActivity=${Date.now() - session.lastActivity}ms ago`);
4085
+ res.json({
4086
+ healthy: true,
4087
+ sessionId,
4088
+ cwd: session.cwd,
4089
+ clients: getTotalClients(sessionId),
4090
+ lastActivity: session.lastActivity,
4091
+ backend: session.ptyBackend,
4092
+ mode: session.mode,
4093
+ tmuxSessionName: session.tmuxSessionName,
4094
+ activeProgram: session.activeProgram?.command ?? null,
4095
+ activeProgramRaw: session.activeProgram?.rawArgs ?? null,
4096
+ activeProgramSource: session.activeProgram?.source ?? null,
4097
+ });
4098
+ });
4099
+ router.get('/:sessionId/attach', async (req, res) => {
4100
+ const { sessionId } = req.params;
4101
+ const session = terminalSessions.get(sessionId);
4102
+ if (!session) {
4103
+ return res.status(404).json({ error: 'Session not found' });
4104
+ }
4105
+ session.lastActivity = Date.now();
4106
+ const history = await getRestoreHistory(sessionId, session);
4107
+ // 把当前 history 的最后 seq 一并返回,前端用它作为 WS 重连补帧的基线。
4108
+ // tmux 模式下 sessionHistory 里没东西(capture pane 走不同通道),lastSeq 仍取 0。
4109
+ const lastSeq = session.mode === 'shell' ? getHistoryLastSeq(sessionId) : 0;
4110
+ res.json({
4111
+ sessionId,
4112
+ cwd: session.cwd,
4113
+ backend: session.ptyBackend,
4114
+ clients: getTotalClients(sessionId),
4115
+ mode: session.mode,
4116
+ tmuxSessionName: session.tmuxSessionName,
4117
+ history,
4118
+ lastSeq,
4119
+ activeProgram: session.activeProgram?.command ?? null,
4120
+ activeProgramRaw: session.activeProgram?.rawArgs ?? null,
4121
+ activeProgramSource: session.activeProgram?.source ?? null,
4122
+ });
4123
+ });
4124
+ router.post('/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
4125
+ const { sessionId } = req.params;
4126
+ const session = terminalSessions.get(sessionId);
4127
+ if (!session) {
4128
+ return res.status(404).json({ error: 'Terminal session not found' });
4129
+ }
4130
+ const data = typeof req.body === 'string' ? req.body : '';
4131
+ try {
4132
+ session.ptyProcess.write(data);
4133
+ session.lastActivity = Date.now();
4134
+ res.json({ success: true });
4135
+ }
4136
+ catch (error) {
4137
+ const errorMessage = error instanceof Error ? error.message : String(error);
4138
+ console.error('Failed to write to terminal:', errorMessage);
4139
+ res.status(500).json({ error: errorMessage || 'Failed to write to terminal' });
4140
+ }
4141
+ });
4142
+ router.post('/:sessionId/resize', (req, res) => {
4143
+ const { sessionId } = req.params;
4144
+ const session = terminalSessions.get(sessionId);
4145
+ if (!session) {
4146
+ return res.status(404).json({ error: 'Terminal session not found' });
4147
+ }
4148
+ const { cols, rows } = req.body;
4149
+ if (!cols || !rows) {
4150
+ return res.status(400).json({ error: 'cols and rows are required' });
4151
+ }
4152
+ try {
4153
+ const ok = applyPtyResize(sessionId, session, Number(cols), Number(rows), 'http-resize');
4154
+ if (!ok) {
4155
+ return res.status(400).json({ error: 'invalid cols/rows' });
4156
+ }
4157
+ res.json({ success: true, cols: session.cols, rows: session.rows });
4158
+ }
4159
+ catch (error) {
4160
+ const errorMessage = error instanceof Error ? error.message : String(error);
4161
+ console.error('Failed to resize terminal:', errorMessage);
4162
+ res.status(500).json({ error: errorMessage || 'Failed to resize terminal' });
4163
+ }
4164
+ });
4165
+ router.post('/:sessionId/tmux', async (req, res) => {
4166
+ const { sessionId } = req.params;
4167
+ const session = terminalSessions.get(sessionId);
4168
+ if (!session) {
4169
+ return res.status(404).json({ error: 'Terminal session not found' });
4170
+ }
4171
+ if (session.mode !== 'tmux' || !session.tmuxSessionName) {
4172
+ return res.status(400).json({ error: 'Terminal session is not in tmux mode' });
4173
+ }
4174
+ const { action } = req.body ?? {};
4175
+ try {
4176
+ const tmuxTarget = session.tmuxSessionName;
4177
+ // switch-session needs special handling (tty resolution)
4178
+ if (action === 'switch-session') {
4179
+ const targetSessionName = typeof req.body?.tmuxSessionName === 'string'
4180
+ ? req.body.tmuxSessionName.trim()
4181
+ : '';
4182
+ if (!targetSessionName) {
4183
+ return res.status(400).json({ error: 'tmuxSessionName is required' });
4184
+ }
4185
+ const preferredClientPid = getPtyProcessPid(session.ptyProcess);
4186
+ const clientTty = await resolveTmuxClientTty(tmuxTarget, preferredClientPid);
4187
+ if (!clientTty) {
4188
+ return res.status(500).json({ error: 'No tmux client available for current session' });
4189
+ }
4190
+ await prepareManagedTmuxSession(targetSessionName, session.cwd);
4191
+ await sendTmuxCommand(tmuxTarget, session.tmuxControl, ['switch-client', '-c', clientTty, '-t', targetSessionName]);
4192
+ session.tmuxSessionName = targetSessionName;
4193
+ session.lastActivity = Date.now();
4194
+ if (updateGlobalBindingForBackendSession(sessionId, {
4195
+ mode: 'tmux',
4196
+ tmuxSessionName: targetSessionName,
4197
+ cwd: session.cwd,
4198
+ lastActivity: session.lastActivity,
4199
+ })) {
4200
+ persistAndBroadcastGlobalState();
4201
+ }
4202
+ const layout = await getTmuxLayout(targetSessionName);
4203
+ broadcastEvent(sessionId, { type: 'tmux-layout', layout });
4204
+ return res.json({ success: true, layout });
4205
+ }
4206
+ // All other actions: delegate to shared executeTmuxAction
4207
+ const result = await executeTmuxAction(tmuxTarget, action, req.body, session.tmuxControl);
4208
+ if (result.error) {
4209
+ return res.status(400).json({ error: result.error });
4210
+ }
4211
+ session.lastActivity = Date.now();
4212
+ if (result.shouldBroadcastLayout) {
4213
+ const layout = await getTmuxLayout(tmuxTarget);
4214
+ broadcastEvent(sessionId, { type: 'tmux-layout', layout });
4215
+ return res.json({ success: true, layout });
4216
+ }
4217
+ return res.json({ success: true });
4218
+ }
4219
+ catch (error) {
4220
+ const errorMessage = getErrorMessage(error);
4221
+ console.error(`Failed to execute tmux action ${action}:`, errorMessage);
4222
+ return res.status(500).json({ error: errorMessage || 'Failed to execute tmux action' });
4223
+ }
4224
+ });
4225
+ router.delete('/:sessionId', (req, res) => {
4226
+ const { sessionId } = req.params;
4227
+ const session = terminalSessions.get(sessionId);
4228
+ if (!session) {
4229
+ return res.json({ success: true, alreadyGone: true });
4230
+ }
4231
+ try {
4232
+ cleanupSession(sessionId, { killProcess: true });
4233
+ console.log(`Closed terminal session: ${sessionId}`);
4234
+ res.json({ success: true });
4235
+ }
4236
+ catch (error) {
4237
+ const errorMessage = error instanceof Error ? error.message : String(error);
4238
+ console.error('Failed to close terminal:', errorMessage);
4239
+ res.status(500).json({ error: errorMessage || 'Failed to close terminal' });
4240
+ }
4241
+ });
4242
+ router.post('/:sessionId/restart', async (req, res) => {
4243
+ const { sessionId } = req.params;
4244
+ const { cwd: inputCwd, cols, rows, mode, tmuxSessionName } = req.body;
4245
+ const existingSession = terminalSessions.get(sessionId);
4246
+ if (existingSession) {
4247
+ cleanupSession(sessionId, { killProcess: true });
4248
+ }
4249
+ try {
4250
+ const { sessionId: newSessionId, session } = await spawnTerminalSession(req, {
4251
+ cwd: inputCwd,
4252
+ cols,
4253
+ rows,
4254
+ mode,
4255
+ tmuxSessionName,
4256
+ });
4257
+ console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${session.cwd}`);
4258
+ if (updateGlobalBindingForBackendSession(sessionId, {
4259
+ backendSessionId: newSessionId,
4260
+ mode: session.mode,
4261
+ tmuxSessionName: session.tmuxSessionName,
4262
+ cwd: session.cwd,
4263
+ lastActivity: session.lastActivity,
4264
+ })) {
4265
+ persistAndBroadcastGlobalState();
4266
+ }
4267
+ res.json({
4268
+ sessionId: newSessionId,
4269
+ cols: cols || 80,
4270
+ rows: rows || 24,
4271
+ mode: session.mode,
4272
+ tmuxSessionName: session.tmuxSessionName,
4273
+ });
4274
+ }
4275
+ catch (error) {
4276
+ const errorMessage = error instanceof Error ? error.message : String(error);
4277
+ console.error('Failed to restart terminal session:', errorMessage);
4278
+ res.status(500).json({ error: errorMessage || 'Failed to restart terminal session' });
4279
+ }
4280
+ });
4281
+ router.post('/force-kill', (req, res) => {
4282
+ const { sessionId, cwd } = req.body;
4283
+ let killedCount = 0;
4284
+ if (sessionId) {
4285
+ const session = terminalSessions.get(sessionId);
4286
+ if (session) {
4287
+ cleanupSession(sessionId, { killProcess: true });
4288
+ killedCount++;
4289
+ }
4290
+ }
4291
+ else if (cwd) {
4292
+ for (const [id, session] of terminalSessions) {
4293
+ if (session.cwd === cwd) {
4294
+ cleanupSession(id, { killProcess: true });
4295
+ killedCount++;
4296
+ }
4297
+ }
4298
+ }
4299
+ else {
4300
+ for (const [id] of terminalSessions) {
4301
+ cleanupSession(id, { killProcess: true });
4302
+ killedCount++;
4303
+ }
4304
+ }
4305
+ console.log(`Force killed ${killedCount} terminal session(s)`);
4306
+ res.json({ success: true, killedCount });
4307
+ });
4308
+ // ---- WebSocket handler (replaces SSE + HTTP POST for terminal I/O) ----
4309
+ async function executeTmuxAction(tmuxTarget, action, body, control) {
4310
+ let shouldBroadcastLayout = true;
4311
+ switch (action) {
4312
+ case 'select-pane': {
4313
+ const paneId = typeof body.paneId === 'string' ? body.paneId : '';
4314
+ if (!paneId)
4315
+ return { shouldBroadcastLayout: false, error: 'paneId is required' };
4316
+ await sendTmuxCommand(tmuxTarget, control, ['select-pane', '-t', paneId]);
4317
+ break;
4318
+ }
4319
+ case 'select-window': {
4320
+ const windowId = typeof body.windowId === 'string' ? body.windowId : '';
4321
+ if (!windowId)
4322
+ return { shouldBroadcastLayout: false, error: 'windowId is required' };
4323
+ await sendTmuxCommand(tmuxTarget, control, ['select-window', '-t', windowId]);
4324
+ break;
4325
+ }
4326
+ case 'split-pane': {
4327
+ const dir = body.direction === 'h' ? '-h' : '-v';
4328
+ await sendTmuxCommand(tmuxTarget, control, ['split-window', '-t', tmuxTarget, dir]);
4329
+ break;
4330
+ }
4331
+ case 'close-pane': {
4332
+ const paneId = typeof body.paneId === 'string' ? body.paneId : '';
4333
+ if (!paneId)
4334
+ return { shouldBroadcastLayout: false, error: 'paneId is required' };
4335
+ await sendTmuxCommand(tmuxTarget, control, ['kill-pane', '-t', paneId]);
4336
+ break;
4337
+ }
4338
+ case 'copy-mode': {
4339
+ const enabled = body.enabled !== false;
4340
+ if (enabled) {
4341
+ try {
4342
+ await sendTmuxCommand(tmuxTarget, control, ['copy-mode', '-He', '-t', tmuxTarget]);
4343
+ }
4344
+ catch (error) {
4345
+ if (!isAlreadyInCopyModeError(getErrorMessage(error)))
4346
+ throw error;
4347
+ }
4348
+ }
4349
+ else {
4350
+ try {
4351
+ await sendTmuxCommand(tmuxTarget, control, ['send-keys', '-t', tmuxTarget, '-X', 'cancel']);
4352
+ }
4353
+ catch (error) {
4354
+ if (!isNotInCopyModeError(getErrorMessage(error)))
4355
+ throw error;
4356
+ }
4357
+ }
4358
+ break;
4359
+ }
4360
+ case 'scroll': {
4361
+ const direction = body.direction === 'down' ? 'down' : 'up';
4362
+ const lines = Math.max(1, Math.min(50, Math.floor(Number(body.lines) || 1)));
4363
+ let inCopyMode = await isTmuxPaneInMode(tmuxTarget, control);
4364
+ // Down-scroll outside copy mode: already at the live prompt, nothing to do.
4365
+ if (direction === 'down' && !inCopyMode) {
4366
+ shouldBroadcastLayout = false;
4367
+ break;
4368
+ }
4369
+ // Up-scroll outside copy mode: only enter if the pane has scrollback
4370
+ // history. Without history, copy-mode -He would flash-enter then
4371
+ // `-e` auto-exit instantly.
4372
+ if (!inCopyMode) {
4373
+ // Re-check history_size on every entry — the pane may have
4374
+ // accumulated scrollback since last check.
4375
+ const histRaw = (await sendTmuxCommand(tmuxTarget, control, [
4376
+ 'display-message', '-t', tmuxTarget, '-p', '#{history_size}',
4377
+ ])).trim();
4378
+ const historySize = parseInt(histRaw, 10) || 0;
4379
+ if (historySize === 0) {
4380
+ exitedAtBottom.add(tmuxTarget);
4381
+ shouldBroadcastLayout = false;
4382
+ break;
4383
+ }
4384
+ exitedAtBottom.delete(tmuxTarget);
4385
+ try {
4386
+ await sendTmuxCommand(tmuxTarget, control, ['copy-mode', '-He', '-t', tmuxTarget]);
4387
+ }
4388
+ catch (error) {
4389
+ if (!isAlreadyInCopyModeError(getErrorMessage(error)))
4390
+ throw error;
4391
+ }
4392
+ inCopyMode = await isTmuxPaneInMode(tmuxTarget, control);
4393
+ if (!inCopyMode) {
4394
+ shouldBroadcastLayout = false;
4395
+ break;
4396
+ }
4397
+ }
4398
+ // Scroll commands are fire-and-forget — don't wait for %exit.
4399
+ const scrollCmd = direction === 'up' ? 'scroll-up' : 'scroll-down';
4400
+ const fallbackCmds = direction === 'up'
4401
+ ? ['up-line', 'cursor-up']
4402
+ : ['down-line', 'cursor-down'];
4403
+ let sent = false;
4404
+ for (const cmd of [scrollCmd, ...fallbackCmds]) {
4405
+ try {
4406
+ if (control && !control.dead) {
4407
+ sendTmuxCommandFireAndForget(tmuxTarget, control, [
4408
+ 'send-keys', '-t', tmuxTarget, '-X', '-N', String(lines), cmd,
4409
+ ]);
4410
+ }
4411
+ else {
4412
+ await runTmux(['send-keys', '-t', tmuxTarget, '-X', '-N', String(lines), cmd]);
4413
+ }
4414
+ sent = true;
4415
+ break;
4416
+ }
4417
+ catch (error) {
4418
+ // Try fallback
4419
+ }
4420
+ }
4421
+ if (!sent) {
4422
+ shouldBroadcastLayout = false;
4423
+ break;
4424
+ }
4425
+ if (direction === 'down') {
4426
+ const stillInCopyMode = await isTmuxPaneInMode(tmuxTarget, control);
4427
+ if (!stillInCopyMode) {
4428
+ exitedAtBottom.add(tmuxTarget);
4429
+ }
4430
+ }
4431
+ shouldBroadcastLayout = false;
4432
+ break;
4433
+ }
4434
+ case 'new-window': {
4435
+ await sendTmuxCommand(tmuxTarget, control, ['new-window', '-t', tmuxTarget]);
4436
+ break;
4437
+ }
4438
+ case 'switch-session': {
4439
+ const targetSessionName = typeof body.tmuxSessionName === 'string'
4440
+ ? body.tmuxSessionName.trim()
4441
+ : '';
4442
+ if (!targetSessionName) {
4443
+ return { shouldBroadcastLayout: false, error: 'tmuxSessionName is required' };
4444
+ }
4445
+ await prepareManagedTmuxSession(targetSessionName);
4446
+ await sendTmuxCommand(tmuxTarget, control, ['switch-client', '-t', targetSessionName]);
4447
+ break;
4448
+ }
4449
+ default:
4450
+ return { shouldBroadcastLayout: false, error: `Unknown tmux action: ${action}` };
4451
+ }
4452
+ return { shouldBroadcastLayout };
4453
+ }
4454
+ export function handleTerminalWebSocket(ws, sessionId, clientId, options = {}) {
4455
+ const session = terminalSessions.get(sessionId);
4456
+ if (!session) {
4457
+ ws.close(4001, 'Session not found');
4458
+ return;
4459
+ }
4460
+ const sinceSeq = options.sinceSeq ?? 0;
4461
+ // Register client
4462
+ let clients = wsClients.get(sessionId);
4463
+ if (!clients) {
4464
+ clients = new Map();
4465
+ wsClients.set(sessionId, clients);
4466
+ }
4467
+ clients.set(clientId, ws);
4468
+ session.lastActivity = Date.now();
4469
+ syncClientCountToTmux(sessionId);
4470
+ if (updateGlobalBindingForBackendSession(sessionId, { cwd: session.cwd, lastActivity: session.lastActivity })) {
4471
+ persistAndBroadcastGlobalState();
4472
+ }
4473
+ void (async () => {
4474
+ // 连接时立即检测一次 activeProgram,避免前端首次显示闪烁
4475
+ try {
4476
+ if (session.mode === 'shell') {
4477
+ const ap = await detectShellActiveProgram(session);
4478
+ if (ap)
4479
+ session.activeProgram = ap;
4480
+ }
4481
+ else if (session.mode === 'tmux' && session.tmuxSessionName) {
4482
+ const layout = await getTmuxLayout(session.tmuxSessionName);
4483
+ const ap = getActiveProgramFromTmuxLayout(layout);
4484
+ if (ap)
4485
+ session.activeProgram = ap;
4486
+ }
4487
+ }
4488
+ catch { /* ignore */ }
4489
+ // 计算重连补帧:
4490
+ // - sinceSeq > 0:短线重连,shell 模式按 seq 补增量;超出窗口则发 outOfWindow + 全量。
4491
+ // - sinceSeq == 0:首次连接(包括手机 PWA 冷启动)。以前这里只回 lastSeq、不带数据,
4492
+ // 靠客户端单独走 HTTP /attach 拿 scrollback;但那条路径会阻塞 MultiTerminalView 的
4493
+ // "Restoring sessions..." 全屏 loading(蜂窝 RTT 1-3 秒)。现在直接把 restore history
4494
+ // 塞进 'connected' 事件,省掉一次 HTTP 往返,前端 UI 可以立刻渲染、scrollback 随 WS
4495
+ // 到达即填充。tmux 模式发 capture-pane 输出(带 clear-screen 前缀,避免与 xterm 默认
4496
+ // 内容拼接),shell 模式发 ring buffer 内容(最多 100KB)。
4497
+ //
4498
+ // replayOutOfWindow=true 的语义在 sinceSeq=0 时是"你没有基线,请清空已有内容
4499
+ // 再应用 replayChunks"。这正好覆盖客户端"localStorage hydrate 出来的缓存 buffer"
4500
+ // 场景:缓存内容会被服务端权威版本干净地替换,不会出现重复或顺序错位。
4501
+ let replayChunks = [];
4502
+ let replayLastSeq = 0;
4503
+ let replayOutOfWindow = false;
4504
+ if (sinceSeq > 0 && session.mode === 'shell') {
4505
+ const since = getHistorySince(sessionId, sinceSeq);
4506
+ replayChunks = since.chunks.map((c) => c.data);
4507
+ replayLastSeq = since.lastSeq;
4508
+ replayOutOfWindow = since.outOfWindow;
4509
+ }
4510
+ else {
4511
+ // 首次连接:直接补全量 scrollback,并强制让客户端清空已有内容(处理缓存 hydrate)
4512
+ try {
4513
+ replayChunks = await getRestoreHistory(sessionId, session);
4514
+ }
4515
+ catch (error) {
4516
+ console.warn(`[ws] getRestoreHistory failed for ${sessionId}: ${getErrorMessage(error)}`);
4517
+ replayChunks = [];
4518
+ }
4519
+ replayLastSeq = session.mode === 'shell' ? getHistoryLastSeq(sessionId) : 0;
4520
+ replayOutOfWindow = replayChunks.length > 0;
4521
+ }
4522
+ // Send connected event (after initial detection)
4523
+ const runtime = globalThis.Bun ? 'bun' : 'node';
4524
+ ws.send(JSON.stringify({
4525
+ type: 'connected',
4526
+ runtime,
4527
+ ptyBackend: session.ptyBackend || 'unknown',
4528
+ mode: session.mode,
4529
+ tmuxSessionName: session.tmuxSessionName,
4530
+ cwd: session.cwd ?? null,
4531
+ activeProgram: session.activeProgram?.command ?? null,
4532
+ activeProgramRaw: session.activeProgram?.rawArgs ?? null,
4533
+ activeProgramSource: session.activeProgram?.source ?? null,
4534
+ agentStatus: session.agentStatus,
4535
+ agentColor: session.agentColor,
4536
+ agentIndicator: session.agentIndicator,
4537
+ tuiProgress: session.tuiProgress,
4538
+ focusTrackingRequested: session.focusTrackingRequested,
4539
+ // 短线重连补帧:
4540
+ // replayChunks 为补发数据;replayLastSeq 是客户端应记录的新基线;
4541
+ // replayOutOfWindow 表示客户端基线已被服务端淘汰,前端可以选择清屏后再回放。
4542
+ replayChunks,
4543
+ replayLastSeq,
4544
+ replayOutOfWindow,
4545
+ }));
4546
+ })();
4547
+ // Tmux layout polling (per-client, like the SSE stream does)
4548
+ let tmuxInterval = null;
4549
+ let activeProgramInterval = null;
4550
+ if (session.mode === 'tmux' && session.tmuxSessionName) {
4551
+ let lastTmuxLayoutSnapshot = '';
4552
+ let lastActiveProgramSnapshot = JSON.stringify(session.activeProgram ?? null);
4553
+ let lastTmuxMetadata = null;
4554
+ let lastTmuxMetaWriteAt = 0;
4555
+ const sendTmuxLayout = async () => {
4556
+ if (ws.readyState !== ws.OPEN)
4557
+ return;
4558
+ try {
4559
+ const layout = await getTmuxLayout(session.tmuxSessionName);
4560
+ // Update active program — try ps-based detection for generic commands
4561
+ const activePane = getActivePaneFromLayout(layout);
4562
+ let ap = null;
4563
+ if (activePane) {
4564
+ const resolved = await resolveTmuxPaneProgram(activePane);
4565
+ if (resolved) {
4566
+ ap = { command: resolved.command, source: resolved.source, rawArgs: resolved.rawArgs, updatedAt: Date.now() };
4567
+ }
4568
+ else {
4569
+ ap = getActiveProgramFromTmuxLayout(layout);
4570
+ }
4571
+ }
4572
+ else {
4573
+ ap = getActiveProgramFromTmuxLayout(layout);
4574
+ }
4575
+ const apSnapshot = JSON.stringify(ap ? { command: ap.command, source: ap.source } : null);
4576
+ if (apSnapshot !== lastActiveProgramSnapshot) {
4577
+ lastActiveProgramSnapshot = apSnapshot;
4578
+ session.activeProgram = ap;
4579
+ // Agent status: react to AI tool start/exit
4580
+ evaluateAgentStatus(sessionId, session);
4581
+ console.log(`[active-program][ws] session=${sessionId} cmd=${ap?.command ?? null} source=${ap?.source ?? null}`);
4582
+ ws.send(JSON.stringify({
4583
+ type: 'active-program',
4584
+ activeProgram: ap?.command ?? null,
4585
+ activeProgramRaw: ap?.rawArgs ?? null,
4586
+ activeProgramSource: ap?.source ?? null,
4587
+ }));
4588
+ }
4589
+ // Update cwd from active pane
4590
+ const newCwd = getCwdFromTmuxLayout(layout);
4591
+ if (newCwd && newCwd !== session.cwd) {
4592
+ session.cwd = newCwd;
4593
+ console.log(`[tmux-cwd][ws] session=${sessionId} cwd=${newCwd}`);
4594
+ if (updateGlobalBindingForBackendSession(sessionId, { cwd: newCwd, lastActivity: session.lastActivity })) {
4595
+ schedulePersistGlobalState();
4596
+ }
4597
+ ws.send(JSON.stringify({ type: 'cwd', cwd: newCwd }));
4598
+ }
4599
+ // tmux 消费了 inner shell 发的 OSC 2(存入 pane_title)和 OSC 133,
4600
+ // 不透传到外层 PTY。因此从 tmux layout 提取 active pane 的 title
4601
+ // 和 command 来推导 shell-title / prompt-state。
4602
+ if (activePane) {
4603
+ const paneTitle = activePane.title || '';
4604
+ if (paneTitle && paneTitle !== session.lastOscTitle) {
4605
+ session.lastOscTitle = paneTitle;
4606
+ ws.send(JSON.stringify({ type: 'shell-title', title: paneTitle }));
4607
+ }
4608
+ // prompt-state: command 是 shell 名 → idle;否则 → running
4609
+ const paneCmd = activePane.command || '';
4610
+ const inferredState = paneCmd && !shellNamesBackend.has(paneCmd) ? 'running' : 'idle';
4611
+ if (inferredState !== session.lastPromptState) {
4612
+ session.lastPromptState = inferredState;
4613
+ ws.send(JSON.stringify({ type: 'prompt-state', state: inferredState }));
4614
+ }
4615
+ }
4616
+ // Mirror dynamic metadata onto tmux user options.
4617
+ const meta = syncDynamicTmuxMetadata({
4618
+ tmuxSessionName: session.tmuxSessionName,
4619
+ program: session.activeProgram?.command ?? null,
4620
+ cwd: session.cwd ?? null,
4621
+ previousMetadata: lastTmuxMetadata,
4622
+ lastActiveWriteAt: lastTmuxMetaWriteAt,
4623
+ });
4624
+ lastTmuxMetadata = { program: meta.program, cwd: meta.cwd, label: meta.label };
4625
+ lastTmuxMetaWriteAt = meta.lastActiveWriteAt;
4626
+ const snapshot = JSON.stringify(layout);
4627
+ if (snapshot !== lastTmuxLayoutSnapshot) {
4628
+ lastTmuxLayoutSnapshot = snapshot;
4629
+ ws.send(JSON.stringify({ type: 'tmux-layout', layout }));
4630
+ }
4631
+ }
4632
+ catch { /* ignore polling errors */ }
4633
+ };
4634
+ sendTmuxLayout();
4635
+ tmuxInterval = setInterval(sendTmuxLayout, TMUX_POLL_INTERVAL);
4636
+ }
4637
+ // Active program polling (shell mode)
4638
+ if (session.mode === 'shell') {
4639
+ let lastApSnapshot = JSON.stringify(session.activeProgram ? { command: session.activeProgram.command, source: session.activeProgram.source } : null);
4640
+ const pollActiveProgram = async () => {
4641
+ if (ws.readyState !== ws.OPEN)
4642
+ return;
4643
+ try {
4644
+ const ap = await detectShellActiveProgram(session);
4645
+ const snapshot = JSON.stringify(ap ? { command: ap.command, source: ap.source } : null);
4646
+ if (snapshot !== lastApSnapshot) {
4647
+ lastApSnapshot = snapshot;
4648
+ session.activeProgram = ap;
4649
+ // Agent status: react to AI tool start/exit
4650
+ evaluateAgentStatus(sessionId, session);
4651
+ ws.send(JSON.stringify({
4652
+ type: 'active-program',
4653
+ activeProgram: ap?.command ?? null,
4654
+ activeProgramRaw: ap?.rawArgs ?? null,
4655
+ activeProgramSource: ap?.source ?? null,
4656
+ }));
4657
+ }
4658
+ }
4659
+ catch { /* ignore */ }
4660
+ };
4661
+ activeProgramInterval = setInterval(pollActiveProgram, ACTIVE_PROGRAM_POLL_INTERVAL);
4662
+ }
4663
+ // Handle client → server messages
4664
+ ws.on('message', async (raw) => {
4665
+ let msg;
4666
+ try {
4667
+ msg = JSON.parse(raw.toString());
4668
+ }
4669
+ catch {
4670
+ return;
4671
+ }
4672
+ try {
4673
+ switch (msg.type) {
4674
+ case 'input': {
4675
+ if (typeof msg.data === 'string' && msg.data.length > 0) {
4676
+ session.lastActivity = Date.now();
4677
+ session.ptyProcess.write(msg.data);
4678
+ }
4679
+ break;
4680
+ }
4681
+ case 'resize': {
4682
+ const cols = Number(msg.cols);
4683
+ const rows = Number(msg.rows);
4684
+ if (cols > 0 && rows > 0) {
4685
+ applyPtyResize(sessionId, session, cols, rows, `ws-resize:${clientId}`, clientId);
4686
+ }
4687
+ break;
4688
+ }
4689
+ case 'tmux': {
4690
+ const reqId = msg.reqId;
4691
+ if (session.mode !== 'tmux' || !session.tmuxSessionName) {
4692
+ ws.send(JSON.stringify({ type: 'tmux-result', reqId, success: false, error: 'Not in tmux mode' }));
4693
+ break;
4694
+ }
4695
+ const result = await executeTmuxAction(session.tmuxSessionName, msg.action, msg, session.tmuxControl);
4696
+ ws.send(JSON.stringify({ type: 'tmux-result', reqId, success: !result.error, error: result.error }));
4697
+ if (result.shouldBroadcastLayout && session.tmuxSessionName) {
4698
+ try {
4699
+ const layout = await getTmuxLayout(session.tmuxSessionName);
4700
+ broadcastJsonWs(sessionId, { type: 'tmux-layout', layout });
4701
+ }
4702
+ catch { /* ignore */ }
4703
+ }
4704
+ break;
4705
+ }
4706
+ case 'focus': {
4707
+ const focused = msg.focused === true;
4708
+ const reason = typeof msg.reason === 'string' ? msg.reason : 'client-focus';
4709
+ updateClientFocusState(sessionId, session, clientId, focused, reason);
4710
+ break;
4711
+ }
4712
+ case 'flow-control': {
4713
+ if (typeof msg.paused === 'boolean') {
4714
+ const reason = typeof msg.reason === 'string' ? msg.reason : 'client-flow-control';
4715
+ setClientFlowPaused(sessionId, session, clientId, msg.paused, reason);
4716
+ }
4717
+ break;
4718
+ }
4719
+ case 'ping': {
4720
+ // 心跳算活动:客户端每 20s 发一次 ping,没有这一行就会出现
4721
+ // "用户开着页面看 agent 跑、自己不动键盘"被 idle-cleanup 误杀的情况。
4722
+ session.lastActivity = Date.now();
4723
+ ws.send(JSON.stringify({ type: 'pong' }));
4724
+ break;
4725
+ }
4726
+ }
4727
+ }
4728
+ catch (error) {
4729
+ ws.send(JSON.stringify({
4730
+ type: 'error',
4731
+ error: error instanceof Error ? error.message : 'Unknown error',
4732
+ }));
4733
+ }
4734
+ });
4735
+ // Cleanup on close
4736
+ ws.on('close', () => {
4737
+ if (tmuxInterval)
4738
+ clearInterval(tmuxInterval);
4739
+ if (activeProgramInterval)
4740
+ clearInterval(activeProgramInterval);
4741
+ const clients = wsClients.get(sessionId);
4742
+ if (clients) {
4743
+ clients.delete(clientId);
4744
+ if (clients.size === 0) {
4745
+ wsClients.delete(sessionId);
4746
+ }
4747
+ }
4748
+ removeClientFocus(sessionId, session, clientId);
4749
+ removeClientFlowPaused(sessionId, session, clientId, 'client-disconnect');
4750
+ syncClientCountToTmux(sessionId);
4751
+ broadcastClientState();
4752
+ });
4753
+ ws.on('error', () => {
4754
+ // close handler will clean up
4755
+ });
4756
+ }
4757
+ // Refresh global tmux server options on every server start so existing tmux
4758
+ // sessions pick up focus tracking and the latest copy-mode wheel bindings.
4759
+ ensureSharedTmuxServerReady().catch(() => { });
4760
+ // ── Control WebSocket handler ──
4761
+ //
4762
+ // A separate, lightweight WS that exists purely to push client-state changes
4763
+ // to every connected browser. We don't accept commands here (mutations still
4764
+ // go through HTTP PUT/DELETE for CSRF + auth reuse); this channel is one-way
4765
+ // server→client, with a server-initiated snapshot on connect and a small
4766
+ // heartbeat to detect zombie sockets on iOS PWA resumes.
4767
+ export function handleControlWebSocket(ws, clientId) {
4768
+ controlClients.set(clientId, ws);
4769
+ // Initial snapshot — same shape the HTTP GET returns, with an inventory
4770
+ // projection when tmux/backend state can be queried immediately.
4771
+ void (async () => {
4772
+ const initialSeq = broadcastInventorySeq;
4773
+ const inventory = await getSessionInventorySnapshot().catch((error) => {
4774
+ console.warn('[session-inventory] failed to build initial control snapshot:', getErrorMessage(error));
4775
+ return latestSessionInventory;
4776
+ });
4777
+ if (initialSeq !== broadcastInventorySeq) {
4778
+ // A fresher broadcast was sent while this inventory was being built.
4779
+ // Dropping this initial snapshot avoids replaying stale inventory with
4780
+ // the latest seq; the next reconnect/control broadcast will provide a
4781
+ // fresh baseline.
4782
+ return;
4783
+ }
4784
+ try {
4785
+ ws.send(JSON.stringify({ type: 'client-state', seq: initialSeq, state: globalSessionState, inventory: inventory ?? latestSessionInventory }));
4786
+ }
4787
+ catch {
4788
+ controlClients.delete(clientId);
4789
+ return;
4790
+ }
4791
+ })();
4792
+ // Heartbeat: client never has to send anything. We just send a tiny ping
4793
+ // every 30s; if the underlying socket is dead the close will surface and
4794
+ // the reconnect-on-client-side path takes over.
4795
+ const heartbeat = setInterval(() => {
4796
+ if (ws.readyState !== ws.OPEN) {
4797
+ clearInterval(heartbeat);
4798
+ controlClients.delete(clientId);
4799
+ return;
4800
+ }
4801
+ try {
4802
+ ws.send(JSON.stringify({ type: 'control-ping', ts: Date.now() }));
4803
+ }
4804
+ catch {
4805
+ clearInterval(heartbeat);
4806
+ controlClients.delete(clientId);
4807
+ }
4808
+ }, 30_000);
4809
+ // Clients may send pong (or nothing). We accept and ignore.
4810
+ ws.on('message', (raw) => {
4811
+ try {
4812
+ const msg = JSON.parse(raw.toString());
4813
+ if (msg?.type === 'pong') {
4814
+ // heartbeat ack, nothing to do
4815
+ }
4816
+ }
4817
+ catch { /* ignore malformed input */ }
4818
+ });
4819
+ const cleanup = () => {
4820
+ clearInterval(heartbeat);
4821
+ if (controlClients.get(clientId) === ws) {
4822
+ controlClients.delete(clientId);
4823
+ }
4824
+ };
4825
+ ws.on('close', cleanup);
4826
+ ws.on('error', cleanup);
4827
+ }
4828
+ // ── Periodic reconciler ──
4829
+ //
4830
+ // `pruneOrphanSessions` only runs at boot, so anything that goes wrong at
4831
+ // runtime (external `tmux kill-session`, a shell wrapper whose PTY exited
4832
+ // but whose entry was never PUT/DELETE'd) leaks into globalSessionState.
4833
+ // Walk the persisted list every 30s and drop entries whose backing
4834
+ // session/tmux server is no longer alive; broadcast the cleaned list.
4835
+ const CLIENT_STATE_RECONCILE_INTERVAL_MS = 30_000;
4836
+ const reconcileTimer = setInterval(() => {
4837
+ void reconcileClientState();
4838
+ }, CLIENT_STATE_RECONCILE_INTERVAL_MS);
4839
+ // Don't keep the event loop alive for housekeeping.
4840
+ reconcileTimer.unref?.();
4841
+ async function reconcileClientState() {
4842
+ if (globalSessionState.sessions.length === 0)
4843
+ return;
4844
+ const toRemove = [];
4845
+ for (const entry of globalSessionState.sessions) {
4846
+ if (entry.mode === 'shell') {
4847
+ // Shell wrapper: backendSessionId must map to a live terminal session.
4848
+ // No live wrapper → the shell process is gone, can't reattach.
4849
+ if (!entry.backendSessionId || !terminalSessions.has(entry.backendSessionId)) {
4850
+ toRemove.push(entry.sessionId);
4851
+ }
4852
+ }
4853
+ else if (entry.mode === 'tmux' && entry.tmuxSessionName) {
4854
+ // Tmux entry: the tmux daemon (independent of termdock) must still
4855
+ // own this session name. We deliberately do NOT require a live
4856
+ // terminal wrapper here — detaching from the wrapper doesn't kill
4857
+ // the tmux session.
4858
+ try {
4859
+ const alive = await tmuxSessionExists(entry.tmuxSessionName);
4860
+ if (!alive)
4861
+ toRemove.push(entry.sessionId);
4862
+ }
4863
+ catch {
4864
+ // tmux itself is down — leave the entry alone; the next tick (or
4865
+ // boot-time prune) will clean it up once tmux is back.
4866
+ }
4867
+ }
4868
+ }
4869
+ if (toRemove.length === 0)
4870
+ return;
4871
+ globalSessionState = {
4872
+ sessions: globalSessionState.sessions.filter((s) => !toRemove.includes(s.sessionId)),
4873
+ updatedAt: Date.now(),
4874
+ };
4875
+ schedulePersistGlobalState();
4876
+ broadcastClientState();
4877
+ console.log(`[reconcile] removed ${toRemove.length} orphan client-state entries: ${toRemove.join(', ')}`);
4878
+ }
4879
+ export default router;