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

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 +15 -0
  2. package/bun.lock +5 -0
  3. package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-5PGDJbfF.js} +1 -1
  4. package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-BT4Ow4Kk.js} +1 -1
  5. package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-D21InCnR.js} +1 -1
  6. package/dist/web/assets/{arc-C2Qaz-ch.js → arc-BAOivWpI.js} +1 -1
  7. package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +1 -0
  8. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-Z-4eN4za.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-BCLqzhuZ.js} +1 -1
  11. package/dist/web/assets/browser-tab-BEe89aSD.js +1 -0
  12. package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW-0Vp0Jeas.js} +1 -1
  13. package/dist/web/assets/channel-By7bn0Yq.js +1 -0
  14. package/dist/web/assets/chat-tab-9lqvWozA.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-D4tOov49.js} +1 -1
  17. package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-DJ6BynZ4.js} +1 -1
  18. package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CiyUJxNI.js} +1 -1
  19. package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-Dv-4cAYn.js} +2 -2
  20. package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-D21mS_6G.js} +1 -1
  21. package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-DzqmU2Z7.js} +1 -1
  22. package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-DXncblvW.js} +1 -1
  23. package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-D-pKjlVd.js} +1 -1
  24. package/dist/web/assets/chunk-GLR3WWYH-DKikpoJM.js +2 -0
  25. package/dist/web/assets/chunk-HHEYEP7N-C7vxA5i9.js +1 -0
  26. package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-99JzIdPr.js} +1 -1
  27. package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-CRq1OBZv.js} +1 -1
  28. package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-Bb0MCaIO.js} +1 -1
  29. package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-C7qGJrfV.js} +1 -1
  30. package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-BpS_PtKp.js} +1 -1
  31. package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-z_blpjxi.js} +1 -1
  32. package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-nDhi_cVu.js} +1 -1
  33. package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-BXhYx3nO.js} +1 -1
  34. package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-TF58UVMU.js} +1 -1
  35. package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-ek7k4QVB.js} +1 -1
  36. package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +1 -0
  37. package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-CFwSJijQ.js} +1 -1
  38. package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-ByUrSRin.js} +1 -1
  39. package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-Djlmrely.js} +1 -1
  40. package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-BPQQBakK.js} +1 -1
  41. package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-DxAOx4hG.js} +1 -1
  42. package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-rQG3QH5s.js} +1 -1
  43. package/dist/web/assets/classDiagram-VBA2DB6C-BA8Nj-_C.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-RAHNMMFH-DjYu-6mn.js +1 -0
  45. package/dist/web/assets/clone-LRxlvnMj.js +1 -0
  46. package/dist/web/assets/code-editor-COAIZx-B.js +2 -0
  47. package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-B_AWZsOP.js} +1 -1
  48. package/dist/web/assets/csv-preview-DLqYtXxt.js +10 -0
  49. package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-DHq9bhnd.js} +1 -1
  50. package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-BdJr7Byp.js} +1 -1
  51. package/dist/web/assets/database-viewer-aRR9n_Ui.js +1 -0
  52. package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-_db4pBVA.js} +1 -1
  53. package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-xKoeuiJx.js} +1 -1
  54. package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO-C8tjJsev.js} +1 -1
  55. package/dist/web/assets/diff-viewer-C4KMvpHr.js +4 -0
  56. package/dist/web/assets/dist-CALwEtco.js +41 -0
  57. package/dist/web/assets/dist-CVTST7Gc.js +1 -0
  58. package/dist/web/assets/dist-DGDPTxs1.js +13 -0
  59. package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-BSh2z9Df.js} +1 -1
  60. package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-oYaovqyp.js} +1 -1
  61. package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-DmL26q2P.js} +1 -1
  62. package/dist/web/assets/git-graph-CfJjl4E3.js +1 -0
  63. package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +1 -0
  64. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-CMoukSrY.js} +1 -1
  65. package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-BcsNnGcW.js} +1 -1
  66. package/dist/web/assets/index-Db8uky1a.css +2 -0
  67. package/dist/web/assets/index-DxZuwBDe.js +37 -0
  68. package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +1 -0
  69. package/dist/web/assets/infoDiagram-LFFYTUFH-DWwumDkq.js +2 -0
  70. package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-bnrF3Qbc.js} +1 -1
  71. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-D05_LyL7.js} +1 -1
  72. package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-B_L20qMe.js} +1 -1
  73. package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-CZ535BbZ.js} +1 -1
  74. package/dist/web/assets/keybindings-store-_uWVCZMv.js +1 -0
  75. package/dist/web/assets/lib-BQ34Db2e.js +4 -0
  76. package/dist/web/assets/{line-DBLLF7lH.js → line-CVvo3dRu.js} +1 -1
  77. package/dist/web/assets/{linear-BLFWatDe.js → linear-DP4mkX3m.js} +1 -1
  78. package/dist/web/assets/markdown-renderer-DklUd_Gv.js +69 -0
  79. package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-C7UwoIh6.js} +2 -2
  80. package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-x0MTutJp.js} +1 -1
  81. package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-_K3x1fkz.js} +1 -1
  82. package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +1 -0
  83. package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +1 -0
  84. package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-C1Gjrtzy.js} +1 -1
  85. package/dist/web/assets/postgres-viewer-DEAvAyaX.js +1 -0
  86. package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-C8bzJCjQ.js} +1 -1
  87. package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +1 -0
  88. package/dist/web/assets/react-dom-Bpkvzu3U.js +1 -0
  89. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-pQyah6WB.js} +1 -1
  90. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-T6RgG-N8.js} +1 -1
  91. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-BQDJ4CVs.js} +1 -1
  92. package/dist/web/assets/settings-tab-BQedc-No.js +1 -0
  93. package/dist/web/assets/sqlite-viewer-BPA5idzT.js +1 -0
  94. package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-66vhiIuk.js} +1 -1
  95. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-BGVqj_g9.js +1 -0
  96. package/dist/web/assets/{tab-store-DcIBZTD4.js → tab-store-DhK6EpBT.js} +1 -1
  97. package/dist/web/assets/{terminal-tab-CAZtLK6i.js → terminal-tab-CqRuiIFn.js} +2 -2
  98. package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-DwZqB3nn.js} +1 -1
  99. package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +1 -0
  100. package/dist/web/assets/use-monaco-theme-Dcz3aLAE.js +11 -0
  101. package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-s9Z71fz-.js} +1 -1
  102. package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-DRa_TH4B.js} +1 -1
  103. package/dist/web/index.html +12 -10
  104. package/dist/web/sw.js +1 -1
  105. package/docs/codebase-summary.md +17 -5
  106. package/docs/design-guidelines.md +21 -0
  107. package/docs/project-changelog.md +28 -1
  108. package/docs/project-roadmap.md +2 -2
  109. package/docs/system-architecture.md +151 -0
  110. package/package.json +2 -1
  111. package/src/providers/claude-agent-sdk.ts +32 -10
  112. package/src/server/index.ts +6 -0
  113. package/src/server/routes/chat.ts +4 -2
  114. package/src/server/routes/mcp.ts +84 -0
  115. package/src/server/ws/chat.ts +18 -12
  116. package/src/services/account-selector.service.ts +8 -2
  117. package/src/services/claude-usage.service.ts +24 -10
  118. package/src/services/db.service.ts +53 -6
  119. package/src/services/mcp-config.service.ts +102 -0
  120. package/src/services/supervisor.ts +12 -2
  121. package/src/types/mcp.ts +47 -0
  122. package/src/web/components/editor/code-editor.tsx +36 -26
  123. package/src/web/components/editor/csv-preview.tsx +228 -0
  124. package/src/web/components/editor/editor-breadcrumb.tsx +225 -0
  125. package/src/web/components/editor/editor-toolbar.tsx +74 -0
  126. package/src/web/components/settings/mcp-server-dialog.tsx +208 -0
  127. package/src/web/components/settings/mcp-settings-section.tsx +143 -0
  128. package/src/web/components/settings/settings-tab.tsx +5 -2
  129. package/src/web/lib/api-mcp.ts +38 -0
  130. package/src/web/lib/csv-parser.ts +134 -0
  131. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
  132. package/dist/web/assets/browser-tab-DAvH4mv0.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-BfBM3I7n.js} +0 -0
  165. /package/dist/web/assets/{array-BGFCBI0e.js → array-B9UHiPd-.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-BW-DbntU.js} +0 -0
  168. /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-5eAKkKJC.js} +0 -0
  169. /package/dist/web/assets/{dist-Cce3efmT.js → dist-CSJdAyA9.js} +0 -0
  170. /package/dist/web/assets/{init-B8gtcn7T.js → init-DlZdxViB.js} +0 -0
  171. /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-B_v2FtYn.js} +0 -0
  172. /package/dist/web/assets/{katex-Bbu770d9.js → katex-Bqvo_ZG0.js} +0 -0
  173. /package/dist/web/assets/{math-DwgHI-Cu.js → math-069Z4SuC.js} +0 -0
  174. /package/dist/web/assets/{path-DZF-JdEe.js → path-6uRLdFF7.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-JX0wREDd.js} +0 -0
  178. /package/dist/web/assets/{src-BoSBNdA_.js → src-BqX54PbV.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-BNytJOb1.js} +0 -0
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
6
  const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
7
- const CURRENT_SCHEMA_VERSION = 5;
7
+ const CURRENT_SCHEMA_VERSION = 8;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -228,6 +228,26 @@ function runMigrations(database: Database): void {
228
228
  }
229
229
  database.exec(`PRAGMA user_version = 7`);
230
230
  }
231
+
232
+ if (current < 8) {
233
+ database.exec(`
234
+ CREATE TABLE IF NOT EXISTS mcp_servers (
235
+ name TEXT PRIMARY KEY,
236
+ transport TEXT NOT NULL DEFAULT 'stdio',
237
+ config TEXT NOT NULL,
238
+ created_at TEXT DEFAULT (datetime('now')),
239
+ updated_at TEXT DEFAULT (datetime('now'))
240
+ );
241
+
242
+ CREATE TABLE IF NOT EXISTS session_titles (
243
+ session_id TEXT PRIMARY KEY,
244
+ title TEXT NOT NULL,
245
+ updated_at TEXT DEFAULT (datetime('now'))
246
+ );
247
+
248
+ PRAGMA user_version = 8;
249
+ `);
250
+ }
231
251
  }
232
252
 
233
253
  // ---------------------------------------------------------------------------
@@ -311,6 +331,33 @@ export function getAllSessionMappings(): Record<string, string> {
311
331
  return result;
312
332
  }
313
333
 
334
+ // ---------------------------------------------------------------------------
335
+ // Session title helpers (user-set titles persisted in PPM DB)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ export function getSessionTitle(sessionId: string): string | null {
339
+ const row = getDb().query("SELECT title FROM session_titles WHERE session_id = ?").get(sessionId) as { title: string } | null;
340
+ return row?.title ?? null;
341
+ }
342
+
343
+ export function setSessionTitle(sessionId: string, title: string): void {
344
+ getDb().query(
345
+ "INSERT INTO session_titles (session_id, title, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at",
346
+ ).run(sessionId, title);
347
+ }
348
+
349
+ /** Bulk-fetch DB titles for a list of session IDs. Returns map of id → title. */
350
+ export function getSessionTitles(sessionIds: string[]): Record<string, string> {
351
+ if (sessionIds.length === 0) return {};
352
+ const placeholders = sessionIds.map(() => "?").join(", ");
353
+ const rows = getDb().query(
354
+ `SELECT session_id, title FROM session_titles WHERE session_id IN (${placeholders})`,
355
+ ).all(...sessionIds) as { session_id: string; title: string }[];
356
+ const result: Record<string, string> = {};
357
+ for (const r of rows) result[r.session_id] = r.title;
358
+ return result;
359
+ }
360
+
314
361
  // ---------------------------------------------------------------------------
315
362
  // Push subscription helpers
316
363
  // ---------------------------------------------------------------------------
@@ -441,13 +488,13 @@ export function insertLimitSnapshot(data: Omit<LimitSnapshotRow, "id" | "recorde
441
488
 
442
489
  export function getLatestLimitSnapshot(): LimitSnapshotRow | null {
443
490
  return getDb().query(
444
- "SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC LIMIT 1",
491
+ "SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC, id DESC LIMIT 1",
445
492
  ).get() as LimitSnapshotRow | null;
446
493
  }
447
494
 
448
495
  export function getLatestSnapshotForAccount(accountId: string): LimitSnapshotRow | null {
449
496
  return getDb().query(
450
- "SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1",
497
+ "SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1",
451
498
  ).get(accountId) as LimitSnapshotRow | null;
452
499
  }
453
500
 
@@ -455,17 +502,17 @@ export function getAllLatestSnapshots(): LimitSnapshotRow[] {
455
502
  return getDb().query(
456
503
  `SELECT s.* FROM claude_limit_snapshots s
457
504
  INNER JOIN (
458
- SELECT account_id, MAX(recorded_at) as max_recorded
505
+ SELECT account_id, MAX(id) as max_id
459
506
  FROM claude_limit_snapshots WHERE account_id IS NOT NULL
460
507
  GROUP BY account_id
461
- ) latest ON s.account_id = latest.account_id AND s.recorded_at = latest.max_recorded`,
508
+ ) latest ON s.id = latest.max_id`,
462
509
  ).all() as LimitSnapshotRow[];
463
510
  }
464
511
 
465
512
  export function touchSnapshotTimestamp(accountId: string): void {
466
513
  getDb().query(
467
514
  `UPDATE claude_limit_snapshots SET recorded_at = datetime('now')
468
- WHERE id = (SELECT id FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1)`,
515
+ WHERE id = (SELECT id FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1)`,
469
516
  ).run(accountId);
470
517
  }
471
518
 
@@ -0,0 +1,102 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { McpServerConfig, McpTransportType } from "../types/mcp";
3
+ import { validateMcpName, validateMcpConfig } from "../types/mcp";
4
+ import { getDb } from "./db.service";
5
+
6
+ function resolveTransport(config: McpServerConfig): McpTransportType {
7
+ if ("type" in config && config.type) return config.type;
8
+ return "stdio";
9
+ }
10
+
11
+ function safeParse(json: string, label: string): McpServerConfig | null {
12
+ try { return JSON.parse(json); }
13
+ catch { console.warn(`[mcp] Skipping ${label}: corrupt config`); return null; }
14
+ }
15
+
16
+ export class McpConfigService {
17
+ private explicitDb: Database | null;
18
+
19
+ constructor(db?: Database) {
20
+ this.explicitDb = db ?? null;
21
+ }
22
+
23
+ /** Get DB — explicit (testing) or lazy singleton */
24
+ private get db(): Database {
25
+ return this.explicitDb ?? getDb();
26
+ }
27
+
28
+ /** List all MCP servers as Record (SDK-compatible format) */
29
+ list(): Record<string, McpServerConfig> {
30
+ const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
31
+ const result: Record<string, McpServerConfig> = {};
32
+ for (const row of rows) {
33
+ const parsed = safeParse(row.config, row.name);
34
+ if (parsed) result[row.name] = parsed;
35
+ }
36
+ return result;
37
+ }
38
+
39
+ /** List as array with metadata (for UI) */
40
+ listWithMeta(): Array<{ name: string; transport: string; config: McpServerConfig; createdAt: string; updatedAt: string }> {
41
+ const rows = this.db.query("SELECT name, transport, config, created_at, updated_at FROM mcp_servers ORDER BY name").all() as {
42
+ name: string; transport: string; config: string; created_at: string; updated_at: string;
43
+ }[];
44
+ const result: Array<{ name: string; transport: string; config: McpServerConfig; createdAt: string; updatedAt: string }> = [];
45
+ for (const r of rows) {
46
+ const parsed = safeParse(r.config, r.name);
47
+ if (parsed) result.push({ name: r.name, transport: r.transport, config: parsed, createdAt: r.created_at, updatedAt: r.updated_at });
48
+ }
49
+ return result;
50
+ }
51
+
52
+ /** Get single server */
53
+ get(name: string): McpServerConfig | null {
54
+ const row = this.db.query("SELECT config FROM mcp_servers WHERE name = ?").get(name) as { config: string } | null;
55
+ return row ? safeParse(row.config, name) : null;
56
+ }
57
+
58
+ /** Add or update server */
59
+ set(name: string, config: McpServerConfig): void {
60
+ const transport = resolveTransport(config);
61
+ this.db.query(`
62
+ INSERT INTO mcp_servers (name, transport, config, updated_at)
63
+ VALUES (?, ?, ?, datetime('now'))
64
+ ON CONFLICT(name) DO UPDATE SET
65
+ transport = excluded.transport,
66
+ config = excluded.config,
67
+ updated_at = datetime('now')
68
+ `).run(name, transport, JSON.stringify(config));
69
+ }
70
+
71
+ /** Remove server. Returns true if deleted. */
72
+ remove(name: string): boolean {
73
+ const result = this.db.query("DELETE FROM mcp_servers WHERE name = ?").run(name);
74
+ return result.changes > 0;
75
+ }
76
+
77
+ /** Check if name exists */
78
+ exists(name: string): boolean {
79
+ const row = this.db.query("SELECT 1 FROM mcp_servers WHERE name = ?").get(name);
80
+ return row != null;
81
+ }
82
+
83
+ /** Bulk insert (for import) — validates entries, skips existing/invalid, wrapped in transaction */
84
+ bulkImport(servers: Record<string, McpServerConfig>): { imported: number; skipped: number } {
85
+ let imported = 0, skipped = 0;
86
+ const tx = this.db.transaction(() => {
87
+ for (const [name, config] of Object.entries(servers)) {
88
+ if (this.exists(name)) { skipped++; continue; }
89
+ const nameErr = validateMcpName(name);
90
+ if (nameErr) { skipped++; continue; }
91
+ const configErrs = validateMcpConfig(config);
92
+ if (configErrs.length) { skipped++; continue; }
93
+ this.set(name, config);
94
+ imported++;
95
+ }
96
+ });
97
+ tx();
98
+ return { imported, skipped };
99
+ }
100
+ }
101
+
102
+ export const mcpConfigService = new McpConfigService();
@@ -328,6 +328,9 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
328
328
  const currentSupervisorPid = process.pid;
329
329
 
330
330
  try {
331
+ // Prevent spawnServer crash-restart loop from respawning killed children
332
+ shuttingDown = true;
333
+
331
334
  // Kill server + tunnel children FIRST to free the port for the new supervisor
332
335
  log("INFO", "Stopping server and tunnel before spawning new supervisor");
333
336
  if (serverChild) { try { serverChild.kill(); } catch {} serverChild = null; }
@@ -365,12 +368,14 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
365
368
  } catch {}
366
369
  }
367
370
 
368
- // Timeout — new supervisor didn't start
371
+ // Timeout — new supervisor didn't start, restore old supervisor
369
372
  log("ERROR", "Self-replace timeout: new supervisor did not start");
370
373
  try { child.kill(); } catch {}
374
+ shuttingDown = false;
371
375
  return { success: false, error: "New supervisor failed to start within 30s" };
372
376
  } catch (e) {
373
377
  log("ERROR", `Self-replace error: ${e}`);
378
+ shuttingDown = false;
374
379
  return { success: false, error: (e as Error).message };
375
380
  }
376
381
  }
@@ -436,7 +441,12 @@ export async function runSupervisor(opts: {
436
441
  process.on("SIGUSR1", async () => {
437
442
  log("INFO", "SIGUSR1 received, starting self-replace for upgrade");
438
443
  const result = await selfReplace();
439
- if (!result.success) log("ERROR", `Self-replace failed: ${result.error}`);
444
+ if (!result.success) {
445
+ log("ERROR", `Self-replace failed: ${result.error}, restarting children`);
446
+ // Respawn server (and tunnel if configured) since selfReplace killed them
447
+ spawnServer(serverArgs, logFd);
448
+ if (opts.share) spawnTunnel(opts.port);
449
+ }
440
450
  });
441
451
 
442
452
  // Start health checks
@@ -0,0 +1,47 @@
1
+ /** stdio transport */
2
+ export interface McpStdioConfig {
3
+ type?: "stdio";
4
+ command: string;
5
+ args?: string[];
6
+ env?: Record<string, string>;
7
+ }
8
+
9
+ /** HTTP transport */
10
+ export interface McpHttpConfig {
11
+ type: "http";
12
+ url: string;
13
+ headers?: Record<string, string>;
14
+ }
15
+
16
+ /** SSE transport */
17
+ export interface McpSseConfig {
18
+ type: "sse";
19
+ url: string;
20
+ headers?: Record<string, string>;
21
+ }
22
+
23
+ export type McpServerConfig = McpStdioConfig | McpHttpConfig | McpSseConfig;
24
+ export type McpTransportType = "stdio" | "http" | "sse";
25
+
26
+ export function validateMcpName(name: string): string | null {
27
+ if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) return "Name must start with a letter/digit, then alphanumeric, hyphens, or underscores";
28
+ if (name.length > 50) return "Name max 50 chars";
29
+ return null;
30
+ }
31
+
32
+ export function validateMcpConfig(config: unknown): string[] {
33
+ const errors: string[] = [];
34
+ if (!config || typeof config !== "object") return ["Config must be an object"];
35
+ const c = config as Record<string, unknown>;
36
+ const type = (c.type as string) ?? "stdio";
37
+
38
+ if (type === "stdio") {
39
+ if (!c.command || typeof c.command !== "string") errors.push("command is required for stdio");
40
+ } else if (type === "http" || type === "sse") {
41
+ if (!c.url || typeof c.url !== "string") errors.push("url is required for " + type);
42
+ if (c.url && typeof c.url === "string" && !/^https?:\/\/.+/.test(c.url)) errors.push("url must be HTTP(S)");
43
+ } else {
44
+ errors.push("type must be stdio, http, or sse");
45
+ }
46
+ return errors;
47
+ }
@@ -7,7 +7,12 @@ import { useTabStore } from "@/stores/tab-store";
7
7
  import { useSettingsStore } from "@/stores/settings-store";
8
8
  import { basename } from "@/lib/utils";
9
9
  import { useMonacoTheme } from "@/lib/use-monaco-theme";
10
- import { Loader2, FileWarning, ExternalLink, Code, Eye, WrapText } from "lucide-react";
10
+ import { Loader2, FileWarning, ExternalLink } from "lucide-react";
11
+ import { EditorBreadcrumb } from "./editor-breadcrumb";
12
+ import { EditorToolbar } from "./editor-toolbar";
13
+ import { lazy, Suspense } from "react";
14
+
15
+ const CsvPreview = lazy(() => import("./csv-preview").then((m) => ({ default: m.CsvPreview })));
11
16
 
12
17
  /** Image extensions renderable inline */
13
18
  const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"]);
@@ -58,7 +63,9 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
58
63
  const isPdf = ext === "pdf";
59
64
  const isSqlite = SQLITE_EXTS.has(ext);
60
65
  const isMarkdown = ext === "md" || ext === "mdx";
66
+ const isCsv = ext === "csv";
61
67
  const [mdMode, setMdMode] = useState<"edit" | "preview">("preview");
68
+ const [csvMode, setCsvMode] = useState<"table" | "raw">("table");
62
69
 
63
70
  // Redirect .db files to sqlite viewer by changing tab type
64
71
  useEffect(() => {
@@ -196,33 +203,36 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
196
203
  );
197
204
  }
198
205
 
199
- const mdModeButtons = isMarkdown ? (
200
- <>
201
- <button type="button" onClick={() => setMdMode("edit")}
202
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${mdMode === "edit" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
203
- >
204
- <Code className="size-3" /> Edit
205
- </button>
206
- <button type="button" onClick={() => setMdMode("preview")}
207
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${mdMode === "preview" ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
208
- >
209
- <Eye className="size-3" /> Preview
210
- </button>
211
- </>
212
- ) : null;
213
-
214
- const wrapBtn = (
215
- <button type="button" onClick={toggleWordWrap} title="Toggle word wrap (Alt+Z)"
216
- className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${wordWrap ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}
217
- >
218
- <WrapText className="size-3" />
219
- <span className="hidden sm:inline">Wrap</span>
220
- </button>
221
- );
222
-
223
206
  return (
224
207
  <div className="flex flex-col h-full w-full overflow-hidden">
225
- {isMarkdown && mdMode === "preview" ? (
208
+ {/* Breadcrumb + Toolbar bar desktop only */}
209
+ {filePath && projectName && tabId && (
210
+ <div className="hidden md:flex items-center h-7 border-b border-border bg-background shrink-0">
211
+ <EditorBreadcrumb
212
+ filePath={filePath}
213
+ projectName={projectName}
214
+ tabId={tabId}
215
+ className="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none px-2 gap-0.5"
216
+ />
217
+ <EditorToolbar
218
+ ext={ext}
219
+ mdMode={mdMode}
220
+ onMdModeChange={setMdMode}
221
+ csvMode={csvMode}
222
+ onCsvModeChange={setCsvMode}
223
+ wordWrap={wordWrap}
224
+ onToggleWordWrap={toggleWordWrap}
225
+ className="shrink-0 flex items-center gap-1 px-2"
226
+ />
227
+ </div>
228
+ )}
229
+
230
+ {/* Content area */}
231
+ {isCsv && csvMode === "table" ? (
232
+ <Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="size-5 animate-spin text-text-subtle" /></div>}>
233
+ <CsvPreview content={content ?? ""} onContentChange={handleChange} wordWrap={wordWrap} />
234
+ </Suspense>
235
+ ) : isMarkdown && mdMode === "preview" ? (
226
236
  <MarkdownPreview content={content ?? ""} />
227
237
  ) : (
228
238
  <div className="flex-1 overflow-hidden">
@@ -0,0 +1,228 @@
1
+ import { useState, useMemo, useRef, useCallback, useEffect } from "react";
2
+ import {
3
+ useReactTable,
4
+ getCoreRowModel,
5
+ getSortedRowModel,
6
+ flexRender,
7
+ type ColumnDef,
8
+ type SortingState,
9
+ } from "@tanstack/react-table";
10
+ import { useVirtualizer } from "@tanstack/react-virtual";
11
+ import { parseCsv, serializeCsv } from "@/lib/csv-parser";
12
+ import { ArrowUp, ArrowDown } from "lucide-react";
13
+
14
+ interface CsvPreviewProps {
15
+ content: string;
16
+ onContentChange: (csv: string) => void;
17
+ wordWrap?: boolean;
18
+ }
19
+
20
+ export function CsvPreview({ content, onContentChange, wordWrap }: CsvPreviewProps) {
21
+ const parsed = useMemo(() => parseCsv(content), [content]);
22
+ const [rows, setRows] = useState<string[][]>(() => parsed.rows);
23
+ const [sorting, setSorting] = useState<SortingState>([]);
24
+ const scrollRef = useRef<HTMLDivElement>(null);
25
+ const internalEditRef = useRef(false);
26
+
27
+ // Sync when content changes externally (e.g. file reload) — skip if we triggered it
28
+ useEffect(() => {
29
+ if (internalEditRef.current) {
30
+ internalEditRef.current = false;
31
+ return;
32
+ }
33
+ setRows(parsed.rows);
34
+ }, [parsed.rows]);
35
+
36
+ const headers = parsed.headers;
37
+
38
+ const updateCell = useCallback(
39
+ (rowIndex: number, colIndex: number, value: string) => {
40
+ setRows((prev) => {
41
+ const next = prev.map((r, i) => (i === rowIndex ? [...r] : r));
42
+ next[rowIndex]![colIndex] = value;
43
+ internalEditRef.current = true;
44
+ onContentChange(serializeCsv(headers, next));
45
+ return next;
46
+ });
47
+ },
48
+ [headers, onContentChange],
49
+ );
50
+
51
+ const columns = useMemo<ColumnDef<string[], string>[]>(
52
+ () =>
53
+ headers.map((h, i) => ({
54
+ id: `col-${i}`,
55
+ header: h || `Column ${i + 1}`,
56
+ accessorFn: (row: string[]) => row[i] ?? "",
57
+ cell: ({ row, getValue }) => (
58
+ <CsvCell
59
+ value={getValue()}
60
+ onSave={(v) => updateCell(row.index, i, v)}
61
+ wordWrap={wordWrap}
62
+ />
63
+ ),
64
+ size: 150,
65
+ minSize: 80,
66
+ })),
67
+ [headers, updateCell, wordWrap],
68
+ );
69
+
70
+ const table = useReactTable({
71
+ data: rows,
72
+ columns,
73
+ state: { sorting },
74
+ onSortingChange: setSorting,
75
+ getCoreRowModel: getCoreRowModel(),
76
+ getSortedRowModel: getSortedRowModel(),
77
+ enableColumnResizing: true,
78
+ columnResizeMode: "onChange",
79
+ });
80
+
81
+ const { rows: tableRows } = table.getRowModel();
82
+
83
+ const virtualizer = useVirtualizer({
84
+ count: tableRows.length,
85
+ getScrollElement: () => scrollRef.current,
86
+ estimateSize: () => 32,
87
+ overscan: 20,
88
+ });
89
+
90
+ if (headers.length === 0) {
91
+ return (
92
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
93
+ Empty CSV file
94
+ </div>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <div ref={scrollRef} className="flex-1 overflow-auto">
100
+ <table className="w-full text-xs font-mono border-collapse">
101
+ <thead className="sticky top-0 bg-background z-10 border-b border-border block">
102
+ {table.getHeaderGroups().map((hg) => (
103
+ <tr key={hg.id} className="flex w-full">
104
+ {hg.headers.map((header) => (
105
+ <th
106
+ key={header.id}
107
+ className="relative text-left px-2 py-1.5 font-medium text-muted-foreground select-none cursor-pointer hover:bg-muted/50 border-r border-border last:border-r-0"
108
+ style={{ width: header.getSize(), minWidth: header.getSize() }}
109
+ onClick={header.column.getToggleSortingHandler()}
110
+ >
111
+ <div className="flex items-center gap-1">
112
+ <span className="truncate">
113
+ {flexRender(header.column.columnDef.header, header.getContext())}
114
+ </span>
115
+ {header.column.getIsSorted() === "asc" && <ArrowUp className="size-3 shrink-0" />}
116
+ {header.column.getIsSorted() === "desc" && <ArrowDown className="size-3 shrink-0" />}
117
+ </div>
118
+ {/* Resize handle */}
119
+ <div
120
+ onMouseDown={header.getResizeHandler()}
121
+ onTouchStart={header.getResizeHandler()}
122
+ onClick={(e) => e.stopPropagation()}
123
+ className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary/50 active:bg-primary"
124
+ />
125
+ </th>
126
+ ))}
127
+ </tr>
128
+ ))}
129
+ </thead>
130
+ <tbody style={{ height: virtualizer.getTotalSize(), position: "relative", display: "block" }}>
131
+ {virtualizer.getVirtualItems().map((vRow) => {
132
+ const row = tableRows[vRow.index]!;
133
+ return (
134
+ <tr
135
+ key={row.id}
136
+ data-index={vRow.index}
137
+ ref={(node) => virtualizer.measureElement(node)}
138
+ style={{
139
+ position: "absolute",
140
+ top: 0,
141
+ left: 0,
142
+ width: "100%",
143
+ transform: `translateY(${vRow.start}px)`,
144
+ display: "flex",
145
+ }}
146
+ >
147
+ {row.getVisibleCells().map((cell) => (
148
+ <td
149
+ key={cell.id}
150
+ className={`px-2 py-1 border-b border-border/50 border-r border-r-border/30 last:border-r-0 ${wordWrap ? "whitespace-pre-wrap break-words" : "truncate"}`}
151
+ style={{ width: cell.column.getSize(), minWidth: cell.column.getSize() }}
152
+ >
153
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
154
+ </td>
155
+ ))}
156
+ </tr>
157
+ );
158
+ })}
159
+ </tbody>
160
+ </table>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ function CsvCell({ value, onSave, wordWrap }: { value: string; onSave: (v: string) => void; wordWrap?: boolean }) {
166
+ const [editing, setEditing] = useState(false);
167
+ const [draft, setDraft] = useState(value);
168
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
169
+
170
+ // Auto-resize textarea to fit content
171
+ const autoResize = useCallback((el: HTMLTextAreaElement | null) => {
172
+ if (!el) return;
173
+ el.style.height = "auto";
174
+ el.style.height = `${el.scrollHeight}px`;
175
+ }, []);
176
+
177
+ useEffect(() => {
178
+ if (editing && textareaRef.current) {
179
+ textareaRef.current.focus();
180
+ autoResize(textareaRef.current);
181
+ }
182
+ }, [editing, autoResize]);
183
+
184
+ if (!editing) {
185
+ return (
186
+ <span
187
+ className={`block cursor-text ${wordWrap ? "whitespace-pre-wrap break-words" : "truncate"}`}
188
+ onClick={() => {
189
+ setDraft(value);
190
+ setEditing(true);
191
+ }}
192
+ >
193
+ {value || "\u00A0"}
194
+ </span>
195
+ );
196
+ }
197
+
198
+ const isMultiline = draft.includes("\n");
199
+
200
+ return (
201
+ <textarea
202
+ ref={textareaRef}
203
+ className="w-full bg-transparent outline-none border border-primary/50 rounded text-xs font-mono resize-none p-0.5"
204
+ style={{ minHeight: isMultiline ? 48 : 20 }}
205
+ rows={1}
206
+ value={draft}
207
+ onChange={(e) => {
208
+ setDraft(e.target.value);
209
+ autoResize(e.target);
210
+ }}
211
+ onBlur={() => {
212
+ setEditing(false);
213
+ if (draft !== value) onSave(draft);
214
+ }}
215
+ onKeyDown={(e) => {
216
+ if (e.key === "Enter" && !e.shiftKey) {
217
+ // Enter = save, Shift+Enter = newline
218
+ e.preventDefault();
219
+ setEditing(false);
220
+ if (draft !== value) onSave(draft);
221
+ } else if (e.key === "Escape") {
222
+ setEditing(false);
223
+ setDraft(value);
224
+ }
225
+ }}
226
+ />
227
+ );
228
+ }