@hienlh/ppm 0.1.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.
- package/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +62 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import {
|
|
2
|
+
query,
|
|
3
|
+
listSessions as sdkListSessions,
|
|
4
|
+
getSessionMessages,
|
|
5
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
import type {
|
|
7
|
+
AIProvider,
|
|
8
|
+
Session,
|
|
9
|
+
SessionConfig,
|
|
10
|
+
SessionInfo,
|
|
11
|
+
ChatEvent,
|
|
12
|
+
ChatMessage,
|
|
13
|
+
UsageInfo,
|
|
14
|
+
} from "./provider.interface.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pending approval: canUseTool callback creates a promise,
|
|
18
|
+
* yields an approval_request event, then awaits resolution from FE.
|
|
19
|
+
*/
|
|
20
|
+
interface PendingApproval {
|
|
21
|
+
resolve: (result: { approved: boolean; data?: unknown }) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* AI provider using @anthropic-ai/claude-agent-sdk.
|
|
26
|
+
* Sessions are persisted by Claude Code itself (~/.claude/projects/).
|
|
27
|
+
* Uses canUseTool callback for tool approvals and AskUserQuestion.
|
|
28
|
+
*/
|
|
29
|
+
export class ClaudeAgentSdkProvider implements AIProvider {
|
|
30
|
+
id = "claude-sdk";
|
|
31
|
+
name = "Claude Agent SDK";
|
|
32
|
+
|
|
33
|
+
private activeSessions = new Map<string, Session>();
|
|
34
|
+
private messageCount = new Map<string, number>();
|
|
35
|
+
/** Pending approval promises keyed by requestId */
|
|
36
|
+
private pendingApprovals = new Map<string, PendingApproval>();
|
|
37
|
+
/** Active query objects for abort support */
|
|
38
|
+
private activeQueries = new Map<string, { close: () => void }>();
|
|
39
|
+
/** Latest known usage/rate-limit info (shared across all sessions) */
|
|
40
|
+
private latestUsage: UsageInfo = {};
|
|
41
|
+
|
|
42
|
+
async createSession(config: SessionConfig): Promise<Session> {
|
|
43
|
+
const id = crypto.randomUUID();
|
|
44
|
+
const meta: Session = {
|
|
45
|
+
id,
|
|
46
|
+
providerId: this.id,
|
|
47
|
+
title: config.title ?? "New Chat",
|
|
48
|
+
projectName: config.projectName,
|
|
49
|
+
projectPath: config.projectPath,
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
this.activeSessions.set(id, meta);
|
|
53
|
+
this.messageCount.set(id, 0);
|
|
54
|
+
return meta;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async resumeSession(sessionId: string): Promise<Session> {
|
|
58
|
+
const existing = this.activeSessions.get(sessionId);
|
|
59
|
+
if (existing) return existing;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const sdkSessions = await sdkListSessions({ limit: 100 });
|
|
63
|
+
const found = sdkSessions.find((s) => s.sessionId === sessionId);
|
|
64
|
+
if (found) {
|
|
65
|
+
const meta: Session = {
|
|
66
|
+
id: sessionId,
|
|
67
|
+
providerId: this.id,
|
|
68
|
+
title: found.summary ?? "Resumed Chat",
|
|
69
|
+
createdAt: new Date(found.lastModified).toISOString(),
|
|
70
|
+
};
|
|
71
|
+
this.activeSessions.set(sessionId, meta);
|
|
72
|
+
this.messageCount.set(sessionId, 1);
|
|
73
|
+
return meta;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// SDK not available
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const meta: Session = {
|
|
80
|
+
id: sessionId,
|
|
81
|
+
providerId: this.id,
|
|
82
|
+
title: "Resumed Chat",
|
|
83
|
+
createdAt: new Date().toISOString(),
|
|
84
|
+
};
|
|
85
|
+
this.activeSessions.set(sessionId, meta);
|
|
86
|
+
this.messageCount.set(sessionId, 1);
|
|
87
|
+
return meta;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async listSessions(): Promise<SessionInfo[]> {
|
|
91
|
+
return this.listSessionsByDir();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async listSessionsByDir(dir?: string): Promise<SessionInfo[]> {
|
|
95
|
+
try {
|
|
96
|
+
const sdkSessions = await sdkListSessions({ dir, limit: 50 });
|
|
97
|
+
return sdkSessions.map((s) => ({
|
|
98
|
+
id: s.sessionId,
|
|
99
|
+
providerId: this.id,
|
|
100
|
+
title: s.summary ?? s.firstPrompt ?? "Chat",
|
|
101
|
+
createdAt: new Date(s.lastModified).toISOString(),
|
|
102
|
+
updatedAt: new Date(s.lastModified).toISOString(),
|
|
103
|
+
}));
|
|
104
|
+
} catch {
|
|
105
|
+
return Array.from(this.activeSessions.values()).map((s) => ({
|
|
106
|
+
id: s.id,
|
|
107
|
+
providerId: s.providerId,
|
|
108
|
+
title: s.title,
|
|
109
|
+
projectName: s.projectName,
|
|
110
|
+
createdAt: s.createdAt,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
116
|
+
this.activeSessions.delete(sessionId);
|
|
117
|
+
this.messageCount.delete(sessionId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Ensure a session has projectPath set (for skills/settings support).
|
|
122
|
+
* Called by WS handler to backfill projectPath on resumed sessions.
|
|
123
|
+
*/
|
|
124
|
+
ensureProjectPath(sessionId: string, projectPath: string): void {
|
|
125
|
+
const meta = this.activeSessions.get(sessionId);
|
|
126
|
+
if (meta && !meta.projectPath) {
|
|
127
|
+
meta.projectPath = projectPath;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a pending approval from FE (tool approval or AskUserQuestion answer).
|
|
133
|
+
* Called by WS handler when client sends approval_response.
|
|
134
|
+
*/
|
|
135
|
+
resolveApproval(requestId: string, approved: boolean, data?: unknown): void {
|
|
136
|
+
const pending = this.pendingApprovals.get(requestId);
|
|
137
|
+
if (pending) {
|
|
138
|
+
pending.resolve({ approved, data });
|
|
139
|
+
this.pendingApprovals.delete(requestId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async *sendMessage(
|
|
144
|
+
sessionId: string,
|
|
145
|
+
message: string,
|
|
146
|
+
): AsyncIterable<ChatEvent> {
|
|
147
|
+
if (!this.activeSessions.has(sessionId)) {
|
|
148
|
+
await this.resumeSession(sessionId);
|
|
149
|
+
}
|
|
150
|
+
const meta = this.activeSessions.get(sessionId)!;
|
|
151
|
+
|
|
152
|
+
if (meta.title === "New Chat") {
|
|
153
|
+
meta.title = message.slice(0, 50) + (message.length > 50 ? "..." : "");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const count = this.messageCount.get(sessionId) ?? 0;
|
|
157
|
+
const isFirstMessage = count === 0;
|
|
158
|
+
this.messageCount.set(sessionId, count + 1);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Approval events to yield from the generator.
|
|
162
|
+
* canUseTool pushes events here; the main loop yields them.
|
|
163
|
+
*/
|
|
164
|
+
const approvalEvents: ChatEvent[] = [];
|
|
165
|
+
let approvalNotify: (() => void) | undefined;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* canUseTool: only fires for AskUserQuestion (bypassPermissions auto-approves other tools).
|
|
169
|
+
* Pauses SDK execution, yields approval_request to FE, waits for user response.
|
|
170
|
+
*/
|
|
171
|
+
const canUseTool = async (toolName: string, input: unknown) => {
|
|
172
|
+
// Non-AskUserQuestion tools: auto-approve (shouldn't reach here with bypassPermissions)
|
|
173
|
+
if (toolName !== "AskUserQuestion") {
|
|
174
|
+
return { behavior: "allow" as const, updatedInput: input };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const requestId = crypto.randomUUID();
|
|
178
|
+
|
|
179
|
+
const approvalPromise = new Promise<{ approved: boolean; data?: unknown }>(
|
|
180
|
+
(resolve) => {
|
|
181
|
+
this.pendingApprovals.set(requestId, { resolve });
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Queue event for the generator to yield to FE
|
|
186
|
+
approvalEvents.push({
|
|
187
|
+
type: "approval_request",
|
|
188
|
+
requestId,
|
|
189
|
+
tool: toolName,
|
|
190
|
+
input,
|
|
191
|
+
});
|
|
192
|
+
approvalNotify?.();
|
|
193
|
+
|
|
194
|
+
// Wait for FE to send back answers
|
|
195
|
+
const result = await approvalPromise;
|
|
196
|
+
|
|
197
|
+
if (result.approved && result.data) {
|
|
198
|
+
return {
|
|
199
|
+
behavior: "allow" as const,
|
|
200
|
+
updatedInput: { ...(input as Record<string, unknown>), answers: result.data },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return { behavior: "deny" as const, message: "User skipped the question" };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
let assistantContent = "";
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const q = query({
|
|
210
|
+
prompt: message,
|
|
211
|
+
options: {
|
|
212
|
+
sessionId: isFirstMessage ? sessionId : undefined,
|
|
213
|
+
resume: isFirstMessage ? undefined : sessionId,
|
|
214
|
+
cwd: meta.projectPath,
|
|
215
|
+
settingSources: meta.projectPath ? ["project"] : undefined,
|
|
216
|
+
allowedTools: [
|
|
217
|
+
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
|
|
218
|
+
"WebSearch", "WebFetch", "AskUserQuestion",
|
|
219
|
+
],
|
|
220
|
+
permissionMode: "bypassPermissions",
|
|
221
|
+
allowDangerouslySkipPermissions: true,
|
|
222
|
+
canUseTool,
|
|
223
|
+
includePartialMessages: true,
|
|
224
|
+
} as any,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Track active query for abort support
|
|
228
|
+
this.activeQueries.set(sessionId, q);
|
|
229
|
+
|
|
230
|
+
let lastPartialText = "";
|
|
231
|
+
/** Number of tool_use blocks pending results (tools executing internally by SDK) */
|
|
232
|
+
let pendingToolCount = 0;
|
|
233
|
+
|
|
234
|
+
for await (const msg of q) {
|
|
235
|
+
// Yield any queued approval events
|
|
236
|
+
while (approvalEvents.length > 0) {
|
|
237
|
+
yield approvalEvents.shift()!;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// When tools were pending and a new assistant/stream_event arrives,
|
|
241
|
+
// the SDK has finished executing tools. Fetch tool_results from session history.
|
|
242
|
+
if (pendingToolCount > 0 && (msg.type === "assistant" || (msg as any).type === "partial" || (msg as any).type === "stream_event")) {
|
|
243
|
+
try {
|
|
244
|
+
const sessionMsgs = await getSessionMessages(sessionId);
|
|
245
|
+
// Find the last user message — it contains tool_result blocks
|
|
246
|
+
const lastUserMsg = [...sessionMsgs].reverse().find(
|
|
247
|
+
(m: any) => m.type === "user",
|
|
248
|
+
);
|
|
249
|
+
const userContent = (lastUserMsg as any)?.message?.content;
|
|
250
|
+
if (Array.isArray(userContent)) {
|
|
251
|
+
for (const block of userContent) {
|
|
252
|
+
if (block.type === "tool_result") {
|
|
253
|
+
const output = block.content ?? block.output ?? "";
|
|
254
|
+
yield {
|
|
255
|
+
type: "tool_result" as const,
|
|
256
|
+
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
257
|
+
isError: !!block.is_error,
|
|
258
|
+
toolUseId: block.tool_use_id as string | undefined,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// Session history unavailable — skip tool_results
|
|
265
|
+
}
|
|
266
|
+
pendingToolCount = 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Partial assistant message — streaming text deltas
|
|
270
|
+
if ((msg as any).type === "partial" || (msg as any).type === "stream_event") {
|
|
271
|
+
const partial = msg as any;
|
|
272
|
+
// Handle stream_event (raw API events) for text deltas
|
|
273
|
+
if ((msg as any).type === "stream_event") {
|
|
274
|
+
const event = partial.event;
|
|
275
|
+
if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
276
|
+
const text = event.delta.text ?? "";
|
|
277
|
+
if (text) {
|
|
278
|
+
lastPartialText += text;
|
|
279
|
+
yield { type: "text", content: text };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
// Handle legacy "partial" type
|
|
285
|
+
const content = partial.message?.content;
|
|
286
|
+
if (Array.isArray(content)) {
|
|
287
|
+
let fullText = "";
|
|
288
|
+
for (const block of content) {
|
|
289
|
+
if (block.type === "text") fullText += block.text ?? "";
|
|
290
|
+
}
|
|
291
|
+
if (fullText.length > lastPartialText.length) {
|
|
292
|
+
const delta = fullText.slice(lastPartialText.length);
|
|
293
|
+
lastPartialText = fullText;
|
|
294
|
+
yield { type: "text", content: delta };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Full assistant message
|
|
301
|
+
if (msg.type === "assistant") {
|
|
302
|
+
const content = (msg as any).message?.content;
|
|
303
|
+
if (Array.isArray(content)) {
|
|
304
|
+
for (const block of content) {
|
|
305
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
306
|
+
if (block.text.length > lastPartialText.length) {
|
|
307
|
+
yield { type: "text", content: block.text.slice(lastPartialText.length) };
|
|
308
|
+
} else if (lastPartialText.length === 0) {
|
|
309
|
+
yield { type: "text", content: block.text };
|
|
310
|
+
}
|
|
311
|
+
assistantContent += block.text;
|
|
312
|
+
lastPartialText = "";
|
|
313
|
+
} else if (block.type === "tool_use") {
|
|
314
|
+
pendingToolCount++;
|
|
315
|
+
yield {
|
|
316
|
+
type: "tool_use",
|
|
317
|
+
tool: block.name ?? "unknown",
|
|
318
|
+
input: block.input ?? {},
|
|
319
|
+
toolUseId: block.id as string | undefined,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Rate limit event — extract utilization percentages
|
|
328
|
+
if ((msg as any).type === "rate_limit_event") {
|
|
329
|
+
const info = (msg as any).rate_limit_info;
|
|
330
|
+
if (info) {
|
|
331
|
+
const rateLimitType = info.rateLimitType as string | undefined;
|
|
332
|
+
const utilization = info.utilization as number | undefined;
|
|
333
|
+
if (rateLimitType && utilization != null) {
|
|
334
|
+
const usage: Record<string, number> = {};
|
|
335
|
+
if (rateLimitType === "five_hour") usage.fiveHour = utilization;
|
|
336
|
+
else if (rateLimitType.startsWith("seven_day")) usage.sevenDay = utilization;
|
|
337
|
+
// Cache latest rate limits
|
|
338
|
+
Object.assign(this.latestUsage, usage);
|
|
339
|
+
yield { type: "usage", usage };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (msg.type === "result") {
|
|
346
|
+
// Flush any remaining pending tool_results before finishing
|
|
347
|
+
if (pendingToolCount > 0) {
|
|
348
|
+
try {
|
|
349
|
+
const sessionMsgs = await getSessionMessages(sessionId);
|
|
350
|
+
const lastUserMsg = [...sessionMsgs].reverse().find(
|
|
351
|
+
(m: any) => m.type === "user",
|
|
352
|
+
);
|
|
353
|
+
const userContent = (lastUserMsg as any)?.message?.content;
|
|
354
|
+
if (Array.isArray(userContent)) {
|
|
355
|
+
for (const block of userContent) {
|
|
356
|
+
if (block.type === "tool_result") {
|
|
357
|
+
const output = block.content ?? block.output ?? "";
|
|
358
|
+
yield {
|
|
359
|
+
type: "tool_result" as const,
|
|
360
|
+
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
361
|
+
isError: !!block.is_error,
|
|
362
|
+
toolUseId: block.tool_use_id as string | undefined,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch {}
|
|
368
|
+
pendingToolCount = 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const result = msg as any;
|
|
372
|
+
// Yield final cost + any rate limit info from result
|
|
373
|
+
const usage: Record<string, unknown> = {};
|
|
374
|
+
if (result.total_cost_usd != null) usage.totalCostUsd = result.total_cost_usd;
|
|
375
|
+
if (Object.keys(usage).length > 0) {
|
|
376
|
+
yield { type: "usage", usage };
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Yield remaining approval events
|
|
383
|
+
while (approvalEvents.length > 0) {
|
|
384
|
+
yield approvalEvents.shift()!;
|
|
385
|
+
}
|
|
386
|
+
} catch (e) {
|
|
387
|
+
const msg = (e as Error).message;
|
|
388
|
+
// Don't yield error for intentional abort
|
|
389
|
+
if (!msg.includes("abort")) {
|
|
390
|
+
yield { type: "error", message: `SDK error: ${msg}` };
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
this.activeQueries.delete(sessionId);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
yield { type: "done", sessionId };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Get latest cached usage/rate-limit info */
|
|
400
|
+
getUsage(): UsageInfo {
|
|
401
|
+
return { ...this.latestUsage };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Abort an active query for a session */
|
|
405
|
+
abortQuery(sessionId: string): void {
|
|
406
|
+
const q = this.activeQueries.get(sessionId);
|
|
407
|
+
if (q) {
|
|
408
|
+
q.close();
|
|
409
|
+
this.activeQueries.delete(sessionId);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async getMessages(sessionId: string): Promise<ChatMessage[]> {
|
|
414
|
+
try {
|
|
415
|
+
const messages = await getSessionMessages(sessionId);
|
|
416
|
+
const parsed = messages.map((msg) => parseSessionMessage(msg));
|
|
417
|
+
|
|
418
|
+
// Merge tool_result user messages into the preceding assistant message
|
|
419
|
+
const merged: ChatMessage[] = [];
|
|
420
|
+
for (const msg of parsed) {
|
|
421
|
+
if (msg.events?.length && msg.events.every((e) => e.type === "tool_result")) {
|
|
422
|
+
// This is a tool_result-only message — append events to last assistant
|
|
423
|
+
const lastAssistant = [...merged].reverse().find((m) => m.role === "assistant");
|
|
424
|
+
if (lastAssistant?.events) {
|
|
425
|
+
lastAssistant.events.push(...msg.events);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
merged.push(msg);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return merged.filter(
|
|
433
|
+
(msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
|
|
434
|
+
);
|
|
435
|
+
} catch {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Parse SDK SessionMessage into ChatMessage with events for tool_use blocks */
|
|
442
|
+
function parseSessionMessage(msg: { uuid: string; type: string; message: unknown }): ChatMessage {
|
|
443
|
+
const message = msg.message as Record<string, unknown> | undefined;
|
|
444
|
+
const role = msg.type as "user" | "assistant";
|
|
445
|
+
|
|
446
|
+
// Parse content blocks for both user and assistant messages
|
|
447
|
+
const events: ChatEvent[] = [];
|
|
448
|
+
let textContent = "";
|
|
449
|
+
|
|
450
|
+
if (message && Array.isArray(message.content)) {
|
|
451
|
+
for (const block of message.content as Array<Record<string, unknown>>) {
|
|
452
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
453
|
+
textContent += block.text;
|
|
454
|
+
if (role === "assistant") {
|
|
455
|
+
events.push({ type: "text", content: block.text });
|
|
456
|
+
}
|
|
457
|
+
} else if (block.type === "tool_use") {
|
|
458
|
+
events.push({
|
|
459
|
+
type: "tool_use",
|
|
460
|
+
tool: (block.name as string) ?? "unknown",
|
|
461
|
+
input: block.input ?? {},
|
|
462
|
+
toolUseId: block.id as string | undefined,
|
|
463
|
+
});
|
|
464
|
+
} else if (block.type === "tool_result") {
|
|
465
|
+
const output = block.content ?? block.output ?? "";
|
|
466
|
+
events.push({
|
|
467
|
+
type: "tool_result",
|
|
468
|
+
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
469
|
+
isError: !!(block as Record<string, unknown>).is_error,
|
|
470
|
+
toolUseId: block.tool_use_id as string | undefined,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
textContent = extractText(message);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
id: msg.uuid,
|
|
480
|
+
role,
|
|
481
|
+
content: textContent,
|
|
482
|
+
events: events.length > 0 ? events : undefined,
|
|
483
|
+
timestamp: new Date().toISOString(),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Extract plain text from message payload */
|
|
488
|
+
function extractText(message: unknown): string {
|
|
489
|
+
if (!message || typeof message !== "object") return "";
|
|
490
|
+
const msg = message as Record<string, unknown>;
|
|
491
|
+
if (typeof msg.content === "string") return msg.content;
|
|
492
|
+
if (Array.isArray(msg.content)) {
|
|
493
|
+
return (msg.content as Array<Record<string, unknown>>)
|
|
494
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
495
|
+
.map((b) => b.text as string)
|
|
496
|
+
.join("");
|
|
497
|
+
}
|
|
498
|
+
return "";
|
|
499
|
+
}
|