@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
|
@@ -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
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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:
|
|
307
|
+
title: info.customTitle ?? info.summary ?? "Resumed Chat",
|
|
293
308
|
projectPath: dbProjectPath,
|
|
294
|
-
createdAt: new Date(
|
|
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
|
|
305
|
-
//
|
|
319
|
+
// Session not found in SDK list — it 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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
731
|
-
this.activeQueries.set(sessionId,
|
|
732
|
-
let eventSource: AsyncIterable<any> =
|
|
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
|
|
763
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ───────────────────────────────────────
|