@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.72",
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 start --profile dev -f",
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
- ws = new WebSocket(wsUrl);
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
- ws.onopen = () => {
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 (not in URL)
141
- ws!.send(JSON.stringify({
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 = true;
150
- reconnectAttempt = 0;
151
-
152
- // Flush queued messages
153
- while (outboundQueue.length > 0 && connected) {
154
- const msg = outboundQueue.shift()!;
155
- ws!.send(JSON.stringify(msg));
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
- // Send immediate heartbeat
159
- if (getHeartbeatData) send(getHeartbeatData());
168
+ // Send immediate heartbeat
169
+ if (getHeartbeatData) send(getHeartbeatData());
160
170
 
161
- // Start periodic heartbeat
162
- if (heartbeatTimer) clearInterval(heartbeatTimer);
163
- heartbeatTimer = setInterval(() => {
164
- if (getHeartbeatData && connected) send(getHeartbeatData());
165
- }, HEARTBEAT_INTERVAL_MS);
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
- ws.onmessage = (event) => {
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
- ws.onclose = () => {
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
- ws.onerror = () => {
186
- // onclose will fire after onerror — reconnect handled there
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();