@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.
@@ -1,12 +1,35 @@
1
1
  import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { debug, diagDump, diagLogPath } from "./debug.js";
2
+ import { debug, diagDump, diagGuidance } from "./debug.js";
3
3
  import { type QueryContext } from "./query-state.js";
4
+ import { currentRequestLaneId } from "./request-lane.js";
4
5
  import { summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
5
6
 
6
7
  export interface SessionState {
7
8
  sessionId: string;
8
9
  cursor: number;
9
10
  cwd: string;
11
+ // Claude Code session files and resume IDs are credential-profile scoped.
12
+ // Missing values mean the legacy/default Claude profile (process env rules).
13
+ // `claudeConfigDir` is the RESOLVED dir (see claudeDirForProfile) and is
14
+ // in-memory only — persistence strips it and keeps just the opaque profile
15
+ // id, re-deriving the dir through the router on restore.
16
+ accountProfileId?: string;
17
+ claudeConfigDir?: string;
18
+ // Identity anchor of the pi conversation this record belongs to, encoded
19
+ // component-wise as `u:<12hex>` or `u:<12hex>|a:<12hex>` (see
20
+ // conversationFingerprint in session-persistence.ts): a short sha256 of the
21
+ // FIRST user message's normalized text, plus — once the conversation has
22
+ // one — of the FIRST assistant message's normalized text. Pi histories
23
+ // never rewrite those opening messages — compact/tree-nav mutations set
24
+ // needsRebuild instead — so a component mismatch marks a FOREIGN
25
+ // conversation (a subagent-shaped query arriving while the parent is idle,
26
+ // vstack#1001) that must run as a clean one-shot without touching this
27
+ // record. The user component must always match; the assistant component is
28
+ // compared only when BOTH sides carry one, so a record stamped on turn 1
29
+ // (no assistant yet) still matches its own grown conversation and upgrades
30
+ // to the two-component form on the next REUSE. Absent on records restored
31
+ // from pre-3.1.1 markers → identity unknown, pre-fingerprint behavior.
32
+ conversationFingerprint?: string;
10
33
  // Force the next syncSharedSession call down the REBUILD path. Set when
11
34
  // pi has mutated its messages array out from under us (compact, tree
12
35
  // navigation) or after an abort left the JSONL in an indeterminate state.
@@ -23,16 +46,101 @@ export interface SessionState {
23
46
  forceRotate?: boolean;
24
47
  }
25
48
 
26
- // Shared mutable bridge state. Lives in its own module so the extracted
27
- // modules and index.ts observe the SAME state: ESM live bindings let every
28
- // importer READ these `let` bindings live, but only this module may assign
29
- // them cross-module writes must go through the setters below.
30
- export let sharedSession: SessionState | null = null;
49
+ // Claude session state is scoped to Pi's provider `sessionId`. Parent and child
50
+ // agents can load separate copies of the extension module while sharing the
51
+ // primary provider closure, so the lane registry must live on globalThis rather
52
+ // than in one module instance. The versioned symbol prevents an incompatible
53
+ // future store shape from being mistaken for this one.
54
+ interface SharedSessionLaneStoreV1 {
55
+ defaultSession: SessionState | null;
56
+ sessions: Map<string, SessionState | null>;
57
+ }
58
+
59
+ const SHARED_SESSION_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.shared-session-lanes.v1");
60
+
61
+ function sharedSessionLaneStore(): SharedSessionLaneStoreV1 {
62
+ const host = globalThis as Record<symbol, unknown>;
63
+ let store = host[SHARED_SESSION_LANES_SYMBOL] as SharedSessionLaneStoreV1 | undefined;
64
+ if (!store) {
65
+ store = { defaultSession: null, sessions: new Map() };
66
+ host[SHARED_SESSION_LANES_SYMBOL] = store;
67
+ }
68
+ return store;
69
+ }
70
+
31
71
  export let extensionApi: ExtensionAPI | undefined;
32
72
  export let piUI: ExtensionUIContext | undefined;
33
73
 
74
+ export function getSharedSession(): SessionState | null {
75
+ const store = sharedSessionLaneStore();
76
+ const sessionId = currentRequestLaneId();
77
+ return sessionId === undefined
78
+ ? store.defaultSession
79
+ : (store.sessions.get(sessionId) ?? null);
80
+ }
81
+
34
82
  export function setSharedSession(next: SessionState | null): void {
35
- sharedSession = next;
83
+ const store = sharedSessionLaneStore();
84
+ const sessionId = currentRequestLaneId();
85
+ if (sessionId === undefined) store.defaultSession = next;
86
+ else store.sessions.set(sessionId, next);
87
+ }
88
+
89
+ export function deleteSharedSessionLane(sessionId: string | undefined): void {
90
+ const store = sharedSessionLaneStore();
91
+ if (sessionId === undefined) store.defaultSession = null;
92
+ else store.sessions.delete(sessionId);
93
+ }
94
+
95
+ export function clearSharedSessionLanes(): void {
96
+ const store = sharedSessionLaneStore();
97
+ store.sessions.clear();
98
+ store.defaultSession = null;
99
+ }
100
+
101
+ // The lane each pi session started in, keyed by its SessionManager. An in-memory
102
+ // session (`pi --no-session`) forks by mutating the SAME SessionManager's id
103
+ // before session_shutdown fires, so the live id there names the fork rather than
104
+ // the session being torn down, and the fallback to it would prune a live
105
+ // sibling. Keyed per manager (not one slot) so overlapping parent/child
106
+ // session_start events keep their own entries, and on globalThis like the
107
+ // registry above because session_start and session_shutdown can reach different
108
+ // module instances (`/reload` mid-session, a child agent's own copy) and both
109
+ // must see the same entry.
110
+ const STARTED_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.started-lanes.v1");
111
+
112
+ function startedLaneStore(): WeakMap<object, string> {
113
+ const host = globalThis as Record<symbol, unknown>;
114
+ let store = host[STARTED_LANES_SYMBOL] as WeakMap<object, string> | undefined;
115
+ if (!store) {
116
+ store = new WeakMap<object, string>();
117
+ host[STARTED_LANES_SYMBOL] = store;
118
+ }
119
+ return store;
120
+ }
121
+
122
+ export function recordStartedLane(sessionManager: object, sessionId: string): void {
123
+ startedLaneStore().set(sessionManager, sessionId);
124
+ }
125
+
126
+ /** The lane recorded at this manager's session_start, removed as it is read —
127
+ * one shutdown per start. Undefined when no start was recorded (the caller
128
+ * falls back to the manager's live id). */
129
+ export function takeStartedLane(sessionManager: object): string | undefined {
130
+ const store = startedLaneStore();
131
+ const sessionId = store.get(sessionManager);
132
+ store.delete(sessionManager);
133
+ return sessionId;
134
+ }
135
+
136
+ /** Force the next syncSharedSession down the REBUILD path (no-op without a
137
+ * session). `forceRotate` additionally rotates the session UUID — set it when
138
+ * a concurrent CC writer may still be flushing (abort, idle kill); see the
139
+ * field docs on SessionState. */
140
+ export function markSessionForRebuild(opts: { forceRotate?: boolean } = {}): void {
141
+ const sharedSession = getSharedSession();
142
+ if (!sharedSession) return;
143
+ setSharedSession({ ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) });
36
144
  }
37
145
 
38
146
  export function setExtensionApi(next: ExtensionAPI | undefined): void {
@@ -110,7 +218,7 @@ export function reportSyntheticToolResultRepair(missing: MissingToolResult[], co
110
218
  safeNotify(
111
219
  `Claude bridge: ${missing.length} missing tool result(s) repaired with an explicit error placeholder` +
112
220
  `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
113
- `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
221
+ `Real tool output was lost before Claude session import; ${diagGuidance()}.`,
114
222
  "error",
115
223
  );
116
224
  } catch (error) {
@@ -118,7 +226,12 @@ export function reportSyntheticToolResultRepair(missing: MissingToolResult[], co
118
226
  }
119
227
  }
120
228
 
121
- export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
229
+ export function reportToolResultMismatch(
230
+ queryCtx: QueryContext,
231
+ reason: string,
232
+ cwd: string | undefined,
233
+ opts: { expectedInterruption?: boolean; forceRotate?: boolean } = {},
234
+ ): boolean {
122
235
  try {
123
236
  if (queryCtx.reportedToolResultMismatch) return false;
124
237
  const progress = queryCtx.toolResultProgress();
@@ -127,15 +240,33 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
127
240
  : progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
128
241
  if (!hasMismatch) return false;
129
242
  queryCtx.reportedToolResultMismatch = true;
130
- if (sharedSession) {
131
- sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
243
+ // The single choke point every mismatch path funnels through (abort,
244
+ // unmatched result, stream-idle, teardown). A context with no claim on
245
+ // the shared record (reentrant subagent or foreign one-shot, vstack#1001)
246
+ // still gets the full diagnostics below, but its unresolved tool state is
247
+ // its own — marking the PARENT's record needsRebuild/forceRotate here
248
+ // would flush the parent's prompt cache for a query that never touched
249
+ // its session.
250
+ if (!queryCtx.detachedFromSharedSession) markSessionForRebuild(opts);
251
+ // A user abort interrupting in-flight tool calls is expected teardown, not
252
+ // an integrity fault: mark the rebuild but skip the diag dump and toast.
253
+ if (opts.expectedInterruption) {
254
+ debug(
255
+ `tool result delivery interrupted as expected during ${reason}; ` +
256
+ `delivered=${progress.deliveredCount}/${progress.expectedCount} ` +
257
+ `resolved=${progress.resolvedCount}/${progress.expectedCount} ` +
258
+ `waiting=${progress.waitingCount} queued=${progress.queuedCount}`,
259
+ );
260
+ return true;
132
261
  }
133
262
  const toolNameSummary = compactToolNameSummary(progress.toolNames);
263
+ const sharedSession = getSharedSession();
134
264
  diagDump("tool_result_delivery_mismatch", {
135
265
  reason,
136
266
  cwd,
137
267
  progress,
138
268
  activeQueryExists: queryCtx.activeQuery !== null,
269
+ detachedFromSharedSession: queryCtx.detachedFromSharedSession,
139
270
  sharedSession: sharedSession ? {
140
271
  sessionId: sharedSession.sessionId.slice(0, 8),
141
272
  cursor: sharedSession.cursor,
@@ -158,7 +289,9 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
158
289
  `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
159
290
  `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
160
291
  `${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
161
- `Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
292
+ (queryCtx.detachedFromSharedSession
293
+ ? `Detached one-shot query — shared Claude session record left untouched; ${diagGuidance()}.`
294
+ : `Claude session will rebuild before the next turn; ${diagGuidance()}.`),
162
295
  "error",
163
296
  );
164
297
  return true;
@@ -170,9 +303,19 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
170
303
 
171
304
  export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
172
305
  if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
173
- if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
306
+ if ("sharedSession" in state) {
307
+ if (currentRequestLaneId() !== undefined) setSharedSession(state.sharedSession ?? null);
308
+ else {
309
+ clearSharedSessionLanes();
310
+ sharedSessionLaneStore().defaultSession = state.sharedSession ?? null;
311
+ }
312
+ }
174
313
  }
175
314
 
176
315
  export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
177
- return { sharedSession };
316
+ return { sharedSession: getSharedSession() };
317
+ }
318
+
319
+ export function __testSharedSessionLaneCount(): number {
320
+ return sharedSessionLaneStore().sessions.size;
178
321
  }
package/src/config.ts CHANGED
@@ -7,10 +7,18 @@
7
7
  import type { SettingSource } from "@anthropic-ai/claude-agent-sdk";
8
8
  import { existsSync, readFileSync } from "fs";
9
9
  import { homedir } from "os";
10
- import { dirname, join, resolve } from "path";
10
+ import { dirname, join, resolve, sep } from "path";
11
+ import { debug } from "./debug.js";
11
12
 
12
13
  export const PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
13
14
 
15
+ /**
16
+ * Registry the vstack extension manager reads to display the value an
17
+ * extension actually resolves for a manifest key, for extensions whose config
18
+ * has channels the manager does not own. Keyed by package id.
19
+ */
20
+ const EXTERNAL_CONFIG_RESOLVER_SYMBOL = Symbol.for("vstack.pi.extension-config-resolver");
21
+
14
22
  export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
15
23
 
16
24
  const VALID_EFFORT_LEVELS = new Set<BridgeEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
@@ -28,13 +36,20 @@ export interface Config {
28
36
  /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
29
37
  provider?: {
30
38
  appendSystemPrompt?: boolean;
31
- allowExtraUsage?: boolean;
32
39
  /** Enable Claude Code fast mode for bridge requests. */
33
40
  fastMode?: boolean;
34
41
  /** Force this Claude Code effort level for every bridge request. */
35
42
  forceEffort?: BridgeEffortLevel;
36
43
  /** Per-model Claude Code effort overrides keyed by model id (e.g. claude-opus-4-8). */
37
44
  modelEffortOverrides?: Record<string, BridgeEffortLevel>;
45
+ /**
46
+ * Verbatim override for the child's filesystem setting sources.
47
+ * Defaults (see settingSourcesForQuery in connectors.ts): connectors
48
+ * mode uses ["user"] only, so repo-controlled `.claude/settings.json`
49
+ * (project/local scope) cannot inject `env`/`apiKeyHelper` into the
50
+ * child. Listing "project"/"local" here reopens that surface — only do
51
+ * so for checkouts you trust.
52
+ */
38
53
  settingSources?: SettingSource[];
39
54
  strictMcpConfig?: boolean;
40
55
  pathToClaudeCodeExecutable?: string;
@@ -43,7 +58,9 @@ export interface Config {
43
58
  * connectors (Gmail / Google Calendar / Google Drive, etc.) to the model.
44
59
  * Off by default so Pi owns tool execution and tokens stay lean. Also
45
60
  * settable via the CLAUDE_BRIDGE_ENABLE_CONNECTORS env var (env OR config
46
- * enables it). See docs/plans/claude-bridge-google-connectors.md.
61
+ * enables it). Resolved from USER-scope config and env only — a project's
62
+ * checked-in settings cannot enable it (see USER_SCOPE_ONLY_PROVIDER_KEYS).
63
+ * See the Connectors section of this package's README.
47
64
  */
48
65
  enableConnectors?: boolean;
49
66
  /**
@@ -56,7 +73,8 @@ export interface Config {
56
73
  * one-shot approved-write executor process. Also settable via
57
74
  * CLAUDE_BRIDGE_CONNECTOR_WRITE=deny|allow (env wins over config). Any
58
75
  * value but exact `allow` is treated as `deny`. Ignored when connectors
59
- * are disabled.
76
+ * are disabled. Like enableConnectors, resolved from USER-scope config
77
+ * and env only (see USER_SCOPE_ONLY_PROVIDER_KEYS).
60
78
  */
61
79
  connectorWriteMode?: ConnectorWriteMode;
62
80
  };
@@ -178,29 +196,57 @@ export function tryParseJson(path: string): Partial<Config> {
178
196
  if (!existsSync(path)) return {};
179
197
  try {
180
198
  return JSON.parse(readFileSync(path, "utf-8"));
181
- } catch {
199
+ } catch (error) {
182
200
  // Malformed optional config should not write raw terminal diagnostics;
183
- // stdout/stderr output can corrupt active Pi TUI widgets.
201
+ // stdout/stderr output can corrupt active Pi TUI widgets. The debug log is
202
+ // the one place a silently-ignored file explains itself.
203
+ debug(`config: ignoring malformed ${path}:`, error instanceof Error ? error.message : String(error));
184
204
  return {};
185
205
  }
186
206
  }
187
207
 
188
208
  function readManagerConfig(cwd: string): SettingsRecord {
189
209
  const merged: SettingsRecord = {};
210
+ const userPath = join(piUserDir(), "settings.json");
190
211
  for (const path of settingsPaths(cwd)) {
191
212
  if (!existsSync(path)) continue;
192
213
  try {
193
214
  const parsed = JSON.parse(readFileSync(path, "utf8"));
194
215
  const configRoot = asRecord(asRecord(asRecord(parsed?.vstack)?.extensionManager)?.config);
195
216
  const config = asRecord(configRoot?.[PACKAGE_ID]);
196
- if (config) mergeDeep(merged, config);
197
- } catch {
198
- // Ignore malformed optional manager config; Pi will surface settings issues elsewhere.
217
+ if (config) mergeDeep(merged, path === userPath ? config : withoutUserScopeOnlyKeys(config));
218
+ } catch (error) {
219
+ // Ignore malformed optional manager config; Pi will surface settings
220
+ // issues elsewhere. Still say so in the debug log.
221
+ debug(`config: ignoring malformed manager config ${path}:`, error instanceof Error ? error.message : String(error));
199
222
  }
200
223
  }
201
224
  return merged;
202
225
  }
203
226
 
227
+ // Connector enablement and write mode decide whether the child claude gains
228
+ // access to the account's live connectors (mail, calendar, files) and whether
229
+ // their WRITE tools are exposed. A repo-controlled channel (a checkout's
230
+ // `.pi/settings.json` or `.pi/claude-bridge.json`, even when the project is
231
+ // trusted for ordinary options) must not be able to flip them: these two keys
232
+ // resolve from USER scope and the env vars only, mirroring the
233
+ // settingSourcesForQuery rationale — whoever writes user scope already owns
234
+ // the process.
235
+ const USER_SCOPE_ONLY_PROVIDER_KEYS = ["enableConnectors", "connectorWriteMode"] as const;
236
+
237
+ function withoutUserScopeOnlyKeys(raw: SettingsRecord): SettingsRecord {
238
+ const out = { ...raw };
239
+ for (const key of USER_SCOPE_ONLY_PROVIDER_KEYS) delete out[key];
240
+ return out;
241
+ }
242
+
243
+ function stripUserScopeOnlyProviderKeys(config: Partial<Config>): Partial<Config> {
244
+ if (!config.provider) return config;
245
+ const provider = { ...config.provider };
246
+ for (const key of USER_SCOPE_ONLY_PROVIDER_KEYS) delete provider[key];
247
+ return { ...config, provider };
248
+ }
249
+
204
250
  function boolFrom(raw: SettingsRecord, key: string): boolean | undefined {
205
251
  return typeof raw[key] === "boolean" ? raw[key] as boolean : undefined;
206
252
  }
@@ -277,8 +323,6 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
277
323
 
278
324
  const appendSystemPrompt = boolFrom(raw, "appendSystemPrompt");
279
325
  if (appendSystemPrompt !== undefined) provider.appendSystemPrompt = appendSystemPrompt;
280
- const allowExtraUsage = boolFrom(raw, "allowExtraUsage");
281
- if (allowExtraUsage !== undefined) provider.allowExtraUsage = allowExtraUsage;
282
326
  const fastMode = boolFrom(raw, "fastMode");
283
327
  if (fastMode !== undefined) provider.fastMode = fastMode;
284
328
  if (hasOwn(raw, "forceEffort")) {
@@ -312,17 +356,123 @@ function managerToConfig(raw: SettingsRecord): Partial<Config> {
312
356
  };
313
357
  }
314
358
 
359
+ /**
360
+ * A `claude-bridge.json` file. Legacy files nest provider options under
361
+ * `provider` and prompt-context flags under `promptContext`; the manifest's flat
362
+ * key shape is accepted too, so a file written either way resolves the same.
363
+ * Nested wins when a file carries both shapes for one key.
364
+ */
365
+ function legacyFileConfig(path: string): Partial<Config> {
366
+ const raw = asRecord(tryParseJson(path)) ?? {};
367
+ const flat = managerToConfig(raw);
368
+ const provider = { ...flat.provider, ...asRecord(raw.provider) } as Config["provider"];
369
+ const promptContext = { ...flat.promptContext, ...asRecord(raw.promptContext) } as Config["promptContext"];
370
+ return {
371
+ ...(flat.enabled !== undefined ? { enabled: flat.enabled } : {}),
372
+ ...(Object.keys(provider ?? {}).length ? { provider } : {}),
373
+ ...(Object.keys(promptContext ?? {}).length ? { promptContext } : {}),
374
+ };
375
+ }
376
+
377
+ interface LegacyLayer {
378
+ path: string;
379
+ config: Partial<Config>;
380
+ }
381
+
382
+ /** Legacy config layers, lowest precedence first. */
383
+ function legacyLayers(cwd: string): LegacyLayer[] {
384
+ const globalPath = join(piUserDir(), "claude-bridge.json");
385
+ const layers: LegacyLayer[] = [{ path: globalPath, config: legacyFileConfig(globalPath) }];
386
+ if (isolatedFromEnv()) return layers;
387
+ const projectSettings = projectSettingsPath(cwd);
388
+ if (!projectSettingsTrusted(projectSettings)) return layers;
389
+ const projectPath = join(dirname(projectSettings), "claude-bridge.json");
390
+ // Project trust covers ordinary options only; the connector keys stay
391
+ // user-scope/env (see USER_SCOPE_ONLY_PROVIDER_KEYS).
392
+ return [...layers, { path: projectPath, config: stripUserScopeOnlyProviderKeys(legacyFileConfig(projectPath)) }];
393
+ }
394
+
395
+ function mergeLayers(layers: LegacyLayer[]): Partial<Config> {
396
+ const merged: Partial<Config> = { provider: {}, promptContext: {} };
397
+ for (const layer of layers) {
398
+ if (layer.config.enabled !== undefined) merged.enabled = layer.config.enabled;
399
+ merged.provider = { ...merged.provider, ...layer.config.provider };
400
+ merged.promptContext = { ...merged.promptContext, ...layer.config.promptContext };
401
+ }
402
+ return merged;
403
+ }
404
+
315
405
  export function loadConfig(cwd: string): Config {
316
- const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
317
- const isolated = isolatedFromEnv();
318
- const projectSettings = isolated ? undefined : projectSettingsPath(cwd);
319
- const trustedProject = projectSettings !== undefined && projectSettingsTrusted(projectSettings);
320
- const project = trustedProject ? tryParseJson(join(dirname(projectSettings), "claude-bridge.json")) : {};
321
- const manager: Partial<Config> = isolated ? {} : managerToConfig(readManagerConfig(cwd));
322
- const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
406
+ const legacy = mergeLayers(legacyLayers(cwd));
407
+ const manager: Partial<Config> = isolatedFromEnv() ? {} : managerToConfig(readManagerConfig(cwd));
408
+ const provider = normalizeProviderConfig({ ...legacy.provider, ...manager.provider });
323
409
  return {
324
- enabled: manager.enabled ?? project.enabled ?? global.enabled ?? true,
410
+ enabled: manager.enabled ?? legacy.enabled ?? true,
325
411
  provider,
326
- promptContext: { ...global.promptContext, ...project.promptContext, ...manager.promptContext },
412
+ promptContext: { ...legacy.promptContext, ...manager.promptContext },
327
413
  };
328
414
  }
415
+
416
+ const PROVIDER_KEYS = new Set([
417
+ "appendSystemPrompt",
418
+ "connectorWriteMode",
419
+ "enableConnectors",
420
+ "fastMode",
421
+ "forceEffort",
422
+ "modelEffortOverrides",
423
+ "pathToClaudeCodeExecutable",
424
+ "strictMcpConfig",
425
+ ]);
426
+
427
+ const PROMPT_CONTEXT_KEYS = new Set([
428
+ "includeAppendSystemPromptMd",
429
+ "includeCavemanHook",
430
+ "includeProjectAgentsHook",
431
+ "includeTaskPanelHook",
432
+ ]);
433
+
434
+ function configValueForKey(config: Partial<Config>, key: string): unknown {
435
+ if (key === "enabled") return config.enabled;
436
+ if (PROVIDER_KEYS.has(key)) return normalizeProviderConfig(config.provider)?.[key as keyof NonNullable<Config["provider"]>];
437
+ if (PROMPT_CONTEXT_KEYS.has(key)) return config.promptContext?.[key as keyof NonNullable<Config["promptContext"]>];
438
+ return undefined;
439
+ }
440
+
441
+ /** Home-relative when possible — for user-facing path mentions (what to edit,
442
+ * what to paste into an issue) where an absolute path would leak the username. */
443
+ export function displayPath(path: string): string {
444
+ const home = homedir();
445
+ return home && path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
446
+ }
447
+
448
+ export interface ExternalConfigResolution {
449
+ explicit: boolean;
450
+ value: unknown;
451
+ source?: string;
452
+ }
453
+
454
+ /**
455
+ * The effective value of a manifest key from the config channels the extension
456
+ * manager does not own — the legacy `claude-bridge.json` files. Manager config
457
+ * outranks these, so the manager consults this only when neither of its own
458
+ * scopes holds the key.
459
+ */
460
+ export function resolveExternalConfigValue(key: string, cwd: string): ExternalConfigResolution {
461
+ const layers = legacyLayers(cwd);
462
+ const value = configValueForKey(mergeLayers(layers), key);
463
+ if (value === undefined) return { explicit: false, value: undefined };
464
+ const source = [...layers].reverse().find((layer) => configValueForKey(layer.config, key) !== undefined)?.path;
465
+ return { explicit: true, value, ...(source ? { source: displayPath(source) } : {}) };
466
+ }
467
+
468
+ /**
469
+ * Publish the resolver before any early return, so a bridge disabled by a
470
+ * legacy file still explains itself in the settings editor.
471
+ */
472
+ export function registerExternalConfigResolver(): void {
473
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
474
+ const existing = asRecord(host[EXTERNAL_CONFIG_RESOLVER_SYMBOL]);
475
+ const registry = existing ?? {};
476
+ if (!existing) host[EXTERNAL_CONFIG_RESOLVER_SYMBOL] = registry;
477
+ registry[PACKAGE_ID] = (key: string, cwd: string) => resolveExternalConfigValue(key, cwd);
478
+ }
@@ -22,8 +22,11 @@
22
22
  // bundle to its sidecar process, so rather than calling `listAccountConnectors`
23
23
  // in-process it re-implements the reader half — path
24
24
  // `<piUserDir()>/connector-cache/<sha256(CLAUDE_CONFIG_DIR).hex[0..16]>.json`,
25
- // payload `{version, scope, savedAt, connectors}`, 7-day max age as the
26
- // "is this connector installed" half of its write gate.
25
+ // payload `{version, scope, savedAt, connectors}` where `scope` is the FULL
26
+ // sha256 hex of the scope key (version 2; version 1 stored the raw
27
+ // CLAUDE_CONFIG_DIR path, which is account-identifying and does not belong in
28
+ // a state file), 7-day max age — as the "is this connector installed" half of
29
+ // its write gate.
27
30
  //
28
31
  // That coupling fails OPEN on drift by design, so a format change degrades them
29
32
  // from two gates to one rather than breaking them. It is still worth making the
@@ -34,9 +37,10 @@ import { createHash } from "node:crypto";
34
37
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
35
38
  import { dirname, join } from "node:path";
36
39
  import { piUserDir } from "./config.js";
40
+ import { debug } from "./debug.js";
37
41
  import type { ConnectorEntry } from "./connector-inventory.js";
38
42
 
39
- const CACHE_VERSION = 1;
43
+ const CACHE_VERSION = 2;
40
44
  /** Long enough to be useful across a machine's lifetime, short enough that a
41
45
  * removed connector stops being declared without needing a manual purge. A
42
46
  * stale entry is not dangerous — a connector that no longer resolves simply
@@ -44,8 +48,26 @@ const CACHE_VERSION = 1;
44
48
  * correctness boundary. */
45
49
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
46
50
 
51
+ /**
52
+ * THE canonical credential-scope key: a trimmed CLAUDE_CONFIG_DIR, or the
53
+ * `"<default>"` sentinel for the default account. The on-disk cache format has
54
+ * an external reader (see the module header), and the in-memory prime cache in
55
+ * connector-runtime.ts keys the same scopes — both MUST agree on this rule, so
56
+ * there is exactly one implementation.
57
+ */
58
+ export function scopeKeyFor(claudeConfigDir?: string): string {
59
+ return claudeConfigDir?.trim() || "<default>";
60
+ }
61
+
47
62
  export function connectorCacheScopeKey(env: NodeJS.ProcessEnv = process.env): string {
48
- return env.CLAUDE_CONFIG_DIR?.trim() || "<default>";
63
+ return scopeKeyFor(env.CLAUDE_CONFIG_DIR);
64
+ }
65
+
66
+ // Full sha256 hex of a scope key. The filename keeps only the first 16 chars;
67
+ // the payload stores the whole digest so a truncated-hash collision (or a
68
+ // hand-copied file) is still caught by the scope check in readCachedConnectors.
69
+ function connectorCacheScopeDigest(scopeKey: string): string {
70
+ return createHash("sha256").update(scopeKey).digest("hex");
49
71
  }
50
72
 
51
73
  /**
@@ -55,7 +77,7 @@ export function connectorCacheScopeKey(env: NodeJS.ProcessEnv = process.env): st
55
77
  * dir is an arbitrary absolute path.
56
78
  */
57
79
  export function connectorCachePath(scopeKey: string = connectorCacheScopeKey()): string {
58
- const digest = createHash("sha256").update(scopeKey).digest("hex").slice(0, 16);
80
+ const digest = connectorCacheScopeDigest(scopeKey).slice(0, 16);
59
81
  return join(piUserDir(), "connector-cache", `${digest}.json`);
60
82
  }
61
83
 
@@ -78,15 +100,20 @@ export function readCachedConnectors(
78
100
  let parsed: any;
79
101
  try {
80
102
  parsed = JSON.parse(raw);
81
- } catch {
103
+ } catch (error) {
104
+ // A corrupt cache degrades to the no-cache path by contract, but silently
105
+ // doing so on every turn is how a bad file hides forever.
106
+ debug(`connector-cache: corrupt cache ${connectorCachePath(scopeKey)}:`, error instanceof Error ? error.message : String(error));
82
107
  return undefined;
83
108
  }
84
109
  if (parsed?.version !== CACHE_VERSION) return undefined;
85
- // Scope is stored as well as hashed into the path: a hash collision or a
86
- // hand-copied file would otherwise hand one account another's connectors,
87
- // which is the exact failure the token-scoping note in connector-inventory.ts
88
- // warns about.
89
- if (parsed?.scope !== scopeKey) return undefined;
110
+ // The scope digest is stored as well as hashed into the path: a truncated-
111
+ // hash collision or a hand-copied file would otherwise hand one account
112
+ // another's connectors, which is the exact failure the token-scoping note in
113
+ // connector-inventory.ts warns about. The payload carries the digest, never
114
+ // the raw scope key a config-dir path is account-identifying and the
115
+ // filename is already hashed for the same reason.
116
+ if (parsed?.scope !== connectorCacheScopeDigest(scopeKey)) return undefined;
90
117
  const savedAt = typeof parsed?.savedAt === "number" ? parsed.savedAt : 0;
91
118
  if (!savedAt || now - savedAt > MAX_AGE_MS || savedAt > now) return undefined;
92
119
  if (!Array.isArray(parsed?.connectors)) return undefined;
@@ -108,11 +135,14 @@ export function writeCachedConnectors(
108
135
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
109
136
  writeFileSync(
110
137
  path,
111
- JSON.stringify({ version: CACHE_VERSION, scope: scopeKey, savedAt: now, connectors }),
138
+ JSON.stringify({ version: CACHE_VERSION, scope: connectorCacheScopeDigest(scopeKey), savedAt: now, connectors }),
112
139
  { mode: 0o600 },
113
140
  );
114
141
  return true;
115
- } catch {
142
+ } catch (error) {
143
+ // Best-effort by contract, but a persistent write failure means every cold
144
+ // process re-loses the turn-1 race this cache exists to win — say so.
145
+ debug(`connector-cache: write failed ${path}:`, error instanceof Error ? error.message : String(error));
116
146
  return false;
117
147
  }
118
148
  }
@@ -140,16 +140,23 @@ export function connectorServerNamespace(connectorName: string): string {
140
140
  }
141
141
 
142
142
  // Candidate credential files, in precedence order. CLAUDE_CONFIG_DIR is set
143
- // per-account by hosts that run one sidecar per Claude account, so it must win
144
- // over the home-directory default or a multi-account host reads the wrong
145
- // account's connectors. Both file names are probed under each root because the
146
- // token and the org UUID do not reliably live in the same file across versions.
143
+ // per-account by hosts that run one sidecar per Claude account, so when it is
144
+ // set that root is probed EXCLUSIVELY: falling through to `~/.claude`/$HOME
145
+ // would silently borrow the DEFAULT account's token for a managed profile
146
+ // whose own `.credentials.json` is missing a confident, well-formed answer
147
+ // for the wrong account (see the token-scoping note above). Only the
148
+ // no-config-dir default probes the home locations. Both file names are probed
149
+ // under each root because the token and the org UUID do not reliably live in
150
+ // the same file across versions.
147
151
  export function credentialCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
148
152
  const roots: string[] = [];
149
153
  const configDir = env.CLAUDE_CONFIG_DIR?.trim();
150
- if (configDir) roots.push(configDir);
151
- const home = env.HOME?.trim();
152
- if (home) roots.push(`${home}/.claude`, home);
154
+ if (configDir) {
155
+ roots.push(configDir);
156
+ } else {
157
+ const home = env.HOME?.trim();
158
+ if (home) roots.push(`${home}/.claude`, home);
159
+ }
153
160
  const seen = new Set<string>();
154
161
  const paths: string[] = [];
155
162
  for (const root of roots) {