@vanillagreen/pi-claude-bridge 1.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.9.0",
3
+ "version": "3.2.2",
4
4
  "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -19,12 +19,12 @@
19
19
  },
20
20
  "vstack": {
21
21
  "extensionManager": {
22
- "displayName": "Claude Bridge",
22
+ "displayName": "Pi Claude",
23
23
  "settings": [
24
24
  {
25
25
  "key": "enabled",
26
- "label": "Enable Claude bridge provider",
27
- "description": "Register claude-bridge models that route Pi turns through Claude Code via the Claude Agent SDK.",
26
+ "label": "Enable Pi Claude provider",
27
+ "description": "Register pi-claude models that route Pi turns through Claude Code via the Claude Agent SDK.",
28
28
  "type": "boolean",
29
29
  "default": true,
30
30
  "category": "General",
@@ -34,7 +34,7 @@
34
34
  {
35
35
  "key": "appendSystemPrompt",
36
36
  "label": "Forward AGENTS.md + skills",
37
- "description": "Original bridge behavior: append AGENTS.md and Pi's skills block to Claude Code's claude_code preset prompt.",
37
+ "description": "Original bridge behavior: append the nearest context file (AGENTS.override.md, AGENTS.md, or AGENTS.MD) and Pi's skills block to Claude Code's claude_code preset prompt.",
38
38
  "type": "boolean",
39
39
  "default": true,
40
40
  "category": "Base Prompt",
@@ -85,19 +85,10 @@
85
85
  "category": "Claude Code",
86
86
  "apply": "live"
87
87
  },
88
- {
89
- "key": "allowExtraUsage",
90
- "label": "Allow extra usage helper",
91
- "description": "Allow claude-bridge to launch Claude Code's /extra-usage flow when rate limits require extra usage. Billing/admin approval still happens in Claude's browser page.",
92
- "type": "boolean",
93
- "default": false,
94
- "category": "Claude Code",
95
- "apply": "live"
96
- },
97
88
  {
98
89
  "key": "fastMode",
99
90
  "label": "Fast mode",
100
- "description": "Enable Claude Code fast mode for bridge requests when the selected model supports it. Off by default.",
91
+ "description": "Enable Claude Code fast mode for bridge requests when the selected model and account support it. Off by default.",
101
92
  "type": "boolean",
102
93
  "default": false,
103
94
  "category": "Claude Code",
@@ -106,7 +97,7 @@
106
97
  {
107
98
  "key": "forceEffort",
108
99
  "label": "Force Claude effort",
109
- "description": "Override Pi's thinking-level mapping for all claude-bridge requests. Pi 0.80.6 and newer can select max natively; none keeps Pi's selected level.",
100
+ "description": "Override Pi's thinking-level mapping for all Pi Claude requests. Pi 0.80.6 and newer can select max natively; none keeps Pi's selected level.",
110
101
  "type": "enum",
111
102
  "enumValues": [
112
103
  "none",
@@ -123,7 +114,7 @@
123
114
  {
124
115
  "key": "modelEffortOverrides",
125
116
  "label": "Model effort overrides",
126
- "description": "Optional JSON object mapping claude-bridge model ids to Claude Code effort levels, e.g. {\"claude-opus-4-8\":\"max\"}. Keys may also use claude-bridge/<id> or *.",
117
+ "description": "Optional JSON object mapping Pi Claude model ids to Claude Code effort levels, e.g. {\"claude-opus-4-8\":\"max\"}. Keys may also use pi-claude/<id> or *.",
127
118
  "type": "string",
128
119
  "default": "{}",
129
120
  "category": "Claude Code",
@@ -142,18 +133,18 @@
142
133
  }
143
134
  },
144
135
  "dependencies": {
145
- "@anthropic-ai/claude-agent-sdk": "^0.3.215",
136
+ "@anthropic-ai/claude-agent-sdk": "^0.3.220",
146
137
  "@anthropic-ai/sdk": "^0.112.4",
147
- "cc-session-io": "^0.3.1",
138
+ "cc-session-io": "^0.3.2",
148
139
  "change-case": "^5.4.4"
149
140
  },
150
141
  "peerDependencies": {
151
- "@earendil-works/pi-ai": "*",
152
- "@earendil-works/pi-coding-agent": "*"
142
+ "@earendil-works/pi-ai": ">=0.81.0",
143
+ "@earendil-works/pi-coding-agent": ">=0.81.0"
153
144
  },
154
145
  "devDependencies": {
155
- "@earendil-works/pi-ai": "^0.80.10",
156
- "@earendil-works/pi-coding-agent": "^0.80.10",
146
+ "@earendil-works/pi-ai": "^0.84.0",
147
+ "@earendil-works/pi-coding-agent": "^0.84.0",
157
148
  "@types/node": "^24.3.0",
158
149
  "esbuild": "^0.28.0",
159
150
  "tsx": "^4.21.0",
@@ -0,0 +1,112 @@
1
+ // Reciprocal account-host service: a local /usage probe the companion account
2
+ // manager calls to read a profile's identity and usage figures under that
3
+ // profile's credential scope. Extracted from index.ts (pure move).
4
+
5
+ import { subscriberProfileEnv, type ClaudeAccountRoute, type ClaudeBridgeAccountHostV1 } from "./account-router.js";
6
+ import { preflightClaudeExecutable, resolveClaudeExecutable, spawnClaudeCodeWithDiagnostics } from "./claude-executable.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { CLAUDE_BRIDGE_TOOL_ISOLATION, denyAllToolsHook } from "./connectors.js";
9
+ import { debug, makeCliDebugOptions } from "./debug.js";
10
+ import { sdkQueryFactory } from "./sdk-query.js";
11
+
12
+ /** Local /usage probe for the reciprocal account-host service: the companion
13
+ * account manager asks the bridge (the SDK owner) to read a profile's identity
14
+ * and usage figures under that profile's credential scope. */
15
+ // This is a published entry point (BRIDGE_ACCOUNT_HOST.probeProfile) and
16
+ // `signal` is optional, so a stalled child with no caller signal would hang the
17
+ // returned promise forever and leak the process. The internal deadline bounds
18
+ // every call; generous next to the in-query probe's 1.5s race because a cold
19
+ // child spawn is part of the budget here.
20
+ const ACCOUNT_PROBE_DEADLINE_MS = 10_000;
21
+
22
+ export async function probeClaudeAccountProfile(input: {
23
+ profile: ClaudeAccountRoute;
24
+ cwd: string;
25
+ signal?: AbortSignal;
26
+ /** Deadline override for tests; production callers use the default. */
27
+ deadlineMs?: number;
28
+ }): Promise<{
29
+ identity?: { email?: string; organization?: string; subscriptionType?: string; authMethod?: string };
30
+ usage?: unknown;
31
+ }> {
32
+ const config = loadConfig(input.cwd);
33
+ const claudeExecutable = resolveClaudeExecutable(config.provider?.pathToClaudeCodeExecutable);
34
+ if (claudeExecutable) preflightClaudeExecutable(claudeExecutable, input.cwd);
35
+ const probe = sdkQueryFactory({
36
+ prompt: "/usage",
37
+ options: {
38
+ cwd: input.cwd,
39
+ env: {
40
+ ...subscriberProfileEnv(input.profile),
41
+ ENABLE_CLAUDEAI_MCP_SERVERS: "0",
42
+ DISABLE_AUTO_COMPACT: "1",
43
+ },
44
+ maxTurns: 1,
45
+ // bypassPermissions makes tool containment the only gate, so the probe
46
+ // gets BOTH layers: the standard bridge isolation lists AND a deny-all
47
+ // PreToolUse hook — a /usage probe has no business executing anything.
48
+ permissionMode: "bypassPermissions",
49
+ ...CLAUDE_BRIDGE_TOOL_ISOLATION,
50
+ hooks: { PreToolUse: [{ hooks: [denyAllToolsHook()] }] },
51
+ ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
52
+ spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
53
+ ...makeCliDebugOptions("account-probe"),
54
+ },
55
+ });
56
+ const onAbort = () => {
57
+ void probe.interrupt().catch(() => {});
58
+ try { probe.close(); } catch {}
59
+ };
60
+ if (input.signal?.aborted) onAbort();
61
+ else input.signal?.addEventListener("abort", onAbort, { once: true });
62
+ let controls: Promise<{
63
+ identity?: { email?: string; organization?: string; subscriptionType?: string; authMethod?: string };
64
+ usage?: unknown;
65
+ }> | undefined;
66
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
67
+ const deadline = new Promise<"deadline">((resolveDeadline) => {
68
+ deadlineTimer = setTimeout(() => {
69
+ debug(`account-probe: deadline expired for ${input.profile.label}; killing probe child`);
70
+ onAbort();
71
+ resolveDeadline("deadline");
72
+ }, input.deadlineMs ?? ACCOUNT_PROBE_DEADLINE_MS);
73
+ deadlineTimer.unref?.();
74
+ });
75
+ const consume = (async () => {
76
+ for await (const message of probe) {
77
+ if (message.type === "system" && (message as any).subtype === "init" && !controls) {
78
+ controls = Promise.allSettled([
79
+ probe.accountInfo(),
80
+ probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
81
+ ]).then(([identityResult, usageResult]) => ({
82
+ ...(identityResult.status === "fulfilled" ? { identity: {
83
+ email: identityResult.value.email,
84
+ organization: identityResult.value.organization,
85
+ subscriptionType: identityResult.value.subscriptionType,
86
+ } } : {}),
87
+ ...(usageResult.status === "fulfilled" ? { usage: usageResult.value } : {}),
88
+ }));
89
+ }
90
+ }
91
+ return controls ? await controls : {};
92
+ })();
93
+ try {
94
+ // The race — not close() alone — is what guarantees the promise settles: a
95
+ // truly wedged child can survive close(), and then the for-await above
96
+ // never ends.
97
+ const result = await Promise.race([consume, deadline]);
98
+ return result === "deadline" ? {} : result;
99
+ } finally {
100
+ clearTimeout(deadlineTimer);
101
+ input.signal?.removeEventListener("abort", onAbort);
102
+ probe.close();
103
+ // After a deadline kill the consumer may still reject; that outcome is
104
+ // already accounted for.
105
+ void consume.catch(() => {});
106
+ }
107
+ }
108
+
109
+ export const BRIDGE_ACCOUNT_HOST: ClaudeBridgeAccountHostV1 = {
110
+ version: 1,
111
+ probeProfile: probeClaudeAccountProfile,
112
+ };
@@ -0,0 +1,272 @@
1
+ // Optional multi-account subscription routing contract.
2
+ //
3
+ // A companion extension may publish a router on `globalThis` under the
4
+ // versioned symbol below to supply a subscription profile for each fresh
5
+ // Claude request. The bridge passes only an opaque profile id, a display
6
+ // label, and an optional CLAUDE_CONFIG_DIR; credentials remain owned by the
7
+ // official Claude CLI. Incompatible future shapes must use a new
8
+ // symbol/version instead of mutating this contract in place.
9
+
10
+ import type { AssistantMessageEvent, AssistantMessageEventStream } from "@earendil-works/pi-ai";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { debug } from "./debug.js";
14
+ import { resetTimestampMs } from "./rate-limit.js";
15
+
16
+ export const CLAUDE_ACCOUNT_ROUTER_SYMBOL = Symbol.for("vstack.pi.claude-account-router.v1");
17
+ export const CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = Symbol.for("vstack.pi.claude-bridge.account-host.v1");
18
+
19
+ export interface ClaudeAccountRoute {
20
+ profileId: string;
21
+ label: string;
22
+ configDir?: string;
23
+ /** Effective model selected by the companion after model-scoped quota exhaustion. */
24
+ modelId?: string;
25
+ fallbackReason?: "fable-quota";
26
+ }
27
+
28
+ export type ClaudeAccountFailureKind = "auth" | "billing" | "rate-limit" | "overloaded" | "server" | "network";
29
+
30
+ export interface ClaudeAccountRouterV1 {
31
+ version: 1;
32
+ acquire(input: {
33
+ modelId: string;
34
+ sessionId?: string;
35
+ excludedProfileIds?: string[];
36
+ forceRerank?: boolean;
37
+ reason?: string;
38
+ }): ClaudeAccountRoute;
39
+ recordIdentity(profileId: string, identity: {
40
+ email?: string;
41
+ organization?: string;
42
+ organizationId?: string;
43
+ subscriptionType?: string;
44
+ authMethod?: string;
45
+ }): void;
46
+ recordUsage(profileId: string, usage: unknown): void;
47
+ recordRateLimit(profileId: string, info: Record<string, unknown> | undefined, modelId: string): number;
48
+ recordFailure(profileId: string, kind: ClaudeAccountFailureKind, modelId: string): void;
49
+ recordSuccess(profileId: string, sessionId?: string): void;
50
+ current(modelId: string, sessionId?: string): ClaudeAccountRoute | undefined;
51
+ /** Resolve a previously issued profile id back to its route. The bridge
52
+ * persists ONLY the opaque profile id into Pi session entries (config-dir
53
+ * paths are account-identifying and travel with shared session archives),
54
+ * so restoring a session re-derives the config dir here. A missing router
55
+ * or unknown id degrades to the default-profile rule. */
56
+ resolveProfile?(profileId: string): Pick<ClaudeAccountRoute, "profileId" | "configDir"> | undefined;
57
+ }
58
+
59
+ export interface ClaudeBridgeAccountHostV1 {
60
+ version: 1;
61
+ probeProfile(input: {
62
+ profile: ClaudeAccountRoute;
63
+ cwd: string;
64
+ signal?: AbortSignal;
65
+ }): Promise<{
66
+ identity?: {
67
+ email?: string;
68
+ organization?: string;
69
+ subscriptionType?: string;
70
+ authMethod?: string;
71
+ };
72
+ usage?: unknown;
73
+ }>;
74
+ }
75
+
76
+ export function resolveClaudeAccountRouter(): ClaudeAccountRouterV1 | undefined {
77
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
78
+ const candidate = host[CLAUDE_ACCOUNT_ROUTER_SYMBOL] as ClaudeAccountRouterV1 | undefined;
79
+ return candidate?.version === 1 ? candidate : undefined;
80
+ }
81
+
82
+ /** Run a companion-router telemetry callback without letting it fail the turn.
83
+ * The router is third-party code reached from delivery paths (a throwing
84
+ * recordSuccess would otherwise error a turn whose answer already rendered). */
85
+ export function safeRouterCall(label: string, call: () => void): void {
86
+ try {
87
+ call();
88
+ } catch (error) {
89
+ debug(`router callback ${label} threw:`, error);
90
+ }
91
+ }
92
+
93
+ export function subscriberProfileEnv(
94
+ profile: Pick<ClaudeAccountRoute, "configDir">,
95
+ base: NodeJS.ProcessEnv = process.env,
96
+ ): NodeJS.ProcessEnv {
97
+ const env: NodeJS.ProcessEnv = { ...base };
98
+ // Managed profiles are subscription identities. Inherited API/provider
99
+ // credentials and endpoint overrides would silently bypass the profile and
100
+ // create separate billing or route its OAuth token through a gateway.
101
+ const directOverrides = new Set([
102
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
103
+ "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_AWS_API_KEY",
104
+ "ANTHROPIC_FOUNDRY_AUTH_TOKEN", "ANTHROPIC_BEDROCK_BASE_URL",
105
+ "ANTHROPIC_VERTEX_BASE_URL", "ANTHROPIC_FOUNDRY_BASE_URL", "AWS_BEARER_TOKEN_BEDROCK",
106
+ ]);
107
+ for (const key of Object.keys(env)) {
108
+ if (directOverrides.has(key) || key.startsWith("CLAUDE_CODE_USE_")) delete env[key];
109
+ }
110
+ if (profile.configDir) env.CLAUDE_CONFIG_DIR = profile.configDir;
111
+ else delete env.CLAUDE_CONFIG_DIR;
112
+ return env;
113
+ }
114
+
115
+ /** The claude config dir the CHILD actually uses under this profile: the
116
+ * profile's own configDir, else the real default (`~/.claude`). NEVER the
117
+ * parent's CLAUDE_CONFIG_DIR — subscriberProfileEnv scrubs that from managed
118
+ * children, so any bridge-side session IO that resolved through the process
119
+ * env would write JSONLs where the child never looks and break resume. */
120
+ export function claudeDirForProfile(profile: Pick<ClaudeAccountRoute, "configDir">): string {
121
+ return profile.configDir?.trim() || join(homedir(), ".claude");
122
+ }
123
+
124
+ export interface AccountSessionScope {
125
+ accountProfileId?: string;
126
+ claudeConfigDir?: string;
127
+ }
128
+
129
+ /** Session scope for a managed request: the opaque profile id plus the FULLY
130
+ * RESOLVED claude dir (see claudeDirForProfile). Empty for legacy requests,
131
+ * which keep reading CLAUDE_CONFIG_DIR from the process env as before. */
132
+ export function accountSessionScope(profile: ClaudeAccountRoute | undefined): AccountSessionScope {
133
+ return profile
134
+ ? { accountProfileId: profile.profileId, claudeConfigDir: claudeDirForProfile(profile) }
135
+ : {};
136
+ }
137
+
138
+ export function commitsVisibleOutput(event: AssistantMessageEvent): boolean {
139
+ if (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") {
140
+ return event.delta.length > 0;
141
+ }
142
+ if (event.type === "text_end" || event.type === "thinking_end") return event.content.length > 0;
143
+ return event.type === "toolcall_end";
144
+ }
145
+
146
+ /**
147
+ * Holds protocol setup events until the first visible delta/tool call. A failed
148
+ * account can then be discarded and retried without leaking a duplicate `start`
149
+ * frame into Pi. Terminal success/error commits the buffered frames.
150
+ */
151
+ export class RetryEventBuffer {
152
+ private readonly pending: AssistantMessageEvent[] = [];
153
+ private committed = false;
154
+ private ended = false;
155
+ private discarded = false;
156
+ private readonly target: AssistantMessageEventStream;
157
+ private readonly onCommit?: () => void;
158
+
159
+ constructor(target: AssistantMessageEventStream, onCommit?: () => void) {
160
+ this.target = target;
161
+ this.onCommit = onCommit;
162
+ }
163
+
164
+ push(event: AssistantMessageEvent): void {
165
+ if (this.discarded) return;
166
+ if (this.committed) {
167
+ this.target.push(event);
168
+ return;
169
+ }
170
+ this.pending.push(event);
171
+ if (commitsVisibleOutput(event) || event.type === "done" || event.type === "error") this.commit();
172
+ }
173
+
174
+ end(): void {
175
+ if (this.discarded) return;
176
+ this.ended = true;
177
+ if (this.committed) this.target.end();
178
+ }
179
+
180
+ commit(): void {
181
+ if (this.discarded || this.committed) return;
182
+ this.committed = true;
183
+ this.onCommit?.();
184
+ for (const event of this.pending) this.target.push(event);
185
+ this.pending.length = 0;
186
+ if (this.ended) this.target.end();
187
+ }
188
+
189
+ discard(): void {
190
+ if (this.committed) return;
191
+ this.discarded = true;
192
+ this.pending.length = 0;
193
+ }
194
+
195
+ get hasCommittedOutput(): boolean { return this.committed; }
196
+ }
197
+
198
+ export function rateLimitTypeFromInfo(info: Record<string, unknown> | undefined): unknown {
199
+ return info?.rateLimitType ?? info?.rate_limit_type ?? info?.type;
200
+ }
201
+
202
+ export function rateLimitResetFromInfo(info: Record<string, unknown> | undefined): unknown {
203
+ return info?.resetsAt ?? info?.resets_at ?? info?.resetAt ?? info?.reset_at;
204
+ }
205
+
206
+ export function rateLimitResetMs(info: Record<string, unknown> | undefined): number | undefined {
207
+ // The seconds-vs-ms magnitude heuristic lives in resetTimestampMs; this is
208
+ // just field extraction over it.
209
+ return resetTimestampMs(rateLimitResetFromInfo(info));
210
+ }
211
+
212
+ // An HTTP status code counts only in an http-ish context ("status 429",
213
+ // "HTTP 500", "API Error: 529", "code: 401") — a bare 3-digit number in prose
214
+ // ("took 500ms", "line 502") must not classify (S5).
215
+ function httpStatusInText(normalized: string): number | undefined {
216
+ const match = /\b(?:http|https|status(?: code)?|error|code)\b[^a-z0-9]{0,4}([45]\d\d)\b/.exec(normalized);
217
+ return match ? Number(match[1]) : undefined;
218
+ }
219
+
220
+ function classifyStatusCode(status: number): ClaudeAccountFailureKind | undefined {
221
+ if (status === 401) return "auth";
222
+ // Anthropic maps 403 to permission_error (org restrictions,
223
+ // oauth_org_not_allowed): this credential cannot serve the request, so
224
+ // another profile might — same rotation posture as 401.
225
+ if (status === 403) return "auth";
226
+ if (status === 402) return "billing";
227
+ if (status === 429) return "rate-limit";
228
+ if (status === 529) return "overloaded";
229
+ if (status >= 500 && status <= 599) return "server";
230
+ return undefined;
231
+ }
232
+
233
+ export function classifyClaudeFailure(value: unknown): ClaudeAccountFailureKind | undefined {
234
+ const details: unknown[] = [value];
235
+ let numericStatus: number | undefined;
236
+ if (value && typeof value === "object") {
237
+ const record = value as Record<string, unknown>;
238
+ details.push(record.name, record.type, record.message, record.code, record.status, record.statusCode, record.body, record.error);
239
+ for (const field of [record.status, record.statusCode]) {
240
+ if (typeof field === "number" && Number.isInteger(field)) { numericStatus = field; break; }
241
+ }
242
+ }
243
+ const text = details.map((detail) => {
244
+ if (typeof detail === "string" || typeof detail === "number") return String(detail);
245
+ try { return JSON.stringify(detail ?? ""); } catch { return String(detail); }
246
+ }).join(" ");
247
+ const normalized = text.toLowerCase().replace(/[_-]+/g, " ");
248
+ // Structured status fields are unambiguous — classify them before prose.
249
+ const statusKind = numericStatus !== undefined ? classifyStatusCode(numericStatus) : undefined;
250
+ if (statusKind) return statusKind;
251
+ // `permission error` covers the API's permission_error type (403); the bare
252
+ // words "permission denied" alone stay unclassified — they are just as likely
253
+ // a filesystem EACCES, which no other account can fix.
254
+ if (/authentication (?:failed|error)|permission error|oauth org not allowed|oauth token.*expired|token.*expired|unauthorized|invalid token|login required|please run .*login|not logged in/.test(normalized)
255
+ || httpStatusInText(normalized) === 401 || httpStatusInText(normalized) === 403) return "auth";
256
+ // Extra Usage is controlled by Claude account settings. A request asking for
257
+ // it means the current model allowance was rejected, not that Pi should make
258
+ // a billing-policy decision or globally disable the profile.
259
+ if (/extra usage|overage/.test(normalized)) return "rate-limit";
260
+ if (/billing error|payment|required.*billing|credit balance.*(?:low|insufficient|empty)|insufficient credits/.test(normalized)) return "billing";
261
+ // `quota` alone is ambiguous ("disk quota exceeded") — require rate/usage
262
+ // context words alongside it.
263
+ const quotaInUsageContext = /\bquota\b/.test(normalized) && /\b(?:rate|usage|limits?|requests?|tokens?|messages?|api)\b/.test(normalized);
264
+ if (/\brate limit|usage limit|session limit|weekly limit|monthly limit|limit reached|you(?:'|’)ve hit your .* limit|too many requests|resets? (?:at )?\d/.test(normalized)
265
+ || quotaInUsageContext
266
+ || httpStatusInText(normalized) === 429) return "rate-limit";
267
+ if (/overloaded|capacity/.test(normalized) || httpStatusInText(normalized) === 529) return "overloaded";
268
+ const statusInText = httpStatusInText(normalized);
269
+ if (/server error|internal server/.test(normalized) || (statusInText !== undefined && statusInText >= 500)) return "server";
270
+ if (/network|timeout|timed out|socket|econn|connection closed|fetch failed|unexpected end|\beof\b/.test(normalized)) return "network";
271
+ return undefined;
272
+ }
package/src/agents-md.ts CHANGED
@@ -1,37 +1,78 @@
1
1
  // AGENTS.md discovery and sanitization for forwarding to Claude Code.
2
2
  //
3
3
  // Pi uses AGENTS.md for long-lived instructions; Claude Code reads the same
4
- // content under "# CLAUDE.md". We walk up from cwd looking for AGENTS.md,
4
+ // content under "# CLAUDE.md". We walk up from cwd looking for a context file,
5
5
  // fall back to <piUserDir>/AGENTS.md (~/.pi/agent/AGENTS.md unless
6
6
  // PI_CODING_AGENT_DIR points elsewhere), and rewrite pi-specific references
7
7
  // (~/.pi, .pi/, .pi, pi) to their Claude Code equivalents so any paths or
8
8
  // references in the file still resolve inside the CC subprocess.
9
9
  //
10
+ // Per directory we mirror the AGENTS.* entries of Pi's own candidate order
11
+ // (resource-loader.ts): AGENTS.override.md, AGENTS.md, AGENTS.MD -- the override
12
+ // replaces AGENTS.md in the same directory. Pi's list continues with CLAUDE.md and
13
+ // CLAUDE.MD, which we deliberately omit: the Claude Code subprocess already loads
14
+ // CLAUDE.md natively, so forwarding it would apply the same context twice.
15
+ //
16
+ // We forward the NEAREST context file only, while Pi loads one per ancestor directory
17
+ // and concatenates them. That single-layer model predates override support and is kept
18
+ // deliberately: the bridge sanitizes and re-headers whatever it forwards into one
19
+ // "# CLAUDE.md" block, and the subprocess separately loads the repo's own CLAUDE.md, so
20
+ // concatenating every ancestor layer here risks duplicating context rather than
21
+ // completing it. Changing it is a behavior change for every bridge user and belongs in
22
+ // its own change, not in per-directory override parity.
23
+ //
10
24
  // In isolated mode (CLAUDE_BRIDGE_ISOLATED=1), all AGENTS.md discovery is
11
25
  // disabled. Embedding hosts provide their instruction surface explicitly.
12
26
 
13
- import { existsSync, readFileSync } from "fs";
27
+ import { lstatSync, readFileSync, statSync } from "fs";
14
28
  import { dirname, join, resolve } from "path";
15
29
  import { isolatedFromEnv, piUserDir } from "./config.js";
30
+ import { debug } from "./debug.js";
31
+
32
+ const CONTEXT_FILE_CANDIDATES = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD"];
16
33
 
17
- function globalAgentsPath(): string {
18
- return join(piUserDir(), "AGENTS.md");
34
+ function contextFileInDir(dir: string): string | undefined {
35
+ for (const filename of CONTEXT_FILE_CANDIDATES) {
36
+ const candidate = join(dir, filename);
37
+ try {
38
+ if (statSync(candidate).isFile()) return candidate;
39
+ } catch (error) {
40
+ // A genuinely absent candidate is the normal case and stays silent. Anything else --
41
+ // a dangling symlink (which reports ENOENT through stat but resolves through lstat),
42
+ // a permissions error, an I/O fault -- means a context file the user intended is being
43
+ // skipped, and skipping an override silently forwards the AGENTS.md it was meant to
44
+ // supersede. Only ENOENT from lstat proves genuine absence; a traversal or permission
45
+ // fault throws from both calls and must not be mistaken for "no such file".
46
+ let lstatCode: string | undefined;
47
+ let entryExists = false;
48
+ try {
49
+ lstatSync(candidate);
50
+ entryExists = true;
51
+ } catch (lstatError) {
52
+ lstatCode = (lstatError as NodeJS.ErrnoException).code;
53
+ }
54
+ if (entryExists || lstatCode !== "ENOENT") {
55
+ const detail = (error as NodeJS.ErrnoException).code ?? String(error);
56
+ const suffix = entryExists ? "" : ` (lstat: ${lstatCode ?? "unknown"})`;
57
+ debug(`agents-md: skipping unusable ${candidate}: ${detail}${suffix}`);
58
+ }
59
+ }
60
+ }
61
+ return undefined;
19
62
  }
20
63
 
21
64
  export function resolveAgentsMdPath(): string | undefined {
22
65
  if (isolatedFromEnv()) return undefined;
23
66
  const fromCwd = findAgentsMdInParents(process.cwd());
24
67
  if (fromCwd) return fromCwd;
25
- const globalPath = globalAgentsPath();
26
- if (existsSync(globalPath)) return globalPath;
27
- return undefined;
68
+ return contextFileInDir(piUserDir());
28
69
  }
29
70
 
30
71
  export function findAgentsMdInParents(startDir: string): string | undefined {
31
72
  let current = resolve(startDir);
32
73
  while (true) {
33
- const candidate = join(current, "AGENTS.md");
34
- if (existsSync(candidate)) return candidate;
74
+ const candidate = contextFileInDir(current);
75
+ if (candidate) return candidate;
35
76
  const parent = dirname(current);
36
77
  if (parent === current) break;
37
78
  current = parent;
@@ -47,7 +88,10 @@ export function extractAgentsAppend(): string | undefined {
47
88
  if (!content) return undefined;
48
89
  const sanitized = sanitizeAgentsContent(content);
49
90
  return sanitized.length > 0 ? `# CLAUDE.md\n\n${sanitized}` : undefined;
50
- } catch {
91
+ } catch (error) {
92
+ // An unreadable AGENTS.md silently drops the user's standing instructions
93
+ // from every child prompt — degrade as before, but leave a trace.
94
+ debug(`agents-md: failed to read ${agentsPath}:`, error instanceof Error ? error.message : String(error));
51
95
  return undefined;
52
96
  }
53
97
  }