@hienlh/ppm 0.8.72 → 0.8.74
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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.74] - 2026-04-01
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Cloud WS reconnect loop**: Stale WebSocket closure handlers from replaced connections no longer reset module state
|
|
7
|
+
- **Cloud WS auth race**: Delay heartbeat/queue flush 500ms after auth to let server complete async DB auth — prevents 4002 rejection
|
|
8
|
+
|
|
3
9
|
## [0.8.72] - 2026-03-31
|
|
4
10
|
|
|
5
11
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hienlh/ppm",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.74",
|
|
4
4
|
"description": "Personal Project Manager — mobile-first web IDE with AI assistance",
|
|
5
5
|
"author": "hienlh",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"dev": "concurrently \"bun run dev:server\" \"bun run dev:web\"",
|
|
14
|
-
"dev:server": "bun run --hot src/index.ts
|
|
14
|
+
"dev:server": "bun run --hot src/server/index.ts __serve__ 8081 0.0.0.0 '' dev",
|
|
15
15
|
"dev:web": "bun run vite --config vite.config.ts",
|
|
16
16
|
"build:web": "bun run vite build --config vite.config.ts",
|
|
17
17
|
"build": "bun run build:web && bun build src/index.ts --compile --outfile dist/ppm",
|
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
} from "./provider.interface.ts";
|
|
14
14
|
import { configService } from "../services/config.service.ts";
|
|
15
15
|
import { updateFromSdkEvent } from "../services/claude-usage.service.ts";
|
|
16
|
-
import { getSessionMapping, setSessionMapping, getSessionTitles } from "../services/db.service.ts";
|
|
16
|
+
import { getSessionMapping, setSessionMapping, getSessionTitles, getSessionTitle } from "../services/db.service.ts";
|
|
17
17
|
import { accountSelector } from "../services/account-selector.service.ts";
|
|
18
18
|
import { accountService } from "../services/account.service.ts";
|
|
19
19
|
import { resolve } from "node:path";
|
|
@@ -176,10 +176,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
176
176
|
(s) => s.sessionId === sessionId || s.sessionId === mappedSdkId,
|
|
177
177
|
);
|
|
178
178
|
if (found) {
|
|
179
|
+
const dbTitle = getSessionTitle(found.sessionId);
|
|
179
180
|
const meta: Session = {
|
|
180
181
|
id: sessionId,
|
|
181
182
|
providerId: this.id,
|
|
182
|
-
title: found.customTitle ?? found.summary ?? "Resumed Chat",
|
|
183
|
+
title: dbTitle ?? found.customTitle ?? found.summary ?? "Resumed Chat",
|
|
183
184
|
createdAt: new Date(found.lastModified).toISOString(),
|
|
184
185
|
};
|
|
185
186
|
this.activeSessions.set(sessionId, meta);
|
|
@@ -125,20 +125,26 @@ function doConnect(): void {
|
|
|
125
125
|
if (!shouldConnect || reconnecting) return;
|
|
126
126
|
reconnecting = true;
|
|
127
127
|
|
|
128
|
+
// Capture local ref — if a reconnect replaces `ws` before this socket's
|
|
129
|
+
// handlers fire, stale handlers must not reset module-level state.
|
|
130
|
+
let sock: WebSocket;
|
|
128
131
|
try {
|
|
129
|
-
|
|
132
|
+
sock = new WebSocket(wsUrl);
|
|
133
|
+
ws = sock;
|
|
130
134
|
} catch {
|
|
131
135
|
reconnecting = false;
|
|
132
|
-
scheduleReconnect();
|
|
136
|
+
scheduleReconnect("constructor");
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
|
|
140
|
+
sock.onopen = () => {
|
|
141
|
+
if (ws !== sock) return; // stale — newer connection replaced us
|
|
137
142
|
reconnecting = false;
|
|
143
|
+
reconnectAttempt = 0;
|
|
138
144
|
log("INFO", "Cloud WS connected, sending auth");
|
|
139
145
|
|
|
140
|
-
// Send auth as first message
|
|
141
|
-
|
|
146
|
+
// Send auth as first message — server must process this before any other msg
|
|
147
|
+
sock.send(JSON.stringify({
|
|
142
148
|
type: "auth",
|
|
143
149
|
deviceId,
|
|
144
150
|
secretKey,
|
|
@@ -146,26 +152,31 @@ function doConnect(): void {
|
|
|
146
152
|
version: 1,
|
|
147
153
|
}));
|
|
148
154
|
|
|
149
|
-
connected
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
// Delay setting connected + sending heartbeat to let server process auth.
|
|
156
|
+
// Server's authenticateDevice() is async (DB lookup), so messages sent
|
|
157
|
+
// immediately after auth arrive before authenticated=true → 4002 reject.
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
if (ws !== sock) return; // replaced during delay
|
|
160
|
+
connected = true;
|
|
161
|
+
|
|
162
|
+
// Flush queued messages
|
|
163
|
+
while (outboundQueue.length > 0 && connected) {
|
|
164
|
+
const msg = outboundQueue.shift()!;
|
|
165
|
+
sock.send(JSON.stringify(msg));
|
|
166
|
+
}
|
|
157
167
|
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
// Send immediate heartbeat
|
|
169
|
+
if (getHeartbeatData) send(getHeartbeatData());
|
|
160
170
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
// Start periodic heartbeat
|
|
172
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
173
|
+
heartbeatTimer = setInterval(() => {
|
|
174
|
+
if (getHeartbeatData && connected) send(getHeartbeatData());
|
|
175
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
176
|
+
}, 500); // 500ms for DB auth round-trip
|
|
166
177
|
};
|
|
167
178
|
|
|
168
|
-
|
|
179
|
+
sock.onmessage = (event) => {
|
|
169
180
|
try {
|
|
170
181
|
const msg = JSON.parse(String(event.data)) as CommandMsg;
|
|
171
182
|
if (msg.type === "command" && commandHandler) {
|
|
@@ -174,27 +185,29 @@ function doConnect(): void {
|
|
|
174
185
|
} catch {} // ignore malformed
|
|
175
186
|
};
|
|
176
187
|
|
|
177
|
-
|
|
188
|
+
sock.onclose = (event) => {
|
|
189
|
+
if (ws !== sock) return; // stale — ignore close from replaced connection
|
|
190
|
+
log("WARN", `Cloud WS closed: code=${event.code} reason=${event.reason || ""}`);
|
|
178
191
|
connected = false;
|
|
179
192
|
reconnecting = false;
|
|
180
193
|
ws = null;
|
|
181
194
|
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
182
|
-
if (shouldConnect) scheduleReconnect();
|
|
195
|
+
if (shouldConnect) scheduleReconnect("onclose");
|
|
183
196
|
};
|
|
184
197
|
|
|
185
|
-
|
|
186
|
-
|
|
198
|
+
sock.onerror = (event) => {
|
|
199
|
+
log("ERROR", `Cloud WS error: ${String(event)}`);
|
|
187
200
|
};
|
|
188
201
|
}
|
|
189
202
|
|
|
190
|
-
function scheduleReconnect(): void {
|
|
203
|
+
function scheduleReconnect(source = "unknown"): void {
|
|
191
204
|
if (!shouldConnect || reconnectTimer) return;
|
|
192
205
|
const base = BACKOFF_STEPS[Math.min(reconnectAttempt, BACKOFF_STEPS.length - 1)]!;
|
|
193
206
|
// Add ±30% jitter to prevent thundering herd after Cloud deploy
|
|
194
207
|
const jitter = base * (0.7 + Math.random() * 0.6);
|
|
195
208
|
const delay = Math.round(jitter);
|
|
196
209
|
reconnectAttempt++;
|
|
197
|
-
log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt})`);
|
|
210
|
+
log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt}) src=${source}`);
|
|
198
211
|
reconnectTimer = setTimeout(() => {
|
|
199
212
|
reconnectTimer = null;
|
|
200
213
|
doConnect();
|