@vanillagreen/pi-claude-bridge 1.2.0 → 1.3.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/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthro
6
6
  import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
7
7
  import { spawn as spawnProcess } from "child_process";
8
8
  import { createHash } from "crypto";
9
- import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
9
+ import { accessSync, appendFileSync, chmodSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
10
10
  import { resolve as pathResolve } from "path";
11
11
  import { homedir } from "os";
12
12
  import { delimiter, dirname, join } from "path";
@@ -16,7 +16,8 @@ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.j
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
- import { loadConfig, type Config } from "./config.js";
19
+ import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
20
+ import { loadConfig, normalizeEffortLevel, type Config } from "./config.js";
20
21
  import { extractAgentsAppend } from "./agents-md.js";
21
22
  import { buildPromptContextAppend } from "./prompt-context.js";
22
23
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
@@ -33,13 +34,17 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
33
34
 
34
35
  const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
35
36
  const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
36
- const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
37
+ const DEFAULT_DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
38
+
39
+ function diagLogPath(): string {
40
+ return process.env.CLAUDE_BRIDGE_DIAG_PATH || DEFAULT_DIAG_LOG_PATH;
41
+ }
37
42
 
38
43
  // Ensure log directories exist when debug is enabled
39
44
  if (DEBUG) {
40
45
  try {
41
46
  mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
42
- mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
47
+ mkdirSync(dirname(diagLogPath()), { recursive: true, mode: 0o700 });
43
48
  } catch {
44
49
  // If directory creation fails, debug functions will throw on first use
45
50
  }
@@ -57,7 +62,7 @@ function debug(...args: unknown[]) {
57
62
  return JSON.stringify(a);
58
63
  };
59
64
  const msg = args.map(fmt).join(" ");
60
- appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
65
+ try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`); } catch { /* debug is best effort */ }
61
66
  }
62
67
 
63
68
  function executableFromPath(name: string): string | undefined {
@@ -342,10 +347,109 @@ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string
342
347
 
343
348
  /** Unconditional diagnostic dump — for "should never happen" paths */
344
349
  function diagDump(label: string, data: Record<string, unknown>) {
345
- const ts = new Date().toISOString();
346
- const entry = { ts, moduleInstanceId, label, ...data };
347
- appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
348
- debug(`DIAG: ${label} (see ${DIAG_LOG_PATH})`);
350
+ try {
351
+ const ts = new Date().toISOString();
352
+ const entry = { ts, moduleInstanceId, label, ...data };
353
+ const path = diagLogPath();
354
+ try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } catch { /* best effort */ }
355
+ appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
356
+ try { chmodSync(path, 0o600); } catch { /* best effort */ }
357
+ debug(`DIAG: ${label} (see ${path})`);
358
+ } catch (error) {
359
+ debug(`DIAG FAILED: ${label}`, error);
360
+ }
361
+ }
362
+
363
+ function safeNotify(message: string, level: "info" | "warning" | "error" = "warning"): void {
364
+ try { piUI?.notify(message, level); }
365
+ catch (error) { debug("notify failed:", error); }
366
+ }
367
+
368
+ function argKeys(args: Record<string, unknown> | undefined): string[] {
369
+ return Object.keys(args ?? {}).sort();
370
+ }
371
+
372
+ function safeToolCallSummary(calls: Array<{ id: string; toolName: string; arguments?: Record<string, unknown> }>): Array<{ id: string; toolName: string; argKeys: string[] }> {
373
+ return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
374
+ }
375
+
376
+ function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
377
+ const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
378
+ if (names.length > limit) shown.push(`+${names.length - limit} more`);
379
+ return shown;
380
+ }
381
+
382
+ function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
383
+ try {
384
+ if (missing.length === 0) return;
385
+ const toolNames = summarizeMissingToolNames(missing);
386
+ const toolNameSummary = compactToolNameSummary(toolNames);
387
+ const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
388
+ diagDump("repair_tool_pairing_synthetic_results", {
389
+ count: missing.length,
390
+ toolNames,
391
+ sampledToolCallIds,
392
+ missing: missing.slice(0, 50),
393
+ ...context,
394
+ });
395
+ safeNotify(
396
+ `Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
397
+ `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
398
+ `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
399
+ "error",
400
+ );
401
+ } catch (error) {
402
+ debug("reportSyntheticToolResultRepair failed:", error);
403
+ }
404
+ }
405
+
406
+ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
407
+ try {
408
+ if (queryCtx.reportedToolResultMismatch) return false;
409
+ const progress = queryCtx.toolResultProgress();
410
+ const hasMismatch = progress.expectedCount > 0
411
+ ? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
412
+ : progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
413
+ if (!hasMismatch) return false;
414
+ queryCtx.reportedToolResultMismatch = true;
415
+ if (sharedSession) {
416
+ sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
417
+ }
418
+ const toolNameSummary = compactToolNameSummary(progress.toolNames);
419
+ diagDump("tool_result_delivery_mismatch", {
420
+ reason,
421
+ cwd,
422
+ progress,
423
+ activeQueryExists: queryCtx.activeQuery !== null,
424
+ sharedSession: sharedSession ? {
425
+ sessionId: sharedSession.sessionId.slice(0, 8),
426
+ cursor: sharedSession.cursor,
427
+ needsRebuild: sharedSession.needsRebuild === true,
428
+ forceRotate: sharedSession.forceRotate === true,
429
+ } : null,
430
+ });
431
+ safeNotify(
432
+ `Claude bridge: tool result delivery interrupted during ${reason}; ` +
433
+ `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
434
+ `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
435
+ `${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
436
+ `Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
437
+ "error",
438
+ );
439
+ return true;
440
+ } catch (error) {
441
+ debug("reportToolResultMismatch failed:", error);
442
+ return false;
443
+ }
444
+ }
445
+
446
+ export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
447
+ if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
448
+ if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
449
+ }
450
+
451
+ export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
452
+ return { sharedSession };
349
453
  }
350
454
 
351
455
  // --- Constants ---
@@ -426,6 +530,9 @@ let extensionApi: ExtensionAPI | undefined;
426
530
  let piUI: ExtensionUIContext | undefined;
427
531
  let extraUsageHelperInFlight: Promise<string> | null = null;
428
532
 
533
+ const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
534
+ const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
535
+
429
536
  export function isExtraUsageRequiredMessage(value: unknown): boolean {
430
537
  let text: string;
431
538
  if (typeof value === "string") text = value;
@@ -437,6 +544,64 @@ export function isExtraUsageRequiredMessage(value: unknown): boolean {
437
544
  return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
438
545
  }
439
546
 
547
+ export function uniqueNonEmptyLines(values: unknown[]): string[] {
548
+ const seen = new Set<string>();
549
+ const out: string[] = [];
550
+ for (const value of values) {
551
+ const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
552
+ if (!text || seen.has(text)) continue;
553
+ seen.add(text);
554
+ out.push(text);
555
+ }
556
+ return out;
557
+ }
558
+
559
+ export function formatResetTimestamp(value: unknown): string {
560
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
561
+ if (!Number.isFinite(parsed)) return "unknown";
562
+ return new Date(parsed).toLocaleString(undefined, {
563
+ day: "numeric",
564
+ hour: "numeric",
565
+ minute: "2-digit",
566
+ month: "short",
567
+ second: "2-digit",
568
+ timeZoneName: "short",
569
+ year: "numeric",
570
+ });
571
+ }
572
+
573
+ export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
574
+
575
+ export function normalizeRateLimitUtilization(value: unknown): number | undefined {
576
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
577
+ if (value === 0) return 0;
578
+ // Claude SDK payloads have appeared as both fractions and percentages.
579
+ // Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
580
+ if (value > 0 && value < 1) return value * 100;
581
+ if (value > 1 && value <= 100) return value;
582
+ return undefined;
583
+ }
584
+
585
+ function rateLimitTypeLabel(value: unknown): string {
586
+ const text = typeof value === "string" ? value.trim() : "";
587
+ return text || "unknown";
588
+ }
589
+
590
+ export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
591
+ if (info?.status !== "allowed_warning") return undefined;
592
+ const utilization = normalizeRateLimitUtilization(info.utilization);
593
+ if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
594
+ return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
595
+ }
596
+
597
+ function emitRateLimitEvent(payload: Record<string, unknown>): void {
598
+ try {
599
+ extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
600
+ } catch {
601
+ // Cross-extension broker is best-effort only.
602
+ }
603
+ }
604
+
440
605
  function extraUsageAllowed(config: Config): boolean {
441
606
  return config.provider?.allowExtraUsage === true;
442
607
  }
@@ -641,6 +806,7 @@ function convertAndImportMessages(
641
806
  session: ReturnType<typeof createSession>,
642
807
  messages: Context["messages"],
643
808
  customToolNameToSdk?: Map<string, string>,
809
+ cwd?: string,
644
810
  ): void {
645
811
  const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
646
812
 
@@ -656,7 +822,17 @@ function convertAndImportMessages(
656
822
  [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
657
823
  }
658
824
  // Pre-repair for debug logging; importMessages also repairs internally (idempotent).
825
+ const missingToolResults = findUnpairedToolUses(anthropicMessages);
659
826
  const repaired = repairToolPairing(anthropicMessages);
827
+ if (missingToolResults.length > 0) {
828
+ reportSyntheticToolResultRepair(missingToolResults, {
829
+ cwd,
830
+ messageCount: messages.length,
831
+ anthropicMessageCount: anthropicMessages.length,
832
+ sessionId: session.sessionId,
833
+ jsonlPath: session.jsonlPath,
834
+ });
835
+ }
660
836
  if (repaired.length !== anthropicMessages.length) {
661
837
  debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
662
838
  }
@@ -854,7 +1030,7 @@ function syncSharedSession(
854
1030
  ...(preserveId ? { sessionId: previousSessionId } : {}),
855
1031
  ...(modelId ? { model: modelId } : {}),
856
1032
  });
857
- convertAndImportMessages(session, priorMessages, customToolNameToSdk);
1033
+ convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
858
1034
  session.save();
859
1035
  verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
860
1036
  sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
@@ -954,28 +1130,50 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
954
1130
 
955
1131
  // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
956
1132
  // blocks on a Promise until pi delivers the tool result via streamSimple.
957
- // Handlers are assigned toolCallIds from turnToolCallIds (populated when the SDK
958
- // emits tool_use blocks). Results are matched by ID, not position.
959
- // Handlers close over the captured `queryCtx`, ensuring they operate on the
960
- // correct query's state even across pushContext/popContext calls.
1133
+ // Handlers claim their tool_call id by matching the actual MCP call
1134
+ // (tool name + arguments) against the recorded tool_use blocks, then results
1135
+ // are matched by ID. Handlers close over the captured `queryCtx`, ensuring they
1136
+ // operate on the correct query's state even across pushContext/popContext calls.
961
1137
  function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
962
1138
  if (!tools.length) return undefined;
963
1139
  const mcpTools = tools.map((tool) => ({
964
1140
  name: tool.name,
965
1141
  description: tool.description,
966
1142
  inputSchema: jsonSchemaToZodShape(tool.parameters),
967
- handler: async () => {
968
- const toolCallId = queryCtx.turnToolCallIds[queryCtx.nextHandlerIdx++];
969
- if (!toolCallId) debug(`WARNING: mcp handler ${tool.name} has no toolCallId (idx=${queryCtx.nextHandlerIdx - 1}, available=${queryCtx.turnToolCallIds.length})`);
1143
+ handler: async (args?: Record<string, unknown>) => {
1144
+ const mappedArgs = mapToolArgs(tool.name, args);
1145
+ const claim = queryCtx.claimToolCall(tool.name, mappedArgs);
1146
+ const toolCallId = claim.toolCallId;
1147
+ if (!toolCallId) {
1148
+ debug(`WARNING: mcp handler ${tool.name} has no toolCallId (available=${claim.available})`);
1149
+ diagDump("tool_handler_unmatched", {
1150
+ toolName: tool.name,
1151
+ argKeys: argKeys(mappedArgs),
1152
+ available: claim.available,
1153
+ turnToolCallIds: queryCtx.turnToolCallIds,
1154
+ turnToolCalls: safeToolCallSummary(queryCtx.turnToolCalls),
1155
+ });
1156
+ return { content: [{ type: "text", text: `Claude bridge internal error: no matching tool_call id for ${tool.name}` }], isError: true } satisfies McpResult;
1157
+ }
1158
+ if (claim.match !== "tool-args" || claim.ambiguous) {
1159
+ debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
1160
+ }
970
1161
  if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
971
1162
  const result = queryCtx.pendingResults.get(toolCallId)!;
972
1163
  queryCtx.pendingResults.delete(toolCallId);
1164
+ queryCtx.markToolResultResolved(toolCallId);
973
1165
  debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
974
1166
  return result;
975
1167
  }
976
1168
  debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
977
1169
  return new Promise<McpResult>((resolve) => {
978
- queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
1170
+ queryCtx.pendingToolCalls.set(toolCallId, {
1171
+ toolName: tool.name,
1172
+ resolve: (result) => {
1173
+ queryCtx.markToolResultResolved(toolCallId);
1174
+ resolve(result);
1175
+ },
1176
+ });
979
1177
  });
980
1178
  },
981
1179
  }));
@@ -1004,6 +1202,26 @@ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
1004
1202
  minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
1005
1203
  };
1006
1204
 
1205
+ function normalizeEffortOverrideModelKey(value: string): string {
1206
+ const key = value.trim().toLowerCase();
1207
+ return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
1208
+ }
1209
+
1210
+ export function resolveConfiguredEffort(
1211
+ modelId: string,
1212
+ reasoningEffort: EffortLevel | undefined,
1213
+ providerConfig?: Config["provider"],
1214
+ ): EffortLevel | undefined {
1215
+ const target = normalizeEffortOverrideModelKey(modelId);
1216
+ for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
1217
+ const normalizedKey = normalizeEffortOverrideModelKey(key);
1218
+ if (normalizedKey !== "*" && normalizedKey !== target) continue;
1219
+ const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
1220
+ if (effort) return effort;
1221
+ }
1222
+ return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
1223
+ }
1224
+
1007
1225
  // --- Provider helpers: misc ---
1008
1226
 
1009
1227
  function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
@@ -1054,24 +1272,28 @@ function finalizeCurrentStream(stopReason?: string): void {
1054
1272
 
1055
1273
  /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
1056
1274
  * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
1057
- function processStreamEvent(
1275
+ export function processStreamEvent(
1058
1276
  message: SDKMessage,
1059
1277
  customToolNameToPi: Map<string, string>,
1060
1278
  model: Model<any>,
1061
1279
  ): void {
1062
1280
  const c = ctx();
1063
1281
  if (!c.currentPiStream || !c.turnOutput) return;
1064
- c.turnSawStreamEvent = true;
1065
1282
  const event = (message as SDKMessage & { event: any }).event;
1283
+ if (event?.type === "ping") return;
1284
+ if (event?.type === "message_stop" && !c.turnSawToolCall) {
1285
+ debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
1286
+ return;
1287
+ }
1066
1288
 
1067
1289
  if (event?.type === "message_start") {
1068
- c.turnToolCallIds = [];
1069
- c.nextHandlerIdx = 0;
1290
+ c.resetToolTracking();
1070
1291
  if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
1071
1292
  return;
1072
1293
  }
1073
1294
 
1074
1295
  if (event?.type === "content_block_start") {
1296
+ c.turnSawStreamEvent = true;
1075
1297
  ensureTurnStarted();
1076
1298
  if (event.content_block?.type === "text") {
1077
1299
  c.turnBlocks.push({ type: "text", text: "", index: event.index });
@@ -1081,10 +1303,11 @@ function processStreamEvent(
1081
1303
  c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1082
1304
  } else if (event.content_block?.type === "tool_use") {
1083
1305
  c.turnSawToolCall = true;
1084
- c.turnToolCallIds.push(event.content_block.id);
1306
+ const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
1307
+ c.recordToolCall(event.content_block.id, mappedName, {});
1085
1308
  c.turnBlocks.push({
1086
1309
  type: "toolCall", id: event.content_block.id,
1087
- name: mapToolName(event.content_block.name, customToolNameToPi),
1310
+ name: mappedName,
1088
1311
  arguments: (event.content_block.input as Record<string, unknown>) ?? {},
1089
1312
  partialJson: "", index: event.index,
1090
1313
  });
@@ -1098,7 +1321,11 @@ function processStreamEvent(
1098
1321
  if (event?.type === "content_block_delta") {
1099
1322
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1100
1323
  const block = c.turnBlocks[index];
1101
- if (!block) return;
1324
+ if (!block) {
1325
+ debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
1326
+ return;
1327
+ }
1328
+ c.turnSawStreamEvent = true;
1102
1329
  if (event.delta?.type === "text_delta" && block.type === "text") {
1103
1330
  block.text += event.delta.text;
1104
1331
  c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
@@ -1120,7 +1347,11 @@ function processStreamEvent(
1120
1347
  if (event?.type === "content_block_stop") {
1121
1348
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1122
1349
  const block = c.turnBlocks[index];
1123
- if (!block) return;
1350
+ if (!block) {
1351
+ debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
1352
+ return;
1353
+ }
1354
+ c.turnSawStreamEvent = true;
1124
1355
  delete block.index;
1125
1356
  if (block.type === "text") {
1126
1357
  c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
@@ -1131,6 +1362,7 @@ function processStreamEvent(
1131
1362
  block.arguments = mapToolArgs(
1132
1363
  block.name, parsePartialJson(block.partialJson, block.arguments),
1133
1364
  );
1365
+ c.updateToolCallArgs(block.id, block.arguments);
1134
1366
  delete block.partialJson;
1135
1367
  c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
1136
1368
  }
@@ -1169,13 +1401,73 @@ function processStreamEvent(
1169
1401
  // arrives before any stream_events, this is the primary content path. Must maintain
1170
1402
  // the same stream lifecycle as processStreamEvent — including ending the stream on
1171
1403
  // tool_use to prevent deadlock with the MCP handler.
1172
- function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1404
+ function appendMissingToolUsesFromAssistant(
1405
+ assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
1406
+ model: Model<any>,
1407
+ customToolNameToPi: Map<string, string>,
1408
+ ): boolean {
1409
+ const c = ctx();
1410
+ if (!assistantMsg?.content) return false;
1411
+ let sawToolUse = false;
1412
+ for (const block of assistantMsg.content) {
1413
+ if (block.type !== "tool_use") continue;
1414
+ sawToolUse = true;
1415
+ const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
1416
+ const name = mapToolName(block.name, customToolNameToPi);
1417
+ const mappedArgs = mapToolArgs(name, block.input);
1418
+ c.recordToolCall(block.id, name, mappedArgs);
1419
+ if (existingIdx >= 0) {
1420
+ const existing = c.turnBlocks[existingIdx] as any;
1421
+ existing.name = name;
1422
+ existing.arguments = mappedArgs;
1423
+ c.updateToolCallArgs(block.id, mappedArgs);
1424
+ if ("partialJson" in existing) {
1425
+ delete existing.partialJson;
1426
+ delete existing.index;
1427
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
1428
+ }
1429
+ continue;
1430
+ }
1431
+
1432
+ ensureTurnStarted();
1433
+ c.turnBlocks.push({
1434
+ type: "toolCall", id: block.id,
1435
+ name,
1436
+ arguments: mappedArgs,
1437
+ });
1438
+ const idx = c.turnBlocks.length - 1;
1439
+ const toolBlock = c.turnBlocks[idx];
1440
+ c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1441
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1442
+ }
1443
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1444
+ return sawToolUse;
1445
+ }
1446
+
1447
+ export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1173
1448
  const c = ctx();
1174
- if (c.turnSawStreamEvent) return;
1175
1449
  const assistantMsg = (message as any).message;
1176
1450
  if (!assistantMsg?.content) return;
1177
- c.turnToolCallIds = [];
1178
- c.nextHandlerIdx = 0;
1451
+ if (c.turnSawStreamEvent) {
1452
+ // Claude Agent SDK can yield the completed assistant message before (or
1453
+ // instead of) a stream_event message_stop for a tool-use turn. Treat that
1454
+ // assistant message as a hard turn boundary so Pi executes the tool calls
1455
+ // and the MCP handlers stay blocked until real tool results are delivered.
1456
+ // Without this fallback, Claude Code can continue internally with empty MCP
1457
+ // results and Pi only sees the real outputs one render cycle later.
1458
+ if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
1459
+ c.turnSawToolCall = true;
1460
+ if (c.currentPiStream && c.turnOutput) {
1461
+ c.turnOutput.stopReason = "toolUse";
1462
+ c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1463
+ c.currentPiStream.end();
1464
+ c.currentPiStream = null;
1465
+ debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
1466
+ }
1467
+ }
1468
+ return;
1469
+ }
1470
+ c.resetToolTracking();
1179
1471
  debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1180
1472
  for (const block of assistantMsg.content) {
1181
1473
  if (block.type === "text" && block.text) {
@@ -1195,11 +1487,12 @@ function processAssistantMessage(message: SDKMessage, model: Model<any>, customT
1195
1487
  } else if (block.type === "tool_use") {
1196
1488
  ensureTurnStarted();
1197
1489
  c.turnSawToolCall = true;
1198
- c.turnToolCallIds.push(block.id);
1199
- const mappedArgs = mapToolArgs(mapToolName(block.name, customToolNameToPi), block.input);
1490
+ const mappedName = mapToolName(block.name, customToolNameToPi);
1491
+ const mappedArgs = mapToolArgs(mappedName, block.input);
1492
+ c.recordToolCall(block.id, mappedName, mappedArgs);
1200
1493
  c.turnBlocks.push({
1201
1494
  type: "toolCall", id: block.id,
1202
- name: mapToolName(block.name, customToolNameToPi),
1495
+ name: mappedName,
1203
1496
  arguments: mappedArgs,
1204
1497
  });
1205
1498
  const idx = c.turnBlocks.length - 1;
@@ -1238,7 +1531,9 @@ async function consumeQuery(
1238
1531
 
1239
1532
  for await (const message of sdkQuery) {
1240
1533
  if (wasAborted()) break;
1241
- if (!ctx().currentPiStream || !ctx().turnOutput) continue;
1534
+ const queryCtx = ctx();
1535
+ if (!queryCtx.turnOutput) continue;
1536
+ if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
1242
1537
 
1243
1538
  switch (message.type) {
1244
1539
  case "stream_event":
@@ -1257,8 +1552,10 @@ async function consumeQuery(
1257
1552
  ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
1258
1553
  ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
1259
1554
  } else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
1260
- const errors = Array.isArray((message as any).errors) ? (message as any).errors.join("\n") : message.subtype;
1555
+ const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
1556
+ const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
1261
1557
  const openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
1558
+ ctx().handledTerminalError = true;
1262
1559
  ctx().turnOutput.stopReason = "error";
1263
1560
  ctx().turnOutput.errorMessage = `${errors}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : "\n\nRun /claude-bridge:extra, or enable Allow extra usage helper in settings."}`;
1264
1561
  ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
@@ -1277,12 +1574,25 @@ async function consumeQuery(
1277
1574
  const info = (message as any).rate_limit_info;
1278
1575
  debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1279
1576
  if (info?.status === "rejected") {
1280
- const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1577
+ const resetsAt = formatResetTimestamp(info.resetsAt);
1578
+ const resetAtMs = typeof info.resetsAt === "string" ? Date.parse(info.resetsAt) : undefined;
1281
1579
  const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
1282
1580
  const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
1283
- piUI?.notify(`Claude rate limited (${reason}) — resets at ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
1581
+ emitRateLimitEvent({
1582
+ model: model.id,
1583
+ provider: PROVIDER_ID,
1584
+ rateLimitType: info.rateLimitType,
1585
+ reason,
1586
+ resetAt: info.resetsAt,
1587
+ ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
1588
+ source: "claude-bridge",
1589
+ status: "rejected",
1590
+ });
1591
+ piUI?.notify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
1284
1592
  } else if (info?.status === "allowed_warning") {
1285
- piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1593
+ const warning = formatAllowedRateLimitWarning(info);
1594
+ if (warning) piUI?.notify(warning, "warning");
1595
+ else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
1286
1596
  }
1287
1597
  break;
1288
1598
  }
@@ -1305,6 +1615,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1305
1615
 
1306
1616
  // DEBUG: trace followUp message triggering
1307
1617
  const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1618
+ const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1308
1619
  debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1309
1620
 
1310
1621
  // --- Tool result delivery ---
@@ -1312,30 +1623,48 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1312
1623
  // (everything after the last assistant message) and match against waiting MCP
1313
1624
  // handlers. Results that arrive before their handler get queued in pendingResults.
1314
1625
  if (ctx().activeQuery) {
1315
- ctx().currentPiStream = stream;
1316
- ctx().resetTurnState(model);
1626
+ const queryCtx = ctx();
1627
+ queryCtx.currentPiStream = stream;
1628
+ queryCtx.resetTurnState(model);
1317
1629
  const allResults = extractAllToolResults(context);
1318
- debug(`provider: tool results, ${allResults.length} results, ${ctx().pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1630
+ debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1631
+ const unmatchedResultIds: string[] = [];
1319
1632
  for (const result of allResults) {
1320
1633
  const id = result.toolCallId;
1321
- if (id && ctx().pendingToolCalls.has(id)) {
1322
- const pending = ctx().pendingToolCalls.get(id)!;
1323
- ctx().pendingToolCalls.delete(id);
1634
+ if (id && !queryCtx.hasRecordedToolCall(id)) {
1635
+ queryCtx.markToolResultUnmatched(id);
1636
+ unmatchedResultIds.push(id);
1637
+ debug(`ERROR: tool result [${id}] has no registered tool_call id; refusing to queue or deliver`);
1638
+ continue;
1639
+ }
1640
+ queryCtx.markToolResultDelivered(id);
1641
+ if (id && queryCtx.pendingToolCalls.has(id)) {
1642
+ const pending = queryCtx.pendingToolCalls.get(id)!;
1643
+ queryCtx.pendingToolCalls.delete(id);
1324
1644
  debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
1325
1645
  pending.resolve(result);
1326
1646
  } else if (id) {
1327
- ctx().pendingResults.set(id, result);
1328
- debug(`provider: queued result [${id}] (${ctx().pendingResults.size} pending)`);
1647
+ queryCtx.pendingResults.set(id, result);
1648
+ debug(`provider: queued result [${id}] (${queryCtx.pendingResults.size} pending)`);
1329
1649
  } else {
1330
1650
  debug(`WARNING: tool result without toolCallId, cannot match`);
1331
1651
  }
1332
- if (ctx().pendingToolCalls.size > 0 && ctx().pendingResults.size > 0) {
1333
- debug(`BUG: both maps non-empty! handlers=${ctx().pendingToolCalls.size} results=${ctx().pendingResults.size}`);
1652
+ if (queryCtx.pendingToolCalls.size > 0 && queryCtx.pendingResults.size > 0) {
1653
+ debug(`BUG: both maps non-empty! handlers=${queryCtx.pendingToolCalls.size} results=${queryCtx.pendingResults.size}`);
1334
1654
  }
1335
1655
  }
1336
- if (ctx().pendingToolCalls.size > 0) {
1337
- debug(`WARNING: ${ctx().pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1338
- piUI?.notify(`Claude bridge: ${ctx().pendingToolCalls.size} tool handler(s) still waiting provider may be stuck`, "warning");
1656
+ if (unmatchedResultIds.length > 0) {
1657
+ const errorResult: McpResult = {
1658
+ content: [{ type: "text", text: `Claude bridge internal error: ${unmatchedResultIds.length} tool result(s) did not match any registered tool_call id. The turn was stopped to avoid delivering tool output to the wrong call. Unmatched ids: ${unmatchedResultIds.slice(0, 8).join(", ")}${unmatchedResultIds.length > 8 ? ", ..." : ""}` }],
1659
+ isError: true,
1660
+ };
1661
+ for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(errorResult);
1662
+ queryCtx.pendingToolCalls.clear();
1663
+ reportToolResultMismatch(queryCtx, "unmatched tool result", cwd);
1664
+ }
1665
+ if (queryCtx.pendingToolCalls.size > 0) {
1666
+ debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1667
+ piUI?.notify(`Claude bridge: ${queryCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1339
1668
  }
1340
1669
 
1341
1670
  // Detect user messages (steer/followUp) that pi injected into context
@@ -1355,7 +1684,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1355
1684
  }
1356
1685
 
1357
1686
  if (sharedSession) sharedSession.cursor = context.messages.length;
1358
- ctx().latestCursor = Math.max(ctx().latestCursor, context.messages.length);
1687
+ queryCtx.latestCursor = Math.max(queryCtx.latestCursor, context.messages.length);
1359
1688
  return stream;
1360
1689
  }
1361
1690
 
@@ -1389,10 +1718,10 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1389
1718
  ctx().pendingResults.clear();
1390
1719
  ctx().deferredUserMessages = [];
1391
1720
  ctx().resetTurnState(model);
1721
+ ctx().resetToolTracking();
1392
1722
  ctx().latestCursor = 0;
1393
1723
 
1394
1724
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1395
- const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1396
1725
  const promptBlocks = extractUserPromptBlocks(context.messages);
1397
1726
  let promptText = extractUserPrompt(context.messages) ?? "";
1398
1727
 
@@ -1440,10 +1769,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1440
1769
  // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1441
1770
  // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
1442
1771
  // Fall back to our generic table for older pi-ai or unmapped levels.
1443
- const effort = options?.reasoning
1772
+ const requestedEffort = options?.reasoning
1444
1773
  ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
1445
1774
  ?? REASONING_TO_EFFORT[options.reasoning]
1446
1775
  : undefined;
1776
+ const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
1447
1777
 
1448
1778
  const extraArgs: Record<string, string | null> = { model: model.id };
1449
1779
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
@@ -1468,6 +1798,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1468
1798
  ...CLAUDE_BRIDGE_TOOL_ISOLATION,
1469
1799
  permissionMode: "bypassPermissions",
1470
1800
  includePartialMessages: true,
1801
+ ...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
1471
1802
  systemPrompt: {
1472
1803
  type: "preset", preset: "claude_code",
1473
1804
  append: systemPromptAppend ? systemPromptAppend : undefined,
@@ -1485,7 +1816,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1485
1816
  debug("provider: fresh query",
1486
1817
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
1487
1818
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1488
- `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
1819
+ `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
1489
1820
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
1490
1821
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1491
1822
 
@@ -1507,6 +1838,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1507
1838
  wasAborted = true;
1508
1839
  // Prevent stale deferred messages from being replayed by parent on pop
1509
1840
  abortCtx.deferredUserMessages = [];
1841
+ reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
1510
1842
  for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
1511
1843
  abortCtx.pendingToolCalls.clear();
1512
1844
  abortCtx.pendingResults.clear();
@@ -1553,6 +1885,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1553
1885
  const steerPrompt = ctx().deferredUserMessages.shift()!;
1554
1886
  debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
1555
1887
  ctx().resetTurnState(model);
1888
+ ctx().resetToolTracking();
1556
1889
 
1557
1890
  const resumeId = sharedSession?.sessionId;
1558
1891
  if (!resumeId) {
@@ -1588,13 +1921,18 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1588
1921
  })
1589
1922
  .catch((error) => {
1590
1923
  debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1591
- const openedExtraUsage = isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
1924
+ const suppressDuplicateError = ctx().handledTerminalError;
1925
+ const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
1592
1926
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1593
1927
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1594
1928
  } else {
1595
1929
  sharedSession = null;
1596
1930
  }
1597
1931
  ctx().deferredUserMessages = [];
1932
+ if (suppressDuplicateError) {
1933
+ debug("provider: suppressing duplicate query error after terminal error was already emitted");
1934
+ return;
1935
+ }
1598
1936
  if (ctx().turnOutput) {
1599
1937
  ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1600
1938
  ctx().turnOutput.errorMessage = `${error instanceof Error ? error.message : String(error)}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : ""}`;
@@ -1606,6 +1944,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1606
1944
  .finally(() => {
1607
1945
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1608
1946
  if (ctx().activeQuery === sdkQuery) {
1947
+ reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
1609
1948
  // Drain pending handlers for this query
1610
1949
  for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1611
1950
  ctx().pendingToolCalls.clear();
@@ -1741,6 +2080,9 @@ export default function (pi: ExtensionAPI) {
1741
2080
  // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1742
2081
  // call down the REBUILD path so CC sees the current history.
1743
2082
  const markRebuild = (event: string) => {
2083
+ if (ctx().activeQuery) {
2084
+ reportToolResultMismatch(ctx(), event, sharedSession?.cwd ?? process.cwd());
2085
+ }
1744
2086
  if (sharedSession) {
1745
2087
  debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
1746
2088
  sharedSession = { ...sharedSession, needsRebuild: true };