@hienlh/ppm 0.5.11 → 0.5.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.13] - 2026-03-18
4
+
5
+ ### Fixed
6
+ - **Windows: simulated token streaming** — CLI `stream-json` only emits complete `assistant` messages (no per-token deltas). Now synthesizes `stream_event` / `content_block_delta` events in ~30-char chunks so FE gets smooth typing effect instead of all text appearing at once
7
+
8
+ ## [0.5.12] - 2026-03-18
9
+
10
+ ### Fixed
11
+ - **Windows: direct CLI fallback for chat** — on Windows, bypass SDK `query()` (broken due to Bun subprocess pipe buffering) and spawn `claude -p --verbose --output-format stream-json` directly. Same event format, same features — streaming, tools, session resume all work
12
+ - Removed SDK timeout/diagnostic code (no longer needed with direct CLI fallback)
13
+
3
14
  ## [0.5.11] - 2026-03-18
4
15
 
5
16
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -88,6 +88,130 @@ export class ClaudeAgentSdkProvider implements AIProvider {
88
88
  } catch { return {}; }
89
89
  }
90
90
 
91
+ /**
92
+ * Direct CLI fallback for Windows — spawns `claude -p` with stream-json output.
93
+ * Workaround for Bun + Windows SDK subprocess pipe buffering issue.
94
+ * Returns an async generator yielding the same event types as SDK query().
95
+ */
96
+ private async *queryDirectCli(opts: {
97
+ prompt: string;
98
+ cwd: string;
99
+ sessionId: string;
100
+ sdkId: string;
101
+ isFirstMessage: boolean;
102
+ shouldFork: boolean;
103
+ env: Record<string, string | undefined>;
104
+ providerConfig: Partial<import("../types/config.ts").AIProviderConfig>;
105
+ }): AsyncGenerator<any> {
106
+ const args = ["-p", opts.prompt, "--verbose", "--output-format", "stream-json"];
107
+
108
+ // Session management
109
+ if (!opts.isFirstMessage || opts.shouldFork) {
110
+ args.push("--resume", opts.sdkId);
111
+ }
112
+
113
+ // Config-driven options
114
+ if (opts.providerConfig.model) args.push("--model", opts.providerConfig.model);
115
+ const maxTurns = opts.providerConfig.max_turns ?? 100;
116
+ args.push("--max-turns", String(maxTurns));
117
+ if (opts.providerConfig.effort) args.push("--effort", opts.providerConfig.effort);
118
+
119
+ // Permission mode
120
+ args.push("--permission-mode", "bypassPermissions", "--dangerously-skip-permissions");
121
+
122
+ console.log(`[sdk-cli] spawning: claude ${args.slice(0, 6).join(" ")}... cwd=${opts.cwd}`);
123
+
124
+ const proc = Bun.spawn({
125
+ cmd: ["claude", ...args],
126
+ cwd: opts.cwd,
127
+ stdout: "pipe",
128
+ stderr: "pipe",
129
+ env: opts.env as Record<string, string>,
130
+ });
131
+
132
+ // Store proc for abort support
133
+ const abortHandle = { close: () => { try { proc.kill(); } catch {} } };
134
+ this.activeQueries.set(opts.sessionId, abortHandle as any);
135
+
136
+ try {
137
+ const reader = proc.stdout.getReader();
138
+ const decoder = new TextDecoder();
139
+ let buffer = "";
140
+
141
+ while (true) {
142
+ const { done, value } = await reader.read();
143
+ if (done) break;
144
+
145
+ buffer += decoder.decode(value, { stream: true });
146
+ const lines = buffer.split("\n");
147
+ buffer = lines.pop() ?? ""; // Keep incomplete last line in buffer
148
+
149
+ for (const line of lines) {
150
+ const trimmed = line.trim();
151
+ if (!trimmed) continue;
152
+ try {
153
+ const event = JSON.parse(trimmed);
154
+ // CLI stream-json doesn't emit per-token stream_event deltas — it sends
155
+ // complete assistant messages. Synthesize stream_event deltas so the FE
156
+ // gets a smooth streaming experience (same as SDK with includePartialMessages).
157
+ if (event.type === "assistant" && event.message?.content) {
158
+ for (const block of event.message.content) {
159
+ if (block.type === "text" && block.text) {
160
+ // Emit text in ~30-char chunks as synthetic stream_event deltas
161
+ const text = block.text as string;
162
+ const CHUNK = 30;
163
+ for (let i = 0; i < text.length; i += CHUNK) {
164
+ yield {
165
+ type: "stream_event",
166
+ event: {
167
+ type: "content_block_delta",
168
+ delta: { type: "text_delta", text: text.slice(i, i + CHUNK) },
169
+ },
170
+ };
171
+ }
172
+ } else if (block.type === "thinking" && block.thinking) {
173
+ yield {
174
+ type: "stream_event",
175
+ event: {
176
+ type: "content_block_delta",
177
+ delta: { type: "thinking_delta", thinking: block.thinking },
178
+ },
179
+ };
180
+ }
181
+ }
182
+ }
183
+ // Always yield the original event too (for init, result, rate_limit, etc.)
184
+ yield event;
185
+ } catch {
186
+ // Skip non-JSON lines (e.g. progress indicators)
187
+ }
188
+ }
189
+ }
190
+
191
+ // Process remaining buffer
192
+ if (buffer.trim()) {
193
+ try { yield JSON.parse(buffer.trim()); } catch {}
194
+ }
195
+
196
+ // Wait for process to exit
197
+ const exitCode = await proc.exited;
198
+ console.log(`[sdk-cli] process exited: code=${exitCode}`);
199
+
200
+ // Read stderr if process failed
201
+ if (exitCode !== 0) {
202
+ try {
203
+ const errReader = proc.stderr.getReader();
204
+ const { value: errBytes } = await errReader.read();
205
+ const stderr = errBytes ? new TextDecoder().decode(errBytes).trim() : "";
206
+ if (stderr) console.error(`[sdk-cli] stderr: ${stderr.slice(0, 500)}`);
207
+ } catch {}
208
+ }
209
+ } finally {
210
+ this.activeQueries.delete(opts.sessionId);
211
+ try { proc.kill(); } catch {}
212
+ }
213
+ }
214
+
91
215
  /** Read current provider config from yaml (fresh each call) */
92
216
  private getProviderConfig(): Partial<import("../types/config.ts").AIProviderConfig> {
93
217
  const ai = configService.get("ai");
@@ -289,9 +413,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
289
413
  let resultSubtype: string | undefined;
290
414
  let resultNumTurns: number | undefined;
291
415
  let resultContextWindowPct: number | undefined;
292
- let firstEventReceived = false;
293
- let sdkTimeoutId: ReturnType<typeof setTimeout> | undefined;
294
-
295
416
  try {
296
417
  const providerConfig = this.getProviderConfig();
297
418
  // Resolve SDK's actual session ID for resume (may differ from PPM's UUID)
@@ -300,113 +421,67 @@ export class ClaudeAgentSdkProvider implements AIProvider {
300
421
  // Fallback cwd: SDK needs a valid working directory even when no project is selected.
301
422
  // On Windows daemons, undefined cwd can cause the subprocess to fail silently.
302
423
  const effectiveCwd = meta.projectPath || homedir();
424
+ const queryEnv = { ...process.env, ...this.getProjectEnvOverrides(meta.projectPath) };
303
425
  console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform}`);
304
426
 
305
- const q = query({
306
- prompt: message,
307
- options: {
308
- sessionId: isFirstMessage && !shouldFork ? sessionId : undefined,
309
- resume: (isFirstMessage && !shouldFork) ? undefined : sdkId,
310
- ...(shouldFork && { forkSession: true }),
311
- cwd: effectiveCwd,
312
- // Use full Claude Code system prompt (coding guidelines, security, response style)
313
- systemPrompt: { type: "preset", preset: "claude_code" },
314
- // Load skills/settings from both user (~/.claude) and project directory
315
- settingSources: ["user", "project"],
316
- // Neutralize project .env keys that would override user's global auth.
317
- // Only clear if the project's .env contains them (prevents .env poisoning).
318
- // Keep global env vars intact for API key auth users.
319
- env: {
320
- ...process.env,
321
- ...this.getProjectEnvOverrides(meta.projectPath),
322
- },
323
- // Override project-local Claude settings that may restrict tool permissions
324
- settings: { permissions: { allow: [], deny: [] } },
325
- allowedTools: [
326
- "Read", "Write", "Edit", "Bash", "Glob", "Grep",
327
- "WebSearch", "WebFetch", "AskUserQuestion",
328
- "Agent", "Skill", "TodoWrite", "ToolSearch",
329
- ],
330
- permissionMode: "bypassPermissions",
331
- allowDangerouslySkipPermissions: true,
332
- // Config-driven values from ppm.yaml
333
- ...(providerConfig.model && { model: providerConfig.model }),
334
- ...(providerConfig.effort && { effort: providerConfig.effort }),
335
- maxTurns: providerConfig.max_turns ?? 100,
336
- ...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
337
- ...(providerConfig.thinking_budget_tokens != null && {
338
- thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
339
- }),
340
- canUseTool,
341
- includePartialMessages: true,
342
- } as any,
343
- });
344
-
345
- // Track active query for abort support
346
- this.activeQueries.set(sessionId, q);
347
- console.log(`[sdk] query object created, starting iteration...`);
427
+ // On Windows, use direct CLI fallback (SDK query() hangs due to Bun subprocess pipe buffering)
428
+ const useDirectCli = process.platform === "win32";
429
+ let eventSource: AsyncIterable<any>;
348
430
 
349
- // Verify claude CLI is accessible (early warning on Windows daemons)
350
- try {
351
- const which = Bun.spawnSync({
352
- cmd: process.platform === "win32" ? ["where", "claude"] : ["which", "claude"],
353
- stdout: "pipe", stderr: "pipe",
354
- });
355
- const claudePath = which.stdout.toString().trim().split("\n")[0];
356
- console.log(`[sdk] claude CLI: ${claudePath || "(not found in PATH)"}`);
357
- } catch { console.log("[sdk] claude CLI: check failed"); }
358
-
359
- // Quick CLI version check — verify the binary actually runs from this process
360
- try {
361
- const verProc = Bun.spawnSync({
362
- cmd: ["claude", "--version"],
363
- stdout: "pipe", stderr: "pipe",
431
+ if (useDirectCli) {
432
+ console.log(`[sdk] Windows detected — using direct CLI fallback (bypasses SDK pipe issue)`);
433
+ eventSource = this.queryDirectCli({
434
+ prompt: message,
364
435
  cwd: effectiveCwd,
436
+ sessionId,
437
+ sdkId,
438
+ isFirstMessage,
439
+ shouldFork,
440
+ env: queryEnv,
441
+ providerConfig,
365
442
  });
366
- console.log(`[sdk] claude --version: exit=${verProc.exitCode} out="${verProc.stdout.toString().trim().slice(0, 100)}"`);
367
- if (verProc.exitCode !== 0) {
368
- console.error(`[sdk] claude --version stderr: ${verProc.stderr.toString().trim().slice(0, 300)}`);
369
- }
370
- } catch (e) {
371
- console.error(`[sdk] claude --version failed: ${(e as Error).message}`);
443
+ } else {
444
+ const q = query({
445
+ prompt: message,
446
+ options: {
447
+ sessionId: isFirstMessage && !shouldFork ? sessionId : undefined,
448
+ resume: (isFirstMessage && !shouldFork) ? undefined : sdkId,
449
+ ...(shouldFork && { forkSession: true }),
450
+ cwd: effectiveCwd,
451
+ systemPrompt: { type: "preset", preset: "claude_code" },
452
+ settingSources: ["user", "project"],
453
+ env: queryEnv,
454
+ settings: { permissions: { allow: [], deny: [] } },
455
+ allowedTools: [
456
+ "Read", "Write", "Edit", "Bash", "Glob", "Grep",
457
+ "WebSearch", "WebFetch", "AskUserQuestion",
458
+ "Agent", "Skill", "TodoWrite", "ToolSearch",
459
+ ],
460
+ permissionMode: "bypassPermissions",
461
+ allowDangerouslySkipPermissions: true,
462
+ ...(providerConfig.model && { model: providerConfig.model }),
463
+ ...(providerConfig.effort && { effort: providerConfig.effort }),
464
+ maxTurns: providerConfig.max_turns ?? 100,
465
+ ...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
466
+ ...(providerConfig.thinking_budget_tokens != null && {
467
+ thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
468
+ }),
469
+ canUseTool,
470
+ includePartialMessages: true,
471
+ } as any,
472
+ });
473
+ this.activeQueries.set(sessionId, q);
474
+ eventSource = q;
372
475
  }
373
476
 
374
- // Log env keys relevant to SDK auth (values redacted)
375
- const authKeys = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"];
376
- const envStatus = authKeys.map(k => `${k}=${process.env[k] ? "SET" : "unset"}`).join(" ");
377
- console.log(`[sdk] env auth: ${envStatus}`);
378
-
379
477
  let lastPartialText = "";
380
478
  /** Number of tool_use blocks pending results (top-level tools only, not subagent children) */
381
479
  let pendingToolCount = 0;
382
480
 
383
- // First-event timeout: if SDK hangs (common on Windows/Bun), close query and run diagnostics
384
- const SDK_TIMEOUT_MS = 30_000;
385
- sdkTimeoutId = setTimeout(async () => {
386
- if (firstEventReceived) return;
387
- console.error(`[sdk] TIMEOUT: no events after ${SDK_TIMEOUT_MS / 1000}s — closing query and running diagnostics`);
388
- try { q.close(); } catch {}
389
-
390
- // Direct CLI test to determine if the issue is SDK-specific or CLI-wide
391
- try {
392
- const directProc = Bun.spawnSync({
393
- cmd: ["claude", "-p", "say ok", "--output-format", "stream-json", "--max-turns", "1"],
394
- stdout: "pipe", stderr: "pipe",
395
- cwd: effectiveCwd,
396
- env: { ...process.env, ...this.getProjectEnvOverrides(meta.projectPath) },
397
- });
398
- console.log(`[sdk] direct CLI test: exit=${directProc.exitCode} stdout=${directProc.stdout.toString().trim().slice(0, 500)} stderr=${directProc.stderr.toString().trim().slice(0, 300)}`);
399
- } catch (e) {
400
- console.error(`[sdk] direct CLI test failed: ${(e as Error).message}`);
401
- }
402
- }, SDK_TIMEOUT_MS);
403
-
404
481
  let sdkEventCount = 0;
405
- for await (const msg of q) {
482
+ for await (const msg of eventSource) {
406
483
  sdkEventCount++;
407
484
  if (sdkEventCount === 1) {
408
- firstEventReceived = true;
409
- clearTimeout(sdkTimeoutId);
410
485
  console.log(`[sdk] first event received: type=${(msg as any).type} subtype=${(msg as any).subtype ?? "none"}`);
411
486
  }
412
487
  // Extract parent_tool_use_id from SDK message (present on subagent-scoped messages)
@@ -647,31 +722,16 @@ export class ClaudeAgentSdkProvider implements AIProvider {
647
722
  yield approvalEvents.shift()!;
648
723
  }
649
724
 
650
- // If no events were received, the timeout closed the query — surface error to user
651
- if (sdkEventCount === 0 && !firstEventReceived) {
652
- yield {
653
- type: "error",
654
- message: "Claude SDK did not respond (query timed out). This may be a Bun + Windows compatibility issue. Try: `ppm chat` from terminal, or run with Node.js: `npx tsx src/index.ts start -f`",
655
- };
725
+ if (sdkEventCount === 0) {
726
+ yield { type: "error", message: "Claude did not respond. Check that 'claude' CLI works in your terminal." };
656
727
  }
657
728
  } catch (e) {
658
729
  const msg = (e as Error).message ?? String(e);
659
- const stack = (e as Error).stack ?? "";
660
730
  console.error(`[sdk] error: ${msg}`);
661
- if (stack) console.error(`[sdk] stack: ${stack}`);
662
- // Don't yield error for intentional abort or timeout-triggered close
663
731
  if (!msg.includes("abort") && !msg.includes("closed")) {
664
732
  yield { type: "error", message: `SDK error: ${msg}` };
665
733
  }
666
- // If closed by timeout (no events received), provide user-facing error
667
- if (!firstEventReceived) {
668
- yield {
669
- type: "error",
670
- message: "Claude SDK did not respond (query timed out). This may be a Bun + Windows compatibility issue. Try: `ppm chat` from terminal, or run with Node.js: `npx tsx src/index.ts start -f`",
671
- };
672
- }
673
734
  } finally {
674
- clearTimeout(sdkTimeoutId);
675
735
  this.activeQueries.delete(sessionId);
676
736
  }
677
737
 
package/test-sdk.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Minimal SDK test — run on Windows to diagnose Bun + SDK issue.
3
+ *
4
+ * Usage:
5
+ * bun test-sdk.mjs
6
+ * node --experimental-strip-types test-sdk.mjs
7
+ */
8
+ import { query } from "@anthropic-ai/claude-agent-sdk";
9
+ import { homedir } from "node:os";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ // Remove CLAUDECODE to avoid nested session error
13
+ delete process.env.CLAUDECODE;
14
+
15
+ const cwd = homedir();
16
+
17
+ console.log("=== SDK Test ===");
18
+ console.log(`Platform: ${process.platform}`);
19
+ console.log(`Runtime: ${typeof Bun !== "undefined" ? `Bun ${Bun.version}` : `Node ${process.version}`}`);
20
+ console.log(`CWD: ${cwd}`);
21
+ console.log(`API_KEY: ${process.env.ANTHROPIC_API_KEY ? "SET" : "unset"}`);
22
+
23
+ // Test 1: claude --version
24
+ console.log("\n--- Test 1: claude --version ---");
25
+ try {
26
+ const ver = spawnSync("claude", ["--version"], { encoding: "utf-8", timeout: 10000 });
27
+ console.log(`exit=${ver.status} stdout="${ver.stdout?.trim()}" stderr="${ver.stderr?.trim()}"`);
28
+ } catch (e) {
29
+ console.log(`FAILED: ${e.message}`);
30
+ }
31
+
32
+ // Test 2: claude -p (direct CLI)
33
+ console.log("\n--- Test 2: claude -p (direct spawn) ---");
34
+ try {
35
+ const direct = spawnSync("claude", ["-p", "say ok", "--output-format", "text", "--max-turns", "1"], {
36
+ encoding: "utf-8",
37
+ timeout: 30000,
38
+ cwd,
39
+ env: process.env,
40
+ });
41
+ console.log(`exit=${direct.status}`);
42
+ console.log(`stdout="${direct.stdout?.trim().slice(0, 200)}"`);
43
+ if (direct.stderr?.trim()) console.log(`stderr="${direct.stderr.trim().slice(0, 200)}"`);
44
+ } catch (e) {
45
+ console.log(`FAILED: ${e.message}`);
46
+ }
47
+
48
+ // Test 3: SDK query()
49
+ console.log("\n--- Test 3: SDK query() ---");
50
+ const startTime = Date.now();
51
+ const TIMEOUT = 15000;
52
+ let gotEvent = false;
53
+
54
+ try {
55
+ const q = query({
56
+ prompt: "say ok",
57
+ options: {
58
+ cwd,
59
+ maxTurns: 1,
60
+ permissionMode: "bypassPermissions",
61
+ allowDangerouslySkipPermissions: true,
62
+ systemPrompt: { type: "custom", custom: "Reply only with: ok" },
63
+ },
64
+ });
65
+
66
+ // Race first event against timeout
67
+ const iterator = q[Symbol.asyncIterator]();
68
+ const result = await Promise.race([
69
+ iterator.next(),
70
+ new Promise((resolve) => setTimeout(() => resolve("TIMEOUT"), TIMEOUT)),
71
+ ]);
72
+
73
+ if (result === "TIMEOUT") {
74
+ console.log(`TIMEOUT: no events after ${TIMEOUT / 1000}s`);
75
+ console.log(">>> This confirms the Bun + Windows SDK issue <<<");
76
+ try { q.close(); } catch {}
77
+ } else {
78
+ gotEvent = true;
79
+ const elapsed = Date.now() - startTime;
80
+ const msg = result.value;
81
+ console.log(`First event in ${elapsed}ms: type=${msg?.type} subtype=${msg?.subtype ?? "none"}`);
82
+
83
+ // Read remaining events
84
+ let count = 1;
85
+ for await (const ev of { [Symbol.asyncIterator]: () => iterator }) {
86
+ count++;
87
+ if (ev.type === "assistant") {
88
+ const text = ev.message?.content?.find((b) => b.type === "text")?.text ?? "";
89
+ console.log(`Event #${count}: assistant text="${text.slice(0, 100)}"`);
90
+ } else if (ev.type === "result") {
91
+ console.log(`Event #${count}: result subtype=${ev.subtype}`);
92
+ break;
93
+ } else {
94
+ console.log(`Event #${count}: ${ev.type}`);
95
+ }
96
+ if (count > 20) { console.log("(stopping after 20 events)"); break; }
97
+ }
98
+ console.log(`\nSUCCESS: SDK works! Total events: ${count}`);
99
+ }
100
+ } catch (e) {
101
+ console.log(`ERROR: ${e.message}`);
102
+ if (e.stack) console.log(e.stack);
103
+ }
104
+
105
+ console.log(`\nTotal time: ${((Date.now() - startTime) / 1000).toFixed(1)}s`);
106
+ process.exit(gotEvent ? 0 : 1);