@vanillagreen/pi-claude-bridge 2.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -139
- package/bundle/connector-inventory.js +6 -3
- package/bundle/index.js +2436 -1127
- package/package.json +16 -25
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +189 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +157 -14
- package/src/config.ts +174 -24
- package/src/connector-cache.ts +45 -15
- package/src/connector-inventory.ts +16 -9
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +289 -43
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +6 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +770 -703
- package/src/models.ts +0 -7
- package/src/native-provider.ts +9 -4
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +296 -40
- package/src/rate-limit.ts +18 -15
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +369 -54
- package/src/tool-pairing-audit.ts +69 -0
package/src/bridge-state.ts
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { debug, diagDump,
|
|
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
|
+
// kendex#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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
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("kendex.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
|
-
|
|
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("kendex.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;
|
|
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(
|
|
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
|
-
|
|
131
|
-
|
|
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, kendex#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
|
-
|
|
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)
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// User-facing extension config. Legacy config is loaded from
|
|
2
|
-
// ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json.
|
|
2
|
+
// ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json. kendex extension
|
|
3
3
|
// manager config is loaded from settings.json and overrides legacy files in
|
|
4
4
|
// normal Pi sessions. Isolated embedding hosts consume only the authoritative
|
|
5
5
|
// user claude-bridge.json and never read extension-manager settings.
|
|
@@ -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 kendex 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("kendex.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).
|
|
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
|
};
|
|
@@ -120,14 +138,14 @@ function projectSettingsPath(cwd: string): string {
|
|
|
120
138
|
while (true) {
|
|
121
139
|
const candidate = join(current, ".pi", "settings.json");
|
|
122
140
|
if (existsSync(candidate)) return candidate;
|
|
123
|
-
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".
|
|
141
|
+
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
|
|
124
142
|
const parent = dirname(current);
|
|
125
143
|
if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
|
|
126
144
|
current = parent;
|
|
127
145
|
}
|
|
128
146
|
}
|
|
129
147
|
|
|
130
|
-
const PROJECT_TRUST_SYMBOL = Symbol.for("
|
|
148
|
+
const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
|
|
131
149
|
|
|
132
150
|
interface ProjectTrustRegistry {
|
|
133
151
|
projectSettings?: Map<string, boolean>;
|
|
@@ -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
|
-
const configRoot = asRecord(asRecord(asRecord(parsed?.
|
|
215
|
+
const configRoot = asRecord(asRecord(asRecord(parsed?.kendex)?.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
|
|
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
|
|
317
|
-
const
|
|
318
|
-
const
|
|
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 ??
|
|
410
|
+
enabled: manager.enabled ?? legacy.enabled ?? true,
|
|
325
411
|
provider,
|
|
326
|
-
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
|
+
}
|
package/src/connector-cache.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Cross-PROCESS cache of the connector inventory (
|
|
1
|
+
// Cross-PROCESS cache of the connector inventory (kendex#870).
|
|
2
2
|
//
|
|
3
3
|
// #868 primes the inventory at provider registration, but the fetch takes ~1.5s
|
|
4
4
|
// while the first query is built at ~0.5-0.8s, so turn 1 of a cold sidecar goes
|
|
@@ -18,12 +18,15 @@
|
|
|
18
18
|
// wrong-version cache returns undefined and the caller falls back to today's
|
|
19
19
|
// behaviour — the same fail-open contract as the inventory call itself.
|
|
20
20
|
//
|
|
21
|
-
// The ON-DISK FORMAT HAS AN EXTERNAL READER (
|
|
21
|
+
// The ON-DISK FORMAT HAS AN EXTERNAL READER (kendex#892). drovr quarantines this
|
|
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}
|
|
26
|
-
//
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
86
|
-
// hand-copied file would otherwise hand one account
|
|
87
|
-
// which is the exact failure the token-scoping note in
|
|
88
|
-
// warns about.
|
|
89
|
-
|
|
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
|
}
|