@hienlh/ppm 0.8.58 → 0.9.0-beta.0

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 +34 -0
  2. package/dist/web/assets/chat-tab-BDyjEN8p.js +7 -0
  3. package/dist/web/assets/{code-editor-u6bm6bdq.js → code-editor-BmFI-Khj.js} +1 -1
  4. package/dist/web/assets/{database-viewer-BgPBW1bJ.js → database-viewer-Cb7tqJqX.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-Cho-kjse.js → diff-viewer-D_f9S4Ya.js} +1 -1
  6. package/dist/web/assets/{git-graph-CktRdFwt.js → git-graph-Co3a8y4i.js} +1 -1
  7. package/dist/web/assets/index-BAioKo_2.css +2 -0
  8. package/dist/web/assets/{index-CpOYx0qg.js → index-CqMDTnLp.js} +13 -7
  9. package/dist/web/assets/keybindings-store-CulLCWPX.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-3_CTktzg.js → markdown-renderer-xipSjvIr.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-CH0JfEQ9.js → postgres-viewer-p5tz14oN.js} +1 -1
  12. package/dist/web/assets/{settings-tab-BI0n39LJ.js → settings-tab-CCz5ftre.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-DJyT7YZg.js → sqlite-viewer-vF9L6tfk.js} +1 -1
  14. package/dist/web/assets/{terminal-tab-OqCohyF0.js → terminal-tab-XbV1JFTB.js} +1 -1
  15. package/dist/web/index.html +2 -2
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/snapshot-state.md +1526 -0
  19. package/src/index.ts +0 -0
  20. package/src/server/index.ts +3 -15
  21. package/src/server/routes/proxy.ts +46 -53
  22. package/src/server/ws/chat.ts +184 -139
  23. package/src/services/account-selector.service.ts +8 -6
  24. package/src/services/account.service.ts +1 -0
  25. package/src/services/claude-usage.service.ts +10 -4
  26. package/src/services/proxy.service.ts +4 -19
  27. package/src/types/api.ts +9 -1
  28. package/src/web/components/chat/chat-tab.tsx +14 -5
  29. package/src/web/components/chat/message-list.tsx +15 -12
  30. package/src/web/components/settings/proxy-settings-section.tsx +40 -42
  31. package/src/web/hooks/use-chat.ts +196 -203
  32. package/test-tokens.mjs +212 -0
  33. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  34. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  35. package/dist/web/assets/chat-tab-cawT08fh.js +0 -7
  36. package/dist/web/assets/index-WKLuYsBY.css +0 -2
  37. package/dist/web/assets/keybindings-store-vOnSm10D.js +0 -1
@@ -60,12 +60,6 @@ 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
- }
69
63
  if (token.startsWith("sk-ant-oat")) {
70
64
  const fresh = await accountService.ensureFreshToken(account.id);
71
65
  if (fresh) token = fresh.accessToken;
@@ -114,22 +108,14 @@ class ProxyService {
114
108
  } else if (upstream.status === 401) {
115
109
  accountSelector.onAuthError(account.id);
116
110
  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")})`);
119
111
  } else if (upstream.status >= 200 && upstream.status < 300) {
120
112
  accountSelector.onSuccess(account.id);
121
113
  }
122
114
 
123
115
  // Stream response back as-is (preserves SSE for streaming)
124
116
  const responseHeaders = new Headers();
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
- ]) {
117
+ // Forward key response headers
118
+ for (const key of ["content-type", "x-request-id", "request-id"]) {
133
119
  const val = upstream.headers.get(key);
134
120
  if (val) responseHeaders.set(key, val);
135
121
  }
@@ -141,10 +127,9 @@ class ProxyService {
141
127
  headers: responseHeaders,
142
128
  });
143
129
  } catch (e) {
144
- const msg = e instanceof Error ? e.message : String(e);
145
- console.error(`[proxy] Error forwarding to Anthropic:`, msg);
130
+ console.error(`[proxy] Error forwarding to Anthropic:`, (e as Error).message);
146
131
  return new Response(
147
- JSON.stringify({ type: "error", error: { type: "api_error", message: msg || "Unknown proxy error" } }),
132
+ JSON.stringify({ type: "error", error: { type: "api_error", message: (e as Error).message } }),
148
133
  { status: 502, headers: { "Content-Type": "application/json" } },
149
134
  );
150
135
  }
package/src/types/api.ts CHANGED
@@ -28,6 +28,9 @@ export type ChatWsClientMessage =
28
28
  | { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown }
29
29
  | { type: "ready" };
30
30
 
31
+ /** Session phase for the 5-state machine (BE-owned) */
32
+ export type SessionPhase = "initializing" | "connecting" | "thinking" | "streaming" | "idle";
33
+
31
34
  export type ChatWsServerMessage =
32
35
  | { type: "text"; content: string; parentToolUseId?: string }
33
36
  | { type: "thinking"; content: string; parentToolUseId?: string }
@@ -36,4 +39,9 @@ export type ChatWsServerMessage =
36
39
  | { type: "approval_request"; requestId: string; tool: string; input: unknown }
37
40
  | { type: "done"; sessionId: string; contextWindowPct?: number }
38
41
  | { type: "error"; message: string }
39
- | { type: "account_info"; accountId: string; accountLabel: string };
42
+ | { type: "account_info"; accountId: string; accountLabel: string }
43
+ | { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
44
+ | { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null }
45
+ | { type: "turn_events"; events: unknown[] }
46
+ | { type: "title_updated"; title: string }
47
+ | { type: "ping" };
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
- import { Upload, X } from "lucide-react";
2
+ import { Loader2, Upload, X } from "lucide-react";
3
3
  import { api, projectUrl } from "@/lib/api-client";
4
4
  import { useChat } from "@/hooks/use-chat";
5
5
  import { useUsage } from "@/hooks/use-usage";
@@ -83,9 +83,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
83
83
  messages,
84
84
  messagesLoading,
85
85
  isStreaming,
86
- streamingStatus,
86
+ phase,
87
+ isReconnecting,
87
88
  connectingElapsed,
88
- thinkingWarningThreshold,
89
89
  pendingApproval,
90
90
  contextWindowPct,
91
91
  sessionTitle,
@@ -311,6 +311,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
311
311
  </div>
312
312
  )}
313
313
 
314
+ {/* Reconnect overlay */}
315
+ {isReconnecting && (
316
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-sm">
317
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
318
+ <Loader2 className="size-4 animate-spin" />
319
+ <span>Reconnecting...</span>
320
+ </div>
321
+ </div>
322
+ )}
323
+
314
324
  {/* Messages */}
315
325
  <MessageList
316
326
  messages={messages}
@@ -318,9 +328,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
318
328
  pendingApproval={pendingApproval}
319
329
  onApprovalResponse={respondToApproval}
320
330
  isStreaming={isStreaming}
321
- streamingStatus={streamingStatus}
331
+ phase={phase}
322
332
  connectingElapsed={connectingElapsed}
323
- thinkingWarningThreshold={thinkingWarningThreshold}
324
333
  projectName={projectName}
325
334
  onFork={!isStreaming ? handleFork : undefined}
326
335
  />
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
2
2
  import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
3
3
  import { getAuthToken } from "@/lib/api-client";
4
4
  import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
- import type { StreamingStatus } from "@/hooks/use-chat";
5
+ import type { SessionPhase } from "../../../types/api";
6
6
  import { ToolCard } from "./tool-cards";
7
7
  import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
8
8
  import { cn, basename } from "@/lib/utils";
@@ -39,9 +39,8 @@ interface MessageListProps {
39
39
  pendingApproval: { requestId: string; tool: string; input: unknown } | null;
40
40
  onApprovalResponse: (requestId: string, approved: boolean, data?: unknown) => void;
41
41
  isStreaming: boolean;
42
- streamingStatus?: StreamingStatus;
42
+ phase?: SessionPhase;
43
43
  connectingElapsed?: number;
44
- thinkingWarningThreshold?: number;
45
44
  projectName?: string;
46
45
  /** Called when user clicks Fork/Rewind — opens new forked chat tab */
47
46
  onFork?: (userMessage: string) => void;
@@ -53,9 +52,8 @@ export function MessageList({
53
52
  pendingApproval,
54
53
  onApprovalResponse,
55
54
  isStreaming,
56
- streamingStatus,
55
+ phase,
57
56
  connectingElapsed,
58
- thinkingWarningThreshold,
59
57
  projectName,
60
58
  onFork,
61
59
  }: MessageListProps) {
@@ -108,7 +106,7 @@ export function MessageList({
108
106
  : <ApprovalCard approval={pendingApproval} onRespond={onApprovalResponse} />
109
107
  )}
110
108
 
111
- {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} streamingStatus={streamingStatus} elapsed={connectingElapsed} warningThreshold={thinkingWarningThreshold} />}
109
+ {isStreaming && <ThinkingIndicator lastMessage={messages[messages.length - 1]} phase={phase} elapsed={connectingElapsed} />}
112
110
  </StickToBottom.Content>
113
111
  <ScrollToBottomButton />
114
112
  </StickToBottom>
@@ -751,14 +749,13 @@ function StreamingText({ content, animate: isStreaming, projectName }: { content
751
749
  * - After tool: "Processing..."
752
750
  * - Text streaming: hidden
753
751
  */
754
- function ThinkingIndicator({ lastMessage, streamingStatus, elapsed, warningThreshold = 15 }: { lastMessage?: ChatMessage; streamingStatus?: StreamingStatus; elapsed?: number; warningThreshold?: number }) {
755
- // Show "Thinking" when:
752
+ function ThinkingIndicator({ lastMessage, phase, elapsed }: { lastMessage?: ChatMessage; phase?: SessionPhase; elapsed?: number }) {
753
+ // Show indicator when:
756
754
  // 1. No assistant message yet (waiting for first response)
757
- // 2. Last event is tool_use/tool_result (Claude thinking after tool execution)
755
+ // 2. Last event is tool_result (Claude thinking after tool execution)
758
756
  // Hide when text is actively streaming (text itself is the indicator)
759
757
 
760
758
  const isWaiting = !lastMessage || lastMessage.role !== "assistant";
761
- // Show Thinking only after tool_result (tool finished), not tool_use (tool still running)
762
759
  const isAfterTool = (() => {
763
760
  if (!lastMessage?.events?.length) return false;
764
761
  const last = lastMessage.events[lastMessage.events.length - 1]!;
@@ -767,13 +764,19 @@ function ThinkingIndicator({ lastMessage, streamingStatus, elapsed, warningThres
767
764
 
768
765
  if (!isWaiting && !isAfterTool) return null;
769
766
 
770
- const isLong = isWaiting && (elapsed ?? 0) >= warningThreshold;
767
+ const label = phase === "initializing" ? "Initializing"
768
+ : phase === "connecting" ? "Connecting"
769
+ : phase === "thinking" ? "Thinking"
770
+ : "Processing";
771
+
772
+ const isLong = phase === "connecting" && (elapsed ?? 0) >= 30;
773
+
771
774
  return (
772
775
  <div className="flex flex-col gap-1 text-sm">
773
776
  <div className="flex items-center gap-2 text-text-subtle">
774
777
  <Loader2 className="size-3 animate-spin" />
775
778
  <span>
776
- Thinking
779
+ {label}
777
780
  {isWaiting && (elapsed ?? 0) > 0 && <span className="text-text-subtle/60">... ({elapsed}s)</span>}
778
781
  </span>
779
782
  </div>
@@ -127,78 +127,76 @@ 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
- {/* Base URL */}
130
+ {/* Local endpoint */}
131
131
  <div className="space-y-1">
132
- <Label className="text-[10px] text-muted-foreground">Base URL</Label>
132
+ <Label className="text-[10px] text-muted-foreground">Local Endpoint</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
- {hasTunnel && settings.tunnelUrl
136
- ? `${settings.tunnelUrl}/proxy`
137
- : `${window.location.origin}/proxy`}
135
+ {`${window.location.origin}/proxy/v1/messages`}
138
136
  </code>
139
137
  <Button
140
138
  variant="ghost"
141
139
  size="sm"
142
140
  className="h-6 px-1.5 cursor-pointer shrink-0"
143
- onClick={() => copyToClipboard(
144
- hasTunnel && settings.tunnelUrl
145
- ? `${settings.tunnelUrl}/proxy`
146
- : `${window.location.origin}/proxy`,
147
- "baseurl",
148
- )}
141
+ onClick={() => copyToClipboard(`${window.location.origin}/proxy/v1/messages`, "local")}
149
142
  >
150
- {copied === "baseurl" ? "Copied!" : <Copy className="size-3" />}
143
+ {copied === "local" ? "Copied!" : <Copy className="size-3" />}
151
144
  </Button>
152
145
  </div>
153
146
  </div>
154
147
 
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
+
155
168
  {!hasTunnel && (
156
169
  <p className="text-[10px] text-muted-foreground">
157
170
  Start a Cloudflare tunnel (Share) to get a public URL.
158
171
  </p>
159
172
  )}
160
173
 
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 */}
174
+ {/* Usage example */}
185
175
  <div className="space-y-1 pt-1">
186
- <Label className="text-[10px] text-muted-foreground">Environment Variables</Label>
176
+ <Label className="text-[10px] text-muted-foreground">Usage Example</Label>
187
177
  <div className="relative">
188
178
  <pre className="text-[9px] font-mono bg-muted p-2 rounded overflow-x-auto whitespace-pre">
189
- {`export ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}
190
- export ANTHROPIC_API_KEY=${settings.authKey}`}
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"}]}'`}
191
189
  </pre>
192
190
  <Button
193
191
  variant="ghost"
194
192
  size="sm"
195
193
  className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
196
194
  onClick={() => copyToClipboard(
197
- `export ANTHROPIC_BASE_URL=${hasTunnel && settings.tunnelUrl ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}\nexport ANTHROPIC_API_KEY=${settings.authKey}`,
198
- "envvars",
195
+ `ANTHROPIC_BASE_URL=${hasTunnel ? settings.tunnelUrl + "/proxy" : window.location.origin + "/proxy"}\nANTHROPIC_API_KEY=${settings.authKey}`,
196
+ "example",
199
197
  )}
200
198
  >
201
- {copied === "envvars" ? "Copied!" : <Copy className="size-2.5" />}
199
+ {copied === "example" ? "Copied!" : <Copy className="size-2.5" />}
202
200
  </Button>
203
201
  </div>
204
202
  </div>