@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.
- package/CHANGELOG.md +238 -0
- package/bun.lock +17 -0
- package/dist/web/assets/api-settings-BUvk6Saw.js +1 -0
- package/dist/web/assets/arrow-up-BYhx9ckd.js +1 -0
- package/dist/web/assets/browser-tab-CrkhFCaw.js +1 -0
- package/dist/web/assets/chat-tab-C6jpiwh7.js +8 -0
- package/dist/web/assets/chevron-right-5HgK6l7K.js +1 -0
- package/dist/web/assets/code-editor-CBIPzlP2.js +2 -0
- package/dist/web/assets/columns-2-cEVJHYd7.js +1 -0
- package/dist/web/assets/createLucideIcon-PuMiQgHl.js +1 -0
- package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-ncSOnJSC.js} +2 -2
- package/dist/web/assets/database-viewer-BqOJR_zi.js +1 -0
- package/dist/web/assets/diff-viewer-CcLyp4eY.js +4 -0
- package/dist/web/assets/{dist-CALwEtco.js → dist-DIV6WgAG.js} +1 -1
- package/dist/web/assets/{dist-DGDPTxs1.js → dist-ovWkrgO-.js} +1 -1
- package/dist/web/assets/extension-webview-NiZ7Ybvv.js +3 -0
- package/dist/web/assets/git-graph-CoTvMrIo.js +1 -0
- package/dist/web/assets/index-C8byznLO.js +37 -0
- package/dist/web/assets/index-KwC2YrG4.css +2 -0
- package/dist/web/assets/jsx-runtime-kMwlnEGE.js +1 -0
- package/dist/web/assets/keybindings-store-DPYzBe_M.js +1 -0
- package/dist/web/assets/{markdown-renderer-DklUd_Gv.js → markdown-renderer-DPLdR9xc.js} +4 -4
- package/dist/web/assets/postgres-viewer-BeiK4lCa.js +1 -0
- package/dist/web/assets/settings-tab-D3AvU4lu.js +1 -0
- package/dist/web/assets/sqlite-viewer-nA2sD4Yv.js +1 -0
- package/dist/web/assets/tab-store-BOgTrqRr.js +1 -0
- package/dist/web/assets/table-DFevCOMd.js +1 -0
- package/dist/web/assets/tag-CXMT0QB6.js +1 -0
- package/dist/web/assets/{terminal-tab-CqRuiIFn.js → terminal-tab-BBi0pEji.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-Dcz3aLAE.js → use-monaco-theme-B5pG2d1w.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
- package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
- package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +128 -1
- package/docs/codebase-summary.md +79 -12
- package/docs/extension-development-guide.md +532 -0
- package/docs/project-changelog.md +51 -1
- package/docs/project-roadmap.md +9 -3
- package/docs/streaming-input-guide.md +267 -0
- package/docs/system-architecture.md +432 -3
- package/package.json +6 -3
- package/packages/ext-database/package.json +41 -0
- package/packages/ext-database/src/connection-tree.ts +142 -0
- package/packages/ext-database/src/extension.ts +346 -0
- package/packages/ext-database/src/query-panel.ts +120 -0
- package/packages/ext-database/src/table-viewer-panel.ts +410 -0
- package/packages/ext-database/tsconfig.json +8 -0
- package/packages/vscode-compat/package.json +16 -0
- package/packages/vscode-compat/src/commands.ts +39 -0
- package/packages/vscode-compat/src/context.ts +65 -0
- package/packages/vscode-compat/src/disposable.ts +21 -0
- package/packages/vscode-compat/src/env.ts +20 -0
- package/packages/vscode-compat/src/event-emitter.ts +28 -0
- package/packages/vscode-compat/src/index.ts +93 -0
- package/packages/vscode-compat/src/not-supported.ts +15 -0
- package/packages/vscode-compat/src/types.ts +167 -0
- package/packages/vscode-compat/src/uri.ts +65 -0
- package/packages/vscode-compat/src/window.ts +229 -0
- package/packages/vscode-compat/src/workspace.ts +76 -0
- package/packages/vscode-compat/tsconfig.json +10 -0
- package/snapshot-state.md +1526 -0
- package/src/cli/commands/autostart.ts +1 -1
- package/src/cli/commands/ext-cmd.ts +121 -0
- package/src/cli/commands/restart.ts +9 -1
- package/src/cli/commands/status.ts +19 -0
- package/src/index.ts +5 -3
- package/src/providers/claude-agent-sdk.ts +221 -17
- package/src/providers/cli-provider-base.ts +6 -0
- package/src/server/index.ts +55 -155
- package/src/server/routes/chat.ts +81 -11
- package/src/server/routes/extensions.ts +81 -0
- package/src/server/routes/project-scoped.ts +2 -0
- package/src/server/routes/settings.ts +27 -0
- package/src/server/routes/workspace.ts +35 -0
- package/src/server/ws/chat.ts +9 -3
- package/src/server/ws/extensions.ts +175 -0
- package/src/services/account-selector.service.ts +14 -5
- package/src/services/account.service.ts +20 -15
- package/src/services/claude-usage.service.ts +29 -24
- package/src/services/cloud-ws.service.ts +228 -0
- package/src/services/cloud.service.ts +11 -6
- package/src/services/contribution-registry.ts +110 -0
- package/src/services/db.service.ts +181 -4
- package/src/services/extension-host-worker.ts +160 -0
- package/src/services/extension-installer.ts +112 -0
- package/src/services/extension-manifest.ts +65 -0
- package/src/services/extension-rpc-handlers.ts +235 -0
- package/src/services/extension-rpc.ts +105 -0
- package/src/services/extension.service.ts +228 -0
- package/src/services/mcp-config.service.ts +15 -6
- package/src/services/supervisor.ts +271 -25
- package/src/types/api.ts +1 -0
- package/src/types/chat.ts +4 -0
- package/src/types/extension-messages.ts +64 -0
- package/src/types/extension.ts +131 -0
- package/src/web/app.tsx +69 -48
- package/src/web/components/chat/account-rotation-settings.tsx +163 -0
- package/src/web/components/chat/chat-history-bar.tsx +106 -10
- package/src/web/components/chat/chat-tab.tsx +15 -10
- package/src/web/components/chat/chat-welcome.tsx +148 -0
- package/src/web/components/chat/message-list.tsx +19 -6
- package/src/web/components/chat/session-picker.tsx +80 -32
- package/src/web/components/chat/usage-badge.tsx +68 -8
- package/src/web/components/editor/editor-breadcrumb.tsx +20 -29
- package/src/web/components/extensions/extension-inputbox.tsx +92 -0
- package/src/web/components/extensions/extension-quickpick.tsx +194 -0
- package/src/web/components/extensions/extension-tree-view.tsx +240 -0
- package/src/web/components/extensions/extension-webview.tsx +83 -0
- package/src/web/components/layout/command-palette.tsx +22 -2
- package/src/web/components/layout/editor-panel.tsx +163 -18
- package/src/web/components/layout/mobile-nav.tsx +2 -1
- package/src/web/components/layout/sidebar.tsx +21 -3
- package/src/web/components/layout/status-bar.tsx +64 -0
- package/src/web/components/layout/tab-bar.tsx +2 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/layout/upgrade-banner.tsx +15 -5
- package/src/web/components/settings/change-password-section.tsx +128 -0
- package/src/web/components/settings/extension-manager-section.tsx +214 -0
- package/src/web/components/settings/settings-tab.tsx +9 -2
- package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
- package/src/web/hooks/use-chat.ts +28 -0
- package/src/web/hooks/use-extension-ws.ts +181 -0
- package/src/web/hooks/use-global-keybindings.ts +18 -2
- package/src/web/hooks/use-server-reload.ts +9 -0
- package/src/web/hooks/use-url-sync.ts +173 -21
- package/src/web/stores/connection-store.ts +39 -0
- package/src/web/stores/extension-store.ts +204 -0
- package/src/web/stores/panel-store.ts +63 -9
- package/src/web/stores/panel-utils.ts +145 -3
- package/src/web/stores/settings-store.ts +7 -2
- package/src/web/stores/tab-store.ts +2 -1
- package/test-session-ops.mjs +444 -0
- package/test-tokens.mjs +212 -0
- package/tsconfig.json +3 -1
- package/dist/web/assets/api-settings-D21InCnR.js +0 -1
- package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
- package/dist/web/assets/browser-tab-BEe89aSD.js +0 -1
- package/dist/web/assets/chat-tab-9lqvWozA.js +0 -7
- package/dist/web/assets/chevron-right-CHnjJt4E.js +0 -1
- package/dist/web/assets/code-editor-COAIZx-B.js +0 -2
- package/dist/web/assets/columns-2-DbesTfa7.js +0 -1
- package/dist/web/assets/database-viewer-aRR9n_Ui.js +0 -1
- package/dist/web/assets/diff-viewer-C4KMvpHr.js +0 -4
- package/dist/web/assets/dist-CVTST7Gc.js +0 -1
- package/dist/web/assets/git-graph-CfJjl4E3.js +0 -1
- package/dist/web/assets/index-Db8uky1a.css +0 -2
- package/dist/web/assets/index-DxZuwBDe.js +0 -37
- package/dist/web/assets/jsx-runtime-BRW_vwa9.js +0 -1
- package/dist/web/assets/keybindings-store-_uWVCZMv.js +0 -1
- package/dist/web/assets/postgres-viewer-DEAvAyaX.js +0 -1
- package/dist/web/assets/settings-tab-BQedc-No.js +0 -1
- package/dist/web/assets/sqlite-viewer-BPA5idzT.js +0 -1
- package/dist/web/assets/tab-store-DhK6EpBT.js +0 -1
- package/dist/web/assets/table-CQVQM2SB.js +0 -1
- 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(
|
|
89
|
+
pickedId = this.pickLowestUsage(candidates);
|
|
81
90
|
} else if (strategy === "fill-first") {
|
|
82
|
-
const sorted = [...
|
|
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 %
|
|
87
|
-
pickedId =
|
|
88
|
-
this.cursor = (this.cursor + 1) %
|
|
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}
|
|
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:
|
|
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
|
|
627
|
-
//
|
|
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]
|
|
644
|
+
console.log(`[accounts] Updated ${row.email ?? existing.id} tokens from import`);
|
|
640
645
|
}
|
|
641
|
-
continue; // skip if
|
|
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 (
|
|
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}
|
|
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
|
-
|
|
737
|
+
acctHotState.refreshTimer = setInterval(() => {
|
|
733
738
|
refreshExpiring().catch(() => {});
|
|
734
739
|
cleanupExpiredTemporary();
|
|
735
740
|
}, CHECK_INTERVAL_MS);
|
|
736
741
|
|
|
737
|
-
if (typeof
|
|
738
|
-
(
|
|
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 (
|
|
744
|
-
clearInterval(
|
|
745
|
-
|
|
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
|
-
|
|
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
|
|
276
|
+
/** Get usage for all accounts */
|
|
272
277
|
export function getAllAccountUsages(): AccountUsageEntry[] {
|
|
273
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|