@hienlh/ppm 0.5.11 → 0.5.12

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.12] - 2026-03-18
4
+
5
+ ### Fixed
6
+ - **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
7
+ - Removed SDK timeout/diagnostic code (no longer needed with direct CLI fallback)
8
+
3
9
  ## [0.5.11] - 2026-03-18
4
10
 
5
11
  ### 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.12",
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,100 @@ 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
+ yield event;
155
+ } catch {
156
+ // Skip non-JSON lines (e.g. progress indicators)
157
+ }
158
+ }
159
+ }
160
+
161
+ // Process remaining buffer
162
+ if (buffer.trim()) {
163
+ try { yield JSON.parse(buffer.trim()); } catch {}
164
+ }
165
+
166
+ // Wait for process to exit
167
+ const exitCode = await proc.exited;
168
+ console.log(`[sdk-cli] process exited: code=${exitCode}`);
169
+
170
+ // Read stderr if process failed
171
+ if (exitCode !== 0) {
172
+ try {
173
+ const errReader = proc.stderr.getReader();
174
+ const { value: errBytes } = await errReader.read();
175
+ const stderr = errBytes ? new TextDecoder().decode(errBytes).trim() : "";
176
+ if (stderr) console.error(`[sdk-cli] stderr: ${stderr.slice(0, 500)}`);
177
+ } catch {}
178
+ }
179
+ } finally {
180
+ this.activeQueries.delete(opts.sessionId);
181
+ try { proc.kill(); } catch {}
182
+ }
183
+ }
184
+
91
185
  /** Read current provider config from yaml (fresh each call) */
92
186
  private getProviderConfig(): Partial<import("../types/config.ts").AIProviderConfig> {
93
187
  const ai = configService.get("ai");
@@ -289,9 +383,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
289
383
  let resultSubtype: string | undefined;
290
384
  let resultNumTurns: number | undefined;
291
385
  let resultContextWindowPct: number | undefined;
292
- let firstEventReceived = false;
293
- let sdkTimeoutId: ReturnType<typeof setTimeout> | undefined;
294
-
295
386
  try {
296
387
  const providerConfig = this.getProviderConfig();
297
388
  // Resolve SDK's actual session ID for resume (may differ from PPM's UUID)
@@ -300,113 +391,67 @@ export class ClaudeAgentSdkProvider implements AIProvider {
300
391
  // Fallback cwd: SDK needs a valid working directory even when no project is selected.
301
392
  // On Windows daemons, undefined cwd can cause the subprocess to fail silently.
302
393
  const effectiveCwd = meta.projectPath || homedir();
394
+ const queryEnv = { ...process.env, ...this.getProjectEnvOverrides(meta.projectPath) };
303
395
  console.log(`[sdk] query: session=${sessionId} sdkId=${sdkId} isFirst=${isFirstMessage} fork=${shouldFork} cwd=${effectiveCwd} platform=${process.platform}`);
304
396
 
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...`);
397
+ // On Windows, use direct CLI fallback (SDK query() hangs due to Bun subprocess pipe buffering)
398
+ const useDirectCli = process.platform === "win32";
399
+ let eventSource: AsyncIterable<any>;
348
400
 
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",
401
+ if (useDirectCli) {
402
+ console.log(`[sdk] Windows detected — using direct CLI fallback (bypasses SDK pipe issue)`);
403
+ eventSource = this.queryDirectCli({
404
+ prompt: message,
364
405
  cwd: effectiveCwd,
406
+ sessionId,
407
+ sdkId,
408
+ isFirstMessage,
409
+ shouldFork,
410
+ env: queryEnv,
411
+ providerConfig,
365
412
  });
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}`);
413
+ } else {
414
+ const q = query({
415
+ prompt: message,
416
+ options: {
417
+ sessionId: isFirstMessage && !shouldFork ? sessionId : undefined,
418
+ resume: (isFirstMessage && !shouldFork) ? undefined : sdkId,
419
+ ...(shouldFork && { forkSession: true }),
420
+ cwd: effectiveCwd,
421
+ systemPrompt: { type: "preset", preset: "claude_code" },
422
+ settingSources: ["user", "project"],
423
+ env: queryEnv,
424
+ settings: { permissions: { allow: [], deny: [] } },
425
+ allowedTools: [
426
+ "Read", "Write", "Edit", "Bash", "Glob", "Grep",
427
+ "WebSearch", "WebFetch", "AskUserQuestion",
428
+ "Agent", "Skill", "TodoWrite", "ToolSearch",
429
+ ],
430
+ permissionMode: "bypassPermissions",
431
+ allowDangerouslySkipPermissions: true,
432
+ ...(providerConfig.model && { model: providerConfig.model }),
433
+ ...(providerConfig.effort && { effort: providerConfig.effort }),
434
+ maxTurns: providerConfig.max_turns ?? 100,
435
+ ...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
436
+ ...(providerConfig.thinking_budget_tokens != null && {
437
+ thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
438
+ }),
439
+ canUseTool,
440
+ includePartialMessages: true,
441
+ } as any,
442
+ });
443
+ this.activeQueries.set(sessionId, q);
444
+ eventSource = q;
372
445
  }
373
446
 
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
447
  let lastPartialText = "";
380
448
  /** Number of tool_use blocks pending results (top-level tools only, not subagent children) */
381
449
  let pendingToolCount = 0;
382
450
 
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
451
  let sdkEventCount = 0;
405
- for await (const msg of q) {
452
+ for await (const msg of eventSource) {
406
453
  sdkEventCount++;
407
454
  if (sdkEventCount === 1) {
408
- firstEventReceived = true;
409
- clearTimeout(sdkTimeoutId);
410
455
  console.log(`[sdk] first event received: type=${(msg as any).type} subtype=${(msg as any).subtype ?? "none"}`);
411
456
  }
412
457
  // Extract parent_tool_use_id from SDK message (present on subagent-scoped messages)
@@ -647,31 +692,16 @@ export class ClaudeAgentSdkProvider implements AIProvider {
647
692
  yield approvalEvents.shift()!;
648
693
  }
649
694
 
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
- };
695
+ if (sdkEventCount === 0) {
696
+ yield { type: "error", message: "Claude did not respond. Check that 'claude' CLI works in your terminal." };
656
697
  }
657
698
  } catch (e) {
658
699
  const msg = (e as Error).message ?? String(e);
659
- const stack = (e as Error).stack ?? "";
660
700
  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
701
  if (!msg.includes("abort") && !msg.includes("closed")) {
664
702
  yield { type: "error", message: `SDK error: ${msg}` };
665
703
  }
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
704
  } finally {
674
- clearTimeout(sdkTimeoutId);
675
705
  this.activeQueries.delete(sessionId);
676
706
  }
677
707
 
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);