@hienlh/ppm 0.8.72 → 0.8.73

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.73] - 2026-04-01
4
+
5
+ ### Fixed
6
+ - **Cloud WS reconnect loop**: Stale WebSocket closure handlers from replaced connections no longer reset module state — prevents infinite reconnect cycle after upgrade/restart
7
+
3
8
  ## [0.8.72] - 2026-03-31
4
9
 
5
10
  ### 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.73",
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,24 @@ 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;
138
143
  log("INFO", "Cloud WS connected, sending auth");
139
144
 
140
- // Send auth as first message (not in URL)
141
- ws!.send(JSON.stringify({
145
+ sock.send(JSON.stringify({
142
146
  type: "auth",
143
147
  deviceId,
144
148
  secretKey,
@@ -152,7 +156,7 @@ function doConnect(): void {
152
156
  // Flush queued messages
153
157
  while (outboundQueue.length > 0 && connected) {
154
158
  const msg = outboundQueue.shift()!;
155
- ws!.send(JSON.stringify(msg));
159
+ sock.send(JSON.stringify(msg));
156
160
  }
157
161
 
158
162
  // Send immediate heartbeat
@@ -165,7 +169,7 @@ function doConnect(): void {
165
169
  }, HEARTBEAT_INTERVAL_MS);
166
170
  };
167
171
 
168
- ws.onmessage = (event) => {
172
+ sock.onmessage = (event) => {
169
173
  try {
170
174
  const msg = JSON.parse(String(event.data)) as CommandMsg;
171
175
  if (msg.type === "command" && commandHandler) {
@@ -174,27 +178,29 @@ function doConnect(): void {
174
178
  } catch {} // ignore malformed
175
179
  };
176
180
 
177
- ws.onclose = () => {
181
+ sock.onclose = (event) => {
182
+ if (ws !== sock) return; // stale — ignore close from replaced connection
183
+ log("WARN", `Cloud WS closed: code=${event.code} reason=${event.reason || ""}`);
178
184
  connected = false;
179
185
  reconnecting = false;
180
186
  ws = null;
181
187
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
182
- if (shouldConnect) scheduleReconnect();
188
+ if (shouldConnect) scheduleReconnect("onclose");
183
189
  };
184
190
 
185
- ws.onerror = () => {
186
- // onclose will fire after onerror — reconnect handled there
191
+ sock.onerror = (event) => {
192
+ log("ERROR", `Cloud WS error: ${String(event)}`);
187
193
  };
188
194
  }
189
195
 
190
- function scheduleReconnect(): void {
196
+ function scheduleReconnect(source = "unknown"): void {
191
197
  if (!shouldConnect || reconnectTimer) return;
192
198
  const base = BACKOFF_STEPS[Math.min(reconnectAttempt, BACKOFF_STEPS.length - 1)]!;
193
199
  // Add ±30% jitter to prevent thundering herd after Cloud deploy
194
200
  const jitter = base * (0.7 + Math.random() * 0.6);
195
201
  const delay = Math.round(jitter);
196
202
  reconnectAttempt++;
197
- log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt})`);
203
+ log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt}) src=${source}`);
198
204
  reconnectTimer = setTimeout(() => {
199
205
  reconnectTimer = null;
200
206
  doConnect();