autowonder 0.2.144 → 0.2.145

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/README.md CHANGED
@@ -6,16 +6,16 @@ AutoWonder 本地 agent runtime。安装后启动 daemon,持续轮询本地 as
6
6
 
7
7
  ```bash
8
8
  # Qoder CLI(模型使用 provider model ID;Context Window 使用固定档位)
9
- npx -y autowonder@0.2.144 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
9
+ npx -y autowonder@0.2.145 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qoder --model qmodel_latest --reasoning-effort medium --context-window 260000
10
10
 
11
11
  # Qoder CLI CN
12
- npx -y autowonder@0.2.144 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
12
+ npx -y autowonder@0.2.145 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider qodercn
13
13
 
14
14
  # Claude Code
15
- npx -y autowonder@0.2.144 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider claude
15
+ npx -y autowonder@0.2.145 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider claude
16
16
 
17
17
  # Codex CLI
18
- npx -y autowonder@0.2.144 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider codex --model gpt-5.5 --reasoning-effort medium
18
+ npx -y autowonder@0.2.145 connect --ws-url <wss-endpoint> --token <executor-token> --executor-id <executor-id> --provider codex --model gpt-5.5 --reasoning-effort medium
19
19
  ```
20
20
 
21
21
  `connect` 会安装当前 npm 包内置的 daemon,并使用页面生成的 WebSocket endpoint、Token 和执行器 ID 建立连接。选择 Qoder 时需要 Node.js 20+;runtime 会在缺少 `qodercli` 时自动通过 npm 安装,并在尚未登录时打开 Qoder 浏览器登录。Claude Code 和 Codex CLI 仍需提前安装并登录。runtime 会继承当前用户的 HOME、环境变量和 CLI 登录状态。
@@ -66,7 +66,7 @@ mv "$queue/.assignment.tmp" "$queue/assignment.json"
66
66
  也可以直接提交到本地 API:
67
67
 
68
68
  ```bash
69
- npx -y autowonder@0.2.144 dispatch ./assignment.json
69
+ npx -y autowonder@0.2.145 dispatch ./assignment.json
70
70
  ```
71
71
 
72
72
  ## 上传工单附件
@@ -74,7 +74,7 @@ npx -y autowonder@0.2.144 dispatch ./assignment.json
74
74
  把本地需求/设计文档直接上传到工单,避免在会话里传递大文件:
75
75
 
76
76
  ```bash
77
- npx -y autowonder@0.2.144 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
77
+ npx -y autowonder@0.2.145 workitem upload --server-url <autowonder-server-url> --workitem-id <id> --file <filepath-1> --file <filepath-2> --json
78
78
  ```
79
79
 
80
80
  上传令牌通过 MCP 工具 `autowonder.workitem_cli_upload_token` 签发,经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 环境变量传入。`--file` 可重复指定多个文件;`--json` 输出机器可读结果。失败时按错误类型返回不同退出码(401→3、403→4、404→5、409→6、413→7、其他 4xx→8、网络/重定向→9)。
@@ -84,7 +84,7 @@ npx -y autowonder@0.2.144 workitem upload --server-url <autowonder-server-url> -
84
84
  把需求/设计文档上传到 7x24 定时任务,用法与工单附件一致(同一套上传令牌与预检规则):
85
85
 
86
86
  ```bash
87
- npx -y autowonder@0.2.144 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
87
+ npx -y autowonder@0.2.145 scheduled-task upload --server-url <autowonder-server-url> --scheduled-task-id <id> --file <filepath-1> --file <filepath-2> --json
88
88
  ```
89
89
 
90
90
  上传令牌同样通过 `autowonder.workitem_cli_upload_token` 签发(令牌为用户级,对当前可修改的任意定时任务生效),经 `--token` 或 `AUTOWONDER_UPLOAD_TOKEN` 传入;退出码约定与 `workitem upload` 相同。
@@ -92,8 +92,8 @@ npx -y autowonder@0.2.144 scheduled-task upload --server-url <autowonder-server-
92
92
  ## 管理 daemon
93
93
 
94
94
  ```bash
95
- npx -y autowonder@0.2.144 status
96
- npx -y autowonder@0.2.144 stop
95
+ npx -y autowonder@0.2.145 status
96
+ npx -y autowonder@0.2.145 stop
97
97
  ```
98
98
 
99
99
  默认 API 是 `http://127.0.0.1:34989`,日志位于 `~/.autowonder/daemon.log`。npm 包不包含任何 agent、MCP 或服务端凭证。
@@ -101,7 +101,7 @@ npx -y autowonder@0.2.144 stop
101
101
  ## 调试
102
102
 
103
103
  ```bash
104
- npx -y autowonder@0.2.144 connect ... --debug
104
+ npx -y autowonder@0.2.145 connect ... --debug
105
105
  ```
106
106
 
107
107
  `--debug` 会让 CLI 以 `AUTOWONDER_DEBUG=1` 启动 daemon,把完整的 daemon 与 agent 活动(runtime 事件流、provider 事件解码、重试退避与会话门等待、空闲时的阻塞点)同时输出到控制台和会话调试日志。调试日志不做脱敏,仅用于本机排障;不传 `--debug` 时该环境变量不会注入。
package/bin/cli.js CHANGED
@@ -438,9 +438,16 @@ async function cmdConnect(args) {
438
438
  const proxyPort = parseInt(process.env.TOKEN_PROXY_PORT || "19876", 10);
439
439
  const caCertPath = path.join(AUTOWONDER_HOME, "token-tap", "ca-cert.pem");
440
440
  try {
441
+ const proxyLogPath = path.join(AUTOWONDER_HOME, "token-tap", "proxy-diag.log");
442
+ const proxyStdio = flags.debug
443
+ ? ["ignore", fs.openSync(proxyLogPath, "a"), fs.openSync(proxyLogPath, "a")]
444
+ : ["ignore", "ignore", "ignore"];
445
+ const proxyEnv = { ...process.env };
446
+ if (flags.debug) proxyEnv.AUTOWONDER_DEBUG = "1";
441
447
  tokenProxyChild = spawn(process.execPath, [tokenProxyScript, "start", "--port", String(proxyPort)], {
442
448
  detached: true,
443
- stdio: ["ignore", "ignore", "ignore"],
449
+ stdio: proxyStdio,
450
+ env: proxyEnv,
444
451
  });
445
452
  tokenProxyChild.unref();
446
453
 
@@ -913,12 +920,17 @@ function ensureWindowsUTF8() {
913
920
  process.env.PYTHONUTF8 = process.env.PYTHONUTF8 || "1";
914
921
  process.env.JAVA_TOOL_OPTIONS = process.env.JAVA_TOOL_OPTIONS || "-Dfile.encoding=UTF-8";
915
922
  if (!process.env.GIT_CONFIG_COUNT) {
916
- process.env.GIT_CONFIG_COUNT = "2";
923
+ process.env.GIT_CONFIG_COUNT = "3";
917
924
  process.env.GIT_CONFIG_KEY_0 = "i18n.logOutputEncoding";
918
925
  process.env.GIT_CONFIG_VALUE_0 = "utf-8";
919
926
  process.env.GIT_CONFIG_KEY_1 = "i18n.commitEncoding";
920
927
  process.env.GIT_CONFIG_VALUE_1 = "utf-8";
928
+ process.env.GIT_CONFIG_KEY_2 = "core.longpaths";
929
+ process.env.GIT_CONFIG_VALUE_2 = "true";
921
930
  }
931
+ // Persist core.longpaths in git global config so all git operations
932
+ // (including checkpoint restore) can handle paths > MAX_PATH (260 chars).
933
+ spawnSync("git", ["config", "--global", "core.longpaths", "true"], { stdio: "ignore", timeout: 5000 });
922
934
  }
923
935
 
924
936
  // ─── Main ────────────────────────────────────────────────────────────
@@ -36,15 +36,17 @@ const CA_KEY_PATH = path.join(DATA_DIR, 'ca-key.pem');
36
36
  const CA_CERT_PATH = path.join(DATA_DIR, 'ca-cert.pem');
37
37
  const USAGE_LOG_PATH = path.join(DATA_DIR, 'token-usage.jsonl');
38
38
  const USAGE_LOG_OLD = path.join(DATA_DIR, 'token-usage.old.jsonl');
39
+ const RAW_LOG_DIR = path.join(DATA_DIR, 'raw');
40
+ const DEBUG = process.env.AUTOWONDER_DEBUG === '1';
39
41
 
40
42
  // Qoder/QoderCN LLM API hosts to sniff (others are tunneled without inspection).
41
43
  // Only LLM endpoints are listed — non-LLM APIs (openapi.*) are excluded to avoid
42
44
  // unnecessary MITM overhead on high-frequency config/auth calls.
43
45
  const SNIFF_HOSTS = new Set([
44
46
  // Qoder (international)
45
- 'api3.qoder.sh', 'api2.qoder.sh', 'api.qoder.sh',
47
+ 'api3.qoder.sh', 'api2.qoder.sh', 'api.qoder.sh', 'openapi.qoder.sh',
46
48
  // QoderCN (China)
47
- 'gateway.qoder.com.cn',
49
+ 'gateway.qoder.com.cn', 'openapi.qoder.com.cn',
48
50
  'api3.qodercn.com', 'api2.qodercn.com', 'api.qodercn.com',
49
51
  ]);
50
52
 
@@ -163,15 +165,16 @@ function extractUsageFromResponse(buffer) {
163
165
  // 1. Qoder SSE (current): data:{"headers":{...},"body":"{...\"usage\":{...}}"}
164
166
  // 2. Standard OpenAI SSE: data: {"choices":[...],"usage":{...}}
165
167
  // 3. NDJSON streaming: {"choices":[...],"usage":{...}}\n
166
- // 4. Single JSON response: {"choices":[...],"usage":{...}}
167
- //
168
- // Strategy: scan lines backwards for "prompt_tokens", try multiple parse strategies.
168
+ // 4. Single JSON response (non-streaming): {"choices":[...],"usage":{...}}
169
+ // 5. Regex extraction: find "usage":{...} anywhere in buffer
169
170
  try {
170
171
  const text = buffer.toString('utf8');
171
172
  const lines = text.split('\n');
173
+
174
+ // Strategy 1-3: line-based parsing (backwards scan)
172
175
  for (let i = lines.length - 1; i >= 0; i--) {
173
176
  const line = lines[i].trim();
174
- if (!line.includes('prompt_tokens')) continue;
177
+ if (!line.includes('"usage"') && !line.includes('\\"usage\\"')) continue;
175
178
 
176
179
  // Strategy 1: Qoder nested SSE — data:{"headers":...,"body":"<escaped json>"}
177
180
  if (line.startsWith('data:')) {
@@ -182,16 +185,67 @@ function extractUsageFromResponse(buffer) {
182
185
  if (usage2) return usage2;
183
186
  }
184
187
 
185
- // Strategy 3: Plain JSON line (NDJSON or single response)
188
+ // Strategy 3: Qoder nested without "data:" prefix {"headers":...,"body":"..."}
189
+ if (line.startsWith('{') && line.includes('"body"')) {
190
+ const usage3a = tryQoderNestedSSE(line);
191
+ if (usage3a) return usage3a;
192
+ }
193
+
194
+ // Strategy 4: Plain JSON line (NDJSON or single response)
186
195
  const usage3 = tryDirectJSON(line);
187
196
  if (usage3) return usage3;
188
197
  }
198
+
199
+ // Strategy 4: Regex extraction — find usage JSON object directly in buffer
200
+ // Handles cases where the response is not line-delimited or spans multiple chunks
201
+ const usageMatch = text.match(/"usage"\s*:\s*\{[^}]*"prompt_tokens"\s*:\s*\d+[^}]*\}/);
202
+ if (usageMatch) {
203
+ const usage4 = tryExtractUsageObject(usageMatch[0], text);
204
+ if (usage4) return usage4;
205
+ }
206
+ // Also check input_tokens variant
207
+ const usageMatch2 = text.match(/"usage"\s*:\s*\{[^}]*"input_tokens"\s*:\s*\d+[^}]*\}/);
208
+ if (usageMatch2) {
209
+ const usage5 = tryExtractUsageObject(usageMatch2[0], text);
210
+ if (usage5) return usage5;
211
+ }
189
212
  } catch (_) {
190
213
  // Silent: parsing failure only loses token data, never breaks traffic
191
214
  }
192
215
  return null;
193
216
  }
194
217
 
218
+ function tryExtractUsageObject(usageStr, fullText) {
219
+ // Extract usage from a regex-matched "usage":{...} fragment
220
+ // Handle nested objects like prompt_tokens_details by using a greedy match
221
+ try {
222
+ // Try to parse just the usage value (strip the key prefix)
223
+ let jsonStr = usageStr.replace(/^"usage"\s*:\s*/, '');
224
+ // Handle nested braces (prompt_tokens_details, completion_tokens_details)
225
+ // Re-extract with nested brace awareness
226
+ const startIdx = fullText.indexOf(usageStr);
227
+ if (startIdx >= 0) {
228
+ const usageStart = fullText.indexOf('{', startIdx + 7);
229
+ if (usageStart >= 0) {
230
+ let depth = 0, end = usageStart;
231
+ for (let i = usageStart; i < fullText.length && i < usageStart + 2000; i++) {
232
+ if (fullText[i] === '{') depth++;
233
+ else if (fullText[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
234
+ }
235
+ jsonStr = fullText.slice(usageStart, end + 1);
236
+ }
237
+ }
238
+ const u = JSON.parse(jsonStr);
239
+ if (u && (u.prompt_tokens !== undefined || u.input_tokens !== undefined)) {
240
+ // Try to find model from nearby context
241
+ const modelMatch = fullText.match(/"model"\s*:\s*"([^"]+)"/);
242
+ const model = modelMatch ? modelMatch[1] : 'unknown';
243
+ return buildUsageRecord(u, model);
244
+ }
245
+ } catch (_) {}
246
+ return null;
247
+ }
248
+
195
249
  function tryQoderNestedSSE(data) {
196
250
  try {
197
251
  const outer = JSON.parse(data);
@@ -239,25 +293,32 @@ function appendUsage(usage) {
239
293
  }
240
294
  }
241
295
 
242
- // ─── Dispatch ID extraction from Proxy-Authorization ─────────────────────────
296
+ // ─── Dispatch/Step ID extraction from Proxy-Authorization ───────────────────
243
297
 
244
- function extractDispatchId(req) {
245
- // daemon sets HTTPS_PROXY=http://d-{dispatchId}:x@host:port
246
- // → client sends Proxy-Authorization: Basic <base64("d-{id}:x")>
298
+ function extractDispatchInfo(req) {
299
+ // daemon sets HTTPS_PROXY=http://d-{dispatchId}-s-{stepId}:x@host:port
300
+ // → client sends Proxy-Authorization: Basic <base64("d-{id}-s-{step}:x")>
247
301
  const auth = req.headers['proxy-authorization'] || '';
248
- if (!auth.startsWith('Basic ')) return null;
302
+ if (!auth.startsWith('Basic ')) return { dispatchId: null, stepId: null };
249
303
  try {
250
304
  const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
251
305
  const [user] = decoded.split(':');
252
- if (user && user.startsWith('d-')) return user.slice(2);
306
+ if (!user || !user.startsWith('d-')) return { dispatchId: null, stepId: null };
307
+ const payload = user.slice(2); // remove "d-" prefix
308
+ const stepIdx = payload.indexOf('-s-');
309
+ if (stepIdx >= 0) {
310
+ return { dispatchId: payload.slice(0, stepIdx), stepId: payload.slice(stepIdx + 3) };
311
+ }
312
+ return { dispatchId: payload, stepId: null };
253
313
  } catch (_) {}
254
- return null;
314
+ return { dispatchId: null, stepId: null };
255
315
  }
256
316
 
257
317
  // ─── Forward Proxy (CONNECT method) ─────────────────────────────────────────
258
318
 
259
319
  function startProxy(port) {
260
320
  const ca = ensureCA();
321
+ const dbg = DEBUG ? (...args) => console.log(...args) : () => {};
261
322
 
262
323
  const server = http.createServer((req, res) => {
263
324
  // Non-CONNECT requests: reject (we only handle CONNECT tunnels)
@@ -265,40 +326,47 @@ function startProxy(port) {
265
326
  res.end('Only CONNECT supported');
266
327
  });
267
328
 
329
+ let connSeq = 0;
268
330
  server.on('connect', (req, clientSocket, head) => {
331
+ const seq = ++connSeq;
332
+ const ts = () => new Date().toISOString().slice(11, 23);
269
333
  const [hostname, portStr] = req.url.split(':');
270
334
  const remotePort = parseInt(portStr) || 443;
271
335
  const shouldSniff = SNIFF_HOSTS.has(hostname);
272
- const dispatchId = extractDispatchId(req);
336
+ const { dispatchId, stepId } = extractDispatchInfo(req);
337
+
338
+ dbg(`[${ts()}] CONNECT #${seq} ${hostname}:${remotePort} sniff=${shouldSniff} dispatch=${dispatchId || 'none'} step=${stepId || 'none'}`);
273
339
 
274
340
  if (!shouldSniff) {
275
- // Blind tunnel: don't MITM, just pipe through
341
+ dbg(`[${ts()}] #${seq} BLIND TUNNEL (not in SNIFF_HOSTS)`);
276
342
  const upstream = net.connect(remotePort, hostname, () => {
277
343
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
278
344
  upstream.write(head);
279
345
  upstream.pipe(clientSocket);
280
346
  clientSocket.pipe(upstream);
281
347
  });
282
- upstream.on('error', () => { try { clientSocket.end(); } catch (_) {} });
283
- clientSocket.on('error', () => { try { upstream.end(); } catch (_) {} });
348
+ upstream.on('error', (e) => { dbg(`[${ts()}] #${seq} blind upstream err: ${e.message}`); try { clientSocket.end(); } catch (_) {} });
349
+ clientSocket.on('error', (e) => { dbg(`[${ts()}] #${seq} blind client err: ${e.message}`); try { upstream.end(); } catch (_) {} });
284
350
  return;
285
351
  }
286
352
 
287
353
  // MITM for Qoder API hosts: decrypt, sniff usage, re-encrypt
288
354
  const hostCert = getHostCert(hostname, ca);
289
355
  if (!hostCert) {
290
- // Cannot generate cert fall back to blind tunnel
356
+ dbg(`[${ts()}] #${seq} BLIND TUNNEL (cert generation failed for ${hostname})`);
291
357
  const upstream = net.connect(remotePort, hostname, () => {
292
358
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
293
359
  upstream.write(head);
294
360
  upstream.pipe(clientSocket);
295
361
  clientSocket.pipe(upstream);
296
362
  });
297
- upstream.on('error', () => { try { clientSocket.end(); } catch (_) {} });
298
- clientSocket.on('error', () => { try { upstream.end(); } catch (_) {} });
363
+ upstream.on('error', (e) => { dbg(`[${ts()}] #${seq} cert-fail upstream err: ${e.message}`); try { clientSocket.end(); } catch (_) {} });
364
+ clientSocket.on('error', (e) => { dbg(`[${ts()}] #${seq} cert-fail client err: ${e.message}`); try { upstream.end(); } catch (_) {} });
299
365
  return;
300
366
  }
301
367
 
368
+ dbg(`[${ts()}] #${seq} → MITM (cert OK for ${hostname})`);
369
+
302
370
  // Accept TLS from client (presenting our per-host cert signed by our CA)
303
371
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
304
372
 
@@ -308,26 +376,71 @@ function startProxy(port) {
308
376
  cert: hostCert.cert,
309
377
  });
310
378
 
379
+ let tlsClientReady = false;
380
+ tlsClient.on('secure', () => { tlsClientReady = true; dbg(`[${ts()}] #${seq} TLS-client handshake OK`); });
381
+
311
382
  // Connect to real upstream
312
383
  const tlsUpstream = tls.connect({ host: hostname, port: remotePort, servername: hostname });
313
384
 
385
+ let tlsUpstreamReady = false;
386
+ tlsUpstream.on('secureConnect', () => { tlsUpstreamReady = true; dbg(`[${ts()}] #${seq} TLS-upstream handshake OK → ${hostname}`); });
387
+
314
388
  let responseBuffer = Buffer.alloc(0);
315
- let usageExtracted = false;
389
+ let usageCount = 0;
390
+ let totalBytes = 0;
391
+ let rawFd = null;
392
+
393
+ // Raw response logging: only in debug mode to avoid disk bloat
394
+ if (DEBUG) {
395
+ try {
396
+ fs.mkdirSync(RAW_LOG_DIR, { recursive: true });
397
+ const rawFile = path.join(RAW_LOG_DIR, `${new Date().toISOString().slice(0,10)}_${seq}_${hostname}.raw`);
398
+ rawFd = fs.openSync(rawFile, 'w');
399
+ } catch (_) {}
400
+ }
401
+
402
+ // Precise trigger: "usage" must co-occur with "prompt_tokens"/"input_tokens"
403
+ // This avoids false positives when model output TEXT contains "prompt_tokens"
404
+ function hasUsageSignal(text) {
405
+ if (!text.includes('"usage"') && !text.includes('\\"usage\\"')) return false;
406
+ return text.includes('prompt_tokens') || text.includes('input_tokens');
407
+ }
408
+
409
+ // lineBuffer accumulates complete lines; incomplete trailing data stays for next chunk
410
+ let lineRemainder = '';
411
+
412
+ function processLines(lines) {
413
+ // Extract ALL usage records from a batch of complete lines (not just the last one)
414
+ for (const line of lines.split('\n')) {
415
+ const trimmed = line.trim();
416
+ if (!trimmed) continue;
417
+ if (!hasUsageSignal(trimmed)) continue;
418
+ const buf = Buffer.from(trimmed, 'utf8');
419
+ const usage = extractUsageFromResponse(buf);
420
+ if (usage) {
421
+ usageCount++;
422
+ if (dispatchId) usage.dispatch_id = dispatchId;
423
+ if (stepId) usage.step_id = stepId;
424
+ appendUsage(usage);
425
+ console.log(`[${ts()}] #${seq} ✓ USAGE CAPTURED (#${usageCount}) credits=${usage.credits} model=${usage.model} in=${usage.input_tokens} out=${usage.output_tokens}`);
426
+ }
427
+ }
428
+ }
316
429
 
317
430
  tlsUpstream.on('data', (chunk) => {
431
+ totalBytes += chunk.length;
318
432
  try { tlsClient.write(chunk); } catch (_) {}
433
+ if (rawFd !== null) { try { fs.writeSync(rawFd, chunk); } catch (_) {} }
319
434
  try {
320
- // Keep last 8KB of response for usage extraction
321
- responseBuffer = Buffer.concat([responseBuffer.slice(-8192), chunk]);
322
- // Try to extract usage eagerly (the usage chunk arrives before connection close)
323
- if (!usageExtracted && chunk.toString('utf8').includes('prompt_tokens')) {
324
- const usage = extractUsageFromResponse(responseBuffer);
325
- if (usage) {
326
- if (dispatchId) usage.dispatch_id = dispatchId;
327
- appendUsage(usage);
328
- usageExtracted = true;
329
- }
435
+ const text = lineRemainder + chunk.toString('utf8');
436
+ const lastNL = text.lastIndexOf('\n');
437
+ if (lastNL === -1) {
438
+ lineRemainder = text.length > 65536 ? text.slice(-65536) : text;
439
+ return;
330
440
  }
441
+ const complete = text.slice(0, lastNL);
442
+ lineRemainder = text.slice(lastNL + 1);
443
+ processLines(complete);
331
444
  } catch (_) {}
332
445
  });
333
446
 
@@ -336,25 +449,21 @@ function startProxy(port) {
336
449
  });
337
450
 
338
451
  const cleanup = () => {
339
- // Final attempt to extract usage if not already done
340
- if (!usageExtracted) {
341
- try {
342
- const usage = extractUsageFromResponse(responseBuffer);
343
- if (usage) {
344
- if (dispatchId) usage.dispatch_id = dispatchId;
345
- appendUsage(usage);
346
- }
347
- } catch (_) {}
348
- }
349
- responseBuffer = Buffer.alloc(0);
452
+ // Final extraction from any remaining data (lineRemainder may hold last line without trailing \n)
453
+ try {
454
+ if (lineRemainder) processLines(lineRemainder);
455
+ } catch (_) {}
456
+ lineRemainder = '';
457
+ dbg(`[${ts()}] #${seq} CLOSED ${hostname} bytes=${totalBytes} usages=${usageCount} tlsClient=${tlsClientReady} tlsUpstream=${tlsUpstreamReady}`);
458
+ if (rawFd !== null) { try { fs.closeSync(rawFd); } catch (_) {} rawFd = null; }
350
459
  try { tlsClient.end(); } catch (_) {}
351
460
  try { tlsUpstream.end(); } catch (_) {}
352
461
  };
353
462
 
354
463
  tlsClient.on('end', cleanup);
355
- tlsClient.on('error', () => { cleanup(); });
464
+ tlsClient.on('error', (e) => { dbg(`[${ts()}] #${seq} TLS-client ERROR: ${e.message}`); cleanup(); });
356
465
  tlsUpstream.on('end', cleanup);
357
- tlsUpstream.on('error', () => { cleanup(); });
466
+ tlsUpstream.on('error', (e) => { dbg(`[${ts()}] #${seq} TLS-upstream ERROR: ${e.message}`); cleanup(); });
358
467
 
359
468
  if (head.length > 0) tlsClient.emit('data', head);
360
469
  });
@@ -467,3 +576,7 @@ Files:
467
576
  CA cert: ${CA_CERT_PATH}
468
577
  Usage log: ${USAGE_LOG_PATH}`);
469
578
  }
579
+
580
+ if (require.main !== module) {
581
+ module.exports = { extractUsageFromResponse, extractDispatchInfo, buildUsageRecord };
582
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autowonder",
3
- "version": "0.2.144",
3
+ "version": "0.2.145",
4
4
  "description": "AutoWonder local runtime — execute AI agent dispatch packages on your machine",
5
5
  "bin": {
6
6
  "autowonder": "bin/cli.js"