@hienlh/ppm 0.8.84 → 0.8.86

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 (221) hide show
  1. package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/CHANGELOG.md +5 -179
  4. package/bun.lock +0 -5
  5. package/dist/web/assets/{_basePickBy-5PGDJbfF.js → _basePickBy-5eBmZ_lt.js} +1 -1
  6. package/dist/web/assets/{_baseUniq-BT4Ow4Kk.js → _baseUniq-DimLlN0y.js} +1 -1
  7. package/dist/web/assets/api-settings-CFw-lh5k.js +1 -0
  8. package/dist/web/assets/{arc-BAOivWpI.js → arc-D4SasZrA.js} +1 -1
  9. package/dist/web/assets/architecture-PBZL5I3N-CJupe6q_.js +1 -0
  10. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-DWBCPMLF.js → architectureDiagram-2XIMDMQ5-nv0WbM7d.js} +1 -1
  11. package/dist/web/assets/{blockDiagram-WCTKOSBZ-TEF8Ally.js → blockDiagram-WCTKOSBZ-C1XvYrb8.js} +1 -1
  12. package/dist/web/assets/browser-tab-CmsL5eny.js +1 -0
  13. package/dist/web/assets/{c4Diagram-IC4MRINW-dV22iAsY.js → c4Diagram-IC4MRINW-CygDrbWJ.js} +1 -1
  14. package/dist/web/assets/channel-DmKoFTd_.js +1 -0
  15. package/dist/web/assets/chat-tab-CFWsf13Z.js +7 -0
  16. package/dist/web/assets/{chunk-4BX2VUAB-D4tOov49.js → chunk-4BX2VUAB-C2FDgsgT.js} +1 -1
  17. package/dist/web/assets/{chunk-55IACEB6-DJ6BynZ4.js → chunk-55IACEB6-jF4w6cat.js} +1 -1
  18. package/dist/web/assets/{chunk-7E7YKBS2-CiyUJxNI.js → chunk-7E7YKBS2-BVCECZFi.js} +1 -1
  19. package/dist/web/assets/{chunk-7R4GIKGN-BbIFzsIv.js → chunk-7R4GIKGN-DXTbeu5d.js} +2 -2
  20. package/dist/web/assets/{chunk-C72U2L5F-D21mS_6G.js → chunk-C72U2L5F-BaZqOsTs.js} +1 -1
  21. package/dist/web/assets/{chunk-EGIJ26TM-DzqmU2Z7.js → chunk-EGIJ26TM-Bky2tcH7.js} +1 -1
  22. package/dist/web/assets/{chunk-FMBD7UC4-DXncblvW.js → chunk-FMBD7UC4-Cp4BK9A8.js} +1 -1
  23. package/dist/web/assets/{chunk-GEFDOKGD-BbQkJu8C.js → chunk-GEFDOKGD-BosFEH7G.js} +1 -1
  24. package/dist/web/assets/chunk-GLR3WWYH-BnP-hOp6.js +2 -0
  25. package/dist/web/assets/chunk-HHEYEP7N-DKDPTPEZ.js +1 -0
  26. package/dist/web/assets/{chunk-JSJVCQXG-23tyvw8k.js → chunk-JSJVCQXG-H5Gbjsbr.js} +1 -1
  27. package/dist/web/assets/{chunk-KX2RTZJC-sQ0o-39C.js → chunk-KX2RTZJC-CWerSUwS.js} +1 -1
  28. package/dist/web/assets/{chunk-KYZI473N-BcUZNnwd.js → chunk-KYZI473N-FvwP7jUy.js} +1 -1
  29. package/dist/web/assets/{chunk-L3YUKLVL-C7qGJrfV.js → chunk-L3YUKLVL-D1PI_ORP.js} +1 -1
  30. package/dist/web/assets/{chunk-MX3YWQON-BpS_PtKp.js → chunk-MX3YWQON-C7Vzk_AI.js} +1 -1
  31. package/dist/web/assets/{chunk-NQ4KR5QH-wMgTlP7f.js → chunk-NQ4KR5QH-BceYBGYX.js} +1 -1
  32. package/dist/web/assets/{chunk-O4XLMI2P-JC6EGoUz.js → chunk-O4XLMI2P-WPtzgxql.js} +1 -1
  33. package/dist/web/assets/{chunk-OZEHJAEY-BXhYx3nO.js → chunk-OZEHJAEY-DlHXDeLY.js} +1 -1
  34. package/dist/web/assets/{chunk-PQ6SQG4A-D6BTbCQw.js → chunk-PQ6SQG4A-Ci_Prygb.js} +1 -1
  35. package/dist/web/assets/{chunk-PU5JKC2W-Dw8ClWch.js → chunk-PU5JKC2W-CO0zMN-z.js} +1 -1
  36. package/dist/web/assets/chunk-QZHKN3VN-C_wpI9wz.js +1 -0
  37. package/dist/web/assets/{chunk-R5LLSJPH-CFwSJijQ.js → chunk-R5LLSJPH-IAEEzfpM.js} +1 -1
  38. package/dist/web/assets/{chunk-WL4C6EOR-DfofndiH.js → chunk-WL4C6EOR-BLXalOgc.js} +1 -1
  39. package/dist/web/assets/{chunk-XIRO2GV7-Djlmrely.js → chunk-XIRO2GV7-Dx1Ri_p2.js} +1 -1
  40. package/dist/web/assets/{chunk-XPW4576I-BPQQBakK.js → chunk-XPW4576I-m9pPGKn7.js} +1 -1
  41. package/dist/web/assets/{chunk-XZSTWKYB-DxAOx4hG.js → chunk-XZSTWKYB-B_08ExbI.js} +1 -1
  42. package/dist/web/assets/{chunk-YBOYWFTD-CeU4Q-xC.js → chunk-YBOYWFTD-DqSOVcYe.js} +1 -1
  43. package/dist/web/assets/classDiagram-VBA2DB6C-B1T5uY-F.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-RAHNMMFH-xs5vI3xC.js +1 -0
  45. package/dist/web/assets/clone-CijCFRT5.js +1 -0
  46. package/dist/web/assets/code-editor-H_dAh_fJ.js +1 -0
  47. package/dist/web/assets/{cose-bilkent-S5V4N54A-B_AWZsOP.js → cose-bilkent-S5V4N54A-DlL82QHu.js} +1 -1
  48. package/dist/web/assets/{dagre-Dbb5k38K.js → dagre-BmVoh2At.js} +1 -1
  49. package/dist/web/assets/{dagre-KLK3FWXG-BH7aWGRP.js → dagre-KLK3FWXG-sDrRW9MQ.js} +1 -1
  50. package/dist/web/assets/database-viewer-DBzsgEJ8.js +1 -0
  51. package/dist/web/assets/{diagram-E7M64L7V-B1Qz70Do.js → diagram-E7M64L7V-ChnAhgni.js} +1 -1
  52. package/dist/web/assets/{diagram-IFDJBPK2-k55eVqVU.js → diagram-IFDJBPK2-DW1J1uJd.js} +1 -1
  53. package/dist/web/assets/{diagram-P4PSJMXO-BkfNRc9U.js → diagram-P4PSJMXO-CQ32hyG_.js} +1 -1
  54. package/dist/web/assets/diff-viewer-DzS-OnAR.js +4 -0
  55. package/dist/web/assets/dist-0Va_2L7G.js +16 -0
  56. package/dist/web/assets/dist-D9irYETY.js +41 -0
  57. package/dist/web/assets/{erDiagram-INFDFZHY-CKzVujYI.js → erDiagram-INFDFZHY-6CHo6nOw.js} +1 -1
  58. package/dist/web/assets/{flowDiagram-PKNHOUZH-DIqcTrDV.js → flowDiagram-PKNHOUZH-DroDiNT0.js} +1 -1
  59. package/dist/web/assets/{ganttDiagram-A5KZAMGK-D4v7ZbVE.js → ganttDiagram-A5KZAMGK-DP0QBh8w.js} +1 -1
  60. package/dist/web/assets/git-graph-D3C7F8o3.js +1 -0
  61. package/dist/web/assets/gitGraph-HDMCJU4V-B0KvGQG8.js +1 -0
  62. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-BTXo57mF.js → gitGraphDiagram-K3NZZRJ6-DvU3JGZn.js} +1 -1
  63. package/dist/web/assets/{graphlib-BcsNnGcW.js → graphlib-CQBb2thr.js} +1 -1
  64. package/dist/web/assets/index-CIkjfera.js +31 -0
  65. package/dist/web/assets/index-WKLuYsBY.css +2 -0
  66. package/dist/web/assets/info-3K5VOQVL-1uJ6_hCm.js +1 -0
  67. package/dist/web/assets/infoDiagram-LFFYTUFH-DLA5Q-3y.js +2 -0
  68. package/dist/web/assets/input-CGp1nFIg.js +1 -0
  69. package/dist/web/assets/{isEmpty-bnrF3Qbc.js → isEmpty-B4kqZBtn.js} +1 -1
  70. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-BOyvKMmB.js → ishikawaDiagram-PHBUUO56-46yibrV5.js} +1 -1
  71. package/dist/web/assets/{journeyDiagram-4ABVD52K-ufoasAy6.js → journeyDiagram-4ABVD52K-BcmRwjK-.js} +1 -1
  72. package/dist/web/assets/{kanban-definition-K7BYSVSG-Bi0UTUeN.js → kanban-definition-K7BYSVSG-B619K53y.js} +1 -1
  73. package/dist/web/assets/keybindings-store-BdaoLwSo.js +1 -0
  74. package/dist/web/assets/{line-B78g-52T.js → line-1gcO63_w.js} +1 -1
  75. package/dist/web/assets/{linear-DP4mkX3m.js → linear-DfRqDoVd.js} +1 -1
  76. package/dist/web/assets/markdown-renderer-DH49Zag7.js +69 -0
  77. package/dist/web/assets/{mermaid-parser.core-DMIWdgEW.js → mermaid-parser.core-XtjZQOeM.js} +2 -2
  78. package/dist/web/assets/{mindmap-definition-YRQLILUH-BsfWvIoO.js → mindmap-definition-YRQLILUH-CifOFo_q.js} +1 -1
  79. package/dist/web/assets/{ordinal-_K3x1fkz.js → ordinal-BJYw-iDX.js} +1 -1
  80. package/dist/web/assets/packet-RMMSAZCW-34C4o9yj.js +1 -0
  81. package/dist/web/assets/pie-UPGHQEXC-D9ekKlh9.js +1 -0
  82. package/dist/web/assets/{pieDiagram-SKSYHLDU-WP0XXw51.js → pieDiagram-SKSYHLDU-BuHUh_fO.js} +1 -1
  83. package/dist/web/assets/postgres-viewer-B9FYk8sD.js +1 -0
  84. package/dist/web/assets/{quadrantDiagram-337W2JSQ-FHMogtsh.js → quadrantDiagram-337W2JSQ-Bau_hj6Z.js} +1 -1
  85. package/dist/web/assets/radar-KQ55EAFF-DEuXOXSD.js +1 -0
  86. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-BatTxyWb.js → requirementDiagram-Z7DCOOCP-Cq2b-uwp.js} +1 -1
  87. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-ClJuW3Hv.js → sankeyDiagram-WA2Y5GQK-DrdGQxWQ.js} +1 -1
  88. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-ByxQqGgs.js → sequenceDiagram-2WXFIKYE-qPxiTUcS.js} +1 -1
  89. package/dist/web/assets/settings-store-DWXGVHsE.js +2 -0
  90. package/dist/web/assets/settings-tab-D-q8pd-5.js +1 -0
  91. package/dist/web/assets/sqlite-viewer-CDqcTePw.js +1 -0
  92. package/dist/web/assets/{stateDiagram-RAJIS63D-f8opcZNY.js → stateDiagram-RAJIS63D-Dulj2oa8.js} +1 -1
  93. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CAkzLlhk.js +1 -0
  94. package/dist/web/assets/tab-store-BPeiymiH.js +1 -0
  95. package/dist/web/assets/{terminal-tab-D5TyPXKq.js → terminal-tab-wKgpSPAT.js} +2 -2
  96. package/dist/web/assets/{timeline-definition-YZTLITO2-58BlOSf9.js → timeline-definition-YZTLITO2-BWyDnCYq.js} +1 -1
  97. package/dist/web/assets/treemap-KZPCXAKY-nc7a1Ia1.js +1 -0
  98. package/dist/web/assets/use-monaco-theme-CCBTQ0S3.js +11 -0
  99. package/dist/web/assets/{vennDiagram-LZ73GAT5-BOSy9ma9.js → vennDiagram-LZ73GAT5-B9Iv2bNV.js} +1 -1
  100. package/dist/web/assets/{xychartDiagram-JWTSCODW-z5MVJauZ.js → xychartDiagram-JWTSCODW-ChXcMzBQ.js} +1 -1
  101. package/dist/web/index.html +11 -12
  102. package/dist/web/sw.js +1 -1
  103. package/docs/code-standards.md +7 -232
  104. package/docs/codebase-summary.md +3 -9
  105. package/docs/design-guidelines.md +0 -21
  106. package/docs/project-changelog.md +1 -115
  107. package/docs/project-roadmap.md +19 -41
  108. package/docs/system-architecture.md +15 -212
  109. package/package.json +2 -3
  110. package/src/cli/commands/autostart.ts +1 -1
  111. package/src/cli/commands/restart.ts +1 -9
  112. package/src/cli/commands/status.ts +0 -19
  113. package/src/index.ts +3 -2
  114. package/src/providers/claude-agent-sdk.ts +31 -94
  115. package/src/providers/mock-provider.ts +1 -6
  116. package/src/server/index.ts +166 -38
  117. package/src/server/routes/chat.ts +3 -52
  118. package/src/server/routes/project-scoped.ts +0 -2
  119. package/src/server/routes/proxy.ts +53 -46
  120. package/src/server/routes/tunnel.ts +32 -0
  121. package/src/server/ws/chat.ts +146 -207
  122. package/src/services/account-selector.service.ts +8 -16
  123. package/src/services/account.service.ts +13 -19
  124. package/src/services/claude-usage.service.ts +11 -48
  125. package/src/services/cloud.service.ts +6 -10
  126. package/src/services/db.service.ts +6 -111
  127. package/src/services/port-tunnel.service.ts +97 -0
  128. package/src/services/proxy.service.ts +19 -4
  129. package/src/services/supervisor.ts +25 -285
  130. package/src/types/api.ts +1 -9
  131. package/src/types/chat.ts +1 -3
  132. package/src/web/app.tsx +35 -37
  133. package/src/web/components/browser/browser-tab.tsx +97 -106
  134. package/src/web/components/chat/chat-history-bar.tsx +6 -72
  135. package/src/web/components/chat/chat-tab.tsx +16 -32
  136. package/src/web/components/chat/message-input.tsx +13 -107
  137. package/src/web/components/chat/message-list.tsx +15 -27
  138. package/src/web/components/chat/session-picker.tsx +31 -78
  139. package/src/web/components/chat/usage-badge.tsx +1 -11
  140. package/src/web/components/editor/code-editor.tsx +26 -36
  141. package/src/web/components/layout/command-palette.tsx +1 -3
  142. package/src/web/components/layout/editor-panel.tsx +18 -162
  143. package/src/web/components/layout/panel-layout.tsx +1 -17
  144. package/src/web/components/settings/proxy-settings-section.tsx +42 -40
  145. package/src/web/hooks/use-chat.ts +201 -211
  146. package/src/web/hooks/use-global-keybindings.ts +0 -7
  147. package/src/web/hooks/use-url-sync.ts +21 -173
  148. package/src/web/stores/keybindings-store.ts +0 -1
  149. package/src/web/stores/panel-store.ts +19 -73
  150. package/src/web/stores/panel-utils.ts +3 -145
  151. package/dist/web/assets/api-settings-Bx1GaNmQ.js +0 -1
  152. package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +0 -1
  153. package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
  154. package/dist/web/assets/browser-tab-83kPKHv_.js +0 -1
  155. package/dist/web/assets/channel-wrd-NHWf.js +0 -1
  156. package/dist/web/assets/chat-tab-a7D9FgDq.js +0 -8
  157. package/dist/web/assets/chevron-right-DeV0ehiG.js +0 -1
  158. package/dist/web/assets/chunk-GLR3WWYH-CzYx4w-r.js +0 -2
  159. package/dist/web/assets/chunk-HHEYEP7N-HRhYy3kG.js +0 -1
  160. package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +0 -1
  161. package/dist/web/assets/classDiagram-VBA2DB6C-lse8oZoJ.js +0 -1
  162. package/dist/web/assets/classDiagram-v2-RAHNMMFH-CxkwuInd.js +0 -1
  163. package/dist/web/assets/clone-LRxlvnMj.js +0 -1
  164. package/dist/web/assets/code-editor-D1JIPO6X.js +0 -2
  165. package/dist/web/assets/csv-preview-DLqYtXxt.js +0 -10
  166. package/dist/web/assets/database-viewer-Cv-ZkGs3.js +0 -1
  167. package/dist/web/assets/diff-viewer-DKva_V0e.js +0 -4
  168. package/dist/web/assets/dist-DylI9XxN.js +0 -13
  169. package/dist/web/assets/dist-lF8CoYII.js +0 -41
  170. package/dist/web/assets/git-graph-H7KrfMtT.js +0 -1
  171. package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +0 -1
  172. package/dist/web/assets/index-BWcr-pMn.css +0 -2
  173. package/dist/web/assets/index-Myr2-Cdd.js +0 -37
  174. package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +0 -1
  175. package/dist/web/assets/infoDiagram-LFFYTUFH-B1CX0pbC.js +0 -2
  176. package/dist/web/assets/input-BglMT33g.js +0 -1
  177. package/dist/web/assets/keybindings-store-BR7vgqrv.js +0 -1
  178. package/dist/web/assets/lib-BQ34Db2e.js +0 -4
  179. package/dist/web/assets/markdown-renderer-A6JNS40T.js +0 -69
  180. package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +0 -1
  181. package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +0 -1
  182. package/dist/web/assets/postgres-viewer-BIdyO-DA.js +0 -1
  183. package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +0 -1
  184. package/dist/web/assets/react-dom-Bpkvzu3U.js +0 -1
  185. package/dist/web/assets/settings-tab-BJDnUMoy.js +0 -1
  186. package/dist/web/assets/sqlite-viewer-BkdmedKC.js +0 -1
  187. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-DrxVDY9q.js +0 -1
  188. package/dist/web/assets/tab-store-BJw7OCmy.js +0 -1
  189. package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +0 -1
  190. package/dist/web/assets/use-monaco-theme-Cu_X7Dj3.js +0 -11
  191. package/docs/streaming-input-guide.md +0 -267
  192. package/snapshot-state.md +0 -1526
  193. package/src/server/routes/browser-preview.ts +0 -159
  194. package/src/server/routes/workspace.ts +0 -35
  195. package/src/services/cloud-ws.service.ts +0 -227
  196. package/src/web/components/chat/account-rotation-settings.tsx +0 -163
  197. package/src/web/components/chat/chat-welcome.tsx +0 -148
  198. package/src/web/components/editor/csv-preview.tsx +0 -228
  199. package/src/web/components/editor/editor-breadcrumb.tsx +0 -216
  200. package/src/web/components/editor/editor-toolbar.tsx +0 -74
  201. package/src/web/hooks/use-voice-input.ts +0 -111
  202. package/src/web/lib/csv-parser.ts +0 -134
  203. package/test-tokens.mjs +0 -212
  204. /package/dist/web/assets/{api-client-BfBM3I7n.js → api-client-DOElml5u.js} +0 -0
  205. /package/dist/web/assets/{array-B9UHiPd-.js → array-CYkMkqnU.js} +0 -0
  206. /package/dist/web/assets/{columns-2-DpsNbZOc.js → columns-2-ChOTgl3e.js} +0 -0
  207. /package/dist/web/assets/{cytoscape.esm-BW-DbntU.js → cytoscape.esm-HeHO0VhB.js} +0 -0
  208. /package/dist/web/assets/{defaultLocale-5eAKkKJC.js → defaultLocale-Beh6XjaL.js} +0 -0
  209. /package/dist/web/assets/{dist-CSJdAyA9.js → dist-BUYzeuKe.js} +0 -0
  210. /package/dist/web/assets/{init-DlZdxViB.js → init-Rr1s_RiX.js} +0 -0
  211. /package/dist/web/assets/{isArrayLikeObject-B_v2FtYn.js → isArrayLikeObject-BB-mzMLb.js} +0 -0
  212. /package/dist/web/assets/{katex-Bqvo_ZG0.js → katex-CKoArbIw.js} +0 -0
  213. /package/dist/web/assets/{math-069Z4SuC.js → math-B7b0HgJF.js} +0 -0
  214. /package/dist/web/assets/{path-6uRLdFF7.js → path-BAQ3hXlG.js} +0 -0
  215. /package/dist/web/assets/{preload-helper-uTix4PVD.js → preload-helper-DeiOTZKJ.js} +0 -0
  216. /package/dist/web/assets/{react-ER-4DN55.js → react-Dev-wu-s.js} +0 -0
  217. /package/dist/web/assets/{rough.esm-JX0wREDd.js → rough.esm-Dwml_la6.js} +0 -0
  218. /package/dist/web/assets/{src-BqX54PbV.js → src-B_cC68fH.js} +0 -0
  219. /package/dist/web/assets/{table-C7X5UAEI.js → table-COiJDPRA.js} +0 -0
  220. /package/dist/web/assets/{tag-CCtdV063.js → tag-LMq02LfE.js} +0 -0
  221. /package/dist/web/assets/{utils-BNytJOb1.js → utils-btZ8C8-R.js} +0 -0
@@ -69,14 +69,9 @@ 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
-
78
72
  class AccountService {
79
73
  private pendingStates = new Map<string, { verifier: string; createdAt: number }>();
74
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
80
75
 
81
76
  private toAccount(row: AccountRow): Account {
82
77
  let profileData: OAuthProfileData | null = null;
@@ -535,7 +530,6 @@ class AccountService {
535
530
  client_id: OAUTH_CLIENT_ID,
536
531
  refresh_token: account.refreshToken,
537
532
  }),
538
- signal: AbortSignal.timeout(15_000),
539
533
  });
540
534
  if (!res.ok) {
541
535
  const errorBody = await res.text().catch(() => "");
@@ -623,13 +617,13 @@ class AccountService {
623
617
  if (!row.id || !row.access_token) continue;
624
618
  const hasRefresh = !!row.refresh_token && row.refresh_token !== "";
625
619
 
626
- // Duplicate handling: update existing account tokens from import
620
+ // Duplicate handling: if existing account has no refresh token but import does, upgrade it
627
621
  const existingById = getAccountById(row.id);
628
622
  const existingByEmail = row.email ? this.list().find((a) => a.email === row.email) : null;
629
623
  const existing = existingById ?? (existingByEmail ? getAccountById(existingByEmail.id) : null);
630
624
  if (existing) {
631
- if (hasRefresh) {
632
- // Always update tokens when import has refresh token (handles expired/invalid tokens too)
625
+ if (hasRefresh && !this.hasRefreshToken(existing.id)) {
626
+ // Upgrade: import has refresh token, existing doesn't → update tokens
633
627
  let accessToken = row.access_token;
634
628
  if (!looksEncrypted(accessToken)) accessToken = encrypt(accessToken);
635
629
  const refreshToken = looksEncrypted(row.refresh_token) ? row.refresh_token : encrypt(row.refresh_token);
@@ -641,9 +635,9 @@ class AccountService {
641
635
  });
642
636
  imported++;
643
637
  fullTransferIds.push(existing.id);
644
- console.log(`[accounts] Updated ${row.email ?? existing.id} tokens from import`);
638
+ console.log(`[accounts] Upgraded ${row.email ?? existing.id} with refresh token from import`);
645
639
  }
646
- continue; // skip if import doesn't have refresh token
640
+ continue; // skip if existing already has refresh token or import doesn't
647
641
  }
648
642
 
649
643
  // New account — insert
@@ -694,7 +688,7 @@ class AccountService {
694
688
  // ---------------------------------------------------------------------------
695
689
 
696
690
  startAutoRefresh(): void {
697
- if (acctHotState.refreshTimer) return;
691
+ if (this.refreshTimer) return;
698
692
  const CHECK_INTERVAL_MS = 5 * 60_000;
699
693
  const REFRESH_BUFFER_S = 5 * 60;
700
694
 
@@ -734,20 +728,20 @@ class AccountService {
734
728
  // Run immediately on startup, then every 5 minutes
735
729
  refreshExpiring().catch(() => {});
736
730
  cleanupExpiredTemporary();
737
- acctHotState.refreshTimer = setInterval(() => {
731
+ this.refreshTimer = setInterval(() => {
738
732
  refreshExpiring().catch(() => {});
739
733
  cleanupExpiredTemporary();
740
734
  }, CHECK_INTERVAL_MS);
741
735
 
742
- if (typeof acctHotState.refreshTimer === "object" && acctHotState.refreshTimer !== null && "unref" in acctHotState.refreshTimer) {
743
- (acctHotState.refreshTimer as NodeJS.Timeout).unref();
736
+ if (typeof this.refreshTimer === "object" && this.refreshTimer !== null && "unref" in this.refreshTimer) {
737
+ (this.refreshTimer as NodeJS.Timeout).unref();
744
738
  }
745
739
  }
746
740
 
747
741
  stopAutoRefresh(): void {
748
- if (acctHotState.refreshTimer) {
749
- clearInterval(acctHotState.refreshTimer);
750
- acctHotState.refreshTimer = null;
742
+ if (this.refreshTimer) {
743
+ clearInterval(this.refreshTimer);
744
+ this.refreshTimer = null;
751
745
  }
752
746
  }
753
747
  }
@@ -47,19 +47,10 @@ const POLL_INTERVAL = 300_000; // 5min
47
47
  const ACCOUNT_STAGGER_MS = 1_000; // 1s between accounts
48
48
 
49
49
  let inMemoryCostUsd = 0;
50
-
51
- // Survive Bun --hot reloads: module-level vars reset on reload, globalThis persists.
52
- // Without this, each hot-reload creates a NEW polling timer without clearing the old one,
53
- // leading to N concurrent timers after N reloads (observed: 221 timers → 38k 429 errors/day).
54
- const HOT_KEY = "__PPM_USAGE_POLL__" as const;
55
- const hotState = ((globalThis as any)[HOT_KEY] ??= {
56
- pollTimer: null as ReturnType<typeof setTimeout> | null,
57
- inflightPoll: null as Promise<void> | null,
58
- }) as { pollTimer: ReturnType<typeof setTimeout> | null; inflightPoll: Promise<void> | null };
50
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
59
51
 
60
52
  // Per-token cooldown map: token prefix → earliest allowed fetch time
61
53
  const tokenCooldowns = new Map<string, number>();
62
- const MIN_COOLDOWN_MS = 60_000; // floor: at least 60s cooldown on 429
63
54
 
64
55
  // Legacy: Keychain token cache for users without accounts in DB
65
56
  let tokenCache: { token: string; timestamp: number } | null = null;
@@ -122,10 +113,9 @@ async function fetchUsageForToken(token: string): Promise<ClaudeUsage> {
122
113
  });
123
114
  if (res.status === 429) {
124
115
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
125
- const cooldownMs = Math.max(retryAfter * 1000, MIN_COOLDOWN_MS);
126
116
  const cooldownKey = token.substring(0, 20);
127
- tokenCooldowns.set(cooldownKey, Date.now() + cooldownMs);
128
- throw new Error(`Usage API 429 — cooldown ${Math.ceil(cooldownMs / 1000)}s`);
117
+ tokenCooldowns.set(cooldownKey, Date.now() + retryAfter * 1000);
118
+ throw new Error(`Usage API 429 — cooldown ${retryAfter}s`);
129
119
  }
130
120
  if (!res.ok) throw new Error(`Usage API returned ${res.status}`);
131
121
  const raw = (await res.json()) as Record<string, any>;
@@ -238,7 +228,7 @@ async function fetchLegacySingleAccount(): Promise<void> {
238
228
  } catch {}
239
229
  }
240
230
 
241
- async function pollOnceInternal(): Promise<void> {
231
+ async function pollOnce(): Promise<void> {
242
232
  try {
243
233
  const hasAccounts = accountService.list().length > 0;
244
234
  if (hasAccounts) {
@@ -251,18 +241,6 @@ async function pollOnceInternal(): Promise<void> {
251
241
  }
252
242
  }
253
243
 
254
- /** Deduped: concurrent callers share a single in-flight fetch */
255
- async function pollOnce(): Promise<void> {
256
- if (hotState.inflightPoll) return hotState.inflightPoll;
257
- const thisPoll = pollOnceInternal().finally(() => {
258
- // Only clear if still the current poll — prevents a stale .finally() from
259
- // clearing a newer poll after timeout handler force-nulled inflightPoll.
260
- if (hotState.inflightPoll === thisPoll) hotState.inflightPoll = null;
261
- });
262
- hotState.inflightPoll = thisPoll;
263
- return thisPoll;
264
- }
265
-
266
244
  // ---------------------------------------------------------------------------
267
245
  // Public API
268
246
  // ---------------------------------------------------------------------------
@@ -319,26 +297,20 @@ export function getCachedUsage(): ClaudeUsage & { activeAccountId?: string; acti
319
297
  }
320
298
 
321
299
  export function startUsagePolling(): void {
322
- if (hotState.pollTimer) return;
323
- const POLL_TIMEOUT = 60_000; // max 60s per poll iteration
300
+ if (pollTimer) return;
301
+ // Use recursive setTimeout instead of setInterval to prevent overlap
302
+ // and ensure polling continues even if a single iteration errors
324
303
  const scheduleNext = () => {
325
- hotState.pollTimer = setTimeout(async () => {
326
- const timeout = new Promise<"timeout">(r => setTimeout(() => r("timeout"), POLL_TIMEOUT));
327
- const result = await Promise.race([
328
- pollOnce().then(() => "done" as const),
329
- timeout,
330
- ]).catch(() => "error" as const);
331
- // If the poll timed out, force-clear inflightPoll so next scheduled poll
332
- // starts a fresh fetch instead of reusing the stale hanging promise.
333
- if (result === "timeout") hotState.inflightPoll = null;
304
+ pollTimer = setTimeout(async () => {
305
+ await pollOnce();
334
306
  scheduleNext();
335
307
  }, POLL_INTERVAL);
336
308
  };
337
- pollOnce().then(scheduleNext, scheduleNext);
309
+ pollOnce().then(scheduleNext);
338
310
  }
339
311
 
340
312
  export function stopUsagePolling(): void {
341
- if (hotState.pollTimer) { clearTimeout(hotState.pollTimer); hotState.pollTimer = null; }
313
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
342
314
  }
343
315
 
344
316
  export function updateFromSdkEvent(_rateLimitType?: string, _utilization?: number, costUsd?: number): void {
@@ -349,12 +321,3 @@ export async function refreshUsageNow(): Promise<ClaudeUsage & { activeAccountId
349
321
  await pollOnce();
350
322
  return getCachedUsage();
351
323
  }
352
-
353
- /** @internal Test-only: reset module-level state between tests */
354
- export function _resetForTesting(): void {
355
- inMemoryCostUsd = 0;
356
- if (hotState.pollTimer) { clearTimeout(hotState.pollTimer); hotState.pollTimer = null; }
357
- tokenCooldowns.clear();
358
- hotState.inflightPoll = null;
359
- tokenCache = null;
360
- }
@@ -362,16 +362,12 @@ export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
362
362
  }
363
363
  }
364
364
 
365
- // Survive Bun --hot reloads: persist timer ref across module re-evaluations
366
- const CLOUD_HOT_KEY = "__PPM_CLOUD_HEARTBEAT__" as const;
367
- const cloudHotState = ((globalThis as any)[CLOUD_HOT_KEY] ??= {
368
- heartbeatTimer: null as ReturnType<typeof setInterval> | null,
369
- }) as { heartbeatTimer: ReturnType<typeof setInterval> | null };
365
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
370
366
 
371
367
  /** Start periodic heartbeat (call once after tunnel URL is obtained) */
372
368
  export function startHeartbeat(tunnelUrl: string): void {
373
369
  // Clear any existing heartbeat to prevent duplicates on restart
374
- if (cloudHotState.heartbeatTimer) clearInterval(cloudHotState.heartbeatTimer);
370
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
375
371
 
376
372
  // Initial heartbeat immediately
377
373
  sendHeartbeat(tunnelUrl).then((ok) => {
@@ -380,16 +376,16 @@ export function startHeartbeat(tunnelUrl: string): void {
380
376
  });
381
377
 
382
378
  // Periodic heartbeat every 5 minutes
383
- cloudHotState.heartbeatTimer = setInterval(() => {
379
+ heartbeatTimer = setInterval(() => {
384
380
  sendHeartbeat(tunnelUrl).catch(() => {});
385
381
  }, HEARTBEAT_INTERVAL_MS);
386
382
  }
387
383
 
388
384
  /** Stop periodic heartbeat */
389
385
  export function stopHeartbeat(): void {
390
- if (cloudHotState.heartbeatTimer) {
391
- clearInterval(cloudHotState.heartbeatTimer);
392
- cloudHotState.heartbeatTimer = null;
386
+ if (heartbeatTimer) {
387
+ clearInterval(heartbeatTimer);
388
+ heartbeatTimer = null;
393
389
  }
394
390
  }
395
391
 
@@ -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 = 10;
7
+ const CURRENT_SCHEMA_VERSION = 5;
8
8
 
9
9
  let db: Database | null = null;
10
10
  let dbProfile: string | null = null;
@@ -228,65 +228,6 @@ 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 session_titles (
235
- session_id TEXT PRIMARY KEY,
236
- title TEXT NOT NULL,
237
- updated_at TEXT DEFAULT (datetime('now'))
238
- );
239
-
240
- PRAGMA user_version = 8;
241
- `);
242
- }
243
-
244
- if (current < 9) {
245
- database.exec(`
246
- CREATE TABLE IF NOT EXISTS session_pins (
247
- session_id TEXT PRIMARY KEY,
248
- pinned_at TEXT DEFAULT (datetime('now'))
249
- );
250
-
251
- PRAGMA user_version = 9;
252
- `);
253
- }
254
-
255
- if (current < 10) {
256
- database.exec(`
257
- CREATE TABLE IF NOT EXISTS workspace_state (
258
- project_name TEXT PRIMARY KEY,
259
- layout_json TEXT NOT NULL,
260
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
261
- );
262
-
263
- PRAGMA user_version = 10;
264
- `);
265
- }
266
- }
267
-
268
- // ---------------------------------------------------------------------------
269
- // Workspace helpers
270
- // ---------------------------------------------------------------------------
271
-
272
- export interface WorkspaceRow {
273
- project_name: string;
274
- layout_json: string;
275
- updated_at: string;
276
- }
277
-
278
- export function getWorkspace(projectName: string): WorkspaceRow | null {
279
- return getDb().query(
280
- "SELECT project_name, layout_json, updated_at FROM workspace_state WHERE project_name = ?",
281
- ).get(projectName) as WorkspaceRow | null;
282
- }
283
-
284
- export function setWorkspace(projectName: string, layoutJson: string): string {
285
- const now = new Date().toISOString();
286
- getDb().query(
287
- "INSERT INTO workspace_state (project_name, layout_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(project_name) DO UPDATE SET layout_json = excluded.layout_json, updated_at = excluded.updated_at",
288
- ).run(projectName, layoutJson, now);
289
- return now;
290
231
  }
291
232
 
292
233
  // ---------------------------------------------------------------------------
@@ -370,52 +311,6 @@ export function getAllSessionMappings(): Record<string, string> {
370
311
  return result;
371
312
  }
372
313
 
373
- // ---------------------------------------------------------------------------
374
- // Session title helpers (user-set titles persisted in PPM DB)
375
- // ---------------------------------------------------------------------------
376
-
377
- export function getSessionTitle(sessionId: string): string | null {
378
- const row = getDb().query("SELECT title FROM session_titles WHERE session_id = ?").get(sessionId) as { title: string } | null;
379
- return row?.title ?? null;
380
- }
381
-
382
- export function setSessionTitle(sessionId: string, title: string): void {
383
- getDb().query(
384
- "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",
385
- ).run(sessionId, title);
386
- }
387
-
388
- /** Bulk-fetch DB titles for a list of session IDs. Returns map of id → title. */
389
- export function getSessionTitles(sessionIds: string[]): Record<string, string> {
390
- if (sessionIds.length === 0) return {};
391
- const placeholders = sessionIds.map(() => "?").join(", ");
392
- const rows = getDb().query(
393
- `SELECT session_id, title FROM session_titles WHERE session_id IN (${placeholders})`,
394
- ).all(...sessionIds) as { session_id: string; title: string }[];
395
- const result: Record<string, string> = {};
396
- for (const r of rows) result[r.session_id] = r.title;
397
- return result;
398
- }
399
-
400
- // ---------------------------------------------------------------------------
401
- // Session pin helpers
402
- // ---------------------------------------------------------------------------
403
-
404
- export function pinSession(sessionId: string): void {
405
- getDb().query(
406
- "INSERT INTO session_pins (session_id, pinned_at) VALUES (?, datetime('now')) ON CONFLICT(session_id) DO UPDATE SET pinned_at = datetime('now')",
407
- ).run(sessionId);
408
- }
409
-
410
- export function unpinSession(sessionId: string): void {
411
- getDb().query("DELETE FROM session_pins WHERE session_id = ?").run(sessionId);
412
- }
413
-
414
- export function getPinnedSessionIds(): Set<string> {
415
- const rows = getDb().query("SELECT session_id FROM session_pins ORDER BY pinned_at DESC").all() as { session_id: string }[];
416
- return new Set(rows.map((r) => r.session_id));
417
- }
418
-
419
314
  // ---------------------------------------------------------------------------
420
315
  // Push subscription helpers
421
316
  // ---------------------------------------------------------------------------
@@ -546,13 +441,13 @@ export function insertLimitSnapshot(data: Omit<LimitSnapshotRow, "id" | "recorde
546
441
 
547
442
  export function getLatestLimitSnapshot(): LimitSnapshotRow | null {
548
443
  return getDb().query(
549
- "SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC, id DESC LIMIT 1",
444
+ "SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC LIMIT 1",
550
445
  ).get() as LimitSnapshotRow | null;
551
446
  }
552
447
 
553
448
  export function getLatestSnapshotForAccount(accountId: string): LimitSnapshotRow | null {
554
449
  return getDb().query(
555
- "SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1",
450
+ "SELECT * FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1",
556
451
  ).get(accountId) as LimitSnapshotRow | null;
557
452
  }
558
453
 
@@ -560,17 +455,17 @@ export function getAllLatestSnapshots(): LimitSnapshotRow[] {
560
455
  return getDb().query(
561
456
  `SELECT s.* FROM claude_limit_snapshots s
562
457
  INNER JOIN (
563
- SELECT account_id, MAX(id) as max_id
458
+ SELECT account_id, MAX(recorded_at) as max_recorded
564
459
  FROM claude_limit_snapshots WHERE account_id IS NOT NULL
565
460
  GROUP BY account_id
566
- ) latest ON s.id = latest.max_id`,
461
+ ) latest ON s.account_id = latest.account_id AND s.recorded_at = latest.max_recorded`,
567
462
  ).all() as LimitSnapshotRow[];
568
463
  }
569
464
 
570
465
  export function touchSnapshotTimestamp(accountId: string): void {
571
466
  getDb().query(
572
467
  `UPDATE claude_limit_snapshots SET recorded_at = datetime('now')
573
- WHERE id = (SELECT id FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC, id DESC LIMIT 1)`,
468
+ WHERE id = (SELECT id FROM claude_limit_snapshots WHERE account_id = ? ORDER BY recorded_at DESC LIMIT 1)`,
574
469
  ).run(accountId);
575
470
  }
576
471
 
@@ -0,0 +1,97 @@
1
+ import type { Subprocess } from "bun";
2
+ import { ensureCloudflared } from "./cloudflared.service.ts";
3
+
4
+ const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
5
+ const decoder = new TextDecoder();
6
+
7
+ interface PortTunnel {
8
+ port: number;
9
+ url: string;
10
+ process: Subprocess;
11
+ }
12
+
13
+ /**
14
+ * Manages cloudflare Quick Tunnels for arbitrary local ports.
15
+ * Each port gets its own cloudflared process and public URL.
16
+ */
17
+ class PortTunnelService {
18
+ private tunnels = new Map<number, PortTunnel>();
19
+
20
+ /** Start a tunnel for a given port. Returns the public URL. */
21
+ async start(port: number): Promise<string> {
22
+ const existing = this.tunnels.get(port);
23
+ if (existing) return existing.url;
24
+
25
+ const bin = await ensureCloudflared();
26
+ const proc = Bun.spawn(
27
+ [bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
28
+ { stderr: "pipe", stdout: "ignore", stdin: "ignore" },
29
+ );
30
+
31
+ const reader = proc.stderr.getReader();
32
+ const url = await new Promise<string>((resolve, reject) => {
33
+ const timeout = setTimeout(() => {
34
+ proc.kill();
35
+ reject(new Error("Tunnel timed out after 30s"));
36
+ }, 30_000);
37
+
38
+ let buffer = "";
39
+ let found = false;
40
+ const read = async () => {
41
+ try {
42
+ while (true) {
43
+ const { done, value } = await reader.read();
44
+ if (done) break;
45
+ if (found) continue;
46
+ buffer += decoder.decode(value, { stream: true });
47
+ const match = buffer.match(TUNNEL_URL_REGEX);
48
+ if (match) {
49
+ found = true;
50
+ buffer = "";
51
+ clearTimeout(timeout);
52
+ resolve(match[0]);
53
+ }
54
+ }
55
+ if (!found) {
56
+ clearTimeout(timeout);
57
+ reject(new Error("cloudflared exited without providing tunnel URL"));
58
+ }
59
+ } catch (err) {
60
+ if (!found) { clearTimeout(timeout); reject(err); }
61
+ }
62
+ };
63
+ read();
64
+ });
65
+
66
+ this.tunnels.set(port, { port, url, process: proc });
67
+ console.log(`[port-tunnel] Started tunnel for port ${port} → ${url}`);
68
+ return url;
69
+ }
70
+
71
+ /** Stop a tunnel for a given port */
72
+ stop(port: number): boolean {
73
+ const tunnel = this.tunnels.get(port);
74
+ if (!tunnel) return false;
75
+ try { tunnel.process.kill(); } catch {}
76
+ this.tunnels.delete(port);
77
+ console.log(`[port-tunnel] Stopped tunnel for port ${port}`);
78
+ return true;
79
+ }
80
+
81
+ /** Get tunnel URL for a port (null if not running) */
82
+ getUrl(port: number): string | null {
83
+ return this.tunnels.get(port)?.url ?? null;
84
+ }
85
+
86
+ /** List all active port tunnels */
87
+ list(): { port: number; url: string }[] {
88
+ return Array.from(this.tunnels.values()).map(({ port, url }) => ({ port, url }));
89
+ }
90
+
91
+ /** Stop all tunnels (cleanup) */
92
+ stopAll(): void {
93
+ for (const [port] of this.tunnels) this.stop(port);
94
+ }
95
+ }
96
+
97
+ export const portTunnelService = new PortTunnelService();
@@ -60,6 +60,12 @@ class ProxyService {
60
60
 
61
61
  // Ensure token is fresh for OAuth accounts
62
62
  let token = account.accessToken;
63
+ if (!token) {
64
+ return new Response(
65
+ JSON.stringify({ type: "error", error: { type: "authentication_error", message: "Account has no access token (decryption may have failed)" } }),
66
+ { status: 401, headers: { "Content-Type": "application/json" } },
67
+ );
68
+ }
63
69
  if (token.startsWith("sk-ant-oat")) {
64
70
  const fresh = await accountService.ensureFreshToken(account.id);
65
71
  if (fresh) token = fresh.accessToken;
@@ -108,14 +114,22 @@ class ProxyService {
108
114
  } else if (upstream.status === 401) {
109
115
  accountSelector.onAuthError(account.id);
110
116
  console.log(`[proxy] 401 from Anthropic — account ${account.email ?? account.id} auth error`);
117
+ } else if (upstream.status >= 400) {
118
+ console.log(`[proxy] ${upstream.status} from Anthropic — account ${account.email ?? account.id} (OAuth=${token.startsWith("sk-ant-oat")})`);
111
119
  } else if (upstream.status >= 200 && upstream.status < 300) {
112
120
  accountSelector.onSuccess(account.id);
113
121
  }
114
122
 
115
123
  // Stream response back as-is (preserves SSE for streaming)
116
124
  const responseHeaders = new Headers();
117
- // Forward key response headers
118
- for (const key of ["content-type", "x-request-id", "request-id"]) {
125
+ // Forward all relevant response headers from Anthropic
126
+ for (const key of [
127
+ "content-type", "x-request-id", "request-id",
128
+ "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining",
129
+ "anthropic-ratelimit-requests-reset", "anthropic-ratelimit-tokens-limit",
130
+ "anthropic-ratelimit-tokens-remaining", "anthropic-ratelimit-tokens-reset",
131
+ "retry-after",
132
+ ]) {
119
133
  const val = upstream.headers.get(key);
120
134
  if (val) responseHeaders.set(key, val);
121
135
  }
@@ -127,9 +141,10 @@ class ProxyService {
127
141
  headers: responseHeaders,
128
142
  });
129
143
  } catch (e) {
130
- console.error(`[proxy] Error forwarding to Anthropic:`, (e as Error).message);
144
+ const msg = e instanceof Error ? e.message : String(e);
145
+ console.error(`[proxy] Error forwarding to Anthropic:`, msg);
131
146
  return new Response(
132
- JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
147
+ JSON.stringify({ type: "error", error: { type: "api_error", message: msg || "Unknown proxy error" } }),
133
148
  { status: 502, headers: { "Content-Type": "application/json" } },
134
149
  );
135
150
  }