@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.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. 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
+ }