@hienlh/ppm 0.9.0-beta.7 → 0.9.0-beta.9

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 (181) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bun.lock +5 -0
  3. package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-3Xe18azI.js} +1 -1
  4. package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-Yy35llnn.js} +1 -1
  5. package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-CEMxVMCV.js} +1 -1
  6. package/dist/web/assets/{arc-C2Qaz-ch.js → arc-B9n1Gvb5.js} +1 -1
  7. package/dist/web/assets/architecture-PBZL5I3N-CFzkFKEL.js +1 -0
  8. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-DqAZP_F6.js} +1 -1
  9. package/dist/web/assets/arrow-up--LjUXLEt.js +1 -0
  10. package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-h3cDF2vI.js} +1 -1
  11. package/dist/web/assets/{browser-tab-DAvH4mv0.js → browser-tab-D1Zua62g.js} +1 -1
  12. package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW--pF1r5lr.js} +1 -1
  13. package/dist/web/assets/channel-C2fMafck.js +1 -0
  14. package/dist/web/assets/chat-tab-BnD27Vp9.js +7 -0
  15. package/dist/web/assets/chevron-right-CHnjJt4E.js +1 -0
  16. package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-C3aZvW7B.js} +1 -1
  17. package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-D5cABeB9.js} +1 -1
  18. package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CkFGv6Zs.js} +1 -1
  19. package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-Dvbyu4Zw.js} +2 -2
  20. package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-CtqKiH4q.js} +1 -1
  21. package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-Cpr87sBR.js} +1 -1
  22. package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-D23YVTOU.js} +1 -1
  23. package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-tDjHsAUs.js} +1 -1
  24. package/dist/web/assets/chunk-GLR3WWYH-DBdWQ3zy.js +2 -0
  25. package/dist/web/assets/chunk-HHEYEP7N-BBw_z0fW.js +1 -0
  26. package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-BBmymCjA.js} +1 -1
  27. package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-DP36BDiU.js} +1 -1
  28. package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-Djw13C-3.js} +1 -1
  29. package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-HG_eMj_C.js} +1 -1
  30. package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-C2UEioMs.js} +1 -1
  31. package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-DXUTQ-BL.js} +1 -1
  32. package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-BsUWb9d0.js} +1 -1
  33. package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-rG0P22U9.js} +1 -1
  34. package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-DX0xW7kO.js} +1 -1
  35. package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-C7Gry6md.js} +1 -1
  36. package/dist/web/assets/chunk-QZHKN3VN-DFKFM_C1.js +1 -0
  37. package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-CMY0PkRK.js} +1 -1
  38. package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-CXuQvlyu.js} +1 -1
  39. package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-DRJEb7Zb.js} +1 -1
  40. package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-BPEX8KhL.js} +1 -1
  41. package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-Cb0iqycX.js} +1 -1
  42. package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-av5aeHLq.js} +1 -1
  43. package/dist/web/assets/classDiagram-VBA2DB6C-Dp4Kk3Yb.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-RAHNMMFH-D8IvcV_B.js +1 -0
  45. package/dist/web/assets/clone-B2hUek6n.js +1 -0
  46. package/dist/web/assets/code-editor-DGRg8stf.js +2 -0
  47. package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-qudEiMCT.js} +1 -1
  48. package/dist/web/assets/csv-preview-DUbHtTAS.js +10 -0
  49. package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-BFcnKyBF.js} +1 -1
  50. package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-C3O-MTLf.js} +1 -1
  51. package/dist/web/assets/database-viewer-DxCXZQcE.js +1 -0
  52. package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-DxPjK7_c.js} +1 -1
  53. package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-sqTog_XV.js} +1 -1
  54. package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO-hzmp0GHK.js} +1 -1
  55. package/dist/web/assets/diff-viewer-C1sDJG35.js +4 -0
  56. package/dist/web/assets/dist-CALwEtco.js +41 -0
  57. package/dist/web/assets/dist-DGDPTxs1.js +13 -0
  58. package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-DLeYhAAT.js} +1 -1
  59. package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-CRxlE9Sr.js} +1 -1
  60. package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-BdjmoMLS.js} +1 -1
  61. package/dist/web/assets/git-graph-BDn-EiGE.js +1 -0
  62. package/dist/web/assets/gitGraph-HDMCJU4V-CNlas3Rz.js +1 -0
  63. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-BeHSX7kk.js} +1 -1
  64. package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-Duh_bWLa.js} +1 -1
  65. package/dist/web/assets/index-Bun94AK3.js +37 -0
  66. package/dist/web/assets/index-Db8uky1a.css +2 -0
  67. package/dist/web/assets/info-3K5VOQVL-BDzTLc11.js +1 -0
  68. package/dist/web/assets/infoDiagram-LFFYTUFH-ZZmpgc6t.js +2 -0
  69. package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-B9L-Ge-H.js} +1 -1
  70. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-Cu0Rt1Ok.js} +1 -1
  71. package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-CgDI-UG4.js} +1 -1
  72. package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-h4g10UHL.js} +1 -1
  73. package/dist/web/assets/keybindings-store-COmK4Dte.js +1 -0
  74. package/dist/web/assets/lib-BeaDXEkP.js +4 -0
  75. package/dist/web/assets/{line-DBLLF7lH.js → line-B75-Rx70.js} +1 -1
  76. package/dist/web/assets/{linear-BLFWatDe.js → linear-Bcjv9FQt.js} +1 -1
  77. package/dist/web/assets/markdown-renderer-VIZB1GXE.js +69 -0
  78. package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-8u2leTXI.js} +2 -2
  79. package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-BaOBwb-W.js} +1 -1
  80. package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-LFEjVtwQ.js} +1 -1
  81. package/dist/web/assets/packet-RMMSAZCW-IVa5F-go.js +1 -0
  82. package/dist/web/assets/pie-UPGHQEXC-CvXHKAzp.js +1 -0
  83. package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-At5Kz0KK.js} +1 -1
  84. package/dist/web/assets/postgres-viewer-CvQZ8gkh.js +1 -0
  85. package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-CdjGIDfw.js} +1 -1
  86. package/dist/web/assets/radar-KQ55EAFF-Z-Tr5wtS.js +1 -0
  87. package/dist/web/assets/react-dom-Bpkvzu3U.js +1 -0
  88. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-B9F_Cx_p.js} +1 -1
  89. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-RolPi8bU.js} +1 -1
  90. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-DM-tMAhx.js} +1 -1
  91. package/dist/web/assets/settings-tab-RCnvZ29H.js +1 -0
  92. package/dist/web/assets/sqlite-viewer-CEEm2W4C.js +1 -0
  93. package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-C4EMl6jf.js} +1 -1
  94. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-B-UjZch3.js +1 -0
  95. package/dist/web/assets/{tab-store-DcIBZTD4.js → tab-store-Bjh6bXFP.js} +1 -1
  96. package/dist/web/assets/{terminal-tab-CAZtLK6i.js → terminal-tab-XhKfb4ei.js} +2 -2
  97. package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-A4PN_Efm.js} +1 -1
  98. package/dist/web/assets/treemap-KZPCXAKY-C9TYRE0k.js +1 -0
  99. package/dist/web/assets/use-monaco-theme-0p0-84jJ.js +11 -0
  100. package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-ywK7LMaH.js} +1 -1
  101. package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-DylHYNtJ.js} +1 -1
  102. package/dist/web/index.html +11 -10
  103. package/dist/web/sw.js +1 -1
  104. package/docs/codebase-summary.md +17 -5
  105. package/docs/design-guidelines.md +21 -0
  106. package/docs/project-changelog.md +28 -1
  107. package/docs/project-roadmap.md +2 -2
  108. package/docs/system-architecture.md +151 -0
  109. package/package.json +2 -1
  110. package/src/providers/claude-agent-sdk.ts +32 -10
  111. package/src/server/index.ts +6 -0
  112. package/src/server/routes/chat.ts +4 -2
  113. package/src/server/routes/mcp.ts +84 -0
  114. package/src/server/ws/chat.ts +18 -12
  115. package/src/services/account-selector.service.ts +8 -2
  116. package/src/services/account.service.ts +13 -8
  117. package/src/services/claude-usage.service.ts +37 -18
  118. package/src/services/cloud.service.ts +10 -6
  119. package/src/services/db.service.ts +53 -6
  120. package/src/services/mcp-config.service.ts +102 -0
  121. package/src/services/supervisor.ts +12 -2
  122. package/src/types/mcp.ts +47 -0
  123. package/src/web/components/editor/code-editor.tsx +36 -26
  124. package/src/web/components/editor/csv-preview.tsx +228 -0
  125. package/src/web/components/editor/editor-breadcrumb.tsx +216 -0
  126. package/src/web/components/editor/editor-toolbar.tsx +74 -0
  127. package/src/web/components/settings/mcp-server-dialog.tsx +208 -0
  128. package/src/web/components/settings/mcp-settings-section.tsx +143 -0
  129. package/src/web/components/settings/settings-tab.tsx +5 -2
  130. package/src/web/lib/api-mcp.ts +38 -0
  131. package/src/web/lib/csv-parser.ts +134 -0
  132. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
  133. package/dist/web/assets/channel-w7yboq56.js +0 -1
  134. package/dist/web/assets/chat-tab-WEBXxGgN.js +0 -7
  135. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
  136. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
  137. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
  138. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
  139. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
  140. package/dist/web/assets/clone-BSi6cgDh.js +0 -1
  141. package/dist/web/assets/code-editor-B5sg_uJQ.js +0 -1
  142. package/dist/web/assets/database-viewer-CwtyWCkE.js +0 -1
  143. package/dist/web/assets/diff-viewer-CzE5M-Wd.js +0 -4
  144. package/dist/web/assets/dist-T0Vhi0Mh.js +0 -16
  145. package/dist/web/assets/git-graph-6yxCeeN9.js +0 -1
  146. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
  147. package/dist/web/assets/index-DE8b9u8F.css +0 -2
  148. package/dist/web/assets/index-wuWZBO9y.js +0 -37
  149. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
  150. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
  151. package/dist/web/assets/input-Brjz2Vv-.js +0 -41
  152. package/dist/web/assets/keybindings-store-mkBHnWN1.js +0 -1
  153. package/dist/web/assets/markdown-renderer-CxWxvrzT.js +0 -69
  154. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
  155. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
  156. package/dist/web/assets/postgres-viewer-UP3yv9Yh.js +0 -1
  157. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
  158. package/dist/web/assets/settings-store-Bbhg_ptG.js +0 -2
  159. package/dist/web/assets/settings-tab-BoBXlVHe.js +0 -1
  160. package/dist/web/assets/sqlite-viewer-lzRVvM5j.js +0 -1
  161. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
  162. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
  163. package/dist/web/assets/use-monaco-theme-vwto-Vlf.js +0 -11
  164. /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-BKIT_Qeg.js} +0 -0
  165. /package/dist/web/assets/{array-BGFCBI0e.js → array-DqLCdDFv.js} +0 -0
  166. /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-DbesTfa7.js} +0 -0
  167. /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-CWPXKqbJ.js} +0 -0
  168. /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-CrJzLgRD.js} +0 -0
  169. /package/dist/web/assets/{dist-Cce3efmT.js → dist-Cep75xXf.js} +0 -0
  170. /package/dist/web/assets/{init-B8gtcn7T.js → init-C0r9Gk5G.js} +0 -0
  171. /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-CGBoxvCD.js} +0 -0
  172. /package/dist/web/assets/{katex-Bbu770d9.js → katex-DzXRfQ_m.js} +0 -0
  173. /package/dist/web/assets/{math-DwgHI-Cu.js → math-y9zN1W-N.js} +0 -0
  174. /package/dist/web/assets/{path-DZF-JdEe.js → path-DIKpVbHL.js} +0 -0
  175. /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-Bf_JiD2A.js} +0 -0
  176. /package/dist/web/assets/{react-BGf7KNLk.js → react-SKk5z-bm.js} +0 -0
  177. /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-nHaDi0Kw.js} +0 -0
  178. /package/dist/web/assets/{src-BoSBNdA_.js → src-Dw4QhedI.js} +0 -0
  179. /package/dist/web/assets/{table-Yo02WRH-.js → table-CQVQM2SB.js} +0 -0
  180. /package/dist/web/assets/{tag-CaC1ng2E.js → tag-Q2dZiSPX.js} +0 -0
  181. /package/dist/web/assets/{utils-btZ8C8-R.js → utils-DMiycH3O.js} +0 -0
@@ -945,6 +945,157 @@ ppm db data <name> <table> # Show table data (paginated)
945
945
 
946
946
  ---
947
947
 
948
+ ## MCP Server Management
949
+
950
+ ### Overview
951
+ MCP (Model Context Protocol) servers extend Claude with custom tools and resources. PPM manages MCP server configurations via Settings UI, storing them in SQLite and passing them to the Claude Agent SDK.
952
+
953
+ **Features:**
954
+ - **Add/Edit/Delete** MCP servers via Settings UI
955
+ - **Auto-import** from `~/.claude.json` on first access (convenience, no forced import)
956
+ - **Three transport types:** stdio, HTTP, SSE
957
+ - **Validation** on name and config before storage
958
+ - **SDK integration:** Servers passed to `query()` as `mcpServers` object, tools auto-allowed via `mcp__*` wildcard
959
+
960
+ ### Storage Schema
961
+
962
+ ```sql
963
+ CREATE TABLE mcp_servers (
964
+ name TEXT PRIMARY KEY,
965
+ transport TEXT NOT NULL DEFAULT 'stdio', -- 'stdio' | 'http' | 'sse'
966
+ config TEXT NOT NULL, -- JSON: McpServerConfig
967
+ created_at TEXT DEFAULT (datetime('now')),
968
+ updated_at TEXT DEFAULT (datetime('now'))
969
+ );
970
+ ```
971
+
972
+ **Config Format (JSON):**
973
+ ```json
974
+ {
975
+ "type": "stdio",
976
+ "command": "path/to/server",
977
+ "args": ["--flag"],
978
+ "env": { "VAR": "value" }
979
+ }
980
+ ```
981
+
982
+ Or HTTP/SSE:
983
+ ```json
984
+ {
985
+ "type": "http",
986
+ "url": "http://localhost:3000",
987
+ "headers": { "Authorization": "Bearer token" }
988
+ }
989
+ ```
990
+
991
+ ### REST API
992
+
993
+ **Endpoints** (`src/server/routes/mcp.ts`):
994
+
995
+ | Method | Endpoint | Description |
996
+ |--------|----------|-------------|
997
+ | **GET** | `/api/settings/mcp` | List all servers; auto-import on first access |
998
+ | **GET** | `/api/settings/mcp/:name` | Get single server config |
999
+ | **POST** | `/api/settings/mcp` | Add new server (validates name + config) |
1000
+ | **PUT** | `/api/settings/mcp/:name` | Update existing server |
1001
+ | **DELETE** | `/api/settings/mcp/:name` | Remove server |
1002
+ | **GET** | `/api/settings/mcp/import/preview` | Preview servers in `~/.claude.json` |
1003
+ | **POST** | `/api/settings/mcp/import` | Bulk import from `~/.claude.json` |
1004
+
1005
+ **Add Server Example:**
1006
+ ```bash
1007
+ POST /api/settings/mcp
1008
+ Content-Type: application/json
1009
+
1010
+ {
1011
+ "name": "file-server",
1012
+ "config": {
1013
+ "type": "stdio",
1014
+ "command": "/usr/local/bin/file-server",
1015
+ "args": ["--port", "8000"]
1016
+ }
1017
+ }
1018
+ ```
1019
+
1020
+ ### Service Layer
1021
+
1022
+ **McpConfigService** (`src/services/mcp-config.service.ts`):
1023
+ - `list()` — Record<name, McpServerConfig> (SDK-compatible format)
1024
+ - `listWithMeta()` — Array with metadata (for UI)
1025
+ - `get(name)` — Single server config
1026
+ - `set(name, config)` — Add or update (upsert)
1027
+ - `remove(name)` — Delete server
1028
+ - `exists(name)` — Check if name exists
1029
+ - `bulkImport(servers)` — Transactional import from `~/.claude.json`, skips existing/invalid
1030
+
1031
+ **Validation:**
1032
+ - `validateMcpName(name)` — alphanumeric + hyphens/underscores, max 50 chars
1033
+ - `validateMcpConfig(config)` — type-specific checks (command for stdio, url for http/sse)
1034
+
1035
+ ### Frontend Integration
1036
+
1037
+ **UI Components:**
1038
+ - `MCP Settings Section` (`src/web/components/settings/mcp-settings-section.tsx`) — Tab in Settings UI
1039
+ - `MCP Server Dialog` (`src/web/components/settings/mcp-server-dialog.tsx`) — Add/Edit modal
1040
+ - `API client` (`src/web/lib/api-mcp.ts`) — Fetch/mutate operations
1041
+
1042
+ **Workflow:**
1043
+ 1. User opens Settings → MCP tab
1044
+ 2. **GET** `/api/settings/mcp` (auto-imports on first access)
1045
+ 3. Display list with transport badge + actions (edit, delete)
1046
+ 4. Click "Add" → Dialog with name + transport selector + config fields
1047
+ 5. **POST** to `/api/settings/mcp` or **PUT** to update
1048
+ 6. On success, list refreshes
1049
+
1050
+ ### SDK Integration
1051
+
1052
+ **Claude Agent SDK Provider** (`src/providers/claude-agent-sdk.ts`):
1053
+ ```typescript
1054
+ // Line ~574
1055
+ const mcpServers = mcpConfigService.list();
1056
+ const hasMcp = Object.keys(mcpServers).length > 0;
1057
+
1058
+ // Line ~589: Pass to query() if servers exist
1059
+ const mcpTools = ["mcp__*"];
1060
+ const queryConfig = {
1061
+ // ... other options
1062
+ ...(hasMcp && { mcpServers }),
1063
+ allowedTools: [...otherTools, ...mcpTools],
1064
+ };
1065
+
1066
+ const query = new Query(messages, queryConfig);
1067
+ ```
1068
+
1069
+ **Tool Allow List:**
1070
+ - All MCP tools automatically allowed via wildcard `mcp__*`
1071
+ - MCP server connection failures don't block chat (logged as warning)
1072
+
1073
+ ### Import Flow
1074
+
1075
+ **Auto-import on first access:**
1076
+ 1. GET `/api/settings/mcp` called
1077
+ 2. If table is empty, read `~/.claude.json`
1078
+ 3. If `mcpServers` key exists, bulk import (validate + skip duplicates)
1079
+ 4. Return populated list
1080
+
1081
+ **Manual import:**
1082
+ 1. GET `/api/settings/mcp/import/preview` — show what's available
1083
+ 2. POST `/api/settings/mcp/import` — import validated servers
1084
+ 3. Returns `{ imported: N, skipped: M }`
1085
+
1086
+ ### Error Handling
1087
+
1088
+ | Scenario | Response |
1089
+ |----------|----------|
1090
+ | Invalid name (non-alphanumeric) | 400 Bad Request |
1091
+ | Invalid config (missing required fields) | 400 Bad Request |
1092
+ | Duplicate name | 409 Conflict |
1093
+ | Server not found (GET/:name, PUT/:name, DELETE/:name) | 404 Not Found |
1094
+ | `~/.claude.json` not found (import) | 404 Not Found |
1095
+ | Corrupt config JSON (recovery) | Log warning, skip entry, continue |
1096
+
1097
+ ---
1098
+
948
1099
  ## Deployment Architecture
949
1100
 
950
1101
  ### Single-Machine Deployment (Current)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.0-beta.7",
3
+ "version": "0.9.0-beta.9",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -45,6 +45,7 @@
45
45
  "@radix-ui/react-switch": "^1.2.6",
46
46
  "@skitee3000/bun-pty": "^0.3.3",
47
47
  "@tanstack/react-table": "^8.21.3",
48
+ "@tanstack/react-virtual": "^3.13.23",
48
49
  "@uiw/react-codemirror": "^4.25.8",
49
50
  "@xterm/addon-fit": "^0.11.0",
50
51
  "@xterm/addon-web-links": "^0.12.0",
@@ -13,8 +13,9 @@ import type {
13
13
  ModelOption,
14
14
  } from "./provider.interface.ts";
15
15
  import { configService } from "../services/config.service.ts";
16
+ import { mcpConfigService } from "../services/mcp-config.service.ts";
16
17
  import { updateFromSdkEvent } from "../services/claude-usage.service.ts";
17
- import { getSessionMapping, setSessionMapping } from "../services/db.service.ts";
18
+ import { getSessionMapping, setSessionMapping, getSessionTitles } from "../services/db.service.ts";
18
19
  import { accountSelector } from "../services/account-selector.service.ts";
19
20
  import { accountService } from "../services/account.service.ts";
20
21
  import { resolve } from "node:path";
@@ -281,10 +282,13 @@ export class ClaudeAgentSdkProvider implements AIProvider {
281
282
  async listSessionsByDir(dir?: string): Promise<SessionInfo[]> {
282
283
  try {
283
284
  const sdkSessions = await sdkListSessions({ dir, limit: 50 });
285
+ // Overlay DB titles (user-set) over SDK titles
286
+ const ids = sdkSessions.map((s) => s.sessionId);
287
+ const dbTitles = getSessionTitles(ids);
284
288
  return sdkSessions.map((s) => ({
285
289
  id: s.sessionId,
286
290
  providerId: this.id,
287
- title: s.customTitle ?? s.summary ?? s.firstPrompt ?? "Chat",
291
+ title: dbTitles[s.sessionId] ?? s.customTitle ?? s.summary ?? s.firstPrompt ?? "Chat",
288
292
  createdAt: new Date(s.lastModified).toISOString(),
289
293
  updatedAt: new Date(s.lastModified).toISOString(),
290
294
  }));
@@ -430,9 +434,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
430
434
  // go through the permission evaluation chain → canUseTool callback.
431
435
  const readOnlyTools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch", "ToolSearch"];
432
436
  const writeTools = ["Write", "Edit", "Bash", "Agent", "Skill", "TodoWrite", "AskUserQuestion"];
437
+ const mcpTools = ["mcp__*"];
433
438
  const allowedTools = isBypass
434
- ? [...readOnlyTools, ...writeTools]
435
- : readOnlyTools;
439
+ ? [...readOnlyTools, ...writeTools, ...mcpTools]
440
+ : [...readOnlyTools, ...mcpTools];
436
441
 
437
442
  /**
438
443
  * Approval events to yield from the generator.
@@ -568,6 +573,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
568
573
  }
569
574
  console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform} accountMode=${!!account} permissionMode=${permissionMode} isBypass=${isBypass}`);
570
575
 
576
+ // Read MCP servers from PPM DB (fresh per query — user may add/remove between chats)
577
+ const mcpServers = mcpConfigService.list();
578
+ const hasMcp = Object.keys(mcpServers).length > 0;
579
+
571
580
  const queryOptions: Record<string, any> = {
572
581
  // On Windows, child_process.spawn("bun") fails with ENOENT — force node
573
582
  ...(process.platform === "win32" && { executable: "node" }),
@@ -580,6 +589,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
580
589
  env: queryEnv,
581
590
  settings: { permissions: { allow: [], deny: [] } },
582
591
  allowedTools,
592
+ ...(hasMcp && { mcpServers }),
583
593
  permissionMode,
584
594
  allowDangerouslySkipPermissions: isBypass,
585
595
  ...(providerConfig.model && { model: providerConfig.model }),
@@ -925,7 +935,16 @@ export class ClaudeAgentSdkProvider implements AIProvider {
925
935
  }
926
936
 
927
937
  // Surface non-success subtypes as errors so FE can display them
938
+ // But suppress abort errors — user-initiated cancel is not a real error
928
939
  if (subtype && subtype !== "success") {
940
+ const errorsArr0 = Array.isArray(result.errors) ? result.errors : [];
941
+ const abortDetail = errorsArr0.join(" ") + " " + (typeof result.error === "string" ? result.error : "");
942
+ if (subtype === "error_during_execution" && /abort|request was aborted/i.test(abortDetail)) {
943
+ console.log(`[sdk] session=${sessionId} suppressing abort error (user-initiated cancel)`);
944
+ resultSubtype = subtype;
945
+ resultNumTurns = result.num_turns as number | undefined;
946
+ break;
947
+ }
929
948
  // SDK error results use `errors: string[]` array (not singular `error`)
930
949
  const errorsArr = Array.isArray(result.errors) ? result.errors : [];
931
950
  const sdkDetail = errorsArr.length > 0
@@ -1050,20 +1069,23 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1050
1069
  }
1051
1070
 
1052
1071
 
1053
- /** Interrupt the current turn session stays alive for the next message */
1072
+ /** Abort and fully teardown the streaming session user must resume to continue */
1054
1073
  abortQuery(sessionId: string): void {
1055
1074
  const ss = this.streamingSessions.get(sessionId);
1056
- if (ss && typeof ss.query.interrupt === "function") {
1057
- ss.query.interrupt().catch(() => {});
1058
- console.log(`[sdk] abortQuery: interrupted session=${sessionId}`);
1075
+ if (ss) {
1076
+ // Signal generator to end, then close the query (kills bun subprocess)
1077
+ ss.controller.done();
1078
+ ss.query.close();
1079
+ this.streamingSessions.delete(sessionId);
1080
+ this.activeQueries.delete(sessionId);
1081
+ console.log(`[sdk] abortQuery: closed streaming session=${sessionId}`);
1059
1082
  return;
1060
1083
  }
1061
- // Fallback: close query entirely and clean up streaming session
1084
+ // Non-streaming fallback
1062
1085
  const q = this.activeQueries.get(sessionId);
1063
1086
  if (q) {
1064
1087
  q.close();
1065
1088
  this.activeQueries.delete(sessionId);
1066
- this.streamingSessions.delete(sessionId);
1067
1089
  }
1068
1090
  }
1069
1091
 
@@ -14,6 +14,7 @@ import { databaseRoutes } from "./routes/database.ts";
14
14
  import { fsBrowseRoutes } from "./routes/fs-browse.ts";
15
15
  import { accountsRoutes } from "./routes/accounts.ts";
16
16
  import { proxyRoutes } from "./routes/proxy.ts";
17
+ import { mcpRoutes } from "./routes/mcp.ts";
17
18
  import { browserPreviewRoutes } from "./routes/browser-preview.ts";
18
19
  import { initAdapters } from "../services/database/init-adapters.ts";
19
20
  import { terminalWebSocket } from "./ws/terminal.ts";
@@ -22,6 +23,10 @@ import { ok, err } from "../types/api.ts";
22
23
 
23
24
  /** Tee console.log/error to ~/.ppm/ppm.log while preserving terminal output */
24
25
  async function setupLogFile() {
26
+ // Guard: prevent re-wrapping console on hot-reload (bun --hot re-executes the module)
27
+ if ((globalThis as any).__PPM_LOG_SETUP__) return;
28
+ (globalThis as any).__PPM_LOG_SETUP__ = true;
29
+
25
30
  const { resolve } = await import("node:path");
26
31
  const { homedir } = await import("node:os");
27
32
  const { appendFileSync, mkdirSync, existsSync } = await import("node:fs");
@@ -135,6 +140,7 @@ app.route("/api/fs", fsBrowseRoutes);
135
140
 
136
141
  // API routes
137
142
  app.route("/api/settings", settingsRoutes);
143
+ app.route("/api/settings/mcp", mcpRoutes);
138
144
  app.route("/api/tunnel", tunnelRoutes);
139
145
  app.route("/api/push", pushRoutes);
140
146
  app.route("/api/projects", projectRoutes);
@@ -8,7 +8,7 @@ import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sd
8
8
  import { listSlashItems } from "../../services/slash-items.service.ts";
9
9
  import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
10
10
  import { getSessionLog } from "../../services/session-log.service.ts";
11
- import { getSessionMapping } from "../../services/db.service.ts";
11
+ import { getSessionMapping, setSessionTitle } from "../../services/db.service.ts";
12
12
  import { ok, err } from "../../types/api.ts";
13
13
 
14
14
  type Env = { Variables: { projectPath: string; projectName: string } };
@@ -133,7 +133,9 @@ chatRoutes.patch("/sessions/:id", async (c) => {
133
133
  // Resolve PPM UUID → SDK session ID if mapped
134
134
  const sdkId = getSessionMapping(id) ?? id;
135
135
  const projectPath = c.get("projectPath");
136
- // Persist to SDK so Claude Code CLI also sees the custom title
136
+ // Persist to PPM DB (authoritative source for user-set titles)
137
+ setSessionTitle(sdkId, title);
138
+ // Also persist to SDK so Claude Code CLI sees the custom title
137
139
  await sdkRenameSession(sdkId, title, { dir: projectPath });
138
140
  // Also update in-memory session
139
141
  const session = chatService.getSession(id);
@@ -0,0 +1,84 @@
1
+ import { Hono } from "hono";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { mcpConfigService } from "../../services/mcp-config.service";
6
+ import { validateMcpName, validateMcpConfig, type McpServerConfig } from "../../types/mcp";
7
+ import { ok, err } from "../../types/api";
8
+
9
+ export const mcpRoutes = new Hono();
10
+
11
+ const CLAUDE_CONFIG = join(homedir(), ".claude.json");
12
+
13
+ function readClaudeMcpServers(): Record<string, unknown> | null {
14
+ if (!existsSync(CLAUDE_CONFIG)) return null;
15
+ try {
16
+ const data = JSON.parse(readFileSync(CLAUDE_CONFIG, "utf-8"));
17
+ return data.mcpServers ?? null;
18
+ } catch { return null; }
19
+ }
20
+
21
+ // GET / — list all (auto-imports from ~/.claude.json on first access if table empty)
22
+ mcpRoutes.get("/", (c) => {
23
+ let servers = mcpConfigService.listWithMeta();
24
+ if (servers.length === 0) {
25
+ const claudeServers = readClaudeMcpServers();
26
+ if (claudeServers && Object.keys(claudeServers).length > 0) {
27
+ mcpConfigService.bulkImport(claudeServers as Record<string, McpServerConfig>);
28
+ servers = mcpConfigService.listWithMeta();
29
+ }
30
+ }
31
+ return c.json(ok(servers));
32
+ });
33
+
34
+ // GET /import/preview — show what would be imported
35
+ mcpRoutes.get("/import/preview", (c) => {
36
+ const servers = readClaudeMcpServers();
37
+ if (!servers) return c.json(ok({ available: false, servers: {} }));
38
+ return c.json(ok({ available: true, servers }));
39
+ });
40
+
41
+ // POST /import — import from ~/.claude.json
42
+ mcpRoutes.post("/import", (c) => {
43
+ const servers = readClaudeMcpServers();
44
+ if (!servers) return c.json(err("~/.claude.json not found or has no mcpServers"), 404);
45
+ const result = mcpConfigService.bulkImport(servers as Record<string, McpServerConfig>);
46
+ return c.json(ok(result));
47
+ });
48
+
49
+ // GET /:name — single server
50
+ mcpRoutes.get("/:name", (c) => {
51
+ const config = mcpConfigService.get(c.req.param("name"));
52
+ if (!config) return c.json(err("Server not found"), 404);
53
+ return c.json(ok(config));
54
+ });
55
+
56
+ // POST / — add new server
57
+ mcpRoutes.post("/", async (c) => {
58
+ const { name, config } = await c.req.json();
59
+ const nameErr = validateMcpName(name);
60
+ if (nameErr) return c.json(err(nameErr), 400);
61
+ const configErrs = validateMcpConfig(config);
62
+ if (configErrs.length) return c.json(err(configErrs.join("; ")), 400);
63
+ if (mcpConfigService.exists(name)) return c.json(err("Server already exists"), 409);
64
+ mcpConfigService.set(name, config);
65
+ return c.json(ok({ name }), 201);
66
+ });
67
+
68
+ // PUT /:name — update server config
69
+ mcpRoutes.put("/:name", async (c) => {
70
+ const name = c.req.param("name");
71
+ if (!mcpConfigService.exists(name)) return c.json(err("Server not found"), 404);
72
+ const config = await c.req.json();
73
+ const configErrs = validateMcpConfig(config);
74
+ if (configErrs.length) return c.json(err(configErrs.join("; ")), 400);
75
+ mcpConfigService.set(name, config);
76
+ return c.json(ok({ name }));
77
+ });
78
+
79
+ // DELETE /:name — remove server
80
+ mcpRoutes.delete("/:name", (c) => {
81
+ const removed = mcpConfigService.remove(c.req.param("name"));
82
+ if (!removed) return c.json(err("Server not found"), 404);
83
+ return c.json(ok(true));
84
+ });
@@ -3,6 +3,7 @@ import { providerRegistry } from "../../providers/registry.ts";
3
3
  import { resolveProjectPath } from "../helpers/resolve-project.ts";
4
4
  import { logSessionEvent } from "../../services/session-log.service.ts";
5
5
  import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
6
+ import { getSessionTitle } from "../../services/db.service.ts";
6
7
  import type { ChatWsClientMessage, SessionPhase } from "../../types/api.ts";
7
8
 
8
9
  const PING_INTERVAL_MS = 15_000; // 15s keepalive
@@ -118,11 +119,12 @@ function clearClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
118
119
  }
119
120
  }
120
121
 
121
- /** Start cleanup timer — only called when Claude is done AND no FE connected */
122
- function startCleanupTimer(sessionId: string): void {
122
+ /** Start cleanup timer — called when no FE connected. Urgent mode (30s) for orphaned streaming sessions. */
123
+ function startCleanupTimer(sessionId: string, urgent = false): void {
123
124
  const entry = activeSessions.get(sessionId);
124
125
  if (!entry) return;
125
126
  if (entry.cleanupTimer) clearTimeout(entry.cleanupTimer);
127
+ const delay = urgent ? 30_000 : CLEANUP_TIMEOUT_MS;
126
128
  entry.cleanupTimer = setTimeout(() => {
127
129
  console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
128
130
  logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
@@ -134,7 +136,7 @@ function startCleanupTimer(sessionId: string): void {
134
136
  for (const interval of entry.pingIntervals.values()) clearInterval(interval);
135
137
  entry.pingIntervals.clear();
136
138
  activeSessions.delete(sessionId);
137
- }, CLEANUP_TIMEOUT_MS);
139
+ }, delay);
138
140
  }
139
141
 
140
142
  /**
@@ -259,10 +261,11 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
259
261
  logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
260
262
  if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
261
263
 
262
- // Fire-and-forget: title + notification
264
+ // Fire-and-forget: fetch updated session title (DB title takes priority) + notification
263
265
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
264
266
  const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
265
- const title = found?.customTitle ?? found?.summary;
267
+ const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
268
+ const title = dbTitle ?? found?.customTitle ?? found?.summary;
266
269
  if (title) {
267
270
  broadcast(sessionId, { type: "title_updated", title });
268
271
  const session = chatService.getSession(sessionId);
@@ -386,11 +389,12 @@ export const chatWebSocket = {
386
389
  existing.clients.add(ws);
387
390
  setupClientPing(existing, ws);
388
391
 
389
- // Async: resolve title from SDK if in-memory title is generic
392
+ // Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
390
393
  if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
391
394
  sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
392
395
  const found = sessions.find((s) => s.sessionId === sessionId);
393
- const title = found?.customTitle ?? found?.summary;
396
+ const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
397
+ const title = dbTitle ?? found?.customTitle ?? found?.summary;
394
398
  if (title) {
395
399
  broadcast(sessionId, { type: "title_updated", title });
396
400
  if (session) session.title = title;
@@ -423,11 +427,12 @@ export const chatWebSocket = {
423
427
  sessionTitle: session?.title || null,
424
428
  }));
425
429
 
426
- // Async: resolve title from SDK if in-memory title is generic
430
+ // Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
427
431
  if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
428
432
  sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
429
433
  const found = sessions.find((s) => s.sessionId === sessionId);
430
- const title = found?.customTitle ?? found?.summary;
434
+ const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
435
+ const title = dbTitle ?? found?.customTitle ?? found?.summary;
431
436
  if (title) {
432
437
  broadcast(sessionId, { type: "title_updated", title });
433
438
  if (session) session.title = title;
@@ -564,7 +569,7 @@ export const chatWebSocket = {
564
569
  console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
565
570
  }
566
571
  } else if (parsed.type === "cancel") {
567
- // Interrupt current turnsession stays alive for next message
572
+ // Fully teardown streaming session user must resume to continue
568
573
  const provider = providerRegistry.get(providerId);
569
574
  provider?.abortQuery?.(sessionId);
570
575
  } else if (parsed.type === "approval_response") {
@@ -589,8 +594,9 @@ export const chatWebSocket = {
589
594
  evictClient(entry, ws);
590
595
  console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
591
596
 
592
- if (entry.clients.size === 0 && !entry.isStreamingActive) {
593
- startCleanupTimer(sessionId);
597
+ if (entry.clients.size === 0) {
598
+ // Use shorter timeout if streaming is still active (orphaned session — no FE to consume events)
599
+ startCleanupTimer(sessionId, entry.isStreamingActive);
594
600
  }
595
601
  },
596
602
  };
@@ -57,8 +57,14 @@ class AccountSelectorService {
57
57
  // Clear expired cooldowns
58
58
  for (const acc of allAccounts) {
59
59
  if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
60
- accountService.setEnabled(acc.id);
61
- this.retryCounts.delete(acc.id);
60
+ try {
61
+ accountService.setEnabled(acc.id);
62
+ this.retryCounts.delete(acc.id);
63
+ } catch {
64
+ // Account expired or cannot be re-enabled — disable it
65
+ accountService.setDisabled(acc.id);
66
+ this.retryCounts.delete(acc.id);
67
+ }
62
68
  }
63
69
  }
64
70
 
@@ -69,9 +69,14 @@ const OAUTH_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
69
69
  const OAUTH_SCOPE = "org:create_api_key user:profile user:inference";
70
70
  const OAUTH_PLATFORM_REDIRECT = "https://platform.claude.com/oauth/code/callback";
71
71
 
72
+ // Survive Bun --hot reloads: persist timer ref across module re-evaluations
73
+ const ACCT_HOT_KEY = "__PPM_ACCT_REFRESH__" as const;
74
+ const acctHotState = ((globalThis as any)[ACCT_HOT_KEY] ??= {
75
+ refreshTimer: null as ReturnType<typeof setInterval> | null,
76
+ }) as { refreshTimer: ReturnType<typeof setInterval> | null };
77
+
72
78
  class AccountService {
73
79
  private pendingStates = new Map<string, { verifier: string; createdAt: number }>();
74
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
75
80
 
76
81
  private toAccount(row: AccountRow): Account {
77
82
  let profileData: OAuthProfileData | null = null;
@@ -689,7 +694,7 @@ class AccountService {
689
694
  // ---------------------------------------------------------------------------
690
695
 
691
696
  startAutoRefresh(): void {
692
- if (this.refreshTimer) return;
697
+ if (acctHotState.refreshTimer) return;
693
698
  const CHECK_INTERVAL_MS = 5 * 60_000;
694
699
  const REFRESH_BUFFER_S = 5 * 60;
695
700
 
@@ -729,20 +734,20 @@ class AccountService {
729
734
  // Run immediately on startup, then every 5 minutes
730
735
  refreshExpiring().catch(() => {});
731
736
  cleanupExpiredTemporary();
732
- this.refreshTimer = setInterval(() => {
737
+ acctHotState.refreshTimer = setInterval(() => {
733
738
  refreshExpiring().catch(() => {});
734
739
  cleanupExpiredTemporary();
735
740
  }, CHECK_INTERVAL_MS);
736
741
 
737
- if (typeof this.refreshTimer === "object" && this.refreshTimer !== null && "unref" in this.refreshTimer) {
738
- (this.refreshTimer as NodeJS.Timeout).unref();
742
+ if (typeof acctHotState.refreshTimer === "object" && acctHotState.refreshTimer !== null && "unref" in acctHotState.refreshTimer) {
743
+ (acctHotState.refreshTimer as NodeJS.Timeout).unref();
739
744
  }
740
745
  }
741
746
 
742
747
  stopAutoRefresh(): void {
743
- if (this.refreshTimer) {
744
- clearInterval(this.refreshTimer);
745
- this.refreshTimer = null;
748
+ if (acctHotState.refreshTimer) {
749
+ clearInterval(acctHotState.refreshTimer);
750
+ acctHotState.refreshTimer = null;
746
751
  }
747
752
  }
748
753
  }