@hienlh/ppm 0.8.55 → 0.8.57

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.
Files changed (28) hide show
  1. package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/CHANGELOG.md +10 -0
  4. package/dist/web/assets/{chat-tab-BUOCxR2G.js → chat-tab-cawT08fh.js} +3 -3
  5. package/dist/web/assets/{code-editor-os78eUN8.js → code-editor-u6bm6bdq.js} +1 -1
  6. package/dist/web/assets/{database-viewer-DTwe0h8F.js → database-viewer-BgPBW1bJ.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-CSyOOmS2.js → diff-viewer-Cho-kjse.js} +1 -1
  8. package/dist/web/assets/{git-graph-CwYW3F4P.js → git-graph-CktRdFwt.js} +1 -1
  9. package/dist/web/assets/{index-yMR7OUDx.js → index-CpOYx0qg.js} +7 -13
  10. package/dist/web/assets/keybindings-store-vOnSm10D.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-DQWY7QvX.js → markdown-renderer-3_CTktzg.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-Ctv7NTI_.js → postgres-viewer-CH0JfEQ9.js} +1 -1
  13. package/dist/web/assets/{settings-tab-Daap0c_B.js → settings-tab-BI0n39LJ.js} +1 -1
  14. package/dist/web/assets/{sqlite-viewer-DtNk76CE.js → sqlite-viewer-DJyT7YZg.js} +1 -1
  15. package/dist/web/assets/{terminal-tab-JEpjt3RD.js → terminal-tab-OqCohyF0.js} +1 -1
  16. package/dist/web/index.html +1 -1
  17. package/dist/web/sw.js +1 -1
  18. package/package.json +1 -1
  19. package/src/index.ts +0 -0
  20. package/src/server/index.ts +15 -3
  21. package/src/server/routes/proxy.ts +53 -46
  22. package/src/services/proxy.service.ts +19 -4
  23. package/src/services/supervisor.ts +14 -1
  24. package/src/web/components/chat/usage-badge.tsx +38 -6
  25. package/src/web/components/settings/proxy-settings-section.tsx +42 -40
  26. package/dist/web/assets/keybindings-store-B-BLLKiZ.js +0 -1
  27. package/snapshot-state.md +0 -1526
  28. package/test-tokens.mjs +0 -212
@@ -60,6 +60,12 @@ class ProxyService {
60
60
 
61
61
  // Ensure token is fresh for OAuth accounts
62
62
  let token = account.accessToken;
63
+ if (!token) {
64
+ return new Response(
65
+ JSON.stringify({ type: "error", error: { type: "authentication_error", message: "Account has no access token (decryption may have failed)" } }),
66
+ { status: 401, headers: { "Content-Type": "application/json" } },
67
+ );
68
+ }
63
69
  if (token.startsWith("sk-ant-oat")) {
64
70
  const fresh = await accountService.ensureFreshToken(account.id);
65
71
  if (fresh) token = fresh.accessToken;
@@ -108,14 +114,22 @@ class ProxyService {
108
114
  } else if (upstream.status === 401) {
109
115
  accountSelector.onAuthError(account.id);
110
116
  console.log(`[proxy] 401 from Anthropic — account ${account.email ?? account.id} auth error`);
117
+ } else if (upstream.status >= 400) {
118
+ console.log(`[proxy] ${upstream.status} from Anthropic — account ${account.email ?? account.id} (OAuth=${token.startsWith("sk-ant-oat")})`);
111
119
  } else if (upstream.status >= 200 && upstream.status < 300) {
112
120
  accountSelector.onSuccess(account.id);
113
121
  }
114
122
 
115
123
  // Stream response back as-is (preserves SSE for streaming)
116
124
  const responseHeaders = new Headers();
117
- // Forward key response headers
118
- for (const key of ["content-type", "x-request-id", "request-id"]) {
125
+ // Forward all relevant response headers from Anthropic
126
+ for (const key of [
127
+ "content-type", "x-request-id", "request-id",
128
+ "anthropic-ratelimit-requests-limit", "anthropic-ratelimit-requests-remaining",
129
+ "anthropic-ratelimit-requests-reset", "anthropic-ratelimit-tokens-limit",
130
+ "anthropic-ratelimit-tokens-remaining", "anthropic-ratelimit-tokens-reset",
131
+ "retry-after",
132
+ ]) {
119
133
  const val = upstream.headers.get(key);
120
134
  if (val) responseHeaders.set(key, val);
121
135
  }
@@ -127,9 +141,10 @@ class ProxyService {
127
141
  headers: responseHeaders,
128
142
  });
129
143
  } catch (e) {
130
- console.error(`[proxy] Error forwarding to Anthropic:`, (e as Error).message);
144
+ const msg = e instanceof Error ? e.message : String(e);
145
+ console.error(`[proxy] Error forwarding to Anthropic:`, msg);
131
146
  return new Response(
132
- JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
147
+ JSON.stringify({ type: "error", error: { type: "api_error", message: msg || "Unknown proxy error" } }),
133
148
  { status: 502, headers: { "Content-Type": "application/json" } },
134
149
  );
135
150
  }
@@ -328,6 +328,16 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
328
328
  const currentSupervisorPid = process.pid;
329
329
 
330
330
  try {
331
+ // Kill server + tunnel children FIRST to free the port for the new supervisor
332
+ log("INFO", "Stopping server and tunnel before spawning new supervisor");
333
+ if (serverChild) { try { serverChild.kill(); } catch {} serverChild = null; }
334
+ if (tunnelChild) { try { tunnelChild.kill(); } catch {} tunnelChild = null; }
335
+ // Clear health timers so we don't try to respawn killed children
336
+ if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
337
+ if (tunnelProbeTimer) { clearInterval(tunnelProbeTimer); tunnelProbeTimer = null; }
338
+ // Brief wait for port release
339
+ await Bun.sleep(500);
340
+
331
341
  // Spawn new supervisor using saved argv
332
342
  const cmd = originalArgv.slice();
333
343
  const logFd = openSync(LOG_FILE, "a");
@@ -346,7 +356,10 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
346
356
  const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
347
357
  if (data.supervisorPid && data.supervisorPid !== currentSupervisorPid) {
348
358
  log("INFO", `New supervisor detected (PID: ${data.supervisorPid}), old exiting`);
349
- shutdown();
359
+ // Children already killed, just clear remaining timers and exit
360
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
361
+ if (upgradeCheckTimer) clearInterval(upgradeCheckTimer);
362
+ if (upgradeDelayTimer) clearTimeout(upgradeDelayTimer);
350
363
  process.exit(0);
351
364
  }
352
365
  } catch {}
@@ -97,7 +97,7 @@ function BucketRow({ label, bucket }: { label: string; bucket?: LimitBucket }) {
97
97
  <div className="flex items-center justify-between">
98
98
  <span className="text-xs font-medium text-text-primary">{label}</span>
99
99
  {reset && (
100
- <span className="text-[10px] text-text-subtle">↻ {reset}</span>
100
+ <span className="text-[10px] text-text-subtle" title="Resets in">↻ {reset}</span>
101
101
  )}
102
102
  </div>
103
103
  <div className="flex items-center gap-2">
@@ -115,6 +115,28 @@ function BucketRow({ label, bucket }: { label: string; bucket?: LimitBucket }) {
115
115
  );
116
116
  }
117
117
 
118
+ function formatExpiry(expiresAt: number): string {
119
+ const diff = expiresAt - Date.now();
120
+ if (diff <= 0) return "expired";
121
+ const mins = Math.ceil(diff / 60_000);
122
+ const h = Math.floor(mins / 60);
123
+ const d = Math.floor(h / 24);
124
+ if (d > 0) return `${d}d ${h % 24}h`;
125
+ if (h > 0) return `${h}h ${mins % 60}m`;
126
+ return `${mins}m`;
127
+ }
128
+
129
+ /** Derive a human-readable token status from account info */
130
+ function tokenStatus(info?: AccountInfo): { label: string; tip: string; color: string } {
131
+ if (!info) return { label: "unknown", tip: "No account info available", color: "text-text-subtle" };
132
+ if (!info.expiresAt) return { label: "key", tip: "API key (no expiry)", color: "text-text-subtle" };
133
+ const expired = info.expiresAt * 1000 < Date.now(); // expiresAt is seconds
134
+ if (expired && info.hasRefreshToken) return { label: "expired", tip: "Token expired but has refresh token — will auto-renew", color: "text-amber-500" };
135
+ if (expired) return { label: "expired", tip: "Token expired, no refresh token", color: "text-red-500" };
136
+ if (info.hasRefreshToken) return { label: "long-lived", tip: "OAuth token with refresh — long-lived", color: "text-green-500" };
137
+ return { label: "temp", tip: "Temporary token without refresh — will expire", color: "text-amber-500" };
138
+ }
139
+
118
140
  function formatLastUpdated(ts: number | null | undefined): string | null {
119
141
  if (!ts) return null;
120
142
  const secs = Math.round((Date.now() - ts) / 1000);
@@ -193,11 +215,21 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
193
215
  {entry.isOAuth ? "No usage data yet" : "Usage tracking not available for API keys"}
194
216
  </p>
195
217
  )}
196
- {usage.lastFetchedAt && (
197
- <p className="text-[9px] text-text-subtle">
198
- Updated: {formatLastUpdated(new Date(usage.lastFetchedAt).getTime())}
199
- </p>
200
- )}
218
+ {/* Footer: updated · expires · type */}
219
+ {(() => {
220
+ const ts = tokenStatus(accountInfo);
221
+ return (
222
+ <div className="flex items-center gap-1.5 text-[9px] text-text-subtle flex-wrap">
223
+ {usage.lastFetchedAt && (
224
+ <span title="Last usage data update">↻ {formatLastUpdated(new Date(usage.lastFetchedAt).getTime())}</span>
225
+ )}
226
+ {accountInfo?.expiresAt && accountInfo.expiresAt * 1000 > Date.now() && (
227
+ <span title="Token expires in">⏱ {formatExpiry(accountInfo.expiresAt * 1000)}</span>
228
+ )}
229
+ <span className={ts.color} title={ts.tip}>© {ts.label}</span>
230
+ </div>
231
+ );
232
+ })()}
201
233
  </div>
202
234
  );
203
235
  }
@@ -127,76 +127,78 @@ export function ProxySettingsSection() {
127
127
  <div className="space-y-2 rounded-md border p-3 bg-muted/30">
128
128
  <h4 className="text-[11px] font-medium">Connection Info</h4>
129
129
 
130
- {/* Local endpoint */}
130
+ {/* Base URL */}
131
131
  <div className="space-y-1">
132
- <Label className="text-[10px] text-muted-foreground">Local Endpoint</Label>
132
+ <Label className="text-[10px] text-muted-foreground">Base URL</Label>
133
133
  <div className="flex gap-1.5 items-center">
134
134
  <code className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded flex-1 truncate">
135
- {`${window.location.origin}/proxy/v1/messages`}
135
+ {hasTunnel && settings.tunnelUrl
136
+ ? `${settings.tunnelUrl}/proxy`
137
+ : `${window.location.origin}/proxy`}
136
138
  </code>
137
139
  <Button
138
140
  variant="ghost"
139
141
  size="sm"
140
142
  className="h-6 px-1.5 cursor-pointer shrink-0"
141
- onClick={() => copyToClipboard(`${window.location.origin}/proxy/v1/messages`, "local")}
143
+ onClick={() => copyToClipboard(
144
+ hasTunnel && settings.tunnelUrl
145
+ ? `${settings.tunnelUrl}/proxy`
146
+ : `${window.location.origin}/proxy`,
147
+ "baseurl",
148
+ )}
142
149
  >
143
- {copied === "local" ? "Copied!" : <Copy className="size-3" />}
150
+ {copied === "baseurl" ? "Copied!" : <Copy className="size-3" />}
144
151
  </Button>
145
152
  </div>
146
153
  </div>
147
154
 
148
- {/* Tunnel endpoint */}
149
- {hasTunnel && settings.proxyEndpoint && (
150
- <div className="space-y-1">
151
- <Label className="text-[10px] text-muted-foreground">Public Endpoint (Tunnel)</Label>
152
- <div className="flex gap-1.5 items-center">
153
- <code className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded flex-1 truncate">
154
- {settings.proxyEndpoint}
155
- </code>
156
- <Button
157
- variant="ghost"
158
- size="sm"
159
- className="h-6 px-1.5 cursor-pointer shrink-0"
160
- onClick={() => copyToClipboard(settings.proxyEndpoint!, "tunnel")}
161
- >
162
- {copied === "tunnel" ? "Copied!" : <Copy className="size-3" />}
163
- </Button>
164
- </div>
165
- </div>
166
- )}
167
-
168
155
  {!hasTunnel && (
169
156
  <p className="text-[10px] text-muted-foreground">
170
157
  Start a Cloudflare tunnel (Share) to get a public URL.
171
158
  </p>
172
159
  )}
173
160
 
174
- {/* Usage example */}
161
+ {/* Claude Code CLI usage */}
162
+ <div className="space-y-1 pt-1">
163
+ <Label className="text-[10px] text-muted-foreground">Claude Code CLI</Label>
164
+ <div className="relative">
165
+ <pre className="text-[9px] font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre">
166
+ {`ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"} \\
167
+ ANTHROPIC_API_KEY=${settings.authKey} \\
168
+ claude`}
169
+ </pre>
170
+ <Button
171
+ variant="ghost"
172
+ size="sm"
173
+ className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
174
+ onClick={() => copyToClipboard(
175
+ `ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"} ANTHROPIC_API_KEY=${settings.authKey} claude`,
176
+ "claude-cli",
177
+ )}
178
+ >
179
+ {copied === "claude-cli" ? "Copied!" : <Copy className="size-2.5" />}
180
+ </Button>
181
+ </div>
182
+ </div>
183
+
184
+ {/* Generic env vars */}
175
185
  <div className="space-y-1 pt-1">
176
- <Label className="text-[10px] text-muted-foreground">Usage Example</Label>
186
+ <Label className="text-[10px] text-muted-foreground">Environment Variables</Label>
177
187
  <div className="relative">
178
188
  <pre className="text-[9px] font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre">
179
- {`# Set as base URL in your tool
180
- ANTHROPIC_BASE_URL=${hasTunnel && settings.proxyEndpoint ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}
181
- ANTHROPIC_API_KEY=${settings.authKey}
182
-
183
- # Or use curl
184
- curl ${hasTunnel && settings.proxyEndpoint ? settings.proxyEndpoint : window.location.origin + "/proxy/v1/messages"} \\
185
- -H "x-api-key: ${settings.authKey}" \\
186
- -H "content-type: application/json" \\
187
- -H "anthropic-version: 2023-06-01" \\
188
- -d '{"model":"claude-sonnet-4-6","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}'`}
189
+ {`export ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}
190
+ export ANTHROPIC_API_KEY=${settings.authKey}`}
189
191
  </pre>
190
192
  <Button
191
193
  variant="ghost"
192
194
  size="sm"
193
195
  className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
194
196
  onClick={() => copyToClipboard(
195
- `ANTHROPIC_BASE_URL=${hasTunnel ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}\nANTHROPIC_API_KEY=${settings.authKey}`,
196
- "example",
197
+ `export ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}\nexport ANTHROPIC_API_KEY=${settings.authKey}`,
198
+ "envvars",
197
199
  )}
198
200
  >
199
- {copied === "example" ? "Copied!" : <Copy className="size-2.5" />}
201
+ {copied === "envvars" ? "Copied!" : <Copy className="size-2.5" />}
200
202
  </Button>
201
203
  </div>
202
204
  </div>
@@ -1 +0,0 @@
1
- import"./react-nm2Ru1Pt.js";import"./api-client-DpGMOZNf.js";import{T as e}from"./index-yMR7OUDx.js";export{e as useKeybindingsStore};