@hienlh/ppm 0.8.13 → 0.8.15
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 +13 -0
- package/package.json +2 -2
- package/scripts/patch-sdk.mjs +202 -0
- package/scripts/test-drain-bug.mjs +131 -0
- package/src/providers/claude-agent-sdk.ts +26 -19
- package/src/server/ws/chat.ts +3 -1
- package/src/services/account-selector.service.ts +33 -3
- package/src/services/claude-usage.service.ts +19 -8
- package/src/services/file.service.ts +2 -2
- package/test-tool.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.15] - 2026-03-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Usage polling reliability**: Replace `setInterval` with recursive `setTimeout` to prevent overlap and timer death from unhandled async rejections; wrap `pollOnce` in try/catch
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **Lowest-usage account strategy**: New `lowest-usage` routing strategy picks account with lowest 5-hour utilization, skips accounts at 100% weekly/5hr, falls back gracefully when all exhausted
|
|
10
|
+
|
|
11
|
+
## [0.8.14] - 2026-03-24
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **CLI error logging**: Always read stderr (even on exit code 0), log error event content to server logs for Windows CLI debugging
|
|
15
|
+
|
|
3
16
|
## [0.8.13] - 2026-03-24
|
|
4
17
|
|
|
5
18
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hienlh/ppm",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.15",
|
|
4
4
|
"description": "Personal Project Manager — mobile-first web IDE with AI assistance",
|
|
5
5
|
"author": "hienlh",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"start": "bun run src/index.ts start",
|
|
19
19
|
"typecheck": "bunx tsc --noEmit",
|
|
20
20
|
"prepublishOnly": "bun run build:web",
|
|
21
|
-
"postinstall": "
|
|
21
|
+
"postinstall": "node scripts/patch-sdk.mjs"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@tailwindcss/vite": "^4.2.1",
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall patch for @anthropic-ai/claude-agent-sdk
|
|
4
|
+
*
|
|
5
|
+
* Fixes Windows + Bun subprocess pipe issues:
|
|
6
|
+
* 1. Adding drain() handling to ProcessTransport.write()
|
|
7
|
+
* 2. Awaiting the initial prompt write in query() entry point
|
|
8
|
+
* 3. Replacing readline async iterator with manual line reader in readMessages()
|
|
9
|
+
*
|
|
10
|
+
* Bun on Windows has broken: stdin pipe backpressure, unawaited async writes,
|
|
11
|
+
* and readline.createInterface() async iterator (Symbol.asyncIterator).
|
|
12
|
+
*
|
|
13
|
+
* Tracking issues:
|
|
14
|
+
* - TS SDK #44: https://github.com/anthropics/claude-agent-sdk-typescript/issues/44
|
|
15
|
+
* - TS SDK #64: https://github.com/anthropics/claude-agent-sdk-typescript/issues/64
|
|
16
|
+
*
|
|
17
|
+
* Remove this patch when upstream fixes land.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
|
|
23
|
+
const sdkPath = join(
|
|
24
|
+
import.meta.dirname,
|
|
25
|
+
"..",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"@anthropic-ai",
|
|
28
|
+
"claude-agent-sdk",
|
|
29
|
+
"sdk.mjs",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (!existsSync(sdkPath)) {
|
|
33
|
+
console.log("[patch-sdk] SDK not found, skipping patch");
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let content = readFileSync(sdkPath, "utf8");
|
|
38
|
+
let patches = 0;
|
|
39
|
+
|
|
40
|
+
// ── Patch 1: ProcessTransport.write() — add drain handling ──
|
|
41
|
+
|
|
42
|
+
if (content.includes("waiting for drain")) {
|
|
43
|
+
console.log("[patch-sdk] Patch 1 (drain): already applied");
|
|
44
|
+
} else {
|
|
45
|
+
// Surgical approach: find the backpressure line and patch it
|
|
46
|
+
const drainPattern =
|
|
47
|
+
/if\(!this\.processStdin\.write\(([A-Za-z_$][A-Za-z0-9_$]*)\)\)([A-Za-z_$][A-Za-z0-9_$]*)\("\[ProcessTransport\] Write buffer full, data queued"\)/;
|
|
48
|
+
const drainMatch = content.match(drainPattern);
|
|
49
|
+
|
|
50
|
+
if (!drainMatch) {
|
|
51
|
+
console.warn("[patch-sdk] Patch 1 (drain): pattern not found, skipping");
|
|
52
|
+
} else {
|
|
53
|
+
const oldLine = drainMatch[0];
|
|
54
|
+
const arg = drainMatch[1];
|
|
55
|
+
const logger = drainMatch[2];
|
|
56
|
+
|
|
57
|
+
// Replace backpressure line: await drain instead of just logging
|
|
58
|
+
const newLine =
|
|
59
|
+
`if(!this.processStdin.write(${arg})){` +
|
|
60
|
+
`${logger}("[ProcessTransport] Write buffer full, waiting for drain");` +
|
|
61
|
+
`await new Promise(_dr=>this.processStdin.once("drain",_dr))}`;
|
|
62
|
+
|
|
63
|
+
content = content.replace(oldLine, newLine);
|
|
64
|
+
|
|
65
|
+
// Make the method async
|
|
66
|
+
const writeIdx = content.indexOf(newLine);
|
|
67
|
+
const oldDecl = `write(${arg}){`;
|
|
68
|
+
const declIdx = content.lastIndexOf(oldDecl, writeIdx);
|
|
69
|
+
if (declIdx >= 0) {
|
|
70
|
+
content =
|
|
71
|
+
content.substring(0, declIdx) +
|
|
72
|
+
`async write(${arg}){` +
|
|
73
|
+
content.substring(declIdx + oldDecl.length);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
patches++;
|
|
77
|
+
console.log("[patch-sdk] Patch 1 (drain): applied");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Patch 2: Await initial prompt write in query() entry point ──
|
|
82
|
+
// The query() function writes the user prompt to transport.write() without
|
|
83
|
+
// awaiting. Since write() is now async (returns Promise on backpressure),
|
|
84
|
+
// the prompt data can be lost on Windows where pipe buffers are small.
|
|
85
|
+
//
|
|
86
|
+
// Pattern (minified):
|
|
87
|
+
// if(typeof Q==="string")TRANSPORT.write(SERIALIZE({type:"user",...})+"\n");
|
|
88
|
+
// else QUERY.streamInput(Q);
|
|
89
|
+
//
|
|
90
|
+
// We need to await the write and make the surrounding context async-compatible.
|
|
91
|
+
// Since write is fire-and-forget here (the Promise is dropped), we wrap it.
|
|
92
|
+
|
|
93
|
+
if (content.includes("__ppm_await_write__")) {
|
|
94
|
+
console.log("[patch-sdk] Patch 2 (await prompt): already applied");
|
|
95
|
+
} else {
|
|
96
|
+
// Match: TRANSPORT.write(SERIALIZE({type:"user",...})+`\n`);
|
|
97
|
+
// Anchor on stable string literals: type:"user",session_id:"",message:{role:"user"
|
|
98
|
+
const promptWritePattern =
|
|
99
|
+
/([A-Za-z_$][A-Za-z0-9_$]*)\.write\(([A-Za-z_$][A-Za-z0-9_$]*)\(\{type:"user",session_id:"",message:\{role:"user",content:\[\{type:"text",text:([A-Za-z_$][A-Za-z0-9_$]*)\}\]\},parent_tool_use_id:null\}\)\+(?:`\n`|"\\n")\)/;
|
|
100
|
+
const promptMatch = content.match(promptWritePattern);
|
|
101
|
+
|
|
102
|
+
if (!promptMatch) {
|
|
103
|
+
console.warn(
|
|
104
|
+
"[patch-sdk] Patch 2 (await prompt): pattern not found, skipping",
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
const oldPromptWrite = promptMatch[0];
|
|
108
|
+
// Wrap in async IIFE — keeps query() sync so callers don't need `await query()`
|
|
109
|
+
const newPromptWrite =
|
|
110
|
+
`/*__ppm_await_write__*/(async()=>{await ${oldPromptWrite}})()`;
|
|
111
|
+
|
|
112
|
+
content = content.replace(oldPromptWrite, newPromptWrite);
|
|
113
|
+
patches++;
|
|
114
|
+
console.log("[patch-sdk] Patch 2 (await prompt): applied");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Patch 3: Replace readline async iterator in readMessages() ──
|
|
119
|
+
// Bun on Windows doesn't implement Symbol.asyncIterator for
|
|
120
|
+
// readline.createInterface(), causing "undefined is not a function"
|
|
121
|
+
// when the SDK does `for await (let X of readlineInterface)`.
|
|
122
|
+
//
|
|
123
|
+
// Replace with a manual line reader using raw stream 'data' events.
|
|
124
|
+
|
|
125
|
+
if (content.includes("__ppm_manual_readline__")) {
|
|
126
|
+
console.log("[patch-sdk] Patch 3 (readline): already applied");
|
|
127
|
+
} else {
|
|
128
|
+
// Match the readMessages method by anchoring on the stable error string
|
|
129
|
+
const readMsgPattern =
|
|
130
|
+
/async\s?\*\s?readMessages\(\)\{if\(!this\.processStdout\)throw Error\("ProcessTransport output stream not available"\);let ([A-Za-z_$][A-Za-z0-9_$]*)=([A-Za-z_$][A-Za-z0-9_$]*)\(\{input:this\.processStdout\}\);try\{for await\(let ([A-Za-z_$][A-Za-z0-9_$]*) of \1\)if\(\3\.trim\(\)\)try\{yield ([A-Za-z_$][A-Za-z0-9_$]*)\(\3\)\}catch\(([A-Za-z_$][A-Za-z0-9_$]*)\)\{throw ([A-Za-z_$][A-Za-z0-9_$]*)\(`Non-JSON stdout: \$\{\3\}`\),Error\(`CLI output was not valid JSON\. This may indicate an error during startup\. Output: \$\{\3\.slice\(0,200\)\}\$\{\3\.length>200\?"\.\.\.":""\}`\)\}await this\.waitForExit\(\)\}catch\(\3\)\{throw \3\}finally\{\1\.close\(\)\}\}/;
|
|
131
|
+
const readMsgMatch = content.match(readMsgPattern);
|
|
132
|
+
|
|
133
|
+
if (!readMsgMatch) {
|
|
134
|
+
console.warn(
|
|
135
|
+
"[patch-sdk] Patch 3 (readline): pattern not found, skipping",
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
const oldReadMsg = readMsgMatch[0];
|
|
139
|
+
const rlVar = readMsgMatch[1]; // Q (readline interface)
|
|
140
|
+
const createRL = readMsgMatch[2]; // DU (createInterface)
|
|
141
|
+
const lineVar = readMsgMatch[3]; // X (line variable)
|
|
142
|
+
const parseJSON = readMsgMatch[4]; // O1 (JSON parser)
|
|
143
|
+
const errVar = readMsgMatch[5]; // Y (error variable)
|
|
144
|
+
const logger = readMsgMatch[6]; // i0 (logger)
|
|
145
|
+
|
|
146
|
+
// Manual line reader: use stream 'data' events + buffer splitting
|
|
147
|
+
// This avoids readline's broken async iterator on Bun/Windows
|
|
148
|
+
const newReadMsg =
|
|
149
|
+
`/*__ppm_manual_readline__*/async*readMessages(){` +
|
|
150
|
+
`if(!this.processStdout)throw Error("ProcessTransport output stream not available");` +
|
|
151
|
+
// Create a manual async line iterator using stream events
|
|
152
|
+
`let _buf="";` +
|
|
153
|
+
`const _lines=[];` +
|
|
154
|
+
`let _done=false;` +
|
|
155
|
+
`let _err=null;` +
|
|
156
|
+
`let _resolve=null;` +
|
|
157
|
+
`const _notify=()=>{if(_resolve){const r=_resolve;_resolve=null;r()}};` +
|
|
158
|
+
`this.processStdout.setEncoding("utf8");` +
|
|
159
|
+
`this.processStdout.on("data",(chunk)=>{` +
|
|
160
|
+
`_buf+=chunk;` +
|
|
161
|
+
`let nl;` +
|
|
162
|
+
`while((nl=_buf.indexOf("\\n"))!==-1){` +
|
|
163
|
+
`_lines.push(_buf.slice(0,nl));` +
|
|
164
|
+
`_buf=_buf.slice(nl+1)` +
|
|
165
|
+
`}` +
|
|
166
|
+
`_notify()` +
|
|
167
|
+
`});` +
|
|
168
|
+
`this.processStdout.on("end",()=>{` +
|
|
169
|
+
`if(_buf.trim())_lines.push(_buf);` +
|
|
170
|
+
`_buf="";_done=true;_notify()` +
|
|
171
|
+
`});` +
|
|
172
|
+
`this.processStdout.on("error",(e)=>{_err=e;_done=true;_notify()});` +
|
|
173
|
+
`try{` +
|
|
174
|
+
`while(true){` +
|
|
175
|
+
`while(_lines.length>0){` +
|
|
176
|
+
`const ${lineVar}=_lines.shift();` +
|
|
177
|
+
`if(${lineVar}.trim())` +
|
|
178
|
+
`try{yield ${parseJSON}(${lineVar})}` +
|
|
179
|
+
`catch(${errVar}){` +
|
|
180
|
+
`throw ${logger}(\`Non-JSON stdout: \${${lineVar}}\`),` +
|
|
181
|
+
`Error(\`CLI output was not valid JSON. This may indicate an error during startup. Output: \${${lineVar}.slice(0,200)}\${${lineVar}.length>200?"...":""}\`)` +
|
|
182
|
+
`}` +
|
|
183
|
+
`}` +
|
|
184
|
+
`if(_err)throw _err;` +
|
|
185
|
+
`if(_done)break;` +
|
|
186
|
+
`await new Promise(r=>{_resolve=r})` +
|
|
187
|
+
`}` +
|
|
188
|
+
`await this.waitForExit()` +
|
|
189
|
+
`}catch(${lineVar}){throw ${lineVar}}}`;
|
|
190
|
+
|
|
191
|
+
content = content.replace(oldReadMsg, newReadMsg);
|
|
192
|
+
patches++;
|
|
193
|
+
console.log("[patch-sdk] Patch 3 (readline): applied");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (patches > 0) {
|
|
198
|
+
writeFileSync(sdkPath, content, "utf8");
|
|
199
|
+
console.log(`[patch-sdk] Done — ${patches} patch(es) written`);
|
|
200
|
+
} else {
|
|
201
|
+
console.log("[patch-sdk] No patches needed");
|
|
202
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Reproduces the stdin backpressure bug that causes SDK to hang on Windows.
|
|
4
|
+
*
|
|
5
|
+
* The issue: ProcessTransport.write() calls stdin.write() but ignores the
|
|
6
|
+
* return value (false = buffer full). Without awaiting 'drain', the subprocess
|
|
7
|
+
* may never receive the data — causing a hang.
|
|
8
|
+
*
|
|
9
|
+
* This script simulates the scenario with a slow-reading subprocess.
|
|
10
|
+
* On macOS/Linux the OS pipe buffer is larger (64KB+), so we write enough
|
|
11
|
+
* to overflow it. On Windows + Bun, even small writes can trigger this.
|
|
12
|
+
*
|
|
13
|
+
* Usage: node scripts/test-drain-bug.mjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
// Subprocess that reads stdin slowly (simulates claude CLI processing)
|
|
19
|
+
const slowReader = spawn("node", [
|
|
20
|
+
"-e",
|
|
21
|
+
`
|
|
22
|
+
// Read stdin 1 byte at a time with delays to create backpressure
|
|
23
|
+
process.stdin.setEncoding("utf8");
|
|
24
|
+
let total = 0;
|
|
25
|
+
process.stdin.on("data", (chunk) => {
|
|
26
|
+
total += chunk.length;
|
|
27
|
+
// Pause stdin to simulate slow processing (like claude thinking)
|
|
28
|
+
process.stdin.pause();
|
|
29
|
+
setTimeout(() => process.stdin.resume(), 50);
|
|
30
|
+
});
|
|
31
|
+
process.stdin.on("end", () => {
|
|
32
|
+
process.stdout.write(JSON.stringify({ received: total }));
|
|
33
|
+
});
|
|
34
|
+
`,
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const CHUNK = "x".repeat(1024); // 1KB chunk
|
|
38
|
+
const TOTAL_WRITES = 256; // 256KB total — enough to overflow pipe buffer
|
|
39
|
+
|
|
40
|
+
// ── Test 1: WITHOUT drain (current SDK behavior) ──
|
|
41
|
+
console.log("=== Test 1: Write WITHOUT drain (current SDK bug) ===");
|
|
42
|
+
let writesFailed = 0;
|
|
43
|
+
let writesOk = 0;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < TOTAL_WRITES; i++) {
|
|
46
|
+
const ok = slowReader.stdin.write(CHUNK);
|
|
47
|
+
if (!ok) writesFailed++;
|
|
48
|
+
else writesOk++;
|
|
49
|
+
}
|
|
50
|
+
slowReader.stdin.end();
|
|
51
|
+
|
|
52
|
+
let output = "";
|
|
53
|
+
slowReader.stdout.on("data", (d) => (output += d));
|
|
54
|
+
|
|
55
|
+
await new Promise((resolve) => slowReader.on("close", resolve));
|
|
56
|
+
const result1 = JSON.parse(output || '{"received":0}');
|
|
57
|
+
|
|
58
|
+
console.log(` Writes OK: ${writesOk}`);
|
|
59
|
+
console.log(` Writes FULL: ${writesFailed} (buffer was full, SDK just logs & continues)`);
|
|
60
|
+
console.log(` Data sent: ${TOTAL_WRITES * 1024} bytes`);
|
|
61
|
+
console.log(` Data received: ${result1.received} bytes`);
|
|
62
|
+
console.log(
|
|
63
|
+
` Lost data: ${writesFailed > 0 ? "POSSIBLE — depends on OS buffer behavior" : "none (buffer was big enough)"}`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// ── Test 2: WITH drain (patched behavior) ──
|
|
67
|
+
console.log("\n=== Test 2: Write WITH drain (patched SDK) ===");
|
|
68
|
+
|
|
69
|
+
const slowReader2 = spawn("node", [
|
|
70
|
+
"-e",
|
|
71
|
+
`
|
|
72
|
+
process.stdin.setEncoding("utf8");
|
|
73
|
+
let total = 0;
|
|
74
|
+
process.stdin.on("data", (chunk) => {
|
|
75
|
+
total += chunk.length;
|
|
76
|
+
process.stdin.pause();
|
|
77
|
+
setTimeout(() => process.stdin.resume(), 50);
|
|
78
|
+
});
|
|
79
|
+
process.stdin.on("end", () => {
|
|
80
|
+
process.stdout.write(JSON.stringify({ received: total }));
|
|
81
|
+
});
|
|
82
|
+
`,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
let drainWaits = 0;
|
|
86
|
+
const start = Date.now();
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < TOTAL_WRITES; i++) {
|
|
89
|
+
const ok = slowReader2.stdin.write(CHUNK);
|
|
90
|
+
if (!ok) {
|
|
91
|
+
drainWaits++;
|
|
92
|
+
await new Promise((r) => slowReader2.stdin.once("drain", r));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
slowReader2.stdin.end();
|
|
96
|
+
|
|
97
|
+
let output2 = "";
|
|
98
|
+
slowReader2.stdout.on("data", (d) => (output2 += d));
|
|
99
|
+
await new Promise((resolve) => slowReader2.on("close", resolve));
|
|
100
|
+
const result2 = JSON.parse(output2 || '{"received":0}');
|
|
101
|
+
const elapsed = Date.now() - start;
|
|
102
|
+
|
|
103
|
+
console.log(` Drain waits: ${drainWaits}`);
|
|
104
|
+
console.log(` Data sent: ${TOTAL_WRITES * 1024} bytes`);
|
|
105
|
+
console.log(` Data received: ${result2.received} bytes`);
|
|
106
|
+
console.log(` Match: ${result2.received === TOTAL_WRITES * 1024 ? "YES — all data delivered" : "NO — data lost!"}`);
|
|
107
|
+
console.log(` Time: ${elapsed}ms (slower due to drain waits, but reliable)`);
|
|
108
|
+
|
|
109
|
+
// ── Summary ──
|
|
110
|
+
console.log("\n=== Summary ===");
|
|
111
|
+
if (writesFailed > 0) {
|
|
112
|
+
console.log(
|
|
113
|
+
`Buffer overflowed ${writesFailed}x in Test 1 (no drain).`,
|
|
114
|
+
);
|
|
115
|
+
console.log(
|
|
116
|
+
"On Windows + Bun, this causes the SDK subprocess to hang indefinitely.",
|
|
117
|
+
);
|
|
118
|
+
console.log(
|
|
119
|
+
"The patch adds 'await drain' to prevent data loss → fixes the hang.",
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
console.log(
|
|
123
|
+
"Buffer did NOT overflow on this OS (macOS/Linux has large pipe buffers).",
|
|
124
|
+
);
|
|
125
|
+
console.log(
|
|
126
|
+
"On Windows + Bun, pipe buffers are smaller → overflow happens even with small prompts.",
|
|
127
|
+
);
|
|
128
|
+
console.log(
|
|
129
|
+
"The patch is still correct: it's a no-op when write() returns true.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -17,6 +17,7 @@ import { getSessionMapping, setSessionMapping } from "../services/db.service.ts"
|
|
|
17
17
|
import { accountSelector } from "../services/account-selector.service.ts";
|
|
18
18
|
import { accountService } from "../services/account.service.ts";
|
|
19
19
|
import { resolve } from "node:path";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
20
21
|
import { homedir } from "node:os";
|
|
21
22
|
|
|
22
23
|
function getSdkSessionId(ppmId: string): string {
|
|
@@ -231,6 +232,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
231
232
|
}
|
|
232
233
|
}
|
|
233
234
|
}
|
|
235
|
+
// Log error/result events for diagnostics
|
|
236
|
+
if (event.type === "error") {
|
|
237
|
+
console.error(`[sdk-cli] error event: ${JSON.stringify(event).slice(0, 1000)}`);
|
|
238
|
+
} else if (event.type === "result" && event.is_error) {
|
|
239
|
+
console.error(`[sdk-cli] result error: ${JSON.stringify(event).slice(0, 1000)}`);
|
|
240
|
+
}
|
|
234
241
|
// Always yield the original event too (for init, result, rate_limit, etc.)
|
|
235
242
|
yield event;
|
|
236
243
|
} catch {
|
|
@@ -248,22 +255,20 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
248
255
|
const exitCode = await proc.exited;
|
|
249
256
|
console.log(`[sdk-cli] process exited: code=${exitCode}`);
|
|
250
257
|
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.error(`[sdk-cli] stderr (last 1500): ${fullStderr.slice(-1500)}`);
|
|
266
|
-
// Extract meaningful error line (e.g. "Error: ...", "TypeError: ...")
|
|
258
|
+
// Always read stderr for diagnostics (errors can occur even with exit code 0)
|
|
259
|
+
try {
|
|
260
|
+
const errReader = proc.stderr.getReader();
|
|
261
|
+
const stderrDecoder = new TextDecoder();
|
|
262
|
+
const errParts: string[] = [];
|
|
263
|
+
while (true) {
|
|
264
|
+
const { done, value } = await errReader.read();
|
|
265
|
+
if (done) break;
|
|
266
|
+
if (value) errParts.push(stderrDecoder.decode(value, { stream: true }));
|
|
267
|
+
}
|
|
268
|
+
const fullStderr = errParts.join("").trim();
|
|
269
|
+
if (fullStderr) {
|
|
270
|
+
console.error(`[sdk-cli] stderr (last 1500): ${fullStderr.slice(-1500)}`);
|
|
271
|
+
if (exitCode !== 0) {
|
|
267
272
|
const errMatch = fullStderr.match(/\b(?:Error|TypeError|SyntaxError|ReferenceError|RangeError):\s*.+/);
|
|
268
273
|
const errorMsg = errMatch ? errMatch[0].slice(0, 500) : fullStderr.slice(-300);
|
|
269
274
|
yield {
|
|
@@ -272,8 +277,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
272
277
|
error: `CLI exited with code ${exitCode}: ${errorMsg}`,
|
|
273
278
|
};
|
|
274
279
|
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
280
|
+
}
|
|
281
|
+
} catch {}
|
|
277
282
|
} finally {
|
|
278
283
|
this.activeQueries.delete(opts.sessionId);
|
|
279
284
|
try { proc.kill(); } catch {}
|
|
@@ -533,7 +538,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
533
538
|
const sdkId = shouldFork ? getSdkSessionId(forkSourceId!) : getSdkSessionId(sessionId);
|
|
534
539
|
// Fallback cwd: SDK needs a valid working directory even when no project is selected.
|
|
535
540
|
// On Windows daemons, undefined cwd can cause the subprocess to fail silently.
|
|
536
|
-
|
|
541
|
+
// Resolve path and validate existence — invalid cwd causes spawn to hang on Windows.
|
|
542
|
+
const rawCwd = meta.projectPath || homedir();
|
|
543
|
+
const effectiveCwd = existsSync(rawCwd) ? rawCwd : homedir();
|
|
537
544
|
|
|
538
545
|
// Account-based auth injection (multi-account mode)
|
|
539
546
|
// Fallback to existing env (ANTHROPIC_API_KEY) when no accounts configured.
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -174,7 +174,9 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
174
174
|
} else if (evType === "tool_result") {
|
|
175
175
|
logSessionEvent(sessionId, "TOOL_RESULT", `error=${ev.isError ?? false} ${(ev.output ?? "").slice(0, 300)}`);
|
|
176
176
|
} else if (evType === "error") {
|
|
177
|
-
|
|
177
|
+
const errorDetail = ev.message ?? JSON.stringify(ev).slice(0, 500);
|
|
178
|
+
console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
|
|
179
|
+
logSessionEvent(sessionId, "ERROR", errorDetail);
|
|
178
180
|
} else if (evType === "done") {
|
|
179
181
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
180
182
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { accountService, type AccountWithTokens } from "./account.service.ts";
|
|
2
|
-
import { getConfigValue, setConfigValue } from "./db.service.ts";
|
|
2
|
+
import { getConfigValue, setConfigValue, getLatestSnapshotForAccount } from "./db.service.ts";
|
|
3
3
|
|
|
4
|
-
export type AccountStrategy = "round-robin" | "fill-first";
|
|
4
|
+
export type AccountStrategy = "round-robin" | "fill-first" | "lowest-usage";
|
|
5
5
|
|
|
6
6
|
const STRATEGY_CONFIG_KEY = "account_strategy";
|
|
7
7
|
const MAX_RETRY_CONFIG_KEY = "account_max_retry";
|
|
@@ -68,7 +68,10 @@ class AccountSelectorService {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
let pickedId: string;
|
|
71
|
-
|
|
71
|
+
const strategy = this.getStrategy();
|
|
72
|
+
if (strategy === "lowest-usage") {
|
|
73
|
+
pickedId = this.pickLowestUsage(active);
|
|
74
|
+
} else if (strategy === "fill-first") {
|
|
72
75
|
const sorted = [...active].sort((a, b) => b.priority - a.priority || a.createdAt - b.createdAt);
|
|
73
76
|
pickedId = sorted[0]!.id;
|
|
74
77
|
} else {
|
|
@@ -85,6 +88,33 @@ class AccountSelectorService {
|
|
|
85
88
|
return result;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Pick account with lowest 5-hour utilization.
|
|
93
|
+
* Skips accounts with weekly >= 100% (fully exhausted).
|
|
94
|
+
* Accounts with no usage data are treated as 0% (preferred).
|
|
95
|
+
* Falls back to round-robin if all accounts are maxed.
|
|
96
|
+
*/
|
|
97
|
+
private pickLowestUsage(active: { id: string; createdAt: number }[]): string {
|
|
98
|
+
const scored = active.map((acc) => {
|
|
99
|
+
const snap = getLatestSnapshotForAccount(acc.id);
|
|
100
|
+
const fiveHour = snap?.five_hour_util ?? 0;
|
|
101
|
+
const weekly = snap?.weekly_util ?? 0;
|
|
102
|
+
// weekly >= 1.0 means fully exhausted — mark as unavailable
|
|
103
|
+
const exhausted = weekly >= 1.0 || fiveHour >= 1.0;
|
|
104
|
+
return { id: acc.id, fiveHour, weekly, exhausted };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const available = scored.filter((s) => !s.exhausted);
|
|
108
|
+
if (available.length > 0) {
|
|
109
|
+
available.sort((a, b) => a.fiveHour - b.fiveHour || a.weekly - b.weekly);
|
|
110
|
+
return available[0]!.id;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// All exhausted — fallback: pick the one with earliest reset (lowest current util)
|
|
114
|
+
scored.sort((a, b) => a.fiveHour - b.fiveHour || a.weekly - b.weekly);
|
|
115
|
+
return scored[0]!.id;
|
|
116
|
+
}
|
|
117
|
+
|
|
88
118
|
/** Called when account receives 429 — apply exponential backoff */
|
|
89
119
|
onRateLimit(accountId: string): void {
|
|
90
120
|
const retries = (this.retryCounts.get(accountId) ?? 0) + 1;
|
|
@@ -225,11 +225,15 @@ async function fetchLegacySingleAccount(): Promise<void> {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
async function pollOnce(): Promise<void> {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
try {
|
|
229
|
+
const hasAccounts = accountService.list().length > 0;
|
|
230
|
+
if (hasAccounts) {
|
|
231
|
+
await fetchAllAccountUsages();
|
|
232
|
+
} else {
|
|
233
|
+
await fetchLegacySingleAccount();
|
|
234
|
+
}
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.error("[usage] pollOnce error:", (e as Error).message);
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
|
|
@@ -285,12 +289,19 @@ export function getCachedUsage(): ClaudeUsage & { activeAccountId?: string; acti
|
|
|
285
289
|
|
|
286
290
|
export function startUsagePolling(): void {
|
|
287
291
|
if (pollTimer) return;
|
|
288
|
-
|
|
289
|
-
|
|
292
|
+
// Use recursive setTimeout instead of setInterval to prevent overlap
|
|
293
|
+
// and ensure polling continues even if a single iteration errors
|
|
294
|
+
const scheduleNext = () => {
|
|
295
|
+
pollTimer = setTimeout(async () => {
|
|
296
|
+
await pollOnce();
|
|
297
|
+
scheduleNext();
|
|
298
|
+
}, POLL_INTERVAL);
|
|
299
|
+
};
|
|
300
|
+
pollOnce().then(scheduleNext);
|
|
290
301
|
}
|
|
291
302
|
|
|
292
303
|
export function stopUsagePolling(): void {
|
|
293
|
-
if (pollTimer) {
|
|
304
|
+
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
294
305
|
}
|
|
295
306
|
|
|
296
307
|
export function updateFromSdkEvent(_rateLimitType?: string, _utilization?: number, costUsd?: number): void {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
rmSync,
|
|
10
10
|
renameSync,
|
|
11
11
|
} from "node:fs";
|
|
12
|
-
import { resolve, relative, basename, dirname, join, normalize } from "node:path";
|
|
12
|
+
import { resolve, relative, basename, dirname, join, normalize, sep } from "node:path";
|
|
13
13
|
import ignore, { type Ignore } from "ignore";
|
|
14
14
|
import type { FileNode } from "../types/project.ts";
|
|
15
15
|
|
|
@@ -43,7 +43,7 @@ class FileService {
|
|
|
43
43
|
const normalizedTarget = normalize(resolve(projectPath, targetPath));
|
|
44
44
|
const normalizedProject = normalize(projectPath);
|
|
45
45
|
if (
|
|
46
|
-
!normalizedTarget.startsWith(normalizedProject +
|
|
46
|
+
!normalizedTarget.startsWith(normalizedProject + sep) &&
|
|
47
47
|
normalizedTarget !== normalizedProject
|
|
48
48
|
) {
|
|
49
49
|
throw new SecurityError("Path traversal not allowed");
|
package/test-tool.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
1
2
|
import { ClaudeAgentSdkProvider } from "./src/providers/claude-agent-sdk.ts";
|
|
2
3
|
|
|
3
4
|
// Remove CLAUDECODE to avoid nested session error
|
|
4
5
|
delete process.env.CLAUDECODE;
|
|
5
6
|
|
|
6
|
-
const projectPath = process.argv[2] ||
|
|
7
|
+
const projectPath = process.argv[2] || tmpdir();
|
|
7
8
|
const prompt = process.argv[3] || "Run bash: echo TOOL_TEST_OK";
|
|
8
9
|
|
|
9
10
|
console.log(`Testing tools with projectPath: ${projectPath}`);
|