@vanillagreen/pi-claude-bridge 1.4.0 → 1.5.0
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 +8 -2
- package/bundle/index.js +144 -22
- package/package.json +1 -1
- package/src/config.ts +39 -2
- package/src/index.ts +28 -3
- package/src/models.ts +49 -3
- package/src/session-verify.ts +54 -8
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Forked from [`elidickinson/pi-claude-bridge`](https://github.com/elidickinson/pi
|
|
|
9
9
|
|
|
10
10
|
## Highlights
|
|
11
11
|
|
|
12
|
-
- `claude-bridge/claude-
|
|
12
|
+
- `claude-bridge/claude-fable-5`, Opus 4.8, Opus 4.7, Sonnet, and Haiku in `/model`.
|
|
13
13
|
- Pi tool calls run on Pi; Claude Code handles reasoning.
|
|
14
14
|
- Tool-use turns block until Pi-delivered tool results reach Claude Code, including persistent subagent panes.
|
|
15
15
|
- Session continuity across normal turns, `/compact`, tree navigation, and abort recovery.
|
|
@@ -45,6 +45,8 @@ Extra Pi context is off by default. Enable per item in the extension manager whe
|
|
|
45
45
|
|
|
46
46
|
Open `/extensions:settings`; settings appear under the **Claude Bridge** tab.
|
|
47
47
|
|
|
48
|
+
Project settings in `.pi/settings.json` apply only after Pi marks the workspace trusted; before trust, vstack Pi extensions read user/global settings only.
|
|
49
|
+
|
|
48
50
|
### General
|
|
49
51
|
|
|
50
52
|
| Setting | What it does |
|
|
@@ -90,6 +92,10 @@ Pi does not have a native `max` thinking level; it exposes up to `xhigh`, and ea
|
|
|
90
92
|
|
|
91
93
|
Keys may be bare model IDs (`claude-opus-4-8`), `claude-bridge/<id>`, or `*` for all bridge models. Values are `low`, `medium`, `high`, `xhigh`, or `max`.
|
|
92
94
|
|
|
95
|
+
### Fable 5 caveat
|
|
96
|
+
|
|
97
|
+
The bridge registers `claude-bridge/claude-fable-5` and `claude-bridge/claude-opus-4-8` even when Pi's Anthropic model registry has not shipped those entries yet. For Fable 5, the bridge asks Claude Code to use Opus 4.8 as the availability fallback and preserves Claude Code's content-safety fallback events so Pi labels rerouted turns as Opus 4.8. Content-safety fallback still depends on Claude Code's own Fable 5 support; use Claude Code 2.1.170 or newer, and set `ANTHROPIC_DEFAULT_FABLE_MODEL` / `ANTHROPIC_DEFAULT_OPUS_MODEL` yourself when routing provider-specific model IDs through Bedrock, Vertex, or Foundry.
|
|
98
|
+
|
|
93
99
|
## Extra usage and rate limits
|
|
94
100
|
|
|
95
101
|
Claude Code's `/extra-usage` local command works through the Claude Agent SDK. In Pi, use `/claude-bridge:extra` to run that flow from claude-bridge. Persist automatic launch on extra-usage errors with **Allow extra usage helper** in `/extensions:settings`.
|
|
@@ -98,7 +104,7 @@ When Claude Code emits rate-limit reset metadata, the bridge shows one red ASCII
|
|
|
98
104
|
|
|
99
105
|
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
106
|
|
|
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
|
|
107
|
+
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 `pi-agents-tmux` reuse its 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
108
|
|
|
103
109
|
## Debugging
|
|
104
110
|
|
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";
|
|
@@ -22237,9 +22237,41 @@ function convertPiMessages(messages, customToolNameToSdk) {
|
|
|
22237
22237
|
}
|
|
22238
22238
|
|
|
22239
22239
|
// src/models.ts
|
|
22240
|
-
var
|
|
22240
|
+
var FABLE_MODEL_ID = "claude-fable-5";
|
|
22241
|
+
var FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
|
|
22242
|
+
function fallbackModelForPrimaryModel(modelId) {
|
|
22243
|
+
return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : void 0;
|
|
22244
|
+
}
|
|
22245
|
+
var MODEL_IDS_IN_ORDER = [
|
|
22246
|
+
FABLE_MODEL_ID,
|
|
22247
|
+
FABLE_FALLBACK_MODEL_ID,
|
|
22248
|
+
"claude-opus-4-7",
|
|
22249
|
+
"claude-opus-4-6",
|
|
22250
|
+
"claude-sonnet-4-6",
|
|
22251
|
+
"claude-haiku-4-5"
|
|
22252
|
+
];
|
|
22253
|
+
var FALLBACK_MODELS = {
|
|
22254
|
+
[FABLE_MODEL_ID]: {
|
|
22255
|
+
id: FABLE_MODEL_ID,
|
|
22256
|
+
name: "Claude Fable 5",
|
|
22257
|
+
reasoning: true,
|
|
22258
|
+
thinkingLevelMap: { xhigh: "xhigh" },
|
|
22259
|
+
input: ["text", "image"],
|
|
22260
|
+
contextWindow: 1e6,
|
|
22261
|
+
maxTokens: 128e3
|
|
22262
|
+
},
|
|
22263
|
+
[FABLE_FALLBACK_MODEL_ID]: {
|
|
22264
|
+
id: FABLE_FALLBACK_MODEL_ID,
|
|
22265
|
+
name: "Claude Opus 4.8",
|
|
22266
|
+
reasoning: true,
|
|
22267
|
+
thinkingLevelMap: { xhigh: "xhigh" },
|
|
22268
|
+
input: ["text", "image"],
|
|
22269
|
+
contextWindow: 1e6,
|
|
22270
|
+
maxTokens: 128e3
|
|
22271
|
+
}
|
|
22272
|
+
};
|
|
22241
22273
|
function buildModels(piAiModels) {
|
|
22242
|
-
return MODEL_IDS_IN_ORDER.map((id) => piAiModels.find((m4) => m4.id === id)).filter((m4) => m4 != null).map(({ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap }) => ({
|
|
22274
|
+
return MODEL_IDS_IN_ORDER.map((id) => piAiModels.find((m4) => m4.id === id) ?? FALLBACK_MODELS[id]).filter((m4) => m4 != null).map(({ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap }) => ({
|
|
22243
22275
|
id,
|
|
22244
22276
|
name,
|
|
22245
22277
|
reasoning,
|
|
@@ -22272,7 +22304,46 @@ function rewriteSkillsBlock(skillsBlock) {
|
|
|
22272
22304
|
}
|
|
22273
22305
|
|
|
22274
22306
|
// src/session-verify.ts
|
|
22275
|
-
import {
|
|
22307
|
+
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
|
|
22308
|
+
import { StringDecoder } from "node:string_decoder";
|
|
22309
|
+
function forEachJsonlLine(path, onLine) {
|
|
22310
|
+
const fd = openSync2(path, "r");
|
|
22311
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
22312
|
+
const decoder = new StringDecoder("utf8");
|
|
22313
|
+
let pending = "";
|
|
22314
|
+
try {
|
|
22315
|
+
for (; ; ) {
|
|
22316
|
+
const bytesRead = readSync2(fd, buffer, 0, buffer.length, null);
|
|
22317
|
+
if (bytesRead === 0) break;
|
|
22318
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
22319
|
+
let start = 0;
|
|
22320
|
+
for (; ; ) {
|
|
22321
|
+
const newline = pending.indexOf("\n", start);
|
|
22322
|
+
if (newline < 0) {
|
|
22323
|
+
pending = pending.slice(start);
|
|
22324
|
+
break;
|
|
22325
|
+
}
|
|
22326
|
+
const line = pending.slice(start, newline);
|
|
22327
|
+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
22328
|
+
start = newline + 1;
|
|
22329
|
+
}
|
|
22330
|
+
}
|
|
22331
|
+
pending += decoder.end();
|
|
22332
|
+
if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
|
|
22333
|
+
} finally {
|
|
22334
|
+
closeSync2(fd);
|
|
22335
|
+
}
|
|
22336
|
+
}
|
|
22337
|
+
function summarizeJsonl(path) {
|
|
22338
|
+
const summary = { count: 0 };
|
|
22339
|
+
forEachJsonlLine(path, (line) => {
|
|
22340
|
+
if (!line.trim()) return;
|
|
22341
|
+
summary.count += 1;
|
|
22342
|
+
if (summary.firstLine === void 0) summary.firstLine = line;
|
|
22343
|
+
summary.lastLine = line;
|
|
22344
|
+
});
|
|
22345
|
+
return summary;
|
|
22346
|
+
}
|
|
22276
22347
|
function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount) {
|
|
22277
22348
|
const warnings = [];
|
|
22278
22349
|
let st;
|
|
@@ -22282,21 +22353,20 @@ function verifyWrittenSession(jsonlPath, expectedSessionId, expectedRecordCount)
|
|
|
22282
22353
|
warnings.push(`file missing after save \u2014 path=${jsonlPath} err=${e2.message}`);
|
|
22283
22354
|
return warnings;
|
|
22284
22355
|
}
|
|
22285
|
-
let
|
|
22356
|
+
let summary;
|
|
22286
22357
|
try {
|
|
22287
|
-
|
|
22358
|
+
summary = summarizeJsonl(jsonlPath);
|
|
22288
22359
|
} catch (e2) {
|
|
22289
22360
|
warnings.push(`file unreadable \u2014 path=${jsonlPath} size=${st.size} err=${e2.message}`);
|
|
22290
22361
|
return warnings;
|
|
22291
22362
|
}
|
|
22292
|
-
|
|
22293
|
-
|
|
22294
|
-
warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
|
|
22363
|
+
if (summary.count !== expectedRecordCount) {
|
|
22364
|
+
warnings.push(`record count mismatch \u2014 expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
|
|
22295
22365
|
return warnings;
|
|
22296
22366
|
}
|
|
22297
22367
|
try {
|
|
22298
|
-
const firstRec = JSON.parse(
|
|
22299
|
-
const lastRec = JSON.parse(
|
|
22368
|
+
const firstRec = JSON.parse(summary.firstLine ?? "");
|
|
22369
|
+
const lastRec = JSON.parse(summary.lastLine ?? "");
|
|
22300
22370
|
if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
|
|
22301
22371
|
warnings.push(`sessionId drift \u2014 expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
|
|
22302
22372
|
}
|
|
@@ -22564,7 +22634,7 @@ function summarizeMissingToolNames(missing) {
|
|
|
22564
22634
|
}
|
|
22565
22635
|
|
|
22566
22636
|
// src/config.ts
|
|
22567
|
-
import { existsSync as existsSync3, readFileSync as
|
|
22637
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
22568
22638
|
import { homedir as homedir2 } from "os";
|
|
22569
22639
|
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
22570
22640
|
var PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
|
|
@@ -22600,13 +22670,39 @@ function projectSettingsPath(cwd) {
|
|
|
22600
22670
|
current = parent;
|
|
22601
22671
|
}
|
|
22602
22672
|
}
|
|
22673
|
+
var PROJECT_TRUST_SYMBOL = /* @__PURE__ */ Symbol.for("vstack.pi.project-trust");
|
|
22674
|
+
function projectTrustRegistry() {
|
|
22675
|
+
const host = globalThis;
|
|
22676
|
+
const existing = host[PROJECT_TRUST_SYMBOL];
|
|
22677
|
+
if (existing) return existing;
|
|
22678
|
+
const created = {};
|
|
22679
|
+
host[PROJECT_TRUST_SYMBOL] = created;
|
|
22680
|
+
return created;
|
|
22681
|
+
}
|
|
22682
|
+
function recordProjectTrust(ctx2) {
|
|
22683
|
+
if (!ctx2.cwd) return;
|
|
22684
|
+
let trusted = true;
|
|
22685
|
+
try {
|
|
22686
|
+
trusted = ctx2.isProjectTrusted?.() === true;
|
|
22687
|
+
} catch {
|
|
22688
|
+
trusted = false;
|
|
22689
|
+
}
|
|
22690
|
+
const registry2 = projectTrustRegistry();
|
|
22691
|
+
if (!registry2.projectSettings) registry2.projectSettings = /* @__PURE__ */ new Map();
|
|
22692
|
+
registry2.projectSettings.set(projectSettingsPath(ctx2.cwd), trusted);
|
|
22693
|
+
}
|
|
22694
|
+
function projectSettingsTrusted(settingsPath) {
|
|
22695
|
+
return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
|
|
22696
|
+
}
|
|
22603
22697
|
function settingsPaths(cwd) {
|
|
22604
|
-
|
|
22698
|
+
const user = join2(piUserDir(), "settings.json");
|
|
22699
|
+
const project = projectSettingsPath(cwd);
|
|
22700
|
+
return projectSettingsTrusted(project) ? [user, project] : [user];
|
|
22605
22701
|
}
|
|
22606
22702
|
function tryParseJson(path) {
|
|
22607
22703
|
if (!existsSync3(path)) return {};
|
|
22608
22704
|
try {
|
|
22609
|
-
return JSON.parse(
|
|
22705
|
+
return JSON.parse(readFileSync3(path, "utf-8"));
|
|
22610
22706
|
} catch {
|
|
22611
22707
|
return {};
|
|
22612
22708
|
}
|
|
@@ -22616,7 +22712,7 @@ function readManagerConfig(cwd) {
|
|
|
22616
22712
|
for (const path of settingsPaths(cwd)) {
|
|
22617
22713
|
if (!existsSync3(path)) continue;
|
|
22618
22714
|
try {
|
|
22619
|
-
const parsed = JSON.parse(
|
|
22715
|
+
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
22620
22716
|
const configRoot = asRecord(asRecord(asRecord(parsed?.vstack)?.extensionManager)?.config);
|
|
22621
22717
|
const config2 = asRecord(configRoot?.[PACKAGE_ID]);
|
|
22622
22718
|
if (config2) mergeDeep(merged, config2);
|
|
@@ -22709,7 +22805,9 @@ function managerToConfig(raw) {
|
|
|
22709
22805
|
}
|
|
22710
22806
|
function loadConfig(cwd) {
|
|
22711
22807
|
const global2 = tryParseJson(join2(piUserDir(), "claude-bridge.json"));
|
|
22712
|
-
const
|
|
22808
|
+
const projectSettings = projectSettingsPath(cwd);
|
|
22809
|
+
const trustedProject = projectSettingsTrusted(projectSettings);
|
|
22810
|
+
const project = trustedProject ? tryParseJson(join2(dirname2(projectSettings), "claude-bridge.json")) : {};
|
|
22713
22811
|
const manager = managerToConfig(readManagerConfig(cwd));
|
|
22714
22812
|
const provider = normalizeProviderConfig({ ...global2.provider, ...project.provider, ...manager.provider });
|
|
22715
22813
|
return {
|
|
@@ -22720,7 +22818,7 @@ function loadConfig(cwd) {
|
|
|
22720
22818
|
}
|
|
22721
22819
|
|
|
22722
22820
|
// src/agents-md.ts
|
|
22723
|
-
import { existsSync as existsSync4, readFileSync as
|
|
22821
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
22724
22822
|
import { homedir as homedir3 } from "os";
|
|
22725
22823
|
import { dirname as dirname3, join as join3, resolve as resolve2 } from "path";
|
|
22726
22824
|
var GLOBAL_AGENTS_PATH = join3(homedir3(), ".pi", "agent", "AGENTS.md");
|
|
@@ -22745,7 +22843,7 @@ function extractAgentsAppend() {
|
|
|
22745
22843
|
const agentsPath = resolveAgentsMdPath();
|
|
22746
22844
|
if (!agentsPath) return void 0;
|
|
22747
22845
|
try {
|
|
22748
|
-
const content =
|
|
22846
|
+
const content = readFileSync4(agentsPath, "utf-8").trim();
|
|
22749
22847
|
if (!content) return void 0;
|
|
22750
22848
|
const sanitized = sanitizeAgentsContent(content);
|
|
22751
22849
|
return sanitized.length > 0 ? `# CLAUDE.md
|
|
@@ -22765,7 +22863,7 @@ function sanitizeAgentsContent(content) {
|
|
|
22765
22863
|
}
|
|
22766
22864
|
|
|
22767
22865
|
// src/prompt-context.ts
|
|
22768
|
-
import { existsSync as existsSync5, readFileSync as
|
|
22866
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
22769
22867
|
import { homedir as homedir4 } from "os";
|
|
22770
22868
|
import { dirname as dirname4, join as join4, resolve as resolve3 } from "path";
|
|
22771
22869
|
function piUserDir2() {
|
|
@@ -22776,7 +22874,7 @@ function piUserDir2() {
|
|
|
22776
22874
|
function readTrimmed(path) {
|
|
22777
22875
|
try {
|
|
22778
22876
|
if (!existsSync5(path)) return void 0;
|
|
22779
|
-
const content =
|
|
22877
|
+
const content = readFileSync5(path, "utf8").trim();
|
|
22780
22878
|
return content.length > 0 ? content : void 0;
|
|
22781
22879
|
} catch {
|
|
22782
22880
|
return void 0;
|
|
@@ -37607,7 +37705,7 @@ function preflightClaudeExecutable(path, cwd) {
|
|
|
37607
37705
|
}
|
|
37608
37706
|
let fileType;
|
|
37609
37707
|
try {
|
|
37610
|
-
fileType = classifyClaudeExecutableBytes(
|
|
37708
|
+
fileType = classifyClaudeExecutableBytes(readFileSync6(realPath).subarray(0, 16));
|
|
37611
37709
|
} catch (err) {
|
|
37612
37710
|
throw makeClaudePreflightError("Claude Code executable preflight failed: cannot read executable header before spawning Claude Code.", {
|
|
37613
37711
|
code: codeValue(err, "EACCES"),
|
|
@@ -38511,6 +38609,13 @@ function finalizeCurrentStream(stopReason) {
|
|
|
38511
38609
|
ctx().currentPiStream.end();
|
|
38512
38610
|
ctx().currentPiStream = null;
|
|
38513
38611
|
}
|
|
38612
|
+
function updateTurnOutputModel(modelId) {
|
|
38613
|
+
const c2 = ctx();
|
|
38614
|
+
if (typeof modelId !== "string" || !modelId || !c2.turnOutput) return;
|
|
38615
|
+
if (c2.turnOutput.model === modelId) return;
|
|
38616
|
+
debug(`provider: active Claude model changed ${c2.turnOutput.model} -> ${modelId}`);
|
|
38617
|
+
c2.turnOutput.model = modelId;
|
|
38618
|
+
}
|
|
38514
38619
|
function processStreamEvent(message, customToolNameToPi, model) {
|
|
38515
38620
|
const c2 = ctx();
|
|
38516
38621
|
if (!c2.currentPiStream || !c2.turnOutput) return;
|
|
@@ -38522,6 +38627,7 @@ function processStreamEvent(message, customToolNameToPi, model) {
|
|
|
38522
38627
|
}
|
|
38523
38628
|
if (event?.type === "message_start") {
|
|
38524
38629
|
c2.resetToolTracking();
|
|
38630
|
+
updateTurnOutputModel(event.message?.model);
|
|
38525
38631
|
if (event.message?.usage) updateUsage(c2.turnOutput, event.message.usage, model);
|
|
38526
38632
|
return;
|
|
38527
38633
|
}
|
|
@@ -38660,6 +38766,7 @@ function processAssistantMessage(message, model, customToolNameToPi) {
|
|
|
38660
38766
|
const c2 = ctx();
|
|
38661
38767
|
const assistantMsg = message.message;
|
|
38662
38768
|
if (!assistantMsg?.content) return;
|
|
38769
|
+
updateTurnOutputModel(assistantMsg.model);
|
|
38663
38770
|
if (c2.turnSawStreamEvent) {
|
|
38664
38771
|
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
|
|
38665
38772
|
c2.turnSawToolCall = true;
|
|
@@ -38706,6 +38813,8 @@ function processAssistantMessage(message, model, customToolNameToPi) {
|
|
|
38706
38813
|
const toolBlock = c2.turnBlocks[idx];
|
|
38707
38814
|
c2.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c2.turnOutput });
|
|
38708
38815
|
c2.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock, partial: c2.turnOutput });
|
|
38816
|
+
} else if (block.type === "fallback") {
|
|
38817
|
+
updateTurnOutputModel(block.to?.model);
|
|
38709
38818
|
} else {
|
|
38710
38819
|
debug("processAssistantMessage: unhandled block type", block.type);
|
|
38711
38820
|
}
|
|
@@ -38757,6 +38866,14 @@ async function consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConf
|
|
|
38757
38866
|
case "system":
|
|
38758
38867
|
if (message.subtype === "init" && message.session_id) {
|
|
38759
38868
|
capturedSessionId = message.session_id;
|
|
38869
|
+
} else if (message.subtype === "model_refusal_fallback") {
|
|
38870
|
+
const originalModel = message.original_model;
|
|
38871
|
+
const fallbackModel = message.fallback_model;
|
|
38872
|
+
updateTurnOutputModel(fallbackModel);
|
|
38873
|
+
debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
|
|
38874
|
+
if (originalModel === FABLE_MODEL_ID && fallbackModel === FABLE_FALLBACK_MODEL_ID) {
|
|
38875
|
+
safeNotify("Claude bridge switched Fable 5 to Opus 4.8 after Claude Code safety fallback.", "info");
|
|
38876
|
+
}
|
|
38760
38877
|
}
|
|
38761
38878
|
break;
|
|
38762
38879
|
case "user":
|
|
@@ -38911,16 +39028,19 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38911
39028
|
const { sessionId: resumeSessionId } = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
|
|
38912
39029
|
const requestedEffort = options?.reasoning ? model.thinkingLevelMap?.[options.reasoning] ?? REASONING_TO_EFFORT[options.reasoning] : void 0;
|
|
38913
39030
|
const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
|
|
38914
|
-
const extraArgs = {
|
|
39031
|
+
const extraArgs = {};
|
|
38915
39032
|
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
38916
39033
|
if (effort) extraArgs["thinking-display"] = "summarized";
|
|
39034
|
+
const fallbackModel = fallbackModelForPrimaryModel(model.id);
|
|
38917
39035
|
const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
|
|
38918
39036
|
const queryOptions = {
|
|
38919
39037
|
cwd,
|
|
39038
|
+
model: model.id,
|
|
38920
39039
|
env: childEnv,
|
|
38921
39040
|
...CLAUDE_BRIDGE_TOOL_ISOLATION,
|
|
38922
39041
|
permissionMode: "bypassPermissions",
|
|
38923
39042
|
includePartialMessages: true,
|
|
39043
|
+
...fallbackModel ? { fallbackModel } : {},
|
|
38924
39044
|
...providerSettings.fastMode ? { settings: { fastMode: true } } : {},
|
|
38925
39045
|
systemPrompt: {
|
|
38926
39046
|
type: "preset",
|
|
@@ -38940,6 +39060,7 @@ function streamClaudeAgentSdk(model, context, options) {
|
|
|
38940
39060
|
"provider: fresh query",
|
|
38941
39061
|
`model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
|
|
38942
39062
|
`resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
|
|
39063
|
+
`fallback=${fallbackModel ?? "none"}`,
|
|
38943
39064
|
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
|
|
38944
39065
|
`claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
|
|
38945
39066
|
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`
|
|
@@ -39200,6 +39321,7 @@ function index_default(pi) {
|
|
|
39200
39321
|
}
|
|
39201
39322
|
};
|
|
39202
39323
|
pi.on("session_start", (event, ctx2) => {
|
|
39324
|
+
recordProjectTrust(ctx2);
|
|
39203
39325
|
piUI = ctx2.ui;
|
|
39204
39326
|
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
|
39205
39327
|
clearSession(`session_start:${event.reason}`);
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -76,8 +76,43 @@ function projectSettingsPath(cwd: string): string {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const PROJECT_TRUST_SYMBOL = Symbol.for("vstack.pi.project-trust");
|
|
80
|
+
|
|
81
|
+
interface ProjectTrustRegistry {
|
|
82
|
+
projectSettings?: Map<string, boolean>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function projectTrustRegistry(): ProjectTrustRegistry {
|
|
86
|
+
const host = globalThis as unknown as Record<PropertyKey, ProjectTrustRegistry | undefined>;
|
|
87
|
+
const existing = host[PROJECT_TRUST_SYMBOL];
|
|
88
|
+
if (existing) return existing;
|
|
89
|
+
const created: ProjectTrustRegistry = {};
|
|
90
|
+
host[PROJECT_TRUST_SYMBOL] = created;
|
|
91
|
+
return created;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
|
|
95
|
+
if (!ctx.cwd) return;
|
|
96
|
+
let trusted = true;
|
|
97
|
+
try {
|
|
98
|
+
trusted = ctx.isProjectTrusted?.() === true;
|
|
99
|
+
} catch {
|
|
100
|
+
trusted = false;
|
|
101
|
+
}
|
|
102
|
+
const registry = projectTrustRegistry();
|
|
103
|
+
if (!registry.projectSettings) registry.projectSettings = new Map();
|
|
104
|
+
registry.projectSettings.set(projectSettingsPath(ctx.cwd), trusted);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function projectSettingsTrusted(settingsPath: string): boolean {
|
|
108
|
+
return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
79
112
|
function settingsPaths(cwd: string): string[] {
|
|
80
|
-
|
|
113
|
+
const user = join(piUserDir(), "settings.json");
|
|
114
|
+
const project = projectSettingsPath(cwd);
|
|
115
|
+
return projectSettingsTrusted(project) ? [user, project] : [user];
|
|
81
116
|
}
|
|
82
117
|
|
|
83
118
|
export function tryParseJson(path: string): Partial<Config> {
|
|
@@ -202,7 +237,9 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
|
|
|
202
237
|
|
|
203
238
|
export function loadConfig(cwd: string): Config {
|
|
204
239
|
const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
|
|
205
|
-
const
|
|
240
|
+
const projectSettings = projectSettingsPath(cwd);
|
|
241
|
+
const trustedProject = projectSettingsTrusted(projectSettings);
|
|
242
|
+
const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
|
|
206
243
|
const manager = managerToConfig(readManagerConfig(cwd));
|
|
207
244
|
const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
|
|
208
245
|
return {
|
package/src/index.ts
CHANGED
|
@@ -11,13 +11,13 @@ import { resolve as pathResolve } from "path";
|
|
|
11
11
|
import { homedir } from "os";
|
|
12
12
|
import { delimiter, dirname, join } from "path";
|
|
13
13
|
import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
|
|
14
|
-
import { buildModels } from "./models.js";
|
|
14
|
+
import { FABLE_FALLBACK_MODEL_ID, FABLE_MODEL_ID, buildModels, fallbackModelForPrimaryModel } from "./models.js";
|
|
15
15
|
import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.js";
|
|
16
16
|
import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
|
|
17
17
|
import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
|
|
18
18
|
import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
|
|
19
19
|
import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
|
|
20
|
-
import { loadConfig, normalizeEffortLevel, type Config } from "./config.js";
|
|
20
|
+
import { loadConfig, normalizeEffortLevel, recordProjectTrust, type Config } from "./config.js";
|
|
21
21
|
import { extractAgentsAppend } from "./agents-md.js";
|
|
22
22
|
import { buildPromptContextAppend } from "./prompt-context.js";
|
|
23
23
|
import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
|
|
@@ -1401,6 +1401,14 @@ function finalizeCurrentStream(stopReason?: string): void {
|
|
|
1401
1401
|
ctx().currentPiStream = null;
|
|
1402
1402
|
}
|
|
1403
1403
|
|
|
1404
|
+
function updateTurnOutputModel(modelId: unknown): void {
|
|
1405
|
+
const c = ctx();
|
|
1406
|
+
if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
|
|
1407
|
+
if (c.turnOutput.model === modelId) return;
|
|
1408
|
+
debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
|
|
1409
|
+
c.turnOutput.model = modelId;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1404
1412
|
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
1405
1413
|
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
1406
1414
|
export function processStreamEvent(
|
|
@@ -1419,6 +1427,7 @@ export function processStreamEvent(
|
|
|
1419
1427
|
|
|
1420
1428
|
if (event?.type === "message_start") {
|
|
1421
1429
|
c.resetToolTracking();
|
|
1430
|
+
updateTurnOutputModel(event.message?.model);
|
|
1422
1431
|
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
|
|
1423
1432
|
return;
|
|
1424
1433
|
}
|
|
@@ -1579,6 +1588,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
1579
1588
|
const c = ctx();
|
|
1580
1589
|
const assistantMsg = (message as any).message;
|
|
1581
1590
|
if (!assistantMsg?.content) return;
|
|
1591
|
+
updateTurnOutputModel(assistantMsg.model);
|
|
1582
1592
|
if (c.turnSawStreamEvent) {
|
|
1583
1593
|
// Claude Agent SDK can yield the completed assistant message before (or
|
|
1584
1594
|
// instead of) a stream_event message_stop for a tool-use turn. Treat that
|
|
@@ -1630,6 +1640,8 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
1630
1640
|
const toolBlock = c.turnBlocks[idx];
|
|
1631
1641
|
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
1632
1642
|
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
1643
|
+
} else if (block.type === "fallback") {
|
|
1644
|
+
updateTurnOutputModel(block.to?.model);
|
|
1633
1645
|
} else {
|
|
1634
1646
|
debug("processAssistantMessage: unhandled block type", block.type);
|
|
1635
1647
|
}
|
|
@@ -1698,6 +1710,14 @@ async function consumeQuery(
|
|
|
1698
1710
|
case "system":
|
|
1699
1711
|
if ((message as any).subtype === "init" && (message as any).session_id) {
|
|
1700
1712
|
capturedSessionId = (message as any).session_id;
|
|
1713
|
+
} else if ((message as any).subtype === "model_refusal_fallback") {
|
|
1714
|
+
const originalModel = (message as any).original_model;
|
|
1715
|
+
const fallbackModel = (message as any).fallback_model;
|
|
1716
|
+
updateTurnOutputModel(fallbackModel);
|
|
1717
|
+
debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
|
|
1718
|
+
if (originalModel === FABLE_MODEL_ID && fallbackModel === FABLE_FALLBACK_MODEL_ID) {
|
|
1719
|
+
safeNotify("Claude bridge switched Fable 5 to Opus 4.8 after Claude Code safety fallback.", "info");
|
|
1720
|
+
}
|
|
1701
1721
|
}
|
|
1702
1722
|
break;
|
|
1703
1723
|
case "user":
|
|
@@ -1908,11 +1928,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1908
1928
|
: undefined;
|
|
1909
1929
|
const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
|
|
1910
1930
|
|
|
1911
|
-
const extraArgs: Record<string, string | null> = {
|
|
1931
|
+
const extraArgs: Record<string, string | null> = {};
|
|
1912
1932
|
if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
|
|
1913
1933
|
// Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
|
|
1914
1934
|
// Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
|
|
1915
1935
|
if (effort) extraArgs["thinking-display"] = "summarized";
|
|
1936
|
+
const fallbackModel = fallbackModelForPrimaryModel(model.id);
|
|
1916
1937
|
|
|
1917
1938
|
// Suppress claude.ai cloud MCP servers (Figma/Canva/etc. auto-discovered via OAuth
|
|
1918
1939
|
// when the user is logged into Anthropic). These are a separate code path from
|
|
@@ -1927,10 +1948,12 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1927
1948
|
const childEnv = { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" };
|
|
1928
1949
|
const queryOptions: NonNullable<Parameters<typeof query>[0]["options"]> = {
|
|
1929
1950
|
cwd,
|
|
1951
|
+
model: model.id,
|
|
1930
1952
|
env: childEnv,
|
|
1931
1953
|
...CLAUDE_BRIDGE_TOOL_ISOLATION,
|
|
1932
1954
|
permissionMode: "bypassPermissions",
|
|
1933
1955
|
includePartialMessages: true,
|
|
1956
|
+
...(fallbackModel ? { fallbackModel } : {}),
|
|
1934
1957
|
...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
|
|
1935
1958
|
systemPrompt: {
|
|
1936
1959
|
type: "preset", preset: "claude_code",
|
|
@@ -1949,6 +1972,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
|
|
|
1949
1972
|
debug("provider: fresh query",
|
|
1950
1973
|
`model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
|
|
1951
1974
|
`resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
|
|
1975
|
+
`fallback=${fallbackModel ?? "none"}`,
|
|
1952
1976
|
`appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
|
|
1953
1977
|
`claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
|
|
1954
1978
|
`prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
|
|
@@ -2248,6 +2272,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2248
2272
|
}
|
|
2249
2273
|
};
|
|
2250
2274
|
pi.on("session_start", (event, ctx) => {
|
|
2275
|
+
recordProjectTrust(ctx);
|
|
2251
2276
|
piUI = ctx.ui;
|
|
2252
2277
|
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
|
2253
2278
|
clearSession(`session_start:${event.reason}`);
|
package/src/models.ts
CHANGED
|
@@ -2,13 +2,59 @@
|
|
|
2
2
|
// `resolveModelId` returns the first partial match, so `opus` resolves to the first-listed opus entry.
|
|
3
3
|
// Extracted from index.ts so tests can import without activating the extension.
|
|
4
4
|
|
|
5
|
-
export const
|
|
5
|
+
export const FABLE_MODEL_ID = "claude-fable-5";
|
|
6
|
+
export const FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
|
|
7
|
+
|
|
8
|
+
export function fallbackModelForPrimaryModel(modelId: string): string | undefined {
|
|
9
|
+
return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const MODEL_IDS_IN_ORDER = [
|
|
13
|
+
FABLE_MODEL_ID,
|
|
14
|
+
FABLE_FALLBACK_MODEL_ID,
|
|
15
|
+
"claude-opus-4-7",
|
|
16
|
+
"claude-opus-4-6",
|
|
17
|
+
"claude-sonnet-4-6",
|
|
18
|
+
"claude-haiku-4-5",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
type BridgeModelMetadata = {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
reasoning: boolean;
|
|
25
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
26
|
+
input: ("text" | "image")[];
|
|
27
|
+
contextWindow: number;
|
|
28
|
+
maxTokens: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
|
|
32
|
+
[FABLE_MODEL_ID]: {
|
|
33
|
+
id: FABLE_MODEL_ID,
|
|
34
|
+
name: "Claude Fable 5",
|
|
35
|
+
reasoning: true,
|
|
36
|
+
thinkingLevelMap: { xhigh: "xhigh" },
|
|
37
|
+
input: ["text", "image"],
|
|
38
|
+
contextWindow: 1000000,
|
|
39
|
+
maxTokens: 128000,
|
|
40
|
+
},
|
|
41
|
+
[FABLE_FALLBACK_MODEL_ID]: {
|
|
42
|
+
id: FABLE_FALLBACK_MODEL_ID,
|
|
43
|
+
name: "Claude Opus 4.8",
|
|
44
|
+
reasoning: true,
|
|
45
|
+
thinkingLevelMap: { xhigh: "xhigh" },
|
|
46
|
+
input: ["text", "image"],
|
|
47
|
+
contextWindow: 1000000,
|
|
48
|
+
maxTokens: 128000,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
6
51
|
|
|
7
52
|
// Project pi-ai's model entries down to the fields pi's registerProvider expects,
|
|
8
|
-
//
|
|
53
|
+
// keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned future IDs when pi-ai
|
|
54
|
+
// has not shipped metadata for them yet. Unknown missing IDs are still dropped.
|
|
9
55
|
export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
|
|
10
56
|
return MODEL_IDS_IN_ORDER
|
|
11
|
-
.map((id) => piAiModels.find((m) => m.id === id))
|
|
57
|
+
.map((id) => piAiModels.find((m) => m.id === id) ?? FALLBACK_MODELS[id])
|
|
12
58
|
.filter((m) => m != null)
|
|
13
59
|
// Forward thinkingLevelMap so per-model overrides (e.g. opus-4-7 mapping
|
|
14
60
|
// xhigh→xhigh instead of xhigh→max) are visible to the effort lookup.
|
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
|
}
|