@hienlh/ppm 0.9.0-beta.8 → 0.9.1

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 (159) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/bun.lock +17 -0
  3. package/dist/web/assets/api-settings-BUvk6Saw.js +1 -0
  4. package/dist/web/assets/arrow-up-BYhx9ckd.js +1 -0
  5. package/dist/web/assets/browser-tab-CrkhFCaw.js +1 -0
  6. package/dist/web/assets/chat-tab-C6jpiwh7.js +8 -0
  7. package/dist/web/assets/chevron-right-5HgK6l7K.js +1 -0
  8. package/dist/web/assets/code-editor-CBIPzlP2.js +2 -0
  9. package/dist/web/assets/columns-2-cEVJHYd7.js +1 -0
  10. package/dist/web/assets/createLucideIcon-PuMiQgHl.js +1 -0
  11. package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-ncSOnJSC.js} +2 -2
  12. package/dist/web/assets/database-viewer-BqOJR_zi.js +1 -0
  13. package/dist/web/assets/diff-viewer-CcLyp4eY.js +4 -0
  14. package/dist/web/assets/{dist-CALwEtco.js → dist-DIV6WgAG.js} +1 -1
  15. package/dist/web/assets/{dist-DGDPTxs1.js → dist-ovWkrgO-.js} +1 -1
  16. package/dist/web/assets/extension-webview-NiZ7Ybvv.js +3 -0
  17. package/dist/web/assets/git-graph-CoTvMrIo.js +1 -0
  18. package/dist/web/assets/index-C8byznLO.js +37 -0
  19. package/dist/web/assets/index-KwC2YrG4.css +2 -0
  20. package/dist/web/assets/jsx-runtime-kMwlnEGE.js +1 -0
  21. package/dist/web/assets/keybindings-store-DPYzBe_M.js +1 -0
  22. package/dist/web/assets/{markdown-renderer-DklUd_Gv.js → markdown-renderer-DPLdR9xc.js} +4 -4
  23. package/dist/web/assets/postgres-viewer-BeiK4lCa.js +1 -0
  24. package/dist/web/assets/settings-tab-D3AvU4lu.js +1 -0
  25. package/dist/web/assets/sqlite-viewer-nA2sD4Yv.js +1 -0
  26. package/dist/web/assets/tab-store-BOgTrqRr.js +1 -0
  27. package/dist/web/assets/table-DFevCOMd.js +1 -0
  28. package/dist/web/assets/tag-CXMT0QB6.js +1 -0
  29. package/dist/web/assets/{terminal-tab-CqRuiIFn.js → terminal-tab-BBi0pEji.js} +2 -2
  30. package/dist/web/assets/{use-monaco-theme-Dcz3aLAE.js → use-monaco-theme-B5pG2d1w.js} +1 -1
  31. package/dist/web/index.html +8 -8
  32. package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
  33. package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
  34. package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
  35. package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
  36. package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
  37. package/dist/web/sw.js +1 -1
  38. package/docs/code-standards.md +128 -1
  39. package/docs/codebase-summary.md +79 -12
  40. package/docs/extension-development-guide.md +532 -0
  41. package/docs/project-changelog.md +51 -1
  42. package/docs/project-roadmap.md +9 -3
  43. package/docs/streaming-input-guide.md +267 -0
  44. package/docs/system-architecture.md +432 -3
  45. package/package.json +6 -3
  46. package/packages/ext-database/package.json +41 -0
  47. package/packages/ext-database/src/connection-tree.ts +142 -0
  48. package/packages/ext-database/src/extension.ts +346 -0
  49. package/packages/ext-database/src/query-panel.ts +120 -0
  50. package/packages/ext-database/src/table-viewer-panel.ts +410 -0
  51. package/packages/ext-database/tsconfig.json +8 -0
  52. package/packages/vscode-compat/package.json +16 -0
  53. package/packages/vscode-compat/src/commands.ts +39 -0
  54. package/packages/vscode-compat/src/context.ts +65 -0
  55. package/packages/vscode-compat/src/disposable.ts +21 -0
  56. package/packages/vscode-compat/src/env.ts +20 -0
  57. package/packages/vscode-compat/src/event-emitter.ts +28 -0
  58. package/packages/vscode-compat/src/index.ts +93 -0
  59. package/packages/vscode-compat/src/not-supported.ts +15 -0
  60. package/packages/vscode-compat/src/types.ts +167 -0
  61. package/packages/vscode-compat/src/uri.ts +65 -0
  62. package/packages/vscode-compat/src/window.ts +229 -0
  63. package/packages/vscode-compat/src/workspace.ts +76 -0
  64. package/packages/vscode-compat/tsconfig.json +10 -0
  65. package/snapshot-state.md +1526 -0
  66. package/src/cli/commands/autostart.ts +1 -1
  67. package/src/cli/commands/ext-cmd.ts +121 -0
  68. package/src/cli/commands/restart.ts +9 -1
  69. package/src/cli/commands/status.ts +19 -0
  70. package/src/index.ts +5 -3
  71. package/src/providers/claude-agent-sdk.ts +221 -17
  72. package/src/providers/cli-provider-base.ts +6 -0
  73. package/src/server/index.ts +55 -155
  74. package/src/server/routes/chat.ts +81 -11
  75. package/src/server/routes/extensions.ts +81 -0
  76. package/src/server/routes/project-scoped.ts +2 -0
  77. package/src/server/routes/settings.ts +27 -0
  78. package/src/server/routes/workspace.ts +35 -0
  79. package/src/server/ws/chat.ts +9 -3
  80. package/src/server/ws/extensions.ts +175 -0
  81. package/src/services/account-selector.service.ts +14 -5
  82. package/src/services/account.service.ts +20 -15
  83. package/src/services/claude-usage.service.ts +29 -24
  84. package/src/services/cloud-ws.service.ts +228 -0
  85. package/src/services/cloud.service.ts +11 -6
  86. package/src/services/contribution-registry.ts +110 -0
  87. package/src/services/db.service.ts +181 -4
  88. package/src/services/extension-host-worker.ts +160 -0
  89. package/src/services/extension-installer.ts +112 -0
  90. package/src/services/extension-manifest.ts +65 -0
  91. package/src/services/extension-rpc-handlers.ts +235 -0
  92. package/src/services/extension-rpc.ts +105 -0
  93. package/src/services/extension.service.ts +228 -0
  94. package/src/services/mcp-config.service.ts +15 -6
  95. package/src/services/supervisor.ts +271 -25
  96. package/src/types/api.ts +1 -0
  97. package/src/types/chat.ts +4 -0
  98. package/src/types/extension-messages.ts +64 -0
  99. package/src/types/extension.ts +131 -0
  100. package/src/web/app.tsx +69 -48
  101. package/src/web/components/chat/account-rotation-settings.tsx +163 -0
  102. package/src/web/components/chat/chat-history-bar.tsx +106 -10
  103. package/src/web/components/chat/chat-tab.tsx +15 -10
  104. package/src/web/components/chat/chat-welcome.tsx +148 -0
  105. package/src/web/components/chat/message-list.tsx +19 -6
  106. package/src/web/components/chat/session-picker.tsx +80 -32
  107. package/src/web/components/chat/usage-badge.tsx +68 -8
  108. package/src/web/components/editor/editor-breadcrumb.tsx +20 -29
  109. package/src/web/components/extensions/extension-inputbox.tsx +92 -0
  110. package/src/web/components/extensions/extension-quickpick.tsx +194 -0
  111. package/src/web/components/extensions/extension-tree-view.tsx +240 -0
  112. package/src/web/components/extensions/extension-webview.tsx +83 -0
  113. package/src/web/components/layout/command-palette.tsx +22 -2
  114. package/src/web/components/layout/editor-panel.tsx +163 -18
  115. package/src/web/components/layout/mobile-nav.tsx +2 -1
  116. package/src/web/components/layout/sidebar.tsx +21 -3
  117. package/src/web/components/layout/status-bar.tsx +64 -0
  118. package/src/web/components/layout/tab-bar.tsx +2 -0
  119. package/src/web/components/layout/tab-content.tsx +5 -0
  120. package/src/web/components/layout/upgrade-banner.tsx +15 -5
  121. package/src/web/components/settings/change-password-section.tsx +128 -0
  122. package/src/web/components/settings/extension-manager-section.tsx +214 -0
  123. package/src/web/components/settings/settings-tab.tsx +9 -2
  124. package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
  125. package/src/web/hooks/use-chat.ts +28 -0
  126. package/src/web/hooks/use-extension-ws.ts +181 -0
  127. package/src/web/hooks/use-global-keybindings.ts +18 -2
  128. package/src/web/hooks/use-server-reload.ts +9 -0
  129. package/src/web/hooks/use-url-sync.ts +173 -21
  130. package/src/web/stores/connection-store.ts +39 -0
  131. package/src/web/stores/extension-store.ts +204 -0
  132. package/src/web/stores/panel-store.ts +63 -9
  133. package/src/web/stores/panel-utils.ts +145 -3
  134. package/src/web/stores/settings-store.ts +7 -2
  135. package/src/web/stores/tab-store.ts +2 -1
  136. package/test-session-ops.mjs +444 -0
  137. package/test-tokens.mjs +212 -0
  138. package/tsconfig.json +3 -1
  139. package/dist/web/assets/api-settings-D21InCnR.js +0 -1
  140. package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
  141. package/dist/web/assets/browser-tab-BEe89aSD.js +0 -1
  142. package/dist/web/assets/chat-tab-9lqvWozA.js +0 -7
  143. package/dist/web/assets/chevron-right-CHnjJt4E.js +0 -1
  144. package/dist/web/assets/code-editor-COAIZx-B.js +0 -2
  145. package/dist/web/assets/columns-2-DbesTfa7.js +0 -1
  146. package/dist/web/assets/database-viewer-aRR9n_Ui.js +0 -1
  147. package/dist/web/assets/diff-viewer-C4KMvpHr.js +0 -4
  148. package/dist/web/assets/dist-CVTST7Gc.js +0 -1
  149. package/dist/web/assets/git-graph-CfJjl4E3.js +0 -1
  150. package/dist/web/assets/index-Db8uky1a.css +0 -2
  151. package/dist/web/assets/index-DxZuwBDe.js +0 -37
  152. package/dist/web/assets/jsx-runtime-BRW_vwa9.js +0 -1
  153. package/dist/web/assets/keybindings-store-_uWVCZMv.js +0 -1
  154. package/dist/web/assets/postgres-viewer-DEAvAyaX.js +0 -1
  155. package/dist/web/assets/settings-tab-BQedc-No.js +0 -1
  156. package/dist/web/assets/sqlite-viewer-BPA5idzT.js +0 -1
  157. package/dist/web/assets/tab-store-DhK6EpBT.js +0 -1
  158. package/dist/web/assets/table-CQVQM2SB.js +0 -1
  159. package/dist/web/assets/tag-Q2dZiSPX.js +0 -1
@@ -0,0 +1,175 @@
1
+ /**
2
+ * WebSocket handler for extension UI bridge.
3
+ * Routes messages between browser clients and the extension host.
4
+ */
5
+ import { contributionRegistry } from "../../services/contribution-registry.ts";
6
+ import type { ExtServerMsg, ExtClientMsg } from "../../types/extension-messages.ts";
7
+
8
+ type ExtWsSocket = {
9
+ data: { type: string };
10
+ send: (data: string) => void;
11
+ };
12
+
13
+ /** All connected extension WS clients */
14
+ const clients = new Set<ExtWsSocket>();
15
+
16
+ /** Pending request resolvers for quickpick/inputbox responses from browser */
17
+ const pendingRequests = new Map<string, (value: unknown) => void>();
18
+
19
+ // --- Public API for extension service to push UI updates ---
20
+
21
+ /** Broadcast a message to all connected extension WS clients */
22
+ export function broadcastExtMsg(msg: ExtServerMsg): void {
23
+ const data = JSON.stringify(msg);
24
+ for (const ws of clients) {
25
+ try { ws.send(data); } catch {}
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Send a request to browser and wait for response (quickpick, inputbox, notification).
31
+ * The `trackingId` is the key used to match the response.
32
+ * Returns the resolved value or undefined on timeout.
33
+ */
34
+ export function requestFromBrowser<T = unknown>(
35
+ msg: ExtServerMsg,
36
+ trackingId: string,
37
+ timeoutMs = 30_000,
38
+ ): Promise<T | undefined> {
39
+ broadcastExtMsg(msg);
40
+ return new Promise<T | undefined>((resolve) => {
41
+ const timer = setTimeout(() => {
42
+ pendingRequests.delete(trackingId);
43
+ resolve(undefined);
44
+ }, timeoutMs);
45
+ pendingRequests.set(trackingId, (value) => {
46
+ clearTimeout(timer);
47
+ resolve(value as T);
48
+ });
49
+ });
50
+ }
51
+
52
+ /** Get the number of connected extension WS clients */
53
+ export function getExtClientCount(): number {
54
+ return clients.size;
55
+ }
56
+
57
+ // --- WS lifecycle handlers ---
58
+
59
+ function handleOpen(ws: ExtWsSocket): void {
60
+ clients.add(ws);
61
+ console.log(`[ExtWS] Client connected (${clients.size} total)`);
62
+ }
63
+
64
+ async function handleMessage(ws: ExtWsSocket, raw: string | Buffer): Promise<void> {
65
+ let msg: ExtClientMsg;
66
+ try {
67
+ msg = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as ExtClientMsg;
68
+ } catch {
69
+ return;
70
+ }
71
+
72
+ switch (msg.type) {
73
+ case "ready": {
74
+ // Send current contributions on connect
75
+ const contributions = contributionRegistry.getAll();
76
+ ws.send(JSON.stringify({ type: "contributions:update", contributions } satisfies ExtServerMsg));
77
+ break;
78
+ }
79
+
80
+ case "command:execute": {
81
+ try {
82
+ const { extensionService } = await import("../../services/extension.service.ts");
83
+ // Forward to extension host worker via RPC
84
+ if (extensionService["rpc"]) {
85
+ await extensionService["rpc"].sendRequest("ext:command:execute", msg.command, ...(msg.args ?? []));
86
+ }
87
+ } catch (e) {
88
+ console.error(`[ExtWS] command:execute error:`, e);
89
+ }
90
+ break;
91
+ }
92
+
93
+ case "tree:click": {
94
+ if (msg.command) {
95
+ try {
96
+ const { extensionService } = await import("../../services/extension.service.ts");
97
+ if (extensionService["rpc"]) {
98
+ await extensionService["rpc"].sendRequest("ext:command:execute", msg.command);
99
+ }
100
+ } catch (e) {
101
+ console.error(`[ExtWS] tree:click command error:`, e);
102
+ }
103
+ }
104
+ break;
105
+ }
106
+
107
+ case "quickpick:resolve": {
108
+ const resolver = pendingRequests.get(msg.requestId);
109
+ if (resolver) {
110
+ pendingRequests.delete(msg.requestId);
111
+ resolver(msg.selected);
112
+ }
113
+ break;
114
+ }
115
+
116
+ case "inputbox:resolve": {
117
+ const resolver = pendingRequests.get(msg.requestId);
118
+ if (resolver) {
119
+ pendingRequests.delete(msg.requestId);
120
+ resolver(msg.value);
121
+ }
122
+ break;
123
+ }
124
+
125
+ case "notification:action": {
126
+ const resolver = pendingRequests.get(msg.id);
127
+ if (resolver) {
128
+ pendingRequests.delete(msg.id);
129
+ resolver(msg.action);
130
+ }
131
+ break;
132
+ }
133
+
134
+ case "webview:message": {
135
+ try {
136
+ const { extensionService } = await import("../../services/extension.service.ts");
137
+ if (extensionService["rpc"]) {
138
+ await extensionService["rpc"].sendRequest("ext:webview:message", msg.panelId, msg.message);
139
+ }
140
+ } catch (e) {
141
+ console.error(`[ExtWS] webview:message error:`, e);
142
+ }
143
+ break;
144
+ }
145
+
146
+ case "tree:expand": {
147
+ try {
148
+ const { extensionService } = await import("../../services/extension.service.ts");
149
+ if (extensionService["rpc"]) {
150
+ const result = await extensionService["rpc"].sendRequest<{ ok: boolean; items?: unknown[] }>(
151
+ "ext:tree:expand", msg.viewId, msg.itemId,
152
+ );
153
+ if (result?.ok && result.items) {
154
+ // Send children back to the requesting client (parentId distinguishes child updates from root updates)
155
+ ws.send(JSON.stringify({ type: "tree:update", viewId: msg.viewId, items: result.items as import("../../types/extension-messages.ts").TreeItemMsg[], parentId: msg.itemId } satisfies ExtServerMsg));
156
+ }
157
+ }
158
+ } catch (e) {
159
+ console.error(`[ExtWS] tree:expand error:`, e);
160
+ }
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
166
+ function handleClose(ws: ExtWsSocket): void {
167
+ clients.delete(ws);
168
+ console.log(`[ExtWS] Client disconnected (${clients.size} remaining)`);
169
+ }
170
+
171
+ export const extensionWebSocket = {
172
+ open: handleOpen,
173
+ message: handleMessage,
174
+ close: handleClose,
175
+ };
@@ -9,6 +9,8 @@ const MAX_RETRY_CONFIG_KEY = "account_max_retry";
9
9
  const BACKOFF_BASE_MS = 1_000;
10
10
  const BACKOFF_MAX_MS = 30 * 60_000;
11
11
  const AUTH_BACKOFF_BASE_MS = 5 * 60_000; // 5min base for auth errors (longer than rate limits)
12
+ /** Skip accounts whose 5-hour utilization >= this threshold (proactive avoidance) */
13
+ const FIVE_HOUR_SKIP_THRESHOLD = 0.95;
12
14
 
13
15
  class AccountSelectorService {
14
16
  private cursor = 0;
@@ -74,18 +76,25 @@ class AccountSelectorService {
74
76
  return null;
75
77
  }
76
78
 
79
+ // Proactive: skip accounts whose 5-hour utilization >= 95%
80
+ const usable = active.filter((a) => {
81
+ const snap = getLatestSnapshotForAccount(a.id);
82
+ return !snap || (snap.five_hour_util ?? 0) < FIVE_HOUR_SKIP_THRESHOLD;
83
+ });
84
+ const candidates = usable.length > 0 ? usable : active; // fallback to all if every account is near limit
85
+
77
86
  let pickedId: string;
78
87
  const strategy = this.getStrategy();
79
88
  if (strategy === "lowest-usage") {
80
- pickedId = this.pickLowestUsage(active);
89
+ pickedId = this.pickLowestUsage(candidates);
81
90
  } else if (strategy === "fill-first") {
82
- const sorted = [...active].sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt);
91
+ const sorted = [...candidates].sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt);
83
92
  pickedId = sorted[0]!.id;
84
93
  } else {
85
94
  // Round-robin
86
- this.cursor = this.cursor % active.length;
87
- pickedId = active[this.cursor]!.id;
88
- this.cursor = (this.cursor + 1) % active.length;
95
+ this.cursor = this.cursor % candidates.length;
96
+ pickedId = candidates[this.cursor]!.id;
97
+ this.cursor = (this.cursor + 1) % candidates.length;
89
98
  }
90
99
  this._lastPickedId = pickedId;
91
100
  const result = accountService.getWithTokens(pickedId);
@@ -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;
@@ -134,7 +139,7 @@ class AccountService {
134
139
  await this.refreshAccessToken(id, false);
135
140
  return this.getWithTokens(id);
136
141
  } catch (e) {
137
- console.error(`[accounts] Pre-flight refresh failed for ${id}:`, e);
142
+ console.error(`[accounts] Pre-flight refresh failed for ${id}: ${(e as Error).message ?? e}`);
138
143
  return null;
139
144
  }
140
145
  }
@@ -618,13 +623,13 @@ class AccountService {
618
623
  if (!row.id || !row.access_token) continue;
619
624
  const hasRefresh = !!row.refresh_token && row.refresh_token !== "";
620
625
 
621
- // Duplicate handling: if existing account has no refresh token but import does, upgrade it
626
+ // Duplicate handling: update existing account tokens from import
622
627
  const existingById = getAccountById(row.id);
623
628
  const existingByEmail = row.email ? this.list().find((a) => a.email === row.email) : null;
624
629
  const existing = existingById ?? (existingByEmail ? getAccountById(existingByEmail.id) : null);
625
630
  if (existing) {
626
- if (hasRefresh && !this.hasRefreshToken(existing.id)) {
627
- // Upgrade: import has refresh token, existing doesn't update tokens
631
+ if (hasRefresh) {
632
+ // Always update tokens when import has refresh token (handles expired/invalid tokens too)
628
633
  let accessToken = row.access_token;
629
634
  if (!looksEncrypted(accessToken)) accessToken = encrypt(accessToken);
630
635
  const refreshToken = looksEncrypted(row.refresh_token) ? row.refresh_token : encrypt(row.refresh_token);
@@ -636,9 +641,9 @@ class AccountService {
636
641
  });
637
642
  imported++;
638
643
  fullTransferIds.push(existing.id);
639
- console.log(`[accounts] Upgraded ${row.email ?? existing.id} with refresh token from import`);
644
+ console.log(`[accounts] Updated ${row.email ?? existing.id} tokens from import`);
640
645
  }
641
- continue; // skip if existing already has refresh token or import doesn't
646
+ continue; // skip if import doesn't have refresh token
642
647
  }
643
648
 
644
649
  // New account — insert
@@ -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
 
@@ -704,7 +709,7 @@ class AccountService {
704
709
  try {
705
710
  await this.refreshAccessToken(acc.id, false);
706
711
  } catch (e) {
707
- console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
712
+ console.error(`[accounts] Auto-refresh failed for ${acc.id}: ${(e as Error).message ?? e}`);
708
713
  }
709
714
  }
710
715
  };
@@ -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
  }
@@ -47,15 +47,20 @@ 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
- let pollTimer: ReturnType<typeof setInterval> | null = null;
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 };
51
59
 
52
60
  // Per-token cooldown map: token prefix → earliest allowed fetch time
53
61
  const tokenCooldowns = new Map<string, number>();
54
62
  const MIN_COOLDOWN_MS = 60_000; // floor: at least 60s cooldown on 429
55
63
 
56
- // Dedup: if a poll is already in-flight, reuse the same promise
57
- let inflightPoll: Promise<void> | null = null;
58
-
59
64
  // Legacy: Keychain token cache for users without accounts in DB
60
65
  let tokenCache: { token: string; timestamp: number } | null = null;
61
66
  const TOKEN_TTL = 300_000;
@@ -248,13 +253,13 @@ async function pollOnceInternal(): Promise<void> {
248
253
 
249
254
  /** Deduped: concurrent callers share a single in-flight fetch */
250
255
  async function pollOnce(): Promise<void> {
251
- if (inflightPoll) return inflightPoll;
256
+ if (hotState.inflightPoll) return hotState.inflightPoll;
252
257
  const thisPoll = pollOnceInternal().finally(() => {
253
258
  // Only clear if still the current poll — prevents a stale .finally() from
254
259
  // clearing a newer poll after timeout handler force-nulled inflightPoll.
255
- if (inflightPoll === thisPoll) inflightPoll = null;
260
+ if (hotState.inflightPoll === thisPoll) hotState.inflightPoll = null;
256
261
  });
257
- inflightPoll = thisPoll;
262
+ hotState.inflightPoll = thisPoll;
258
263
  return thisPoll;
259
264
  }
260
265
 
@@ -268,28 +273,28 @@ export function getUsageForAccount(accountId: string): ClaudeUsage {
268
273
  return row ? snapshotToUsage(row) : {};
269
274
  }
270
275
 
271
- /** Get usage for all accounts (excludes expired temporary accounts) */
276
+ /** Get usage for all accounts */
272
277
  export function getAllAccountUsages(): AccountUsageEntry[] {
273
- const nowS = Math.floor(Date.now() / 1000);
274
- const accounts = accountService.list().filter(acc => {
275
- // Exclude expired accounts without refresh token (temporary/invalid)
276
- if (!accountService.hasRefreshToken(acc.id) && acc.expiresAt && acc.expiresAt < nowS) return false;
277
- return true;
278
- });
278
+ const accounts = accountService.list();
279
279
  const snapshots = getAllLatestSnapshots();
280
280
  const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
281
- return accounts.map(acc => {
281
+ const nowS = Math.floor(Date.now() / 1000);
282
+ const result: AccountUsageEntry[] = [];
283
+ for (const acc of accounts) {
282
284
  const withTokens = accountService.getWithTokens(acc.id);
285
+ // Skip expired accounts without refresh token (temporary/disposable)
286
+ if (acc.expiresAt && acc.expiresAt < nowS && !withTokens?.refreshToken) continue;
283
287
  const isOAuth = withTokens?.accessToken.startsWith("sk-ant-oat") ?? false;
284
288
  const row = snapshotMap.get(acc.id);
285
- return {
289
+ result.push({
286
290
  accountId: acc.id,
287
291
  accountLabel: acc.label,
288
292
  accountStatus: acc.status,
289
293
  isOAuth,
290
294
  usage: row ? snapshotToUsage(row) : {},
291
- };
292
- });
295
+ });
296
+ }
297
+ return result;
293
298
  }
294
299
 
295
300
  /** Get cached usage for active account (used by chat header) */
@@ -314,10 +319,10 @@ export function getCachedUsage(): ClaudeUsage & { activeAccountId?: string; acti
314
319
  }
315
320
 
316
321
  export function startUsagePolling(): void {
317
- if (pollTimer) return;
322
+ if (hotState.pollTimer) return;
318
323
  const POLL_TIMEOUT = 60_000; // max 60s per poll iteration
319
324
  const scheduleNext = () => {
320
- pollTimer = setTimeout(async () => {
325
+ hotState.pollTimer = setTimeout(async () => {
321
326
  const timeout = new Promise<"timeout">(r => setTimeout(() => r("timeout"), POLL_TIMEOUT));
322
327
  const result = await Promise.race([
323
328
  pollOnce().then(() => "done" as const),
@@ -325,7 +330,7 @@ export function startUsagePolling(): void {
325
330
  ]).catch(() => "error" as const);
326
331
  // If the poll timed out, force-clear inflightPoll so next scheduled poll
327
332
  // starts a fresh fetch instead of reusing the stale hanging promise.
328
- if (result === "timeout") inflightPoll = null;
333
+ if (result === "timeout") hotState.inflightPoll = null;
329
334
  scheduleNext();
330
335
  }, POLL_INTERVAL);
331
336
  };
@@ -333,7 +338,7 @@ export function startUsagePolling(): void {
333
338
  }
334
339
 
335
340
  export function stopUsagePolling(): void {
336
- if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
341
+ if (hotState.pollTimer) { clearTimeout(hotState.pollTimer); hotState.pollTimer = null; }
337
342
  }
338
343
 
339
344
  export function updateFromSdkEvent(_rateLimitType?: string, _utilization?: number, costUsd?: number): void {
@@ -348,8 +353,8 @@ export async function refreshUsageNow(): Promise<ClaudeUsage & { activeAccountId
348
353
  /** @internal Test-only: reset module-level state between tests */
349
354
  export function _resetForTesting(): void {
350
355
  inMemoryCostUsd = 0;
351
- if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
356
+ if (hotState.pollTimer) { clearTimeout(hotState.pollTimer); hotState.pollTimer = null; }
352
357
  tokenCooldowns.clear();
353
- inflightPoll = null;
358
+ hotState.inflightPoll = null;
354
359
  tokenCache = null;
355
360
  }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Cloud WebSocket client — persistent connection from supervisor to PPM Cloud.
3
+ * Auto-reconnects with exponential backoff + jitter. Queues messages when disconnected.
4
+ */
5
+ import { appendFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ // ─── Types (must match Cloud's ws-types.ts) ─────────
10
+ interface WsMessage {
11
+ type: string;
12
+ id?: string;
13
+ timestamp: string;
14
+ }
15
+
16
+ interface HeartbeatMsg extends WsMessage {
17
+ type: "heartbeat";
18
+ tunnelUrl: string | null;
19
+ state: string;
20
+ appVersion: string;
21
+ availableVersion: string | null;
22
+ serverPid: number | null;
23
+ uptime: number;
24
+ deviceName?: string;
25
+ }
26
+
27
+ interface StateChangeMsg extends WsMessage {
28
+ type: "state_change";
29
+ from: string;
30
+ to: string;
31
+ reason: string;
32
+ }
33
+
34
+ interface CommandAckMsg extends WsMessage {
35
+ type: "command_ack";
36
+ id: string;
37
+ }
38
+
39
+ interface CommandResultMsg extends WsMessage {
40
+ type: "command_result";
41
+ id: string;
42
+ success: boolean;
43
+ error?: string;
44
+ data?: Record<string, unknown>;
45
+ }
46
+
47
+ type OutboundMsg = HeartbeatMsg | StateChangeMsg | CommandAckMsg | CommandResultMsg;
48
+
49
+ interface CommandMsg extends WsMessage {
50
+ type: "command";
51
+ id: string;
52
+ action: string;
53
+ params?: Record<string, unknown>;
54
+ }
55
+
56
+ type CommandHandler = (cmd: CommandMsg) => void;
57
+
58
+ // ─── Constants ──────────────────────────────────────
59
+ const BACKOFF_STEPS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
60
+ const MAX_QUEUE_SIZE = 50;
61
+ const HEARTBEAT_INTERVAL_MS = 60_000; // 60s via WS
62
+
63
+ // ─── State ──────────────────────────────────────────
64
+ let ws: WebSocket | null = null;
65
+ let connected = false;
66
+ let reconnecting = false;
67
+ let reconnectAttempt = 0;
68
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
69
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
70
+ let commandHandler: CommandHandler | null = null;
71
+ let outboundQueue: OutboundMsg[] = [];
72
+ let wsUrl = "";
73
+ let shouldConnect = false;
74
+
75
+ // Credentials for first-message auth
76
+ let deviceId = "";
77
+ let secretKey = "";
78
+
79
+ // For heartbeat payload
80
+ let getHeartbeatData: (() => HeartbeatMsg) | null = null;
81
+
82
+ // ─── Public API ─────────────────────────────────────
83
+
84
+ export function connect(opts: {
85
+ cloudUrl: string;
86
+ deviceId: string;
87
+ secretKey: string;
88
+ heartbeatFn: () => HeartbeatMsg;
89
+ }): void {
90
+ // No secret_key in URL — auth via first message after connect
91
+ wsUrl = `${opts.cloudUrl.replace(/^http/, "ws")}/ws/device`;
92
+ deviceId = opts.deviceId;
93
+ secretKey = opts.secretKey;
94
+ getHeartbeatData = opts.heartbeatFn;
95
+ shouldConnect = true;
96
+ reconnectAttempt = 0;
97
+ doConnect();
98
+ }
99
+
100
+ export function disconnect(): void {
101
+ shouldConnect = false;
102
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
103
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
104
+ if (ws) {
105
+ try { ws.close(1000, "shutdown"); } catch {}
106
+ ws = null;
107
+ }
108
+ connected = false;
109
+ outboundQueue = [];
110
+ }
111
+
112
+ export function send(msg: OutboundMsg): void {
113
+ if (connected && ws?.readyState === WebSocket.OPEN) {
114
+ ws.send(JSON.stringify(msg));
115
+ } else {
116
+ outboundQueue.push(msg);
117
+ if (outboundQueue.length > MAX_QUEUE_SIZE) outboundQueue.shift();
118
+ }
119
+ }
120
+
121
+ export function onCommand(handler: CommandHandler): void {
122
+ commandHandler = handler;
123
+ }
124
+
125
+ export function isConnected(): boolean {
126
+ return connected;
127
+ }
128
+
129
+ // ─── Internal ───────────────────────────────────────
130
+
131
+ function doConnect(): void {
132
+ if (!shouldConnect || reconnecting) return;
133
+ reconnecting = true;
134
+
135
+ // Capture local ref — if a reconnect replaces `ws` before this socket's
136
+ // handlers fire, stale handlers must not reset module-level state.
137
+ let sock: WebSocket;
138
+ try {
139
+ sock = new WebSocket(wsUrl);
140
+ ws = sock;
141
+ } catch {
142
+ reconnecting = false;
143
+ scheduleReconnect("constructor");
144
+ return;
145
+ }
146
+
147
+ sock.onopen = () => {
148
+ if (ws !== sock) return; // stale — newer connection replaced us
149
+ reconnecting = false;
150
+ reconnectAttempt = 0;
151
+ log("INFO", "Cloud WS connected, sending auth");
152
+
153
+ // Send auth as first message — server must process this before any other msg
154
+ sock.send(JSON.stringify({
155
+ type: "auth",
156
+ deviceId,
157
+ secretKey,
158
+ timestamp: new Date().toISOString(),
159
+ version: 1,
160
+ }));
161
+
162
+ // Delay setting connected + sending heartbeat to let server process auth.
163
+ // Server's authenticateDevice() is async (DB lookup), so messages sent
164
+ // immediately after auth arrive before authenticated=true → 4002 reject.
165
+ setTimeout(() => {
166
+ if (ws !== sock) return; // replaced during delay
167
+ connected = true;
168
+
169
+ // Flush queued messages
170
+ while (outboundQueue.length > 0 && connected) {
171
+ const msg = outboundQueue.shift()!;
172
+ sock.send(JSON.stringify(msg));
173
+ }
174
+
175
+ // Send immediate heartbeat
176
+ if (getHeartbeatData) send(getHeartbeatData());
177
+
178
+ // Start periodic heartbeat
179
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
180
+ heartbeatTimer = setInterval(() => {
181
+ if (getHeartbeatData && connected) send(getHeartbeatData());
182
+ }, HEARTBEAT_INTERVAL_MS);
183
+ }, 500); // 500ms for DB auth round-trip
184
+ };
185
+
186
+ sock.onmessage = (event) => {
187
+ try {
188
+ const msg = JSON.parse(String(event.data)) as CommandMsg;
189
+ if (msg.type === "command" && commandHandler) {
190
+ commandHandler(msg);
191
+ }
192
+ } catch {} // ignore malformed
193
+ };
194
+
195
+ sock.onclose = (event) => {
196
+ if (ws !== sock) return; // stale — ignore close from replaced connection
197
+ log("WARN", `Cloud WS closed: code=${event.code} reason=${event.reason || ""}`);
198
+ connected = false;
199
+ reconnecting = false;
200
+ ws = null;
201
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
202
+ if (shouldConnect) scheduleReconnect("onclose");
203
+ };
204
+
205
+ sock.onerror = (event) => {
206
+ log("ERROR", `Cloud WS error: ${String(event)}`);
207
+ };
208
+ }
209
+
210
+ function scheduleReconnect(source = "unknown"): void {
211
+ if (!shouldConnect || reconnectTimer) return;
212
+ const base = BACKOFF_STEPS[Math.min(reconnectAttempt, BACKOFF_STEPS.length - 1)]!;
213
+ // Add ±30% jitter to prevent thundering herd after Cloud deploy
214
+ const jitter = base * (0.7 + Math.random() * 0.6);
215
+ const delay = Math.round(jitter);
216
+ reconnectAttempt++;
217
+ log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt}) src=${source}`);
218
+ reconnectTimer = setTimeout(() => {
219
+ reconnectTimer = null;
220
+ doConnect();
221
+ }, delay);
222
+ }
223
+
224
+ function log(level: string, msg: string): void {
225
+ const ts = new Date().toISOString();
226
+ const logFile = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "ppm.log");
227
+ try { appendFileSync(logFile, `[${ts}] [${level}] [cloud-ws] ${msg}\n`); } catch {}
228
+ }