@hienlh/ppm 0.8.88 → 0.8.90

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 (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/web/assets/api-settings-Bid0NHuI.js +1 -0
  3. package/dist/web/assets/{browser-tab-DJLH0eDY.js → browser-tab-SHBc1OCK.js} +1 -1
  4. package/dist/web/assets/chat-tab-dssvQaJN.js +8 -0
  5. package/dist/web/assets/{code-editor-CaGdx-lS.js → code-editor-BomcTYQ4.js} +1 -1
  6. package/dist/web/assets/{database-viewer-i4Ddk6mO.js → database-viewer-B47ck-1v.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-DQDS7yjv.js → diff-viewer-Dw2v2RU2.js} +1 -1
  8. package/dist/web/assets/{git-graph-DUs-TN1u.js → git-graph-Co7fcau-.js} +1 -1
  9. package/dist/web/assets/{index-Dm6RN1A1.js → index-CQu4iIvy.js} +6 -6
  10. package/dist/web/assets/index-CqhIj4Ko.css +2 -0
  11. package/dist/web/assets/keybindings-store-OkhvRBpn.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-L1NgC2Rw.js → markdown-renderer-C0n-Ucfa.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-_uDispGW.js → postgres-viewer-D4vyH--N.js} +1 -1
  14. package/dist/web/assets/{settings-tab-Bp4041i6.js → settings-tab-CisRYqMl.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-GW-QCjHn.js → sqlite-viewer-D3t4nApY.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-E4cWujj4.js → terminal-tab-DFz3Bd_N.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-zABXAAla.js → use-monaco-theme-Dopv6S2i.js} +1 -1
  18. package/dist/web/index.html +3 -3
  19. package/dist/web/sw.js +1 -1
  20. package/docs/streaming-input-guide.md +267 -0
  21. package/package.json +1 -1
  22. package/snapshot-state.md +1526 -0
  23. package/src/services/account.service.ts +2 -2
  24. package/src/services/claude-usage.service.ts +2 -7
  25. package/src/services/mcp-config.service.ts +15 -6
  26. package/src/services/supervisor.ts +19 -2
  27. package/src/web/app.tsx +3 -2
  28. package/src/web/components/chat/usage-badge.tsx +58 -8
  29. package/src/web/components/layout/upgrade-banner.tsx +15 -5
  30. package/test-session-ops.mjs +444 -0
  31. package/test-tokens.mjs +212 -0
  32. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  33. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  34. package/dist/web/assets/api-settings-Dh4oFOpX.js +0 -1
  35. package/dist/web/assets/chat-tab-C8HFXqGS.js +0 -8
  36. package/dist/web/assets/index-DhtLEnPD.css +0 -2
  37. package/dist/web/assets/keybindings-store-qVLDZz97.js +0 -1
@@ -139,7 +139,7 @@ class AccountService {
139
139
  await this.refreshAccessToken(id, false);
140
140
  return this.getWithTokens(id);
141
141
  } catch (e) {
142
- console.error(`[accounts] Pre-flight refresh failed for ${id}:`, e);
142
+ console.error(`[accounts] Pre-flight refresh failed for ${id}: ${(e as Error).message ?? e}`);
143
143
  return null;
144
144
  }
145
145
  }
@@ -709,7 +709,7 @@ class AccountService {
709
709
  try {
710
710
  await this.refreshAccessToken(acc.id, false);
711
711
  } catch (e) {
712
- console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
712
+ console.error(`[accounts] Auto-refresh failed for ${acc.id}: ${(e as Error).message ?? e}`);
713
713
  }
714
714
  }
715
715
  };
@@ -273,14 +273,9 @@ export function getUsageForAccount(accountId: string): ClaudeUsage {
273
273
  return row ? snapshotToUsage(row) : {};
274
274
  }
275
275
 
276
- /** Get usage for all accounts (excludes expired temporary accounts) */
276
+ /** Get usage for all accounts */
277
277
  export function getAllAccountUsages(): AccountUsageEntry[] {
278
- const nowS = Math.floor(Date.now() / 1000);
279
- const accounts = accountService.list().filter(acc => {
280
- // Exclude expired accounts without refresh token (temporary/invalid)
281
- if (!accountService.hasRefreshToken(acc.id) && acc.expiresAt && acc.expiresAt < nowS) return false;
282
- return true;
283
- });
278
+ const accounts = accountService.list();
284
279
  const snapshots = getAllLatestSnapshots();
285
280
  const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
286
281
  return accounts.map(acc => {
@@ -27,13 +27,22 @@ export class McpConfigService {
27
27
 
28
28
  /** List all MCP servers as Record (SDK-compatible format) */
29
29
  list(): Record<string, McpServerConfig> {
30
- const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
31
- const result: Record<string, McpServerConfig> = {};
32
- for (const row of rows) {
33
- const parsed = safeParse(row.config, row.name);
34
- if (parsed) result[row.name] = parsed;
30
+ try {
31
+ const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
32
+ const result: Record<string, McpServerConfig> = {};
33
+ for (const row of rows) {
34
+ const parsed = safeParse(row.config, row.name);
35
+ if (parsed) result[row.name] = parsed;
36
+ }
37
+ return result;
38
+ } catch (e) {
39
+ const msg = (e as Error).message ?? String(e);
40
+ if (msg.includes("no such table")) {
41
+ console.warn("[mcp] mcp_servers table not found — returning empty list");
42
+ return {};
43
+ }
44
+ throw e;
35
45
  }
36
- return result;
37
46
  }
38
47
 
39
48
  /** List as array with metadata (for UI) */
@@ -378,17 +378,33 @@ function adoptTunnel(): boolean {
378
378
  const status = readStatus();
379
379
  const pid = status.tunnelPid as number;
380
380
  const url = status.shareUrl as string;
381
- if (!pid || !url) return false;
381
+ if (!pid || !url) {
382
+ log("DEBUG", `adoptTunnel: missing tunnelPid(${pid}) or shareUrl(${url}) in status`);
383
+ return false;
384
+ }
382
385
  process.kill(pid, 0); // throws if process is dead
383
386
  adoptedTunnelPid = pid;
384
387
  tunnelUrl = url;
385
388
  log("INFO", `Adopted existing tunnel (PID: ${pid}, URL: ${url})`);
386
389
  return true;
387
- } catch {
390
+ } catch (e) {
391
+ log("WARN", `adoptTunnel: tunnel PID ${(readStatus().tunnelPid)} unreachable: ${e}`);
388
392
  return false;
389
393
  }
390
394
  }
391
395
 
396
+ /** Kill stale tunnel PID from status.json (cleanup after failed adoption) */
397
+ function killStaleTunnel() {
398
+ try {
399
+ const status = readStatus();
400
+ const pid = status.tunnelPid as number;
401
+ if (!pid) return;
402
+ try { process.kill(pid, "SIGTERM"); } catch {}
403
+ log("INFO", `Killed stale tunnel (PID: ${pid})`);
404
+ } catch {}
405
+ updateStatus({ tunnelPid: null, shareUrl: null });
406
+ }
407
+
392
408
  /** Spawn new supervisor from updated code, wait for it to be healthy, then exit */
393
409
  async function selfReplace(): Promise<{ success: boolean; error?: string }> {
394
410
  log("INFO", "Starting self-replace for upgrade");
@@ -706,6 +722,7 @@ export async function runSupervisor(opts: {
706
722
  startTunnelProbe(opts.port);
707
723
  // Try adopting tunnel kept alive from previous upgrade; spawn new if dead
708
724
  if (!adoptTunnel()) {
725
+ killStaleTunnel(); // kill orphaned tunnel before spawning new one
709
726
  promises.push(spawnTunnel(opts.port));
710
727
  }
711
728
  }
package/src/web/app.tsx CHANGED
@@ -37,6 +37,7 @@ type AuthState = "checking" | "authenticated" | "unauthenticated";
37
37
 
38
38
  export function App() {
39
39
  const [authState, setAuthState] = useState<AuthState>("checking");
40
+ const [upgradeBannerVisible, setUpgradeBannerVisible] = useState(false);
40
41
  const [drawerOpen, setDrawerOpen] = useState(false);
41
42
  const [drawerTab, setDrawerTab] = useState<"explorer" | "git" | "settings" | undefined>();
42
43
  const [projectSheetOpen, setProjectSheetOpen] = useState(false);
@@ -229,11 +230,11 @@ export function App() {
229
230
  <TooltipProvider>
230
231
  <div className="h-dvh flex flex-col bg-background text-foreground overflow-hidden relative">
231
232
  {/* Upgrade banner — shown when new version available */}
232
- <UpgradeBanner />
233
+ <UpgradeBanner onVisibilityChange={setUpgradeBannerVisible} />
233
234
 
234
235
  {/* Mobile device name badge — floating top-left */}
235
236
  {deviceName && (
236
- <div className="md:hidden fixed top-0 left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br">
237
+ <div className={cn("md:hidden fixed left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br transition-[top]", upgradeBannerVisible ? "top-7" : "top-0")}>
237
238
  {deviceName}
238
239
  </div>
239
240
  )}
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
- import { Activity, RefreshCw, Eye, Download, Upload, Plus, X, Settings } from "lucide-react";
2
+ import { Activity, RefreshCw, Eye, Download, Upload, Plus, X, Settings, Trash2 } from "lucide-react";
3
3
  import { Switch } from "@/components/ui/switch";
4
4
  import type { UsageInfo, LimitBucket } from "../../../types/chat";
5
5
  import {
@@ -7,6 +7,7 @@ import {
7
7
  getActiveAccount,
8
8
  getAllAccountUsages,
9
9
  patchAccount,
10
+ deleteAccount,
10
11
  type AccountInfo,
11
12
  type AccountUsageEntry,
12
13
  type OAuthProfileData,
@@ -152,11 +153,12 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
152
153
  return `${days}d ago`;
153
154
  }
154
155
 
155
- function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, onViewProfile, flash }: {
156
+ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onDelete, onExport, onViewProfile, flash }: {
156
157
  entry: AccountUsageEntry;
157
158
  isActive: boolean;
158
159
  accountInfo?: AccountInfo;
159
160
  onToggle?: (id: string, status: string) => void;
161
+ onDelete?: (id: string, display: string) => void;
160
162
  onExport?: (id: string) => void;
161
163
  onViewProfile?: (profile: OAuthProfileData) => void;
162
164
  flash?: boolean;
@@ -164,19 +166,24 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
164
166
  const { usage } = entry;
165
167
  const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
166
168
  const status = accountInfo?.status ?? entry.accountStatus;
169
+ // Expired: has expiresAt in the past AND no refresh token to auto-renew
170
+ const isExpired = !!(accountInfo && !accountInfo.hasRefreshToken && accountInfo.expiresAt && accountInfo.expiresAt < Math.floor(Date.now() / 1000));
167
171
 
168
172
  return (
169
- <div className={`rounded-md border p-2 space-y-1.5 transition-colors duration-500 min-w-[200px] shrink-0 snap-start ${flash ? "bg-primary/10 border-primary/40" : ""} ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
173
+ <div className={`rounded-md border p-2 space-y-1.5 transition-colors duration-500 min-w-[200px] shrink-0 snap-start ${isExpired ? "opacity-50" : ""} ${flash ? "bg-primary/10 border-primary/40" : ""} ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
170
174
  <div className="flex items-center gap-1.5">
171
175
  <span className="text-xs font-medium truncate flex-1 min-w-0">
172
176
  {entry.accountLabel ?? entry.accountId.slice(0, 8)}
173
177
  </span>
174
- {!entry.isOAuth && (
178
+ {isExpired && (
179
+ <span className="text-[9px] text-red-500 shrink-0 font-medium">Expired</span>
180
+ )}
181
+ {!entry.isOAuth && !isExpired && (
175
182
  <span className="text-[9px] text-text-subtle shrink-0">API key</span>
176
183
  )}
177
184
  {/* Account controls */}
178
185
  <div className="flex items-center gap-0.5 shrink-0">
179
- {onViewProfile && accountInfo?.profileData && (
186
+ {!isExpired && onViewProfile && accountInfo?.profileData && (
180
187
  <button
181
188
  className="p-1 rounded cursor-pointer text-text-subtle hover:text-foreground hover:bg-surface-elevated transition-colors"
182
189
  onClick={() => onViewProfile(accountInfo.profileData!)}
@@ -185,7 +192,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
185
192
  <Eye className="size-3" />
186
193
  </button>
187
194
  )}
188
- {onExport && entry.isOAuth && (
195
+ {!isExpired && onExport && entry.isOAuth && (
189
196
  <button
190
197
  className="p-1 rounded cursor-pointer text-text-subtle hover:text-blue-500 hover:bg-surface-elevated transition-colors"
191
198
  onClick={() => onExport(entry.accountId)}
@@ -194,7 +201,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
194
201
  <Download className="size-3" />
195
202
  </button>
196
203
  )}
197
- {onToggle && (
204
+ {!isExpired && onToggle && (
198
205
  <Switch
199
206
  checked={status !== "disabled"}
200
207
  onCheckedChange={() => onToggle(entry.accountId, status)}
@@ -202,6 +209,15 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
202
209
  className="scale-[0.6] cursor-pointer"
203
210
  />
204
211
  )}
212
+ {onDelete && (
213
+ <button
214
+ className="p-1 rounded cursor-pointer text-text-subtle hover:text-red-500 hover:bg-surface-elevated transition-colors"
215
+ onClick={() => onDelete(entry.accountId, entry.accountLabel ?? entry.accountId.slice(0, 8))}
216
+ title="Remove account"
217
+ >
218
+ <Trash2 className="size-3" />
219
+ </button>
220
+ )}
205
221
  </div>
206
222
  </div>
207
223
  {hasBuckets ? (
@@ -247,6 +263,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
247
263
  const [showExportDialog, setShowExportDialog] = useState(false);
248
264
  const [showImportDialog, setShowImportDialog] = useState(false);
249
265
  const [showRotationSettings, setShowRotationSettings] = useState(false);
266
+ const [deleteTarget, setDeleteTarget] = useState<{ id: string; display: string } | null>(null);
250
267
  const [exportPreselect, setExportPreselect] = useState<string | null>(null);
251
268
  const [message, setMessage] = useState<string | null>(null);
252
269
  const msgTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -325,13 +342,26 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
325
342
  onReload?.();
326
343
  }
327
344
 
345
+ async function confirmDeleteAccount() {
346
+ if (!deleteTarget) return;
347
+ try {
348
+ await deleteAccount(deleteTarget.id);
349
+ showMessage(`Account "${deleteTarget.display}" removed.`);
350
+ loadAll();
351
+ onReload?.();
352
+ } catch (e) {
353
+ showMessage(`Failed to remove: ${(e as Error).message}`);
354
+ }
355
+ setDeleteTarget(null);
356
+ }
357
+
328
358
  function openExportAll() {
329
359
  setExportPreselect(null);
330
360
  setShowExportDialog(true);
331
361
  }
332
362
 
333
363
  return (
334
- <div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
364
+ <div className="relative border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
335
365
  <div className="flex items-center justify-between">
336
366
  <div className="flex items-center gap-2">
337
367
  <span className="text-xs font-semibold text-text-primary">Usage & Accounts</span>
@@ -384,6 +414,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
384
414
  isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
385
415
  accountInfo={accountMap.get(entry.accountId)}
386
416
  onToggle={handleToggle}
417
+ onDelete={(id, display) => setDeleteTarget({ id, display })}
387
418
  onExport={(id) => { setExportPreselect(id); setShowExportDialog(true); }}
388
419
  onViewProfile={setProfileView}
389
420
  flash={flashIds.has(entry.accountId)}
@@ -460,6 +491,25 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
460
491
  </button>
461
492
  </div>
462
493
 
494
+ {/* Delete confirmation overlay */}
495
+ {deleteTarget && (
496
+ <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-md">
497
+ <div className="bg-surface border border-border rounded-lg shadow-lg p-4 mx-4 max-w-[280px] w-full space-y-3">
498
+ <p className="text-xs text-text-primary text-center">
499
+ Remove <strong className="text-foreground">{deleteTarget.display}</strong>?
500
+ </p>
501
+ <div className="flex gap-2">
502
+ <button onClick={() => setDeleteTarget(null)} className="flex-1 px-3 py-1.5 rounded-md text-xs border border-border text-text-secondary hover:bg-surface-hover cursor-pointer transition-colors">
503
+ Cancel
504
+ </button>
505
+ <button onClick={confirmDeleteAccount} className="flex-1 px-3 py-1.5 rounded-md text-xs bg-red-500 text-white hover:bg-red-600 cursor-pointer transition-colors">
506
+ Remove
507
+ </button>
508
+ </div>
509
+ </div>
510
+ </div>
511
+ )}
512
+
463
513
  {/* Account dialogs */}
464
514
  <AddAccountDialog open={showAddDialog} onOpenChange={setShowAddDialog} onSuccess={handleSuccess} />
465
515
  <ExportAccountsDialog open={showExportDialog} onOpenChange={(v) => { setShowExportDialog(v); if (!v) setExportPreselect(null); }} accounts={accounts} preselectId={exportPreselect} onMessage={showMessage} />
@@ -12,7 +12,11 @@ interface UpgradeStatus {
12
12
  installMethod: string;
13
13
  }
14
14
 
15
- export function UpgradeBanner() {
15
+ interface UpgradeBannerProps {
16
+ onVisibilityChange?: (visible: boolean) => void;
17
+ }
18
+
19
+ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
16
20
  const [availableVersion, setAvailableVersion] = useState<string | null>(null);
17
21
  const [upgrading, setUpgrading] = useState(false);
18
22
  const [dismissed, setDismissed] = useState(false);
@@ -61,10 +65,16 @@ export function UpgradeBanner() {
61
65
  setDismissed(true);
62
66
  }, [availableVersion]);
63
67
 
64
- if (!availableVersion || dismissed) return null;
68
+ const visible = !!availableVersion && !dismissed;
69
+
70
+ useEffect(() => {
71
+ onVisibilityChange?.(visible);
72
+ }, [visible, onVisibilityChange]);
73
+
74
+ if (!visible) return null;
65
75
 
66
76
  return (
67
- <div className="w-full bg-blue-600 dark:bg-blue-700 text-white px-3 py-2 flex items-center justify-between gap-2 z-50 text-sm shrink-0">
77
+ <div className="w-full bg-blue-600 dark:bg-blue-700 text-white px-3 py-1 flex items-center justify-between gap-2 z-50 text-sm shrink-0">
68
78
  {upgrading ? (
69
79
  <div className="flex items-center gap-2 flex-1 min-w-0">
70
80
  <Loader2 className="size-4 animate-spin shrink-0" />
@@ -83,13 +93,13 @@ export function UpgradeBanner() {
83
93
  <div className="flex items-center gap-1 shrink-0">
84
94
  <button
85
95
  onClick={handleUpgrade}
86
- className="bg-white text-blue-600 font-medium rounded-full px-3 min-h-[44px] min-w-[44px] flex items-center justify-center hover:bg-blue-50 active:bg-blue-100 transition-colors"
96
+ className="bg-white text-blue-600 font-medium rounded-full px-3 py-0.5 text-xs min-h-[28px] min-w-[28px] flex items-center justify-center hover:bg-blue-50 active:bg-blue-100 transition-colors"
87
97
  >
88
98
  Upgrade
89
99
  </button>
90
100
  <button
91
101
  onClick={handleDismiss}
92
- className="min-h-[44px] min-w-[44px] flex items-center justify-center rounded-full hover:bg-blue-500 active:bg-blue-800 transition-colors"
102
+ className="min-h-[28px] min-w-[28px] flex items-center justify-center rounded-full hover:bg-blue-500 active:bg-blue-800 transition-colors"
93
103
  aria-label="Dismiss upgrade notification"
94
104
  >
95
105
  <X className="size-4" />