@vanillagreen/pi-claude-bridge 1.3.0 → 1.4.1
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 +2 -0
- package/bundle/index.js +206 -19
- package/package.json +1 -1
- package/src/index.ts +194 -2
- package/src/session-verify.ts +54 -8
package/README.md
CHANGED
|
@@ -98,6 +98,8 @@ When Claude Code emits rate-limit reset metadata, the bridge shows one red ASCII
|
|
|
98
98
|
|
|
99
99
|
Allowed-warning rate-limit events are filtered before user notification. The bridge normalizes unambiguous numeric utilization (`0 < value < 1` as fractional, `1 < value <= 100` as percent), suppresses low or unit-ambiguous values such as exact `1`, and only shows a neutral warning at 80%+ instead of claiming an unverified `% used` value. Check Claude Code `/usage` for exact allowed-warning utilization.
|
|
100
100
|
|
|
101
|
+
If Claude Code accepts a turn but produces no assistant/tool output, the bridge treats that stream-idle stall as a retryable overload/rate-limit failure: it closes the stalled Claude Code subprocess, emits a normal assistant error with a backoff hint, and lets Flightdeck or `pi-agents-tmux` reuse their existing rate-limit retry ladder. Tune the first-output timeout with `CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT` (bare numbers are seconds; suffixes `ms`, `s`, and `m` are accepted). Default: `90s`; set `0` to disable.
|
|
102
|
+
|
|
101
103
|
## Debugging
|
|
102
104
|
|
|
103
105
|
Set `CLAUDE_BRIDGE_DEBUG=1` to write bridge logs to `~/.pi/agent/claude-bridge.log` and per-query Claude Code CLI logs under `~/.pi/agent/cc-cli-logs/`.
|
package/bundle/index.js
CHANGED
|
@@ -21976,7 +21976,7 @@ function readSession(jsonlPath, projectPath) {
|
|
|
21976
21976
|
// src/index.ts
|
|
21977
21977
|
import { spawn as spawnProcess } from "child_process";
|
|
21978
21978
|
import { createHash } from "crypto";
|
|
21979
|
-
import { accessSync, appendFileSync as appendFileSync3, chmodSync, constants as fsConstants, mkdirSync as mkdirSync3, readFileSync as
|
|
21979
|
+
import { accessSync, appendFileSync as appendFileSync3, chmodSync, constants as fsConstants, mkdirSync as mkdirSync3, readFileSync as readFileSync6, realpathSync as realpathSync3, statSync as statSync3 } from "fs";
|
|
21980
21980
|
import { resolve as pathResolve } from "path";
|
|
21981
21981
|
import { homedir as homedir5 } from "os";
|
|
21982
21982
|
import { delimiter, dirname as dirname5, join as join5 } from "path";
|
|
@@ -22272,7 +22272,46 @@ function rewriteSkillsBlock(skillsBlock) {
|
|
|
22272
22272
|
}
|
|
22273
22273
|
|
|
22274
22274
|
// src/session-verify.ts
|
|
22275
|
-
import {
|
|
22275
|
+
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
|
|
22276
|
+
import { StringDecoder } from "node:string_decoder";
|
|
22277
|
+
function forEachJsonlLine(path, onLine) {
|
|
22278
|
+
const fd = openSync2(path, "r");
|
|
22279
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
22280
|
+
const decoder = new StringDecoder("utf8");
|
|
22281
|
+
let pending = "";
|
|
22282
|
+
try {
|
|
22283
|
+
for (; ; ) {
|
|
22284
|
+
const bytesRead = readSync2(fd, buffer, 0, buffer.length, null);
|
|
22285
|
+
if (bytesRead === 0) break;
|
|
22286
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
22287
|
+
let start = 0;
|
|
22288
|
+
for (; ; ) {
|
|
22289
|
+
const newline = pending.indexOf("\n", start);
|
|
22290
|
+
if (newline < 0) {
|
|
22291
|
+
pending = pending.slice(start);
|
|
22292
|
+
break;
|
|
22293
|
+
}
|
|
22294
|
+
const line = pending.slice(start, newline);
|
|
22295
|
+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
22296
|
+
start = newline + 1;
|
|
22297
|
+
}
|
|
22298
|
+
}
|
|
22299
|
+
pending += decoder.end();
|
|
22300
|
+
if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
|
|
22301
|
+
} finally {
|
|
22302
|
+
closeSync2(fd);
|
|
22303
|
+
}
|
|
22304
|
+
}
|
|
22305
|
+
function summarizeJsonl(path) {
|
|
22306
|
+
const summary = { count: 0 };
|
|
22307
|
+
forEachJsonlLine(path, (line) => {
|
|
22308
|
+
if (!line.trim()) return;
|
|
22309
|
+
summary.count += 1;
|
|
22310
|
+
if (summary.firstLine === void 0) summary.firstLine = line;
|
|
22311
|
+
summary.lastLine = line;
|
|
22312
|
+
});
|
|
22313
|
+
return summary;
|
|
22314
|
+
}
|
|
22276
22315
|
function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount) {
|
|
22277
22316
|
const warnings = [];
|
|
22278
22317
|
let st;
|
|
@@ -22282,21 +22321,20 @@ function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount)
|
|
|
22282
22321
|
warnings.push(`file missing after save \u2014 path=${jsonlPath} err=${e2.message}`);
|
|
22283
22322
|
return warnings;
|
|
22284
22323
|
}
|
|
22285
|
-
let
|
|
22324
|
+
let summary;
|
|
22286
22325
|
try {
|
|
22287
|
-
|
|
22326
|
+
summary = summarizeJsonl(jsonlPath);
|
|
22288
22327
|
} catch (e2) {
|
|
22289
22328
|
warnings.push(`file unreadable \u2014 path=${jsonlPath} size=${st.size} err=${e2.message}`);
|
|
22290
22329
|
return warnings;
|
|
22291
22330
|
}
|
|
22292
|
-
|
|
22293
|
-
|
|
22294
|
-
warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
|
|
22331
|
+
if (summary.count !== expectedRecordCount) {
|
|
22332
|
+
warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
|
|
22295
22333
|
return warnings;
|
|
22296
22334
|
}
|
|
22297
22335
|
try {
|
|
22298
|
-
const firstRec = JSON.parse(
|
|
22299
|
-
const lastRec = JSON.parse(
|
|
22336
|
+
const firstRec = JSON.parse(summary.firstLine ?? "");
|
|
22337
|
+
const lastRec = JSON.parse(summary.lastLine ?? "");
|
|
22300
22338
|
if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
|
|
22301
22339
|
warnings.push(`sessionId drift \u2014 expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
|
|
22302
22340
|
}
|
|
@@ -22564,7 +22602,7 @@ function summarizeMissingToolNames(missing) {
|
|
|
22564
22602
|
}
|
|
22565
22603
|
|
|
22566
22604
|
// src/config.ts
|
|
22567
|
-
import { existsSync as existsSync3, readFileSync as
|
|
22605
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
22568
22606
|
import { homedir as homedir2 } from "os";
|
|
22569
22607
|
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
22570
22608
|
var PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
|
|
@@ -22606,7 +22644,7 @@ function settingsPaths(cwd) {
|
|
|
22606
22644
|
function tryParseJson(path) {
|
|
22607
22645
|
if (!existsSync3(path)) return {};
|
|
22608
22646
|
try {
|
|
22609
|
-
return JSON.parse(
|
|
22647
|
+
return JSON.parse(readFileSync3(path, "utf-8"));
|
|
22610
22648
|
} catch {
|
|
22611
22649
|
return {};
|
|
22612
22650
|
}
|
|
@@ -22616,7 +22654,7 @@ function readManagerConfig(cwd) {
|
|
|
22616
22654
|
for (const path of settingsPaths(cwd)) {
|
|
22617
22655
|
if (!existsSync3(path)) continue;
|
|
22618
22656
|
try {
|
|
22619
|
-
const parsed = JSON.parse(
|
|
22657
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
22620
22658
|
const configRoot = asRecord(asRecord(asRecord(parsed?.vstack)?.extensionManager)?.config);
|
|
22621
22659
|
const config2 = asRecord(configRoot?.[PACKAGE_ID]);
|
|
22622
22660
|
if (config2) mergeDeep(merged, config2);
|
|
@@ -22720,7 +22758,7 @@ function loadConfig(cwd) {
|
|
|
22720
22758
|
}
|
|
22721
22759
|
|
|
22722
22760
|
// src/agents-md.ts
|
|
22723
|
-
import { existsSync as existsSync4, readFileSync as
|
|
22761
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
22724
22762
|
import { homedir as homedir3 } from "os";
|
|
22725
22763
|
import { dirname as dirname3, join as join3, resolve as resolve2 } from "path";
|
|
22726
22764
|
var GLOBAL_AGENTS_PATH = join3(homedir3(), ".pi", "agent", "AGENTS.md");
|
|
@@ -22745,7 +22783,7 @@ function extractAgentsAppend() {
|
|
|
22745
22783
|
const agentsPath = resolveAgentsMdPath();
|
|
22746
22784
|
if (!agentsPath) return void 0;
|
|
22747
22785
|
try {
|
|
22748
|
-
const content =
|
|
22786
|
+
const content = readFileSync4(agentsPath, "utf-8").trim();
|
|
22749
22787
|
if (!content) return void 0;
|
|
22750
22788
|
const sanitized = sanitizeAgentsContent(content);
|
|
22751
22789
|
return sanitized.length > 0 ? `# CLAUDE.md
|
|
@@ -22765,7 +22803,7 @@ function sanitizeAgentsContent(content) {
|
|
|
22765
22803
|
}
|
|
22766
22804
|
|
|
22767
22805
|
// src/prompt-context.ts
|
|
22768
|
-
import { existsSync as existsSync5, readFileSync as
|
|
22806
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
22769
22807
|
import { homedir as homedir4 } from "os";
|
|
22770
22808
|
import { dirname as dirname4, join as join4, resolve as resolve3 } from "path";
|
|
22771
22809
|
function piUserDir2() {
|
|
@@ -22776,7 +22814,7 @@ function piUserDir2() {
|
|
|
22776
22814
|
function readTrimmed(path) {
|
|
22777
22815
|
try {
|
|
22778
22816
|
if (!existsSync5(path)) return void 0;
|
|
22779
|
-
const content =
|
|
22817
|
+
const content = readFileSync5(path, "utf8").trim();
|
|
22780
22818
|
return content.length > 0 ? content : void 0;
|
|
22781
22819
|
} catch {
|
|
22782
22820
|
return void 0;
|
|
@@ -37607,7 +37645,7 @@ function preflightClaudeExecutable(path, cwd) {
|
|
|
37607
37645
|
}
|
|
37608
37646
|
let fileType;
|
|
37609
37647
|
try {
|
|
37610
|
-
fileType = classifyClaudeExecutableBytes(
|
|
37648
|
+
fileType = classifyClaudeExecutableBytes(readFileSync6(realPath).subarray(0, 16));
|
|
37611
37649
|
} catch (err) {
|
|
37612
37650
|
throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
|
|
37613
37651
|
code: codeValue(err, "EACCES"),
|
|
@@ -37873,6 +37911,90 @@ var piUI;
|
|
|
37873
37911
|
var extraUsageHelperInFlight = null;
|
|
37874
37912
|
var RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
37875
37913
|
var RATE_LIMIT_TOKEN = "\x1B[31m[rate-limit]\x1B[39m";
|
|
37914
|
+
var DEFAULT_STREAM_IDLE_TIMEOUT_MS = 9e4;
|
|
37915
|
+
var STREAM_IDLE_BACKOFF_HINT_MS = 6e4;
|
|
37916
|
+
var STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
|
|
37917
|
+
var activeStreamIdleWatchdogs = /* @__PURE__ */ new WeakMap();
|
|
37918
|
+
function parseDurationLiteralMs(value, defaultUnit = "s") {
|
|
37919
|
+
const text = value.trim().toLowerCase();
|
|
37920
|
+
if (!text) return void 0;
|
|
37921
|
+
if (["off", "false", "disabled", "disable"].includes(text)) return 0;
|
|
37922
|
+
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
|
|
37923
|
+
if (!match) return void 0;
|
|
37924
|
+
const amount = Number(match[1]);
|
|
37925
|
+
if (!Number.isFinite(amount) || amount < 0) return void 0;
|
|
37926
|
+
const unit = (match[2] ?? defaultUnit).toLowerCase();
|
|
37927
|
+
const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit) ? 1 : ["s", "sec", "secs", "second", "seconds"].includes(unit) ? 1e3 : ["m", "min", "mins", "minute", "minutes"].includes(unit) ? 6e4 : void 0;
|
|
37928
|
+
if (multiplier === void 0) return void 0;
|
|
37929
|
+
const ms = Math.round(amount * multiplier);
|
|
37930
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
37931
|
+
}
|
|
37932
|
+
function streamIdleTimeoutMsFromEnv(env = process.env) {
|
|
37933
|
+
const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
|
|
37934
|
+
if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
37935
|
+
return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
37936
|
+
}
|
|
37937
|
+
function formatDurationShort(ms) {
|
|
37938
|
+
if (ms < 18e4 && ms % 1e3 === 0) return `${ms / 1e3}s`;
|
|
37939
|
+
if (ms % 6e4 === 0) return `${ms / 6e4}m`;
|
|
37940
|
+
if (ms % 1e3 === 0) return `${ms / 1e3}s`;
|
|
37941
|
+
return `${ms}ms`;
|
|
37942
|
+
}
|
|
37943
|
+
function buildStreamIdleTimeoutErrorMessage(timeoutMs) {
|
|
37944
|
+
return `Claude Code stream idle timeout after ${formatDurationShort(timeoutMs)} with no assistant/tool output; treating stalled stream as retryable 529 overloaded/rate limit condition. Retry after ${formatDurationShort(STREAM_IDLE_BACKOFF_HINT_MS)}.`;
|
|
37945
|
+
}
|
|
37946
|
+
function createStreamIdleWatchdog({
|
|
37947
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
37948
|
+
getState,
|
|
37949
|
+
now = () => Date.now(),
|
|
37950
|
+
onTimeout,
|
|
37951
|
+
setTimer = (fn, delayMs) => setTimeout(fn, delayMs),
|
|
37952
|
+
timeoutMs
|
|
37953
|
+
}) {
|
|
37954
|
+
let disposed = false;
|
|
37955
|
+
let lastChunkAt = now();
|
|
37956
|
+
let timer = null;
|
|
37957
|
+
let didTimeout = false;
|
|
37958
|
+
const clear = () => {
|
|
37959
|
+
if (!timer) return;
|
|
37960
|
+
try {
|
|
37961
|
+
clearTimer(timer);
|
|
37962
|
+
} catch {
|
|
37963
|
+
}
|
|
37964
|
+
timer = null;
|
|
37965
|
+
};
|
|
37966
|
+
const shouldMonitor = (state) => Boolean(
|
|
37967
|
+
timeoutMs > 0 && state.activeQuery && state.currentPiStream && state.turnOutput && !state.turnStarted && !state.turnSawStreamEvent
|
|
37968
|
+
);
|
|
37969
|
+
const schedule = () => {
|
|
37970
|
+
clear();
|
|
37971
|
+
if (disposed || didTimeout || timeoutMs <= 0) return;
|
|
37972
|
+
const state = getState();
|
|
37973
|
+
if (!shouldMonitor(state)) return;
|
|
37974
|
+
const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
|
|
37975
|
+
const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
|
|
37976
|
+
const idleMs = Math.max(0, now() - idleStartedAt);
|
|
37977
|
+
if (idleMs >= timeoutMs) {
|
|
37978
|
+
didTimeout = true;
|
|
37979
|
+
onTimeout({ idleMs, timeoutMs });
|
|
37980
|
+
return;
|
|
37981
|
+
}
|
|
37982
|
+
timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
|
|
37983
|
+
timer.unref?.();
|
|
37984
|
+
};
|
|
37985
|
+
return {
|
|
37986
|
+
dispose: () => {
|
|
37987
|
+
disposed = true;
|
|
37988
|
+
clear();
|
|
37989
|
+
},
|
|
37990
|
+
noteChunk: () => {
|
|
37991
|
+
lastChunkAt = now();
|
|
37992
|
+
schedule();
|
|
37993
|
+
},
|
|
37994
|
+
refresh: schedule,
|
|
37995
|
+
timedOut: () => didTimeout
|
|
37996
|
+
};
|
|
37997
|
+
}
|
|
37876
37998
|
function isExtraUsageRequiredMessage(value) {
|
|
37877
37999
|
let text;
|
|
37878
38000
|
if (typeof value === "string") text = value;
|
|
@@ -38639,6 +38761,7 @@ async function consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConf
|
|
|
38639
38761
|
for await (const message of sdkQuery) {
|
|
38640
38762
|
if (wasAborted()) break;
|
|
38641
38763
|
const queryCtx = ctx();
|
|
38764
|
+
activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
|
|
38642
38765
|
if (!queryCtx.turnOutput) continue;
|
|
38643
38766
|
if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
|
|
38644
38767
|
switch (message.type) {
|
|
@@ -38720,6 +38843,7 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38720
38843
|
const queryCtx = ctx();
|
|
38721
38844
|
queryCtx.currentPiStream = stream;
|
|
38722
38845
|
queryCtx.resetTurnState(model);
|
|
38846
|
+
activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
|
|
38723
38847
|
const allResults = extractAllToolResults2(context);
|
|
38724
38848
|
debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
|
|
38725
38849
|
const unmatchedResultIds = [];
|
|
@@ -38859,6 +38983,7 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38859
38983
|
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
|
|
38860
38984
|
);
|
|
38861
38985
|
let wasAborted = false;
|
|
38986
|
+
let streamIdleTimedOut = false;
|
|
38862
38987
|
const sdkQuery = jA$({ prompt, options: queryOptions });
|
|
38863
38988
|
ctx().activeQuery = sdkQuery;
|
|
38864
38989
|
const abortCtx = ctx();
|
|
@@ -38870,6 +38995,55 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38870
38995
|
} catch {
|
|
38871
38996
|
}
|
|
38872
38997
|
};
|
|
38998
|
+
const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
|
|
38999
|
+
const streamIdleWatchdog = streamIdleTimeoutMs > 0 ? createStreamIdleWatchdog({
|
|
39000
|
+
getState: () => ({
|
|
39001
|
+
activeQuery: abortCtx.activeQuery,
|
|
39002
|
+
currentPiStream: abortCtx.currentPiStream,
|
|
39003
|
+
turnOutput: abortCtx.turnOutput,
|
|
39004
|
+
turnSawStreamEvent: abortCtx.turnSawStreamEvent,
|
|
39005
|
+
turnStarted: abortCtx.turnStarted
|
|
39006
|
+
}),
|
|
39007
|
+
onTimeout: ({ idleMs, timeoutMs }) => {
|
|
39008
|
+
if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
|
|
39009
|
+
streamIdleTimedOut = true;
|
|
39010
|
+
abortCtx.deferredUserMessages = [];
|
|
39011
|
+
abortCtx.handledTerminalError = true;
|
|
39012
|
+
if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
39013
|
+
const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
|
|
39014
|
+
debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
|
|
39015
|
+
emitRateLimitEvent({
|
|
39016
|
+
idleMs,
|
|
39017
|
+
model: model.id,
|
|
39018
|
+
provider: PROVIDER_ID,
|
|
39019
|
+
rateLimitType: "stream_idle",
|
|
39020
|
+
reason: "Claude Code stream idle timeout",
|
|
39021
|
+
retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
|
|
39022
|
+
source: "claude-bridge",
|
|
39023
|
+
status: "rejected",
|
|
39024
|
+
timeoutMs
|
|
39025
|
+
});
|
|
39026
|
+
piUI?.notify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} \u2014 retrying via rate-limit backoff`, "warning");
|
|
39027
|
+
if (abortCtx.turnOutput) {
|
|
39028
|
+
abortCtx.turnOutput.stopReason = "error";
|
|
39029
|
+
abortCtx.turnOutput.errorMessage = errorMessage;
|
|
39030
|
+
Object.assign(abortCtx.turnOutput, {
|
|
39031
|
+
rateLimitType: "stream_idle",
|
|
39032
|
+
retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
|
|
39033
|
+
streamIdleTimeoutMs: timeoutMs
|
|
39034
|
+
});
|
|
39035
|
+
}
|
|
39036
|
+
abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput });
|
|
39037
|
+
abortCtx.currentPiStream?.end();
|
|
39038
|
+
abortCtx.currentPiStream = null;
|
|
39039
|
+
requestAbort();
|
|
39040
|
+
},
|
|
39041
|
+
timeoutMs: streamIdleTimeoutMs
|
|
39042
|
+
}) : null;
|
|
39043
|
+
if (streamIdleWatchdog) {
|
|
39044
|
+
activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
|
|
39045
|
+
streamIdleWatchdog.refresh();
|
|
39046
|
+
}
|
|
38873
39047
|
const onAbort = () => {
|
|
38874
39048
|
wasAborted = true;
|
|
38875
39049
|
abortCtx.deferredUserMessages = [];
|
|
@@ -38887,6 +39061,11 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38887
39061
|
}
|
|
38888
39062
|
consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted).then(async ({ capturedSessionId }) => {
|
|
38889
39063
|
debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
39064
|
+
if (streamIdleTimedOut) {
|
|
39065
|
+
abortCtx.deferredUserMessages = [];
|
|
39066
|
+
debug("provider: stream idle timeout already surfaced; skipping normal completion");
|
|
39067
|
+
return;
|
|
39068
|
+
}
|
|
38890
39069
|
if (wasAborted || options?.signal?.aborted) {
|
|
38891
39070
|
if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
38892
39071
|
ctx().deferredUserMessages = [];
|
|
@@ -38940,7 +39119,7 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38940
39119
|
finalizeCurrentStream(ctx().turnOutput?.stopReason);
|
|
38941
39120
|
}).catch((error51) => {
|
|
38942
39121
|
debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error51);
|
|
38943
|
-
const suppressDuplicateError = ctx().handledTerminalError;
|
|
39122
|
+
const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
|
|
38944
39123
|
const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error51) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
|
|
38945
39124
|
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
38946
39125
|
sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
@@ -38960,9 +39139,11 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38960
39139
|
ctx().currentPiStream?.end();
|
|
38961
39140
|
ctx().currentPiStream = null;
|
|
38962
39141
|
}).finally(() => {
|
|
39142
|
+
streamIdleWatchdog?.dispose();
|
|
39143
|
+
activeStreamIdleWatchdogs.delete(abortCtx);
|
|
38963
39144
|
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
38964
39145
|
if (ctx().activeQuery === sdkQuery) {
|
|
38965
|
-
reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
|
|
39146
|
+
reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
|
|
38966
39147
|
for (const pending of ctx().pendingToolCalls.values()) {
|
|
38967
39148
|
pending.resolve({ content: [{ type: "text", text: "Query ended" }] });
|
|
38968
39149
|
}
|
|
@@ -39097,10 +39278,15 @@ function index_default(pi) {
|
|
|
39097
39278
|
export {
|
|
39098
39279
|
ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD,
|
|
39099
39280
|
CLAUDE_BRIDGE_TOOL_ISOLATION,
|
|
39281
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
39100
39282
|
DISALLOWED_BUILTIN_TOOLS,
|
|
39283
|
+
STREAM_IDLE_BACKOFF_HINT_MS,
|
|
39284
|
+
STREAM_IDLE_TIMEOUT_ENV,
|
|
39101
39285
|
__testGetBridgeIntegrityState,
|
|
39102
39286
|
__testSetBridgeIntegrityState,
|
|
39287
|
+
buildStreamIdleTimeoutErrorMessage,
|
|
39103
39288
|
classifyClaudeExecutableBytes,
|
|
39289
|
+
createStreamIdleWatchdog,
|
|
39104
39290
|
index_default as default,
|
|
39105
39291
|
formatAllowedRateLimitWarning,
|
|
39106
39292
|
formatResetTimestamp,
|
|
@@ -39115,6 +39301,7 @@ export {
|
|
|
39115
39301
|
restoreSharedSessionFromPi,
|
|
39116
39302
|
shouldRestorePersistedBridgeEntry,
|
|
39117
39303
|
spawnClaudeCodeWithDiagnostics,
|
|
39304
|
+
streamIdleTimeoutMsFromEnv,
|
|
39118
39305
|
uniqueNonEmptyLines,
|
|
39119
39306
|
wrapClaudeSpawnErrorForSdk
|
|
39120
39307
|
};
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -532,6 +532,137 @@ let extraUsageHelperInFlight: Promise<string> | null = null;
|
|
|
532
532
|
|
|
533
533
|
const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
534
534
|
const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
|
|
535
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
|
|
536
|
+
export const STREAM_IDLE_BACKOFF_HINT_MS = 60_000;
|
|
537
|
+
export const STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
|
|
538
|
+
|
|
539
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
540
|
+
|
|
541
|
+
export interface StreamIdleWatchdogState {
|
|
542
|
+
activeQuery: unknown | null;
|
|
543
|
+
currentPiStream: AssistantMessageEventStream | null;
|
|
544
|
+
turnOutput: AssistantMessage | null;
|
|
545
|
+
turnSawStreamEvent: boolean;
|
|
546
|
+
turnStarted: boolean;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export interface StreamIdleTimeoutInfo {
|
|
550
|
+
idleMs: number;
|
|
551
|
+
timeoutMs: number;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export interface StreamIdleWatchdog {
|
|
555
|
+
dispose: () => void;
|
|
556
|
+
noteChunk: () => void;
|
|
557
|
+
refresh: () => void;
|
|
558
|
+
timedOut: () => boolean;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const activeStreamIdleWatchdogs = new WeakMap<QueryContext, StreamIdleWatchdog>();
|
|
562
|
+
|
|
563
|
+
function parseDurationLiteralMs(value: string, defaultUnit: "ms" | "s" = "s"): number | undefined {
|
|
564
|
+
const text = value.trim().toLowerCase();
|
|
565
|
+
if (!text) return undefined;
|
|
566
|
+
if (["off", "false", "disabled", "disable"].includes(text)) return 0;
|
|
567
|
+
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
|
|
568
|
+
if (!match) return undefined;
|
|
569
|
+
const amount = Number(match[1]);
|
|
570
|
+
if (!Number.isFinite(amount) || amount < 0) return undefined;
|
|
571
|
+
const unit = (match[2] ?? defaultUnit).toLowerCase();
|
|
572
|
+
const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit)
|
|
573
|
+
? 1
|
|
574
|
+
: ["s", "sec", "secs", "second", "seconds"].includes(unit)
|
|
575
|
+
? 1000
|
|
576
|
+
: ["m", "min", "mins", "minute", "minutes"].includes(unit)
|
|
577
|
+
? 60_000
|
|
578
|
+
: undefined;
|
|
579
|
+
if (multiplier === undefined) return undefined;
|
|
580
|
+
const ms = Math.round(amount * multiplier);
|
|
581
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function streamIdleTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
|
|
585
|
+
const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
|
|
586
|
+
if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
587
|
+
return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function formatDurationShort(ms: number): string {
|
|
591
|
+
if (ms < 180_000 && ms % 1000 === 0) return `${ms / 1000}s`;
|
|
592
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
|
593
|
+
if (ms % 1000 === 0) return `${ms / 1000}s`;
|
|
594
|
+
return `${ms}ms`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function buildStreamIdleTimeoutErrorMessage(timeoutMs: number): string {
|
|
598
|
+
return `Claude Code stream idle timeout after ${formatDurationShort(timeoutMs)} with no assistant/tool output; treating stalled stream as retryable 529 overloaded/rate limit condition. Retry after ${formatDurationShort(STREAM_IDLE_BACKOFF_HINT_MS)}.`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function createStreamIdleWatchdog({
|
|
602
|
+
clearTimer = (timer: TimerHandle) => clearTimeout(timer),
|
|
603
|
+
getState,
|
|
604
|
+
now = () => Date.now(),
|
|
605
|
+
onTimeout,
|
|
606
|
+
setTimer = (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
|
607
|
+
timeoutMs,
|
|
608
|
+
}: {
|
|
609
|
+
clearTimer?: (timer: TimerHandle) => void;
|
|
610
|
+
getState: () => StreamIdleWatchdogState;
|
|
611
|
+
now?: () => number;
|
|
612
|
+
onTimeout: (info: StreamIdleTimeoutInfo) => void;
|
|
613
|
+
setTimer?: (fn: () => void, delayMs: number) => TimerHandle;
|
|
614
|
+
timeoutMs: number;
|
|
615
|
+
}): StreamIdleWatchdog {
|
|
616
|
+
let disposed = false;
|
|
617
|
+
let lastChunkAt = now();
|
|
618
|
+
let timer: TimerHandle | null = null;
|
|
619
|
+
let didTimeout = false;
|
|
620
|
+
|
|
621
|
+
const clear = () => {
|
|
622
|
+
if (!timer) return;
|
|
623
|
+
try { clearTimer(timer); } catch { /* best effort */ }
|
|
624
|
+
timer = null;
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const shouldMonitor = (state: StreamIdleWatchdogState): boolean => Boolean(
|
|
628
|
+
timeoutMs > 0
|
|
629
|
+
&& state.activeQuery
|
|
630
|
+
&& state.currentPiStream
|
|
631
|
+
&& state.turnOutput
|
|
632
|
+
&& !state.turnStarted
|
|
633
|
+
&& !state.turnSawStreamEvent,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const schedule = () => {
|
|
637
|
+
clear();
|
|
638
|
+
if (disposed || didTimeout || timeoutMs <= 0) return;
|
|
639
|
+
const state = getState();
|
|
640
|
+
if (!shouldMonitor(state)) return;
|
|
641
|
+
const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
|
|
642
|
+
const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
|
|
643
|
+
const idleMs = Math.max(0, now() - idleStartedAt);
|
|
644
|
+
if (idleMs >= timeoutMs) {
|
|
645
|
+
didTimeout = true;
|
|
646
|
+
onTimeout({ idleMs, timeoutMs });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
|
|
650
|
+
(timer as { unref?: () => void }).unref?.();
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
dispose: () => {
|
|
655
|
+
disposed = true;
|
|
656
|
+
clear();
|
|
657
|
+
},
|
|
658
|
+
noteChunk: () => {
|
|
659
|
+
lastChunkAt = now();
|
|
660
|
+
schedule();
|
|
661
|
+
},
|
|
662
|
+
refresh: schedule,
|
|
663
|
+
timedOut: () => didTimeout,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
535
666
|
|
|
536
667
|
export function isExtraUsageRequiredMessage(value: unknown): boolean {
|
|
537
668
|
let text: string;
|
|
@@ -1532,6 +1663,7 @@ async function consumeQuery(
|
|
|
1532
1663
|
for await (const message of sdkQuery) {
|
|
1533
1664
|
if (wasAborted()) break;
|
|
1534
1665
|
const queryCtx = ctx();
|
|
1666
|
+
activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
|
|
1535
1667
|
if (!queryCtx.turnOutput) continue;
|
|
1536
1668
|
if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
|
|
1537
1669
|
|
|
@@ -1626,6 +1758,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1626
1758
|
const queryCtx = ctx();
|
|
1627
1759
|
queryCtx.currentPiStream = stream;
|
|
1628
1760
|
queryCtx.resetTurnState(model);
|
|
1761
|
+
activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
|
|
1629
1762
|
const allResults = extractAllToolResults(context);
|
|
1630
1763
|
debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
|
|
1631
1764
|
const unmatchedResultIds: string[] = [];
|
|
@@ -1822,6 +1955,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1822
1955
|
|
|
1823
1956
|
// 3. Start SDK query and claim it for this context
|
|
1824
1957
|
let wasAborted = false;
|
|
1958
|
+
let streamIdleTimedOut = false;
|
|
1825
1959
|
const sdkQuery = query({ prompt, options: queryOptions });
|
|
1826
1960
|
ctx().activeQuery = sdkQuery;
|
|
1827
1961
|
|
|
@@ -1834,6 +1968,57 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1834
1968
|
void sdkQuery.interrupt().catch(() => {});
|
|
1835
1969
|
try { sdkQuery.close(); } catch {}
|
|
1836
1970
|
};
|
|
1971
|
+
const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
|
|
1972
|
+
const streamIdleWatchdog = streamIdleTimeoutMs > 0
|
|
1973
|
+
? createStreamIdleWatchdog({
|
|
1974
|
+
getState: () => ({
|
|
1975
|
+
activeQuery: abortCtx.activeQuery,
|
|
1976
|
+
currentPiStream: abortCtx.currentPiStream,
|
|
1977
|
+
turnOutput: abortCtx.turnOutput,
|
|
1978
|
+
turnSawStreamEvent: abortCtx.turnSawStreamEvent,
|
|
1979
|
+
turnStarted: abortCtx.turnStarted,
|
|
1980
|
+
}),
|
|
1981
|
+
onTimeout: ({ idleMs, timeoutMs }) => {
|
|
1982
|
+
if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
|
|
1983
|
+
streamIdleTimedOut = true;
|
|
1984
|
+
abortCtx.deferredUserMessages = [];
|
|
1985
|
+
abortCtx.handledTerminalError = true;
|
|
1986
|
+
if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
1987
|
+
const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
|
|
1988
|
+
debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
|
|
1989
|
+
emitRateLimitEvent({
|
|
1990
|
+
idleMs,
|
|
1991
|
+
model: model.id,
|
|
1992
|
+
provider: PROVIDER_ID,
|
|
1993
|
+
rateLimitType: "stream_idle",
|
|
1994
|
+
reason: "Claude Code stream idle timeout",
|
|
1995
|
+
retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
|
|
1996
|
+
source: "claude-bridge",
|
|
1997
|
+
status: "rejected",
|
|
1998
|
+
timeoutMs,
|
|
1999
|
+
});
|
|
2000
|
+
piUI?.notify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} — retrying via rate-limit backoff`, "warning");
|
|
2001
|
+
if (abortCtx.turnOutput) {
|
|
2002
|
+
abortCtx.turnOutput.stopReason = "error";
|
|
2003
|
+
abortCtx.turnOutput.errorMessage = errorMessage;
|
|
2004
|
+
Object.assign(abortCtx.turnOutput as AssistantMessage & Record<string, unknown>, {
|
|
2005
|
+
rateLimitType: "stream_idle",
|
|
2006
|
+
retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
|
|
2007
|
+
streamIdleTimeoutMs: timeoutMs,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
|
|
2011
|
+
abortCtx.currentPiStream?.end();
|
|
2012
|
+
abortCtx.currentPiStream = null;
|
|
2013
|
+
requestAbort();
|
|
2014
|
+
},
|
|
2015
|
+
timeoutMs: streamIdleTimeoutMs,
|
|
2016
|
+
})
|
|
2017
|
+
: null;
|
|
2018
|
+
if (streamIdleWatchdog) {
|
|
2019
|
+
activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
|
|
2020
|
+
streamIdleWatchdog.refresh();
|
|
2021
|
+
}
|
|
1837
2022
|
const onAbort = () => {
|
|
1838
2023
|
wasAborted = true;
|
|
1839
2024
|
// Prevent stale deferred messages from being replayed by parent on pop
|
|
@@ -1853,6 +2038,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1853
2038
|
consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
|
|
1854
2039
|
.then(async ({ capturedSessionId }) => {
|
|
1855
2040
|
debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
|
|
2041
|
+
if (streamIdleTimedOut) {
|
|
2042
|
+
abortCtx.deferredUserMessages = [];
|
|
2043
|
+
debug("provider: stream idle timeout already surfaced; skipping normal completion");
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
1856
2046
|
|
|
1857
2047
|
// --- Abort detection in normal completion path ---
|
|
1858
2048
|
if (wasAborted || options?.signal?.aborted) {
|
|
@@ -1921,7 +2111,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1921
2111
|
})
|
|
1922
2112
|
.catch((error) => {
|
|
1923
2113
|
debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
|
|
1924
|
-
const suppressDuplicateError = ctx().handledTerminalError;
|
|
2114
|
+
const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
|
|
1925
2115
|
const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
|
|
1926
2116
|
if ((wasAborted || options?.signal?.aborted) && sharedSession) {
|
|
1927
2117
|
sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
|
|
@@ -1942,9 +2132,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1942
2132
|
ctx().currentPiStream = null;
|
|
1943
2133
|
})
|
|
1944
2134
|
.finally(() => {
|
|
2135
|
+
streamIdleWatchdog?.dispose();
|
|
2136
|
+
activeStreamIdleWatchdogs.delete(abortCtx);
|
|
1945
2137
|
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
|
1946
2138
|
if (ctx().activeQuery === sdkQuery) {
|
|
1947
|
-
reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
|
|
2139
|
+
reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
|
|
1948
2140
|
// Drain pending handlers for this query
|
|
1949
2141
|
for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
|
|
1950
2142
|
ctx().pendingToolCalls.clear();
|
package/src/session-verify.ts
CHANGED
|
@@ -2,7 +2,54 @@
|
|
|
2
2
|
// callers decide how to surface them (debug log, piUI, diagDump, etc.).
|
|
3
3
|
// Extracted from index.ts so tests can import without activating the extension.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { closeSync, openSync, readSync, statSync } from "fs";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
|
|
8
|
+
interface JsonlSummary {
|
|
9
|
+
count: number;
|
|
10
|
+
firstLine?: string;
|
|
11
|
+
lastLine?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function forEachJsonlLine(path: string, onLine: (line: string) => void): void {
|
|
15
|
+
const fd = openSync(path, "r");
|
|
16
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
17
|
+
const decoder = new StringDecoder("utf8");
|
|
18
|
+
let pending = "";
|
|
19
|
+
try {
|
|
20
|
+
for (;;) {
|
|
21
|
+
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
|
|
22
|
+
if (bytesRead === 0) break;
|
|
23
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
24
|
+
let start = 0;
|
|
25
|
+
for (;;) {
|
|
26
|
+
const newline = pending.indexOf("\n", start);
|
|
27
|
+
if (newline < 0) {
|
|
28
|
+
pending = pending.slice(start);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
const line = pending.slice(start, newline);
|
|
32
|
+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
33
|
+
start = newline + 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
pending += decoder.end();
|
|
37
|
+
if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
|
|
38
|
+
} finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function summarizeJsonl(path: string): JsonlSummary {
|
|
44
|
+
const summary: JsonlSummary = { count: 0 };
|
|
45
|
+
forEachJsonlLine(path, (line) => {
|
|
46
|
+
if (!line.trim()) return;
|
|
47
|
+
summary.count += 1;
|
|
48
|
+
if (summary.firstLine === undefined) summary.firstLine = line;
|
|
49
|
+
summary.lastLine = line;
|
|
50
|
+
});
|
|
51
|
+
return summary;
|
|
52
|
+
}
|
|
6
53
|
|
|
7
54
|
export function verifyWrittenSession(jsonlPath: string, expectedSessionId: string, expectedRecordCount: number): string[] {
|
|
8
55
|
const warnings = [];
|
|
@@ -13,21 +60,20 @@ export function verifyWrittenSession(jsonlPath: string, expectedSessionId: strin
|
|
|
13
60
|
warnings.push(`file missing after save — path=${jsonlPath} err=${e.message}`);
|
|
14
61
|
return warnings;
|
|
15
62
|
}
|
|
16
|
-
let
|
|
63
|
+
let summary;
|
|
17
64
|
try {
|
|
18
|
-
|
|
65
|
+
summary = summarizeJsonl(jsonlPath);
|
|
19
66
|
} catch (e) {
|
|
20
67
|
warnings.push(`file unreadable — path=${jsonlPath} size=${st.size} err=${e.message}`);
|
|
21
68
|
return warnings;
|
|
22
69
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
|
|
70
|
+
if (summary.count !== expectedRecordCount) {
|
|
71
|
+
warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
|
|
26
72
|
return warnings;
|
|
27
73
|
}
|
|
28
74
|
try {
|
|
29
|
-
const firstRec = JSON.parse(
|
|
30
|
-
const lastRec = JSON.parse(
|
|
75
|
+
const firstRec = JSON.parse(summary.firstLine ?? "");
|
|
76
|
+
const lastRec = JSON.parse(summary.lastLine ?? "");
|
|
31
77
|
if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
|
|
32
78
|
warnings.push(`sessionId drift — expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
|
|
33
79
|
}
|