@hienlh/ppm 0.9.68 → 0.9.70

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.70] - 2026-04-09
4
+
5
+ ### Fixed
6
+ - **SDK subprocess crash on resume**: Resumed sessions with existing JSONL files would crash (exit code 1) because `--session-id` was used instead of `--resume`. Now always uses `--resume` for resumed sessions.
7
+ - **Session lookup**: Use targeted `getSessionInfo()` with correct project dir instead of listing all sessions.
8
+ - **SDK stderr capture**: Subprocess stderr is now logged on crash for diagnostics.
9
+ - **Thinking budget option**: Fixed `thinkingBudgetTokens` → `maxThinkingTokens` (correct SDK option name).
10
+
11
+ ## [0.9.69] - 2026-04-09
12
+
13
+ ### Fixed
14
+ - **Cloud WS replaced loop**: `isConnected()` now returns true during 500ms auth handshake, preventing Cloud monitor from killing valid connections. Fixed `disconnect()` not resetting `reconnecting` flag.
15
+ - **Token refresh buffer**: Increased OAuth token refresh buffer from 60s to 1 hour to prevent 401 errors mid-conversation.
16
+ - **SDK retry stale closures**: Extracted `closeCurrentStream()` helper that reads from session map instead of captured variables, preventing retry paths from closing already-replaced streams. Fixed phantom session entries from retry init events.
17
+
3
18
  ## [0.9.68] - 2026-04-08
4
19
 
5
20
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.68",
3
+ "version": "0.9.70",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -280,18 +280,33 @@ export class ClaudeAgentSdkProvider implements AIProvider {
280
280
  // Restore project_path from DB so resumed sessions can find JSONL
281
281
  const dbProjectPath = getSessionProjectPath(sessionId) ?? undefined;
282
282
 
283
+ // Try targeted lookup first (searches all project dirs), then fall back to list scan
283
284
  try {
284
- const sdkSessions = await sdkListSessions({ limit: 100 });
285
- const found = sdkSessions.find(
286
- (s) => s.sessionId === sessionId || s.sessionId === mappedSdkId,
287
- );
288
- if (found) {
285
+ const lookupId = mappedSdkId ?? sessionId;
286
+ const info = await sdkGetSessionInfo(lookupId, { dir: dbProjectPath });
287
+ if (!info && mappedSdkId) {
288
+ // Try the original PPM session ID as well
289
+ const info2 = await sdkGetSessionInfo(sessionId, { dir: dbProjectPath });
290
+ if (info2) {
291
+ const meta: Session = {
292
+ id: sessionId,
293
+ providerId: this.id,
294
+ title: info2.customTitle ?? info2.summary ?? "Resumed Chat",
295
+ projectPath: dbProjectPath,
296
+ createdAt: new Date(info2.lastModified).toISOString(),
297
+ };
298
+ this.activeSessions.set(sessionId, meta);
299
+ this.messageCount.set(sessionId, 1);
300
+ return meta;
301
+ }
302
+ }
303
+ if (info) {
289
304
  const meta: Session = {
290
305
  id: sessionId,
291
306
  providerId: this.id,
292
- title: found.customTitle ?? found.summary ?? "Resumed Chat",
307
+ title: info.customTitle ?? info.summary ?? "Resumed Chat",
293
308
  projectPath: dbProjectPath,
294
- createdAt: new Date(found.lastModified).toISOString(),
309
+ createdAt: new Date(info.lastModified).toISOString(),
295
310
  };
296
311
  this.activeSessions.set(sessionId, meta);
297
312
  this.messageCount.set(sessionId, 1);
@@ -301,8 +316,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
301
316
  // SDK not available
302
317
  }
303
318
 
304
- // Session not found in SDK historytreat as new so first message
305
- // creates a fresh SDK session instead of trying to resume.
319
+ // Session not found in SDK listit may still have a JSONL on disk
320
+ // (sdkListSessions may not search the correct project directory).
321
+ // Use messageCount=1 so sendMessage uses --resume instead of --session-id.
322
+ // --resume gracefully fails if no JSONL exists, while --session-id crashes
323
+ // when a JSONL file for the same ID is already present on disk.
306
324
  const meta: Session = {
307
325
  id: sessionId,
308
326
  providerId: this.id,
@@ -311,7 +329,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
311
329
  createdAt: new Date().toISOString(),
312
330
  };
313
331
  this.activeSessions.set(sessionId, meta);
314
- this.messageCount.set(sessionId, 0);
332
+ this.messageCount.set(sessionId, 1);
315
333
  return meta;
316
334
  }
317
335
 
@@ -677,6 +695,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
677
695
  const mcpServers = mcpConfigService.list();
678
696
  const hasMcp = Object.keys(mcpServers).length > 0;
679
697
 
698
+ // Buffer subprocess stderr for crash diagnostics
699
+ let stderrBuffer = "";
700
+ const stderrCallback = (chunk: string) => {
701
+ stderrBuffer += chunk;
702
+ // Keep only last 2KB to avoid unbounded growth
703
+ if (stderrBuffer.length > 2048) stderrBuffer = stderrBuffer.slice(-2048);
704
+ };
705
+
680
706
  const queryOptions: Record<string, any> = {
681
707
  // On Windows, child_process.spawn("bun") fails with ENOENT — force node
682
708
  ...(process.platform === "win32" && { executable: "node" }),
@@ -697,9 +723,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
697
723
  maxTurns: providerConfig.max_turns ?? 1000,
698
724
  ...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
699
725
  ...(providerConfig.thinking_budget_tokens != null && {
700
- thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
726
+ maxThinkingTokens: providerConfig.thinking_budget_tokens,
701
727
  }),
702
728
  includePartialMessages: true,
729
+ stderr: stderrCallback,
703
730
  };
704
731
 
705
732
  // Crash retry: if subprocess exits with non-zero code before producing events,
@@ -710,16 +737,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
710
737
  crashRetryLoop: for (;;) {
711
738
  try {
712
739
  // Streaming input: create message channel and persistent query
713
- const { generator: streamGen, controller: streamCtrl } = createMessageChannel();
714
740
  const firstMsg = {
715
741
  type: 'user' as const,
716
742
  message: buildMessageParam(message),
717
743
  parent_tool_use_id: null,
718
744
  session_id: sessionId,
719
745
  };
720
- streamCtrl.push(firstMsg);
721
746
 
722
- const q = query({
747
+ const { generator: streamGen, controller: initialCtrl } = createMessageChannel();
748
+ initialCtrl.push(firstMsg);
749
+
750
+ const initialQuery = query({
723
751
  prompt: streamGen,
724
752
  options: {
725
753
  ...queryOptions,
@@ -727,9 +755,19 @@ export class ClaudeAgentSdkProvider implements AIProvider {
727
755
  canUseTool,
728
756
  } as any,
729
757
  });
730
- this.streamingSessions.set(sessionId, { meta, query: q, controller: streamCtrl });
731
- this.activeQueries.set(sessionId, q);
732
- let eventSource: AsyncIterable<any> = q;
758
+ this.streamingSessions.set(sessionId, { meta, query: initialQuery, controller: initialCtrl });
759
+ this.activeQueries.set(sessionId, initialQuery);
760
+ let eventSource: AsyncIterable<any> = initialQuery;
761
+
762
+ // Helper: close the CURRENT streaming session (not stale closure refs).
763
+ // All retry paths must use this instead of closing captured variables directly.
764
+ const closeCurrentStream = () => {
765
+ const ss = this.streamingSessions.get(sessionId);
766
+ if (ss) {
767
+ ss.controller.done();
768
+ ss.query.close();
769
+ }
770
+ };
733
771
 
734
772
  let lastPartialText = "";
735
773
  /** Number of tool_use blocks pending results (top-level tools only, not subagent children) */
@@ -759,9 +797,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
759
797
  if ((msg as any).type === "result" && (msg as any).subtype === "error_during_execution" && ((msg as any).num_turns ?? 0) === 0 && retryCount < MAX_RETRIES) {
760
798
  retryCount++;
761
799
  console.warn(`[sdk] transient error on first event — retrying (attempt ${retryCount}/${MAX_RETRIES})`);
762
- // Close failed query and old channel, create new channel + query for retry
763
- streamCtrl.done();
764
- q.close();
800
+ // Close current streaming session (uses streamingSessions, not stale closure refs)
801
+ closeCurrentStream();
765
802
  const { generator: retryGen, controller: retryCtrl } = createMessageChannel();
766
803
  retryCtrl.push(firstMsg);
767
804
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
@@ -809,9 +846,13 @@ export class ClaudeAgentSdkProvider implements AIProvider {
809
846
  } else {
810
847
  console.log(`[sdk] session=${sessionId} ignoring retry init sdk_id=${initMsg.session_id} (mapping already set)`);
811
848
  }
812
- const oldMeta = this.activeSessions.get(sessionId);
813
- if (oldMeta) {
814
- this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
849
+ // Only create activeSessions alias for first-time SDK id mapping.
850
+ // Retry init events create phantom entries that pollute the map.
851
+ if (isFirstMessage) {
852
+ const oldMeta = this.activeSessions.get(sessionId);
853
+ if (oldMeta) {
854
+ this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
855
+ }
815
856
  }
816
857
  }
817
858
  }
@@ -849,8 +890,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
849
890
  console.log(`[sdk] session=${sessionId} intercepted api_retry 401 — refreshing token for ${account.id} (${label})`);
850
891
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
851
892
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
852
- streamCtrl.done();
853
- q.close();
893
+ closeCurrentStream();
854
894
  const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
855
895
  const currentSdkId = getSessionMapping(sessionId);
856
896
  const canResume = !!currentSdkId;
@@ -877,8 +917,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
877
917
  console.log(`[sdk] session=${sessionId} refresh failed — switching to ${nextAcc.id} (${label})`);
878
918
  yield { type: "account_retry" as const, reason: "Switching account", accountId: nextAcc.id, accountLabel: label };
879
919
  const switchEnv = this.buildQueryEnv(meta.projectPath, nextAcc);
880
- streamCtrl.done();
881
- q.close();
920
+ closeCurrentStream();
882
921
  const { generator: switchGen, controller: switchCtrl } = createMessageChannel();
883
922
  const currentSdkId = getSessionMapping(sessionId);
884
923
  const canResume = !!currentSdkId;
@@ -1030,8 +1069,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1030
1069
  // Re-resolve sdkId: the init event may have mapped ppmId → real SDK session_id
1031
1070
  // after sdkId was originally resolved. Using the stale value would try to
1032
1071
  // resume a non-existent session, causing the SDK to hang forever.
1033
- streamCtrl.done();
1034
- q.close();
1072
+ closeCurrentStream();
1035
1073
  const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
1036
1074
  const currentSdkId = getSessionMapping(sessionId);
1037
1075
  const canResume = !!currentSdkId;
@@ -1082,10 +1120,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1082
1120
  }
1083
1121
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1084
1122
  await new Promise((r) => setTimeout(r, backoff));
1085
- // Close failed query and recreate with (potentially new) account env.
1123
+ // Close current streaming session and recreate with (potentially new) account env.
1086
1124
  // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1087
- streamCtrl.done();
1088
- q.close();
1125
+ closeCurrentStream();
1089
1126
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1090
1127
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1091
1128
  const rlCurrentSdkId = getSessionMapping(sessionId);
@@ -1183,8 +1220,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1183
1220
  yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1184
1221
  await new Promise((r) => setTimeout(r, backoff));
1185
1222
  // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1186
- streamCtrl.done();
1187
- q.close();
1223
+ closeCurrentStream();
1188
1224
  const rlRetryEnv = this.buildQueryEnv(meta.projectPath, account);
1189
1225
  const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1190
1226
  const rlCurrentSdkId2 = getSessionMapping(sessionId);
@@ -1216,8 +1252,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1216
1252
  console.log(`[sdk] 401 in result on account ${account.id} (${label}) — token refreshed, retrying`);
1217
1253
  yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1218
1254
  // Re-resolve sdkId to pick up init-event mapping (see auth retry comment).
1219
- streamCtrl.done();
1220
- q.close();
1255
+ closeCurrentStream();
1221
1256
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1222
1257
  const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1223
1258
  const authCurrentSdkId2 = getSessionMapping(sessionId);
@@ -1383,7 +1418,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1383
1418
  break crashRetryLoop; // Normal completion — exit crash retry loop
1384
1419
  } catch (crashErr) {
1385
1420
  const crashMsg = (crashErr as Error).message ?? String(crashErr);
1386
- console.error(`[sdk] session=${sessionId} cwd=${meta.projectPath} error: ${crashMsg}`);
1421
+ const stderrInfo = stderrBuffer.trim() ? ` stderr: ${stderrBuffer.trim().slice(-500)}` : "";
1422
+ console.error(`[sdk] session=${sessionId} cwd=${meta.projectPath} error: ${crashMsg}${stderrInfo}`);
1387
1423
 
1388
1424
  // Clean up crashed subprocess before retry or error
1389
1425
  this.activeQueries.delete(sessionId);
@@ -1396,12 +1432,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1396
1432
  } else if (crashMsg.includes("exited with code") && crashRetryCount < MAX_CRASH_RETRIES) {
1397
1433
  // Subprocess crashed — auto-retry once before surfacing the error
1398
1434
  crashRetryCount++;
1399
- console.warn(`[sdk] session=${sessionId} subprocess crashed: ${crashMsg} — auto-retrying (attempt ${crashRetryCount}/${MAX_CRASH_RETRIES})`);
1435
+ console.warn(`[sdk] session=${sessionId} subprocess crashed: ${crashMsg} — auto-retrying (attempt ${crashRetryCount}/${MAX_CRASH_RETRIES})${stderrInfo}`);
1436
+ stderrBuffer = ""; // Reset for retry
1400
1437
  await new Promise((r) => setTimeout(r, 1000));
1401
1438
  continue crashRetryLoop;
1402
1439
  } else if (crashMsg.includes("exited with code")) {
1403
- console.warn(`[sdk] session=${sessionId} subprocess crashed after retry: ${crashMsg}`);
1404
- yield { type: "error", message: `SDK subprocess crashed. Send another message to auto-recover.` };
1440
+ console.warn(`[sdk] session=${sessionId} subprocess crashed after retry: ${crashMsg}${stderrInfo}`);
1441
+ const userHint = stderrInfo ? ` (${stderrBuffer.trim().slice(-200)})` : "";
1442
+ yield { type: "error", message: `SDK subprocess crashed.${userHint} Send another message to auto-recover.` };
1405
1443
  } else {
1406
1444
  yield { type: "error", message: `SDK error: ${crashMsg}` };
1407
1445
  }
@@ -125,7 +125,9 @@ class AccountService {
125
125
 
126
126
  /**
127
127
  * Ensure the access token for an OAuth account is still fresh.
128
- * If it's expired or about to expire (within 60s), refresh it proactively.
128
+ * If it's expired or about to expire (within 1 hour), refresh it proactively.
129
+ * The generous buffer prevents 401 errors mid-conversation — the SDK subprocess
130
+ * may run for a long time before the token is actually sent to the API.
129
131
  * Returns the refreshed account with fresh tokens, or null if refresh failed.
130
132
  */
131
133
  async ensureFreshToken(id: string): Promise<AccountWithTokens | null> {
@@ -135,9 +137,10 @@ class AccountService {
135
137
  if (!acc.accessToken.startsWith("sk-ant-oat")) return acc;
136
138
  if (!acc.expiresAt) return acc;
137
139
  const nowS = Math.floor(Date.now() / 1000);
138
- if (acc.expiresAt - nowS > 60) return acc; // still fresh
140
+ const REFRESH_BUFFER_S = 3600; // 1 hour refresh proactively before expiry
141
+ if (acc.expiresAt - nowS > REFRESH_BUFFER_S) return acc; // still fresh
139
142
  try {
140
- console.log(`[accounts] Pre-flight refresh for ${acc.email ?? id} (expires in ${acc.expiresAt - nowS}s)`);
143
+ console.log(`[accounts] Pre-flight refresh for ${acc.email ?? id} (expires in ${acc.expiresAt - nowS}s, buffer=${REFRESH_BUFFER_S}s)`);
141
144
  await this.refreshAccessToken(id, false);
142
145
  return this.getWithTokens(id);
143
146
  } catch (e) {
@@ -99,6 +99,7 @@ export function connect(opts: {
99
99
 
100
100
  export function disconnect(): void {
101
101
  shouldConnect = false;
102
+ reconnecting = false; // prevent stale flag from blocking future doConnect()
102
103
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
103
104
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
104
105
  if (ws) {
@@ -122,8 +123,12 @@ export function onCommand(handler: CommandHandler): void {
122
123
  commandHandler = handler;
123
124
  }
124
125
 
126
+ /** Returns true if WS is authenticated and ready, or still in auth handshake */
125
127
  export function isConnected(): boolean {
126
- return connected;
128
+ // Also treat "connecting/open but auth pending" as connected to prevent
129
+ // external monitors from killing a valid WS during the 500ms auth delay.
130
+ if (connected) return true;
131
+ return ws !== null && ws.readyState <= WebSocket.OPEN;
127
132
  }
128
133
 
129
134
  // ─── Internal ───────────────────────────────────────