@hienlh/ppm 0.8.95 → 0.8.96

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.
@@ -0,0 +1,3 @@
1
+ # Tester Agent Memory Index
2
+
3
+ - [project-ppm-test-conventions.md](project-ppm-test-conventions.md) - PPM test setup, gotchas, and conventions
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: PPM test conventions and gotchas
3
+ description: Key patterns, pitfalls, and setup details for writing tests in the PPM project
4
+ type: project
5
+ ---
6
+
7
+ ## Test runner: `bun test` (Jest-compatible API from `bun:test`)
8
+
9
+ ## Test structure
10
+ - `tests/setup.ts` — shared helpers: `createTempDir`, `cleanupDir`, `createTempGitRepo`, `buildTestApp`
11
+ - `tests/unit/services/` — unit tests for ConfigService, ProjectService, FileService, GitService
12
+ - `tests/integration/api/` — integration tests using `app.request()` (no real server needed)
13
+
14
+ ## Critical gotchas
15
+
16
+ ### ppm.yaml in CWD
17
+ The project root has a real `ppm.yaml` with `port: 5555`. `ConfigService.load(missingPath)` falls through to `LOCAL_CONFIG = "ppm.yaml"` in CWD when the given path doesn't exist. Always write an actual file before calling `load()` to avoid picking up this real config.
18
+
19
+ ### Global configService in git routes
20
+ `src/server/routes/git.ts` imports and uses the global `configService` singleton (not injected). Integration tests for git API must mutate `configService.config.projects` directly to register the test repo. Restore to `[]` in `afterEach`.
21
+
22
+ ### ConfigService.load() fallback behavior
23
+ Candidates checked in order: explicit path → PPM_CONFIG env → LOCAL_CONFIG (ppm.yaml) → HOME_CONFIG (~/.ppm/config.yaml). A missing explicit path does NOT stop the fallback chain.
24
+
25
+ ### buildTestApp in setup.ts
26
+ Overrides `configService.save = () => {}` (no-op) to prevent tests writing to disk. Injects config directly by mutating private fields via `as unknown as`.
27
+
28
+ ### Real git repos for git tests
29
+ `createTempGitRepo()` uses `Bun.spawn` with git env vars (author name/email) to create a real repo with an initial commit. No mocks for git operations.
30
+
31
+ **Why:** Tests must use real implementations — no fakes/mocks that diverge from production behavior.
32
+ **How to apply:** Always use `createTempGitRepo` for anything touching GitService or git API routes.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## [0.8.95] - 2026-04-02
3
+ ## [0.8.96] - 2026-04-03
4
+
5
+ _Version bump — 0.8.95 published with incomplete commit set._
6
+
7
+ ## [0.8.95] - 2026-04-03
8
+
9
+ ### Added
10
+ - **Rate-limit auto-retry**: SDK automatically retries on rate_limit/server_error with exponential backoff (15s, 30s, 60s) up to 3 attempts
11
+ - **Increased max turns**: Default maxTurns bumped from 100 to 1000 for longer agent sessions
4
12
 
5
13
  ### Fixed
6
- - **Streaming auth loop**: SDK auth errors in streaming mode don't emit result events, leaving the session alive with broken credentials — every follow-up message fails with 401 forever. Now breaks the loop, cooldowns the account, and tears down the session so the next message picks a different account.
7
- - **Streaming session resource leak**: `finally` block now properly closes SDK subprocess and generator instead of just removing from map.
14
+ - **Session mapping on resume**: Preserve existing sdk_id mapping when resuming sessions to prevent orphaning JSONL conversation history
15
+ - **Usage list expired accounts**: Exclude expired temporary accounts (no refresh token) from usage dashboard
8
16
 
9
17
  ## [0.8.94] - 2026-04-02
10
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.8.95",
3
+ "version": "0.8.96",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -650,7 +650,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
650
650
  allowDangerouslySkipPermissions: isBypass,
651
651
  ...(providerConfig.model && { model: providerConfig.model }),
652
652
  ...(providerConfig.effort && { effort: providerConfig.effort }),
653
- maxTurns: providerConfig.max_turns ?? 100,
653
+ maxTurns: providerConfig.max_turns ?? 1000,
654
654
  ...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
655
655
  ...(providerConfig.thinking_budget_tokens != null && {
656
656
  thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
@@ -688,7 +688,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
688
688
  // it's a transient subprocess failure — retry once before surfacing the error.
689
689
  // Also handles authentication_failed by refreshing OAuth token and retrying.
690
690
  const MAX_RETRIES = 1;
691
+ const MAX_RATE_LIMIT_RETRIES = 3;
692
+ const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]; // 15s, 30s, 60s
691
693
  let retryCount = 0;
694
+ let rateLimitRetryCount = 0;
692
695
  let authRetried = false;
693
696
 
694
697
  let hadAnyEvents = false;
@@ -736,7 +739,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
736
739
  if (subtype === "init") {
737
740
  const initMsg = msg as any;
738
741
  if (initMsg.session_id && initMsg.session_id !== sessionId) {
739
- setSessionMapping(sessionId, initMsg.session_id, meta.projectName, meta.projectPath);
742
+ // Only update sdk_id mapping for brand-new sessions (first message).
743
+ // For resumed sessions the SDK may create a new session_id, but the
744
+ // old JSONL (keyed by the original sdk_id) still holds the full
745
+ // conversation history. Overwriting the mapping would orphan it.
746
+ const existingSdkId = getSessionMapping(sessionId);
747
+ const isFirstMessage = existingSdkId === null || existingSdkId === sessionId;
748
+ if (isFirstMessage) {
749
+ setSessionMapping(sessionId, initMsg.session_id, meta.projectName, meta.projectPath);
750
+ } else {
751
+ console.log(`[sdk] session=${sessionId} ignoring new sdk_id=${initMsg.session_id} to preserve existing mapping → ${existingSdkId}`);
752
+ }
740
753
  const oldMeta = this.activeSessions.get(sessionId);
741
754
  if (oldMeta) {
742
755
  this.activeSessions.set(initMsg.session_id, { ...oldMeta, id: initMsg.session_id });
@@ -910,24 +923,36 @@ export class ClaudeAgentSdkProvider implements AIProvider {
910
923
  }
911
924
  }
912
925
 
913
- // Auth failed permanently after retry cooldown account and break loop.
914
- // SDK doesn't send a result event after auth errors in streaming mode,
915
- // so the streaming session would stay alive with broken credentials forever.
916
- // Breaking here lets the finally block tear down the session, so the next
917
- // user message creates a fresh session with a different account.
918
- if (assistantError === "authentication_failed" && account && authRetried) {
919
- accountSelector.onAuthError(account.id);
920
- console.warn(`[sdk] session=${sessionId} auth permanently failed — tearing down streaming session`);
921
- yield { type: "error", message: "API authentication failed. Check your account credentials in Settings → Accounts." };
922
- break;
926
+ // Rate limit auto-retry with exponential backoff
927
+ if ((assistantError === "rate_limit" || assistantError === "server_error") && rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) {
928
+ const backoff = RATE_LIMIT_BACKOFF_MS[rateLimitRetryCount] ?? 60_000;
929
+ rateLimitRetryCount++;
930
+ if (account) accountSelector.onRateLimit(account.id);
931
+ console.warn(`[sdk] session=${sessionId} rate limited retrying in ${backoff / 1000}s (attempt ${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})`);
932
+ yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
933
+ await new Promise((r) => setTimeout(r, backoff));
934
+ // Close failed query and recreate
935
+ streamCtrl.done();
936
+ q.close();
937
+ const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
938
+ rlRetryCtrl.push(firstMsg);
939
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
940
+ const rq = query({
941
+ prompt: rlRetryGen,
942
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
943
+ });
944
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: rlRetryCtrl });
945
+ this.activeQueries.set(sessionId, rq);
946
+ eventSource = rq;
947
+ continue retryLoop;
923
948
  }
924
949
 
925
950
  const errorHints: Record<string, string> = {
926
951
  authentication_failed: "API authentication failed. Check your account credentials in Settings → Accounts.",
927
952
  billing_error: "Billing error on this account. Check your subscription status.",
928
- rate_limit: "Rate limited by the API. Please wait and try again.",
953
+ rate_limit: `Rate limited by the API. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.`,
929
954
  invalid_request: "Invalid request sent to the API.",
930
- server_error: "Anthropic API server error. Try again shortly.",
955
+ server_error: `Anthropic API server error. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.`,
931
956
  unknown: `API error in project "${effectiveCwd}". Debug:\n1. Run: \`cd ${effectiveCwd} && claude -p "hi"\`\n2. Check env: \`echo $ANTHROPIC_API_KEY $ANTHROPIC_BASE_URL\` — stale/invalid keys cause this\n3. Try: \`ANTHROPIC_API_KEY="" ANTHROPIC_BASE_URL="" claude -p "hi"\`\n4. Refresh auth: \`claude login\``,
932
957
  };
933
958
  const hint = errorHints[assistantError] ?? `API error: ${assistantError}`;
@@ -985,8 +1010,28 @@ export class ClaudeAgentSdkProvider implements AIProvider {
985
1010
  const errCode = this.detectResultErrorCode(msg);
986
1011
  if (errCode === 429) {
987
1012
  accountSelector.onRateLimit(account.id);
988
- // Post-stream 429 surface error, continue waiting for next turn
989
- yield { type: "error", message: "Rate limited. This account is now on cooldown. Please retry." };
1013
+ // Auto-retry with backoff for result-level 429
1014
+ if (rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) {
1015
+ const backoff = RATE_LIMIT_BACKOFF_MS[rateLimitRetryCount] ?? 60_000;
1016
+ rateLimitRetryCount++;
1017
+ console.warn(`[sdk] session=${sessionId} result 429 — retrying in ${backoff / 1000}s (attempt ${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})`);
1018
+ yield { type: "error", message: `Rate limited. Auto-retrying in ${backoff / 1000}s... (${rateLimitRetryCount}/${MAX_RATE_LIMIT_RETRIES})` };
1019
+ await new Promise((r) => setTimeout(r, backoff));
1020
+ streamCtrl.done();
1021
+ q.close();
1022
+ const { generator: rlRetryGen, controller: rlRetryCtrl } = createMessageChannel();
1023
+ rlRetryCtrl.push(firstMsg);
1024
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
1025
+ const rq = query({
1026
+ prompt: rlRetryGen,
1027
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1028
+ });
1029
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: rlRetryCtrl });
1030
+ this.activeQueries.set(sessionId, rq);
1031
+ eventSource = rq;
1032
+ continue retryLoop;
1033
+ }
1034
+ yield { type: "error", message: `Rate limited. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.` };
990
1035
  continue;
991
1036
  } else if (errCode === 401) {
992
1037
  // Refresh token and retry with fresh session (same logic as assistant-level auth retry)
@@ -1170,13 +1215,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1170
1215
  }
1171
1216
  } finally {
1172
1217
  this.activeQueries.delete(sessionId);
1173
- // Properly close streaming session: terminate subprocess + generator
1174
- const ss = this.streamingSessions.get(sessionId);
1175
- if (ss) {
1176
- ss.controller.done();
1177
- ss.query.close();
1178
- this.streamingSessions.delete(sessionId);
1179
- }
1218
+ this.streamingSessions.delete(sessionId);
1180
1219
  console.log(`[sdk] session=${sessionId} streaming session ended`);
1181
1220
  }
1182
1221
 
@@ -278,18 +278,23 @@ export function getAllAccountUsages(): AccountUsageEntry[] {
278
278
  const accounts = accountService.list();
279
279
  const snapshots = getAllLatestSnapshots();
280
280
  const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
281
- return accounts.map(acc => {
281
+ const nowS = Math.floor(Date.now() / 1000);
282
+ const result: AccountUsageEntry[] = [];
283
+ for (const acc of accounts) {
282
284
  const withTokens = accountService.getWithTokens(acc.id);
285
+ // Skip expired accounts without refresh token (temporary/disposable)
286
+ if (acc.expiresAt && acc.expiresAt < nowS && !withTokens?.refreshToken) continue;
283
287
  const isOAuth = withTokens?.accessToken.startsWith("sk-ant-oat") ?? false;
284
288
  const row = snapshotMap.get(acc.id);
285
- return {
289
+ result.push({
286
290
  accountId: acc.id,
287
291
  accountLabel: acc.label,
288
292
  accountStatus: acc.status,
289
293
  isOAuth,
290
294
  usage: row ? snapshotToUsage(row) : {},
291
- };
292
- });
295
+ });
296
+ }
297
+ return result;
293
298
  }
294
299
 
295
300
  /** Get cached usage for active account (used by chat header) */
@@ -1,267 +0,0 @@
1
- # Streaming Input Migration Quick Reference (v0.8.55+)
2
-
3
- ## What Changed?
4
-
5
- **Before (v0.8.54):** Each message triggered a new SDK query
6
- ```
7
- Message 1 → SDK subprocess spawn → generate response → close
8
- Message 2 → SDK subprocess spawn → generate response → close
9
- (Slow, context resets between messages)
10
- ```
11
-
12
- **After (v0.8.55):** Single persistent streaming session
13
- ```
14
- Session created → AsyncGenerator streaming input opened
15
- Message 1 → Push into generator → process events
16
- Message 2 → Push into same generator → continue streaming
17
- (Fast, continuous context, no SDK restarts)
18
- ```
19
-
20
- ## Key Concepts
21
-
22
- ### Session State (BE-Owned)
23
- The backend maintains a `SessionEntry` per chat session:
24
- - Tracks connected clients (can be zero if FE disconnected)
25
- - Maintains streaming phase (idle, connecting, thinking, streaming)
26
- - Buffers events for reconnection sync
27
- - Auto-cleans after 5 minutes of FE inactivity
28
-
29
- ### Message Priority (v0.8.55+)
30
- ```typescript
31
- // Send message with priority
32
- ws.send({
33
- type: "message",
34
- content: "Debug this code",
35
- priority: "now" // "now" | "next" | "later"
36
- })
37
- ```
38
- - **"now"** — Abort current query, restart with this message
39
- - **"next"** — Queue after current, run next
40
- - **"later"** — Append to queue, run last
41
-
42
- ### Event Buffering on Reconnect
43
- When FE WS reconnects after disconnect:
44
- 1. BE sends `session_state` with current phase + pending approval
45
- 2. BE sends `turn_events` with all buffered events since last connection
46
- 3. FE rebuilds chat UI state from buffered events
47
- 4. No message loss (unless session cleaned up after 5min)
48
-
49
- ## Common Patterns
50
-
51
- ### Frontend: Send Message
52
- ```typescript
53
- // In useChat hook or message input handler
54
- ws.send(JSON.stringify({
55
- type: "message",
56
- content: userInput,
57
- priority: "now", // Optional
58
- images: [{ id: "img1", data: "base64..." }] // Optional
59
- }));
60
- ```
61
-
62
- ### Frontend: Handle Reconnection
63
- ```typescript
64
- function handleReconnect() {
65
- // 1. WS open fires
66
- // 2. Server sends session_state
67
- const sessionState = JSON.parse(msg);
68
- // 3. Server sends turn_events
69
- const turnEvents = JSON.parse(msg);
70
-
71
- // 4. FE rebuilds state from buffered events
72
- turnEvents.events.forEach(event => {
73
- chatStore.addEvent(event);
74
- });
75
-
76
- // 5. FE is now synced with BE
77
- }
78
- ```
79
-
80
- ### Backend: Session Lifecycle
81
- ```typescript
82
- // 1. FE connects
83
- open(ws) {
84
- const entry = activeSessions.get(sessionId);
85
- if (!entry) {
86
- // Create new session entry
87
- activeSessions.set(sessionId, {
88
- phase: "idle",
89
- clients: new Set([ws]),
90
- turnEvents: []
91
- });
92
- } else {
93
- // Reconnect: clear cleanup timer, add client
94
- entry.clients.add(ws);
95
- }
96
- }
97
-
98
- // 2. FE sends message
99
- message(ws, data) {
100
- const parsed = JSON.parse(data);
101
- if (parsed.type === "message") {
102
- // Abort current if streaming, wait for cleanup
103
- if (entry.phase !== "idle") {
104
- entry.abort.abort();
105
- await entry.streamPromise;
106
- }
107
- // Start new streaming loop (detached)
108
- entry.streamPromise = runStreamLoop(...);
109
- }
110
- }
111
-
112
- // 3. Streaming loop runs independently
113
- async function runStreamLoop() {
114
- for await (const event of chatService.sendMessage(...)) {
115
- bufferAndBroadcast(sessionId, event); // To all connected clients
116
- }
117
- setPhase(sessionId, "idle"); // Back to idle when done
118
- if (entry.clients.size === 0) {
119
- startCleanupTimer(sessionId); // 5-min cleanup
120
- }
121
- }
122
-
123
- // 4. FE disconnects
124
- close(ws) {
125
- entry.clients.delete(ws);
126
- // Stream continues! (BE owns the connection)
127
- // Timer started if no more clients
128
- }
129
- ```
130
-
131
- ## Phase State Machine
132
-
133
- ```
134
- ┌─ initializing (setup, session resume)
135
-
136
- idle ←→ connecting (waiting for first SDK event, heartbeat)
137
- ↑ ↓
138
- │ ┌──→ thinking (extended thinking)
139
- │ ↓ ↓
140
- └─── streaming (text/tool_use content)
141
- ↑ ↓
142
- └─────┘ (dynamic switch)
143
- ```
144
-
145
- **Transitions:**
146
- - Heartbeat: `connecting` → (5s elapsed updates) → `thinking` (when content arrives)
147
- - Content: `thinking` → `streaming` (first text event)
148
- - Dynamic: `streaming` ↔ `thinking` (based on event types)
149
- - Done: Any → `idle` (stream complete, ready for next message)
150
-
151
- ## WebSocket Messages (v0.8.55+)
152
-
153
- ### Client → Server
154
- ```typescript
155
- // Send message
156
- { type: "message"; content: string; priority?: string; images?: {...}[] }
157
-
158
- // Approve tool
159
- { type: "approval_response"; requestId: string; approved: boolean }
160
-
161
- // Cancel current
162
- { type: "cancel" }
163
-
164
- // Handshake after open
165
- { type: "ready" }
166
- ```
167
-
168
- ### Server → Client
169
- ```typescript
170
- // Content
171
- { type: "text"; content: string }
172
- { type: "thinking"; content: string }
173
-
174
- // Tool execution
175
- { type: "tool_use"; tool: string; input: unknown }
176
- { type: "tool_result"; output: string; isError?: boolean }
177
-
178
- // User approval request
179
- { type: "approval_request"; requestId: string; tool: string; input: unknown }
180
-
181
- // Session state (sent on open/ready)
182
- { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: {...} | null }
183
-
184
- // Buffered events (on reconnect)
185
- { type: "turn_events"; events: unknown[] }
186
-
187
- // Metadata
188
- { type: "account_info"; accountId: string; accountLabel: string }
189
- { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
190
- { type: "title_updated"; title: string }
191
-
192
- // Completion
193
- { type: "done"; sessionId: string; contextWindowPct?: number }
194
-
195
- // Error
196
- { type: "error"; message: string }
197
-
198
- // Keepalive
199
- { type: "ping" }
200
- ```
201
-
202
- ## Benefits
203
-
204
- | Aspect | Before (v0.8.54) | After (v0.8.55) |
205
- |--------|------------------|-----------------|
206
- | **SDK Restarts** | Per message | Once per session |
207
- | **Context** | Resets between messages | Persistent |
208
- | **Startup Time** | 2-5s per message | Instant follow-ups |
209
- | **Reconnection** | Message loss | Event buffering ensures sync |
210
- | **Concurrency** | N/A | Multiple clients per session |
211
- | **Tool Approvals** | Restarts query | Integrated in stream |
212
-
213
- ## Troubleshooting
214
-
215
- ### Session Cleaned Up (No Longer Exists)
216
- **Cause:** FE disconnected for >5 minutes
217
- **Solution:** Create new session, FE reconnects with new sessionId
218
-
219
- ### Events Missing After Reconnect
220
- **Cause:** Server-side event buffer (10k event limit) overflowed
221
- **Solution:** Flush buffer periodically or increase limit if needed
222
-
223
- ### Phase Stuck in "Connecting"
224
- **Cause:** SDK subprocess not responding (120s timeout)
225
- **Solution:** Check environment (ANTHROPIC_API_KEY, network), see error message for hints
226
-
227
- ### Multiple Clients Out of Sync
228
- **Cause:** Broadcast failed for one client, others ahead
229
- **Solution:** Evicted client will reconnect and re-sync from buffered events
230
-
231
- ## Debugging
232
-
233
- ### Enable Logging
234
- ```bash
235
- # Check server logs for session lifecycle
236
- [chat] session=abc123 phase → connecting
237
- [chat] session=abc123 first SDK event after 1250ms: type=text
238
- [chat] session=abc123 stream completed (45 events)
239
- [chat] session=abc123 phase → idle
240
- ```
241
-
242
- ### Check Session State
243
- ```typescript
244
- // On WS message handler
245
- console.log(`Session entry:`, activeSessions.get(sessionId));
246
- // Outputs: { phase, clients.size, pendingApprovalEvent, turnEvents.length }
247
- ```
248
-
249
- ### Monitor Reconnections
250
- ```typescript
251
- // In WS open handler
252
- console.log(`FE reconnected (phase=${existing.phase}, clients=${existing.clients.size})`);
253
- // Tells you: active streaming, how many clients connected
254
- ```
255
-
256
- ## Performance Notes
257
-
258
- - **No SDK overhead:** Persistent streaming eliminates subprocess spawn overhead
259
- - **Event buffering:** Clients see all events after reconnect (max 10k events per turn)
260
- - **Memory:** Session entries cleaned after 5min (bounded memory usage)
261
- - **Latency:** Follow-up messages start immediately (no SDK init)
262
-
263
- ---
264
-
265
- **For detailed architecture:** See `docs/system-architecture.md` → "Chat Streaming Flow" section
266
- **For API types:** See `src/types/api.ts` and `src/types/chat.ts`
267
- **For implementation:** See `src/server/ws/chat.ts` and `src/providers/claude-agent-sdk.ts`