@vanillagreen/pi-claude-bridge 2.0.0 → 3.2.2

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.
@@ -0,0 +1,312 @@
1
+ // Background SDK-stream consumer + the failure/metadata capture that rides it.
2
+ // Extracted from index.ts (pure move): consumeQuery iterates one SDK query's
3
+ // generator and pushes events into the query's captured Pi stream.
4
+
5
+ import { type Model } from "@earendil-works/pi-ai";
6
+ import { type query } from "@anthropic-ai/claude-agent-sdk";
7
+ import {
8
+ classifyClaudeFailure,
9
+ rateLimitResetFromInfo,
10
+ rateLimitResetMs,
11
+ rateLimitTypeFromInfo,
12
+ safeRouterCall,
13
+ type ClaudeAccountFailureKind,
14
+ type ClaudeAccountRoute,
15
+ type ClaudeAccountRouterV1,
16
+ } from "./account-router.js";
17
+ import { ensureTurnStarted, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, updateTurnOutputModel } from "./assistant-stream.js";
18
+ import { extensionApi, safeNotify } from "./bridge-state.js";
19
+ import { type Config } from "./config.js";
20
+ import { debug } from "./debug.js";
21
+ import { fallbackModelForPrimaryModel, modelDisplayName } from "./models.js";
22
+ import { type QueryContext } from "./query-state.js";
23
+ import { RATE_LIMIT_AUTO_RESUME_EVENT, RATE_LIMIT_TOKEN, formatAllowedRateLimitWarning, formatResetTimestamp, isUsageLimitMessage, uniqueNonEmptyLines } from "./rate-limit.js";
24
+ import { activeStreamIdleWatchdogs } from "./stream-idle-watchdog.js";
25
+
26
+ export function emitRateLimitEvent(payload: Record<string, unknown>): void {
27
+ try {
28
+ extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
29
+ } catch {
30
+ // Cross-extension broker is best-effort only.
31
+ }
32
+ }
33
+
34
+ // The fastMode setting silently no-ops when Claude Code declines fast mode.
35
+ // Surface the typed fast_mode_disabled_reason (SDK 0.3.219+) once per distinct
36
+ // reason so an enabled-but-inert setting explains itself instead of looking
37
+ // broken. Module-level dedup: the same reason repeats on every init message.
38
+ let lastFastModeDisabledNoticeReason: string | null = null;
39
+
40
+ const FAST_MODE_DISABLED_REASON_TEXT: Record<string, string> = {
41
+ disabled_by_env: "disabled by an environment variable",
42
+ extra_usage_disabled: "extra usage is disabled for this account",
43
+ free: "not available on the free plan",
44
+ model_not_allowed: "not available for this model",
45
+ network_error: "the eligibility check hit a network error",
46
+ not_first_party: "not available for this account type",
47
+ preference: "disabled by a Claude Code preference",
48
+ sdk_opt_in_required: "the SDK opt-in is missing",
49
+ unknown: "unavailable for an unknown reason",
50
+ };
51
+
52
+ function noteFastModeDisabledReason(message: unknown, bridgeConfig: Config): void {
53
+ if (bridgeConfig.provider?.fastMode !== true) return;
54
+ const reason = (message as { fast_mode_disabled_reason?: unknown }).fast_mode_disabled_reason;
55
+ // "pending" means the CLI is still deciding — not a verdict worth announcing.
56
+ if (typeof reason !== "string" || reason === "pending") return;
57
+ if (reason === lastFastModeDisabledNoticeReason) return;
58
+ lastFastModeDisabledNoticeReason = reason;
59
+ const text = FAST_MODE_DISABLED_REASON_TEXT[reason] ?? `unavailable (${reason})`;
60
+ safeNotify(`Pi Claude: fast mode is enabled in settings but Claude Code declined it — ${text}.`, "warning");
61
+ }
62
+
63
+ /** Background consumer: iterates the SDK generator, pushing events to currentPiStream.
64
+ * Runs until the query ends. Per turn, the SDK yields stream_events (deltas), then
65
+ * an assistant message (completed blocks). On tool_use, the stream is ended by
66
+ * whichever path handles it first (processStreamEvent or processAssistantMessage),
67
+ * and the MCP handler blocks the generator until pi delivers the tool result. */
68
+ export interface ClaudeAttemptFailure {
69
+ kind?: ClaudeAccountFailureKind;
70
+ message: string;
71
+ rateLimitInfo?: Record<string, unknown>;
72
+ }
73
+
74
+ export interface ConsumeQueryResult {
75
+ capturedSessionId?: string;
76
+ failure?: ClaudeAttemptFailure;
77
+ }
78
+
79
+ export async function consumeQuery(
80
+ sdkQuery: ReturnType<typeof query>,
81
+ // The CAPTURED context of the query being consumed, never the live ctx():
82
+ // an MCP tool can push a reentrant subagent context while this iterator is
83
+ // suspended, and reading live state then would consult the WRONG query — a
84
+ // recovered success could retain its failure and surface an error, or a
85
+ // child session id could stamp the subagent's context.
86
+ queryCtx: QueryContext,
87
+ customToolNameToPi: Map<string, string>,
88
+ model: Model<any>,
89
+ bridgeConfig: Config,
90
+ wasAborted: () => boolean,
91
+ account?: ClaudeAccountRoute,
92
+ router?: ClaudeAccountRouterV1,
93
+ // Mirror of the held failure for the caller's .catch: the SDK iterator can
94
+ // THROW after the failure-signal message (a rejected rate_limit_event is the
95
+ // known case), and the rejection loses this function's return value. Without
96
+ // the mirror the catch re-classifies from the thrown error, misses
97
+ // rateLimitInfo, and double-counts the router cooldown.
98
+ attemptFailureBox?: { failure?: ClaudeAttemptFailure },
99
+ ): Promise<ConsumeQueryResult> {
100
+ let capturedSessionId: string | undefined;
101
+ let failure: ClaudeAttemptFailure | undefined;
102
+ let accountProbe: Promise<void> | undefined;
103
+ const holdFailure = (next: ClaudeAttemptFailure | undefined): void => {
104
+ failure = next;
105
+ if (attemptFailureBox) attemptFailureBox.failure = next;
106
+ };
107
+
108
+ for await (const message of sdkQuery) {
109
+ if (wasAborted()) break;
110
+ activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
111
+ if (account) {
112
+ // Thunk, not a value: this runs once per SDK message — including one
113
+ // stream_event per streamed token — and debug() only evaluates function
114
+ // args after its DEBUG early return (VST-15).
115
+ debug("consumeQuery: managed message", () => JSON.stringify({
116
+ type: message.type,
117
+ subtype: (message as any).subtype,
118
+ error: (message as any).error,
119
+ eventType: (message as any).event?.type,
120
+ deltaType: (message as any).event?.delta?.type,
121
+ contentType: (message as any).event?.content_block?.type,
122
+ }));
123
+ }
124
+ if (!queryCtx.turnOutput) continue;
125
+ // Only RENDERING needs a live Pi stream. Failure metadata and
126
+ // child-executed tool results must be captured even when a tool-use turn
127
+ // boundary has nulled the stream — skipping them there dropped terminal
128
+ // failure classification and audited late connector results "unobserved".
129
+ const streamLive = Boolean(queryCtx.currentPiStream);
130
+
131
+ switch (message.type) {
132
+ case "stream_event":
133
+ if (!streamLive) break;
134
+ processStreamEvent(message, customToolNameToPi, model, queryCtx);
135
+ break;
136
+ case "assistant": {
137
+ // Claude Code emits a synthetic assistant text block carrying friendly
138
+ // rate/auth error copy before the SDK throws. On a managed attempt it
139
+ // is not model output: forwarding it would commit the stream and make
140
+ // safe pre-output account failover impossible, so hold it as failure
141
+ // metadata (with or without a live stream). A legacy attempt renders
142
+ // it exactly as before.
143
+ const sdkError = (message as any).error;
144
+ if (sdkError && account) {
145
+ if (!failure) holdFailure({ kind: classifyClaudeFailure(sdkError), message: String(sdkError) });
146
+ break;
147
+ }
148
+ if (!streamLive && !queryCtx.turnSawToolCall) break;
149
+ processAssistantMessage(message, model, customToolNameToPi, queryCtx);
150
+ break;
151
+ }
152
+ case "result":
153
+ // A failure signal followed by a result whose visible output already
154
+ // committed (e.g. the SDK's fallback-model reroute recovering after a
155
+ // rejected rate limit) means the query ultimately SUCCEEDED: the
156
+ // failure was informational and must neither surface an error after
157
+ // the answer nor skip session persistence.
158
+ if (failure && message.subtype === "success" && queryCtx.committedOutput) {
159
+ debug(`consumeQuery: clearing informational ${failure.kind ?? "unclassified"} failure — query recovered with committed output`);
160
+ holdFailure(undefined);
161
+ }
162
+ // The SDK can label the synthetic friendly error carrier as a
163
+ // successful result immediately before its iterator throws. Once a
164
+ // managed attempt holds a terminal failure signal, that text is still
165
+ // error metadata, not assistant output.
166
+ if (account && failure) break;
167
+ if (!queryCtx.turnSawStreamEvent && message.subtype === "success") {
168
+ if (!streamLive) break;
169
+ const text = message.result || "";
170
+ // The no-stream-events assistant fallback may have already rendered
171
+ // this exact text (it does not set turnSawStreamEvent) — re-pushing
172
+ // it here is the other half of the duplicated-output bug.
173
+ if (queryCtx.turnBlocks.some((b: any) => b.type === "text" && b.text === text)) {
174
+ debug("consumeQuery: result text already rendered by assistant fallback; skipping duplicate");
175
+ break;
176
+ }
177
+ ensureTurnStarted(queryCtx);
178
+ queryCtx.turnBlocks.push({ type: "text", text });
179
+ const idx = queryCtx.turnBlocks.length - 1;
180
+ queryCtx.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: queryCtx.turnOutput });
181
+ queryCtx.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: queryCtx.turnOutput });
182
+ queryCtx.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: queryCtx.turnOutput });
183
+ } else if (message.subtype !== "success") {
184
+ const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
185
+ const errors = errorLines.length > 0 ? errorLines.join("\n") : String((message as any).result || message.subtype || "Claude Code request failed");
186
+ const usageLimit = isUsageLimitMessage(message);
187
+ if (!failure || !failure.rateLimitInfo) {
188
+ holdFailure({ kind: usageLimit ? "rate-limit" : classifyClaudeFailure(errors), message: errors });
189
+ }
190
+ // Managed attempts keep terminal error copy buffered as metadata so
191
+ // a pre-output failure can move to another subscription profile.
192
+ if (account) break;
193
+ if (usageLimit) {
194
+ // isUsageLimitMessage matches the CLI's own usage-limit copy (SDK
195
+ // USAGE_LIMIT_ERROR_PREFIXES). Surface it immediately, exactly as
196
+ // before, and suppress the SDK's raw follow-up throw.
197
+ queryCtx.handledTerminalError = true;
198
+ queryCtx.turnOutput.stopReason = "error";
199
+ queryCtx.turnOutput.errorMessage = errors;
200
+ queryCtx.currentPiStream?.push({ type: "error", reason: "error", error: queryCtx.turnOutput });
201
+ queryCtx.currentPiStream?.end();
202
+ queryCtx.currentPiStream = null;
203
+ }
204
+ // Other non-success subtypes (error_max_turns,
205
+ // error_during_execution) surface at completion via the held
206
+ // failure — an explicit error event where these turns previously
207
+ // ended silently. Session persistence and deferred replay still run.
208
+ }
209
+ break;
210
+ case "system":
211
+ if (!streamLive) break;
212
+ if ((message as any).subtype === "init" && (message as any).session_id) {
213
+ capturedSessionId = (message as any).session_id;
214
+ // Also on this message's query context, so the connector-call audit
215
+ // trail can name the child session that executed a call — including
216
+ // from the teardown flush, which runs outside this function's scope.
217
+ queryCtx.childSessionId = capturedSessionId;
218
+ noteFastModeDisabledReason(message, bridgeConfig);
219
+ if (account && router && !accountProbe) {
220
+ accountProbe = Promise.allSettled([
221
+ sdkQuery.accountInfo().then((info) => router.recordIdentity(account.profileId, {
222
+ email: info.email,
223
+ organization: info.organization,
224
+ subscriptionType: info.subscriptionType,
225
+ })),
226
+ sdkQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()
227
+ .then((usage) => router.recordUsage(account.profileId, usage)),
228
+ ]).then((results) => {
229
+ const labels = ["recordIdentity", "recordUsage"];
230
+ results.forEach((result, i) => {
231
+ if (result.status === "rejected") debug(`consumeQuery: account probe ${labels[i]} rejected:`, result.reason);
232
+ });
233
+ });
234
+ }
235
+ } else if ((message as any).subtype === "model_refusal_fallback") {
236
+ const originalModel = (message as any).original_model;
237
+ const fallbackModel = (message as any).fallback_model;
238
+ updateTurnOutputModel(fallbackModel, queryCtx);
239
+ debug("consumeQuery: model_refusal_fallback", JSON.stringify({ originalModel, fallbackModel }));
240
+ // Notify only for reroutes we configured, so an unexpected pairing from
241
+ // Claude Code is still logged above but not announced as one of ours.
242
+ if (typeof fallbackModel === "string" && typeof originalModel === "string" && fallbackModelForPrimaryModel(originalModel) === fallbackModel) {
243
+ safeNotify(
244
+ `Pi Claude switched ${modelDisplayName(originalModel)} to ${modelDisplayName(fallbackModel)} after Claude Code safety fallback.`,
245
+ "info",
246
+ );
247
+ }
248
+ }
249
+ break;
250
+ case "user":
251
+ // Mostly the SDK echoing the prompt back — nothing to render. The one
252
+ // thing worth reading is a child-executed tool's real result, which
253
+ // arrives here and nowhere else — including AFTER a tool-use turn
254
+ // boundary nulled the stream (noteChildExecutedToolResults is
255
+ // side-effect-free on the Pi stream).
256
+ noteChildExecutedToolResults(message, queryCtx);
257
+ break;
258
+ case "rate_limit_event": {
259
+ if (!streamLive) break;
260
+ const info = (message as any).rate_limit_info as Record<string, unknown> | undefined;
261
+ debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
262
+ if (info?.status === "rejected") {
263
+ const rateLimitType = rateLimitTypeFromInfo(info);
264
+ const resetAt = rateLimitResetFromInfo(info);
265
+ const resetAtMs = rateLimitResetMs(info);
266
+ const reason = `${rateLimitType ?? "unknown"} rate limit`;
267
+ if (account && router) {
268
+ // Rotation may still recover this request, so hold the rejection
269
+ // as failure metadata and teach the router the reset time now.
270
+ // Surfacing (event + toast) happens once in surfaceFailure — only
271
+ // if the attempt is not replayed on another profile.
272
+ holdFailure({ kind: "rate-limit", message: reason, rateLimitInfo: info });
273
+ safeRouterCall("recordRateLimit", () => router.recordRateLimit(account.profileId, info, model.id));
274
+ } else {
275
+ // Legacy: notify once and set NO failure state. The SDK's own
276
+ // fallback-model path may still stream a successful recovery, and
277
+ // that turn must complete exactly like any other success.
278
+ const resetsAt = formatResetTimestamp(resetAtMs ?? resetAt);
279
+ emitRateLimitEvent({
280
+ model: model.id,
281
+ provider: model.provider,
282
+ rateLimitType,
283
+ reason,
284
+ resetAt,
285
+ ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
286
+ source: "claude-bridge",
287
+ status: "rejected",
288
+ });
289
+ safeNotify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}`, "warning");
290
+ }
291
+ } else if (info?.status === "allowed_warning") {
292
+ const warning = formatAllowedRateLimitWarning(info);
293
+ if (warning) safeNotify(warning, "warning");
294
+ else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
295
+ }
296
+ break;
297
+ }
298
+ default:
299
+ debug("consumeQuery: unhandled SDK message type", message.type);
300
+ break;
301
+ }
302
+ }
303
+
304
+ if (accountProbe) {
305
+ await Promise.race([
306
+ accountProbe,
307
+ new Promise<void>((resolve) => setTimeout(resolve, 1_500)),
308
+ ]);
309
+ }
310
+ debug(`consumeQuery: for-await loop exited, wasAborted=${wasAborted()}, capturedSessionId=${capturedSessionId?.slice(0, 8) ?? "none"}, failure=${failure?.kind ?? "none"}`);
311
+ return { capturedSessionId, failure };
312
+ }
package/src/convert.ts CHANGED
@@ -6,7 +6,7 @@ import type { ContentBlock, Message as SessionMessage } from "cc-session-io";
6
6
  import { pascalCase } from "change-case";
7
7
  import { isChildExecutedTool } from "./connectors.js";
8
8
 
9
- export const PROVIDER_ID = "claude-bridge";
9
+ export const PROVIDER_ID = "pi-claude";
10
10
 
11
11
  export const PI_TO_SDK_TOOL_NAME: Record<string, string> = {
12
12
  read: "Read", write: "Write", edit: "Edit", bash: "Bash",
@@ -134,21 +134,17 @@ export function convertPiMessages(
134
134
  if (toolMessages.length === 0) return;
135
135
  anthropicMessages.push({
136
136
  role: "user",
137
- content: toolMessages.map((toolMsg) => {
138
- const content = toolResultContentToAnthropic(toolMsg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>);
139
- return {
140
- type: "tool_result",
141
- tool_use_id: sanitizeToolId((toolMsg as { toolCallId: string }).toolCallId, sanitizedIds),
142
- content: content || "",
143
- is_error: (toolMsg as { isError?: boolean }).isError,
144
- };
145
- }),
137
+ content: toolMessages.map((toolMsg) => toolResultToAnthropicBlock(toolMsg, sanitizedIds)),
146
138
  });
147
139
  };
148
140
 
149
141
  for (let i = 0; i < messages.length; i++) {
150
142
  const msg = messages[i];
151
143
  if (msg.role === "user") {
144
+ // Rebuild imports each pi user message as its OWN record. The REUSE path
145
+ // (extractUserPrompt/extractUserPromptBlocks in index.ts) instead merges a
146
+ // trailing user run into one "\n\n"-joined prompt — accepted divergence,
147
+ // see the comment there; the merged form is never re-imported here.
152
148
  anthropicMessages.push(userMessageToAnthropic(msg));
153
149
  } else if (msg.role === "assistant") {
154
150
  const content = Array.isArray(msg.content) ? msg.content : [];
package/src/debug.ts CHANGED
@@ -13,11 +13,27 @@ export function diagLogPath(): string {
13
13
  return process.env.CLAUDE_BRIDGE_DIAG_PATH || join(piUserDir(), "claude-bridge-diag.log");
14
14
  }
15
15
 
16
- // Ensure log directories exist when debug is enabled
16
+ /** Trailing clause for user-facing integrity notifications. With DEBUG on the
17
+ * diag log exists and is worth pointing at; without it the file was never
18
+ * written (diagDump early-returns), so point at the switch that would have
19
+ * captured a dump instead of at a path that does not exist. */
20
+ export function diagGuidance(): string {
21
+ return DEBUG
22
+ ? `see ${diagLogPath()}`
23
+ : "re-run with CLAUDE_BRIDGE_DEBUG=1 to capture a diagnostic dump";
24
+ }
25
+
26
+ // Ensure log directories exist when debug is enabled. 0o700/0o600 throughout:
27
+ // these logs carry prompt previews and session metadata and belong to the user
28
+ // alone — same discipline as diagDump.
17
29
  if (DEBUG) {
18
30
  try {
19
- mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
31
+ mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true, mode: 0o700 });
20
32
  mkdirSync(dirname(diagLogPath()), { recursive: true, mode: 0o700 });
33
+ // mode on mkdir/append applies only at CREATION — repair permissions on
34
+ // dirs and logs that predate the 0o700/0o600 hardening.
35
+ chmodSync(dirname(DEBUG_LOG_PATH), 0o700);
36
+ chmodSync(DEBUG_LOG_PATH, 0o600);
21
37
  } catch {
22
38
  // If directory creation fails, debug functions will throw on first use
23
39
  }
@@ -32,10 +48,48 @@ export function debug(...args: unknown[]) {
32
48
  const fmt = (a: unknown): string => {
33
49
  if (typeof a === "string") return a;
34
50
  if (a instanceof Error) return `${a.name}: ${a.message}${a.stack ? "\n" + a.stack : ""}`;
51
+ // A function argument is a lazy payload: hot-path call sites (per-token
52
+ // stream events) pass a thunk so the expensive formatting only runs when
53
+ // DEBUG is on — fmt is only reached past the early return (VST-15).
54
+ if (typeof a === "function") return fmt((a as () => unknown)());
35
55
  return JSON.stringify(a);
36
56
  };
37
- const msg = args.map(fmt).join(" ");
38
- try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`); } catch { /* debug is best effort */ }
57
+ // A throwing thunk or JSON.stringify (circular structure, BigInt) must not
58
+ // escape debug() one call site sits inside the SDK stream loop, where a
59
+ // formatting failure would abort the user's turn exactly when they enabled
60
+ // debugging. A failed arg degrades to a placeholder; the rest still log.
61
+ const safeFmt = (a: unknown): string => {
62
+ let out: string | undefined;
63
+ try {
64
+ out = fmt(a);
65
+ } catch (error) {
66
+ // The caught value's own conversion can throw too (a thrown
67
+ // null-prototype object, a throwing toString, an Error whose
68
+ // message is a Symbol — template interpolation would rethrow) —
69
+ // degrade to a constant rather than let the placeholder escape.
70
+ let reason = "formatting failed";
71
+ try {
72
+ reason = String(error instanceof Error ? error.message : error);
73
+ } catch { /* keep the constant */ }
74
+ return `[unprintable: ${reason}]`;
75
+ }
76
+ // JSON.stringify returns undefined (not a string) for undefined,
77
+ // Symbol, AND objects whose toJSON returns undefined — render the
78
+ // slot explicitly instead of letting join() silently drop it. That
79
+ // last shape means `a` can be an arbitrary object here, and its
80
+ // toString can throw, so this conversion needs the same guard as the
81
+ // catch path. (A thunk that returned one of these renders as plain
82
+ // "undefined".)
83
+ if (out !== undefined) return out;
84
+ if (typeof a === "function") return "undefined";
85
+ try {
86
+ return String(a);
87
+ } catch {
88
+ return "[unprintable: formatting failed]";
89
+ }
90
+ };
91
+ const msg = args.map(safeFmt).join(" ");
92
+ try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`, { mode: 0o600 }); } catch { /* debug is best effort */ }
39
93
  }
40
94
 
41
95
  // Per-query CLI debug capture. When CLAUDE_BRIDGE_DEBUG=1, ask the Claude Code
@@ -50,7 +104,7 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
50
104
  const seq = nextCliDebugSeq++;
51
105
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
52
106
  const logDir = join(dirname(DEBUG_LOG_PATH), "cc-cli-logs");
53
- try { mkdirSync(logDir, { recursive: true }); } catch { /* ignore */ }
107
+ try { mkdirSync(logDir, { recursive: true, mode: 0o700 }); chmodSync(logDir, 0o700); } catch { /* ignore */ }
54
108
  const debugFile = join(logDir, `${ts}-${tag}-${seq}.log`);
55
109
  debug(`cli-debug: ${tag} #${seq} → ${debugFile}`);
56
110
  return {
@@ -64,8 +118,12 @@ export function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?:
64
118
  };
65
119
  }
66
120
 
67
- /** Unconditional diagnostic dump — for "should never happen" paths */
121
+ /** Diagnostic dump — for "should never happen" paths. Gated on the same
122
+ * CLAUDE_BRIDGE_DEBUG flag as debug(): the entries carry session metadata
123
+ * and land in a log outside any host app's retention/cleanup boundary, so
124
+ * a host that has not opted into debugging must get no disk write (VST-15). */
68
125
  export function diagDump(label: string, data: Record<string, unknown>) {
126
+ if (!DEBUG) return;
69
127
  try {
70
128
  const ts = new Date().toISOString();
71
129
  const entry = { ts, moduleInstanceId, label, ...data };