@hienlh/ppm 0.8.0 → 0.8.2

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.8.2] - 2026-03-23
4
+
5
+ ### Fixed
6
+ - **Heartbeat during SDK connection**: Keep "Connecting... (Xs)" indicator alive until real content arrives — previously stopped by `account_info` event, causing misleading "thinking" state during 3-minute SDK failures
7
+ - **Unknown API error hint**: "API error: unknown" now shows actionable guidance (check connectivity, re-add account, new session)
8
+
9
+ ## [0.8.1] - 2026-03-23
10
+
11
+ ### Fixed
12
+ - **Silent timeout on account decrypt failure**: When all account tokens fail decryption (e.g. different machine key in WSL), now shows actionable error instead of 120s silent timeout
13
+ - **SDK error extraction**: Use `errors: string[]` array per SDK spec instead of non-existent singular `error` field — previously swallowed error details
14
+ - **Assistant message error detection**: Handle `SDKAssistantMessage.error` field for `authentication_failed`, `billing_error`, `rate_limit`, `server_error` per SDK spec
15
+ - **Empty success detection**: Detect SDK returning `success` with 0 turns and no content as silent failure, surface guidance to user
16
+ - **Network error hints**: Add WSL-specific hints for ConnectionRefused, auth failures, and connectivity issues
17
+
3
18
  ## [0.8.0] - 2026-03-23
4
19
 
5
20
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -106,7 +106,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
106
106
  if (!event || typeof event !== "object") return null;
107
107
  const e = event as Record<string, unknown>;
108
108
  if (e.type === "result" && e.subtype === "error_during_execution") {
109
- const msg = String(e.error ?? e.error_message ?? e.message ?? "");
109
+ // SDK uses `errors: string[]` array for error details
110
+ const errorsArr = Array.isArray(e.errors) ? (e.errors as string[]).join(" ") : "";
111
+ const msg = errorsArr || String(e.error ?? "");
110
112
  if (msg.includes("429") || msg.toLowerCase().includes("rate limit") || msg.toLowerCase().includes("overloaded")) return 429;
111
113
  if (msg.includes("401") || msg.toLowerCase().includes("unauthorized") || msg.toLowerCase().includes("invalid api key")) return 401;
112
114
  }
@@ -533,7 +535,19 @@ export class ClaudeAgentSdkProvider implements AIProvider {
533
535
 
534
536
  // Account-based auth injection (multi-account mode)
535
537
  // Fallback to existing env (ANTHROPIC_API_KEY) when no accounts configured.
536
- const account = accountSelector.isEnabled() ? accountSelector.next() : null;
538
+ const accountsEnabled = accountSelector.isEnabled();
539
+ const account = accountsEnabled ? accountSelector.next() : null;
540
+ if (accountsEnabled && !account) {
541
+ // All accounts in DB but none usable
542
+ const reason = accountSelector.lastFailReason;
543
+ const hint = reason === "all_decrypt_failed"
544
+ ? "Account tokens were encrypted with a different machine key. Re-add your accounts in Settings, or copy ~/.ppm/account.key from the original machine."
545
+ : "All accounts are disabled or in cooldown. Check Settings → Accounts.";
546
+ console.error(`[sdk] session=${sessionId} account auth failed (${reason}): ${hint}`);
547
+ yield { type: "error" as const, message: `Authentication failed: ${hint}` };
548
+ yield { type: "done" as const, sessionId, resultSubtype: "error_auth" };
549
+ return;
550
+ }
537
551
  if (account) {
538
552
  console.log(`[sdk] Using account ${account.id} (${account.email ?? "no-email"})`);
539
553
  yield { type: "account_info" as const, accountId: account.id, accountLabel: account.label ?? account.email ?? "Unknown" };
@@ -774,6 +788,21 @@ export class ClaudeAgentSdkProvider implements AIProvider {
774
788
 
775
789
  // Full assistant message
776
790
  if (msg.type === "assistant") {
791
+ // SDK assistant messages can carry an error field for auth/billing/rate-limit failures
792
+ const assistantError = (msg as any).error as string | undefined;
793
+ if (assistantError) {
794
+ const errorHints: Record<string, string> = {
795
+ authentication_failed: "API authentication failed. Check your account credentials in Settings → Accounts.",
796
+ billing_error: "Billing error on this account. Check your subscription status.",
797
+ rate_limit: "Rate limited by the API. Please wait and try again.",
798
+ invalid_request: "Invalid request sent to the API.",
799
+ server_error: "Anthropic API server error. Try again shortly.",
800
+ unknown: "API connection failed. Possible causes: network unreachable, expired OAuth token, or API outage. Try: 1) Check connectivity (`curl -s https://api.anthropic.com`), 2) Re-add account in Settings, 3) Create a new chat session.",
801
+ };
802
+ const hint = errorHints[assistantError] ?? `API error: ${assistantError}`;
803
+ console.error(`[sdk] session=${sessionId} assistant error: ${assistantError}`);
804
+ yield { type: "error", message: hint };
805
+ }
777
806
  const content = (msg as any).message?.content;
778
807
  if (Array.isArray(content)) {
779
808
  for (const block of content) {
@@ -875,9 +904,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
875
904
 
876
905
  // Surface non-success subtypes as errors so FE can display them
877
906
  if (subtype && subtype !== "success") {
878
- // Extract error detail from SDK result check multiple possible fields
879
- const sdkError = result.error ?? result.error_message ?? result.message ?? result.reason ?? "";
880
- const sdkDetail = typeof sdkError === "string" ? sdkError : JSON.stringify(sdkError);
907
+ // SDK error results use `errors: string[]` array (not singular `error`)
908
+ const errorsArr = Array.isArray(result.errors) ? result.errors : [];
909
+ const sdkDetail = errorsArr.length > 0
910
+ ? errorsArr.join("\n")
911
+ : (typeof result.error === "string" ? result.error : "");
881
912
  // Log full result for debugging (truncated at 2000 chars)
882
913
  console.error(`[sdk] result error: subtype=${subtype} turns=${result.num_turns ?? 0} detail=${sdkDetail || "(none)"}`);
883
914
  console.error(`[sdk] result full dump: ${JSON.stringify(result).slice(0, 2000)}`);
@@ -887,13 +918,35 @@ export class ClaudeAgentSdkProvider implements AIProvider {
887
918
  error_during_execution: "Agent encountered an error during execution.",
888
919
  };
889
920
  const baseMsg = errorMessages[subtype] ?? `Agent stopped: ${subtype}`;
890
- const fullMsg = sdkDetail ? `${baseMsg}\n${sdkDetail}` : baseMsg;
921
+ // Add specific hints for common network/auth errors
922
+ const detailLower = sdkDetail.toLowerCase();
923
+ let hint = "";
924
+ if (detailLower.includes("connectionrefused") || detailLower.includes("connection refused") || detailLower.includes("econnrefused")) {
925
+ hint = "\n\nHint: Cannot reach Anthropic API. If running in WSL, check DNS/proxy settings (e.g. `curl -s https://api.anthropic.com` from WSL terminal).";
926
+ } else if (detailLower.includes("unable to connect")) {
927
+ hint = "\n\nHint: Network connectivity issue. Check your internet connection and firewall/proxy settings.";
928
+ } else if (detailLower.includes("401") || detailLower.includes("unauthorized") || detailLower.includes("invalid api key")) {
929
+ hint = "\n\nHint: Authentication failed. Try re-adding your account in Settings → Accounts.";
930
+ }
931
+ const fullMsg = sdkDetail ? `${baseMsg}\n${sdkDetail}${hint}` : baseMsg;
891
932
  yield {
892
933
  type: "error",
893
934
  message: fullMsg,
894
935
  };
895
936
  }
896
937
 
938
+ // Detect empty/suspicious success — SDK returned "success" but no real assistant content
939
+ if ((!subtype || subtype === "success") && (result.num_turns ?? 0) === 0 && !assistantContent) {
940
+ // SDK success result has `result: string` containing final text
941
+ const resultText = typeof result.result === "string" ? result.result : "";
942
+ console.warn(`[sdk] session=${sessionId} result success but 0 turns, no assistant content, result="${resultText.slice(0, 200)}"`);
943
+ console.warn(`[sdk] result dump: ${JSON.stringify(result).slice(0, 2000)}`);
944
+ const hint = resultText
945
+ ? `Claude returned: "${resultText}"\nThis may indicate a session or connection issue. Try creating a new chat session.`
946
+ : "Claude returned no response (0 turns). This usually means the API connection failed silently. Check that `claude` CLI works in your terminal, or try creating a new chat session.";
947
+ yield { type: "error", message: hint };
948
+ }
949
+
897
950
  // Store subtype and numTurns for the done event
898
951
  resultSubtype = subtype;
899
952
  resultNumTurns = result.num_turns as number | undefined;
@@ -961,7 +1014,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
961
1014
  if (retryMsg.type === "result") {
962
1015
  const r = retryMsg as any;
963
1016
  if (r.subtype && r.subtype !== "success") {
964
- yield { type: "error", message: r.error ?? `Agent stopped: ${r.subtype}` };
1017
+ const retryErrors = Array.isArray(r.errors) ? r.errors.join("\n") : "";
1018
+ yield { type: "error", message: retryErrors || `Agent stopped: ${r.subtype}` };
965
1019
  }
966
1020
  resultSubtype = r.subtype;
967
1021
  resultNumTurns = r.num_turns;
@@ -146,8 +146,12 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
146
146
  const ev = event as any;
147
147
  const evType = ev.type ?? "unknown";
148
148
 
149
- // First event received — stop heartbeat, switch to streaming status
150
- if (!firstEventReceived) {
149
+ // First content event — stop heartbeat, switch to streaming status.
150
+ // Skip metadata events (account_info, streaming_status) that arrive before
151
+ // the SDK subprocess actually produces output — keeps heartbeat + "connecting"
152
+ // indicator alive until real content flows.
153
+ const isMetadataEvent = evType === "account_info" || evType === "streaming_status";
154
+ if (!firstEventReceived && !isMetadataEvent) {
151
155
  firstEventReceived = true;
152
156
  const waitMs = Date.now() - startTime;
153
157
  console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType}`);
@@ -36,11 +36,20 @@ class AccountSelectorService {
36
36
  setConfigValue(MAX_RETRY_CONFIG_KEY, String(n));
37
37
  }
38
38
 
39
+ /** Reason for the last null return from next() */
40
+ private _lastFailReason: "none" | "no_active" | "all_decrypt_failed" = "none";
41
+
42
+ /** Why the last next() call returned null */
43
+ get lastFailReason(): "none" | "no_active" | "all_decrypt_failed" {
44
+ return this._lastFailReason;
45
+ }
46
+
39
47
  /**
40
48
  * Pick next available account (skips cooldown/disabled).
41
49
  * Returns null if no active accounts available.
42
50
  */
43
51
  next(): AccountWithTokens | null {
52
+ this._lastFailReason = "none";
44
53
  const now = Math.floor(Date.now() / 1000);
45
54
  const allAccounts = accountService.list();
46
55
 
@@ -53,7 +62,10 @@ class AccountSelectorService {
53
62
  }
54
63
 
55
64
  const active = accountService.list().filter((a) => a.status === "active");
56
- if (active.length === 0) return null;
65
+ if (active.length === 0) {
66
+ this._lastFailReason = "no_active";
67
+ return null;
68
+ }
57
69
 
58
70
  let pickedId: string;
59
71
  if (this.getStrategy() === "fill-first") {
@@ -66,7 +78,11 @@ class AccountSelectorService {
66
78
  this.cursor = (this.cursor + 1) % active.length;
67
79
  }
68
80
  this._lastPickedId = pickedId;
69
- return accountService.getWithTokens(pickedId);
81
+ const result = accountService.getWithTokens(pickedId);
82
+ if (!result) {
83
+ this._lastFailReason = "all_decrypt_failed";
84
+ }
85
+ return result;
70
86
  }
71
87
 
72
88
  /** Called when account receives 429 — apply exponential backoff */
package/src/types/chat.ts CHANGED
@@ -79,7 +79,8 @@ export type ResultSubtype =
79
79
  | "success"
80
80
  | "error_max_turns"
81
81
  | "error_max_budget_usd"
82
- | "error_during_execution";
82
+ | "error_during_execution"
83
+ | "error_auth";
83
84
 
84
85
  export type ChatEvent =
85
86
  | { type: "text"; content: string; parentToolUseId?: string }