@code-yeongyu/senpi 2026.10.16-2 → 2026.10.16-4

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/bundle/chunks/{chunk-QQUHMCOM.js → chunk-2NO5ZOYE.js} +6 -6
  3. package/dist/bundle/chunks/chunk-74YFRVGQ.js +2 -0
  4. package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-Q7RD3NOC.js → chunk-GY5O7NIQ.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-KRAABTZY.js → chunk-JD4CV3GH.js} +2 -2
  7. package/dist/bundle/chunks/chunk-K2EVQGGT.js +31 -0
  8. package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-V5JFWWGJ.js → chunk-KWC3MCLP.js} +1 -1
  10. package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
  11. package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
  12. package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
  13. package/dist/bundle/chunks/chunk-WL4Y6XN6.js +18 -0
  14. package/dist/bundle/chunks/{chunk-VY43SSYL.js → chunk-XCRGEMY2.js} +144 -134
  15. package/dist/bundle/chunks/{cli-main-JDL52ADK.js → cli-main-I6JF2GPS.js} +1 -1
  16. package/dist/bundle/chunks/google-generative-ai-AI6PAUPA.js +2 -0
  17. package/dist/bundle/chunks/google-vertex-USNRIFF7.js +2 -0
  18. package/dist/bundle/chunks/google-vertex-adc-JZURL4XM.js +2 -0
  19. package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
  20. package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
  21. package/dist/bundle/chunks/{interactive-mode-7IM7YJYH.js → interactive-mode-4Q3TIXOZ.js} +1 -1
  22. package/dist/bundle/chunks/{multi-session-host-3MEEUMYU.js → multi-session-host-XEZB2XVR.js} +6 -5
  23. package/dist/bundle/chunks/{openai-codex-responses-T26WW2RE.js → openai-codex-responses-NV27Q6VV.js} +1 -1
  24. package/dist/bundle/chunks/{package-manager-cli-UCHXVBQ2.js → package-manager-cli-YWZHOZOY.js} +1 -1
  25. package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
  26. package/dist/bundle/chunks/{rpc-mode-D4BJKWFL.js → rpc-mode-SLWXVXXE.js} +1 -1
  27. package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
  28. package/dist/bundle/chunks/{session-control-endpoint-NBSRSAJS.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
  29. package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
  30. package/dist/bundle/chunks/session-worker.js +266 -255
  31. package/dist/bundle/cli.js +2 -2
  32. package/dist/bundle/index.js +1 -1
  33. package/dist/bundle/rpc-entry.js +1 -1
  34. package/dist/bundle/runtime-manifest.json +1 -1
  35. package/dist/core/agent-session-runtime.d.ts +15 -0
  36. package/dist/core/agent-session-runtime.js +53 -2
  37. package/dist/core/agent-session.d.ts +28 -0
  38. package/dist/core/agent-session.js +88 -3
  39. package/dist/core/context-files.d.ts +21 -0
  40. package/dist/core/context-files.js +175 -0
  41. package/dist/core/diagnostics.d.ts +1 -0
  42. package/dist/core/dynamic-prompt/intent-gate.d.ts +3 -0
  43. package/dist/core/dynamic-prompt/intent-gate.js +4 -3
  44. package/dist/core/dynamic-prompt/policies.d.ts +4 -1
  45. package/dist/core/dynamic-prompt/policies.js +3 -2
  46. package/dist/core/dynamic-prompt/working-task.d.ts +4 -1
  47. package/dist/core/dynamic-prompt/working-task.js +3 -2
  48. package/dist/core/dynamic-prompt/workstation.d.ts +4 -3
  49. package/dist/core/dynamic-prompt/workstation.js +1 -0
  50. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
  51. package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
  52. package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
  53. package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
  54. package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
  55. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
  56. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
  57. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
  58. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
  59. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
  60. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
  61. package/dist/core/extensions/builtin/goal/index.js +18 -8
  62. package/dist/core/extensions/builtin/goal/monitor-continuation.js +7 -0
  63. package/dist/core/extensions/builtin/goal/reload-reengagement.js +5 -0
  64. package/dist/core/extensions/builtin/index.js +3 -0
  65. package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
  66. package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
  67. package/dist/core/extensions/builtin/permission-system/index.js +3 -1
  68. package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
  69. package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
  70. package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
  71. package/dist/core/extensions/builtin/permission-system/service.js +1 -1
  72. package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
  73. package/dist/core/extensions/builtin/prompt-preset/contract.js +6 -1
  74. package/dist/core/extensions/builtin/prompt-preset/gemini-4-argon.d.ts +7 -0
  75. package/dist/core/extensions/builtin/prompt-preset/gemini-4-argon.js +83 -0
  76. package/dist/core/extensions/builtin/prompt-preset/matchers.d.ts +2 -0
  77. package/dist/core/extensions/builtin/prompt-preset/matchers.js +12 -0
  78. package/dist/core/extensions/builtin/prompt-preset/presets.js +3 -0
  79. package/dist/core/extensions/builtin/prompt-preset/settings.d.ts +1 -1
  80. package/dist/core/extensions/builtin/prompt-preset/settings.js +1 -0
  81. package/dist/core/extensions/builtin/vertex-model-garden/auth.d.ts +16 -0
  82. package/dist/core/extensions/builtin/vertex-model-garden/auth.js +19 -0
  83. package/dist/core/extensions/builtin/vertex-model-garden/index.d.ts +6 -0
  84. package/dist/core/extensions/builtin/vertex-model-garden/index.js +27 -0
  85. package/dist/core/extensions/builtin/vertex-model-garden/models.d.ts +3 -0
  86. package/dist/core/extensions/builtin/vertex-model-garden/models.js +34 -0
  87. package/dist/core/extensions/builtin/vertex-model-garden/transport.d.ts +14 -0
  88. package/dist/core/extensions/builtin/vertex-model-garden/transport.js +35 -0
  89. package/dist/core/extensions/types.d.ts +32 -1
  90. package/dist/core/package-manager.js +4 -32
  91. package/dist/core/project-trust.d.ts +8 -0
  92. package/dist/core/project-trust.js +12 -0
  93. package/dist/core/provider-api-key-auth.d.ts +9 -1
  94. package/dist/core/provider-api-key-auth.js +34 -0
  95. package/dist/core/provider-composer.d.ts +23 -0
  96. package/dist/core/resource-loader.d.ts +5 -7
  97. package/dist/core/resource-loader.js +11 -84
  98. package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
  99. package/dist/core/retry-fallback/circuit-probes.js +3 -0
  100. package/dist/core/retry-fallback/circuit.d.ts +5 -0
  101. package/dist/core/retry-fallback/circuit.js +27 -4
  102. package/dist/core/retry-fallback/controller.d.ts +9 -4
  103. package/dist/core/retry-fallback/controller.js +21 -4
  104. package/dist/core/settings-manager.d.ts +19 -0
  105. package/dist/core/settings-manager.js +27 -1
  106. package/dist/core/settings-shapes.d.ts +4 -0
  107. package/dist/core/skills-prompt.d.ts +18 -0
  108. package/dist/core/skills-prompt.js +131 -0
  109. package/dist/core/skills.d.ts +1 -9
  110. package/dist/core/skills.js +1 -59
  111. package/dist/core/trust-manager.d.ts +13 -4
  112. package/dist/core/trust-manager.js +52 -21
  113. package/dist/core/turn-deadline.d.ts +92 -0
  114. package/dist/core/turn-deadline.js +179 -0
  115. package/dist/main.js +23 -4
  116. package/dist/modes/interactive/interactive-mode.js +10 -3
  117. package/dist/modes/rpc/abort-grace.d.ts +9 -0
  118. package/dist/modes/rpc/abort-grace.js +11 -0
  119. package/dist/modes/rpc/client-admission-record.d.ts +1 -0
  120. package/dist/modes/rpc/client-admissions.d.ts +6 -3
  121. package/dist/modes/rpc/client-admissions.js +9 -4
  122. package/dist/modes/rpc/connection-handler.js +8 -3
  123. package/dist/modes/rpc/effect-commands.d.ts +1 -3
  124. package/dist/modes/rpc/effect-commands.js +4 -5
  125. package/dist/modes/rpc/effect-outcome-copy.d.ts +4 -4
  126. package/dist/modes/rpc/effect-outcome-copy.js +4 -4
  127. package/dist/modes/rpc/effect-record.d.ts +2 -0
  128. package/dist/modes/rpc/effect-record.js +2 -0
  129. package/dist/modes/rpc/effect-status.js +1 -1
  130. package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
  131. package/dist/modes/rpc/host-handover-parked.js +1 -0
  132. package/dist/modes/rpc/host-handover-reopen.js +11 -2
  133. package/dist/modes/rpc/rpc-client.d.ts +28 -2
  134. package/dist/modes/rpc/rpc-client.js +24 -11
  135. package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
  136. package/dist/modes/rpc/rpc-request-deadline.js +15 -0
  137. package/dist/modes/rpc/session-command-router.js +7 -4
  138. package/dist/modes/rpc/session-registry-attach.js +6 -1
  139. package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
  140. package/dist/modes/rpc/session-registry-claims.js +68 -0
  141. package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
  142. package/dist/modes/rpc/session-registry-opening.js +57 -0
  143. package/dist/modes/rpc/session-registry.d.ts +1 -0
  144. package/dist/modes/rpc/session-registry.js +48 -60
  145. package/dist/modes/rpc/session-unload.js +4 -1
  146. package/docs/rpc.md +10 -3
  147. package/docs/settings.md +16 -1
  148. package/package.json +6 -6
  149. package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
  150. package/dist/bundle/chunks/chunk-7ASXJ4AD.js +0 -47
  151. package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
  152. package/dist/bundle/chunks/google-generative-ai-YNIOL5FT.js +0 -2
  153. package/dist/bundle/chunks/google-vertex-CF6AYVXL.js +0 -2
  154. package/dist/bundle/chunks/session-picker-6TSBGWOA.js +0 -2
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { chmodSync, existsSync, globSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { stripBom } from "../utils/text.js";
5
+ import { projectAgentsSkillDirs } from "./trust-manager.js";
5
6
  function getEnv() {
6
7
  if (process.platform !== "linux" || Object.keys(process.env).length > 0) {
7
8
  return process.env;
@@ -207,36 +208,9 @@ function collectFiles(dir, filePattern, skipNodeModules = true, ignoreMatcher, r
207
208
  }
208
209
  return files;
209
210
  }
210
- function findGitRepoRoot(startDir) {
211
- let dir = resolve(startDir);
212
- while (true) {
213
- if (existsSync(join(dir, ".git"))) {
214
- return dir;
215
- }
216
- const parent = dirname(dir);
217
- if (parent === dir) {
218
- return null;
219
- }
220
- dir = parent;
221
- }
222
- }
223
211
  function collectAncestorAgentsSkillDirs(startDir) {
224
- const skillDirs = [];
225
- const resolvedStartDir = resolve(startDir);
226
- const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
227
- let dir = resolvedStartDir;
228
- while (true) {
229
- skillDirs.push(join(dir, ".agents", "skills"));
230
- if (gitRepoRoot && dir === gitRepoRoot) {
231
- break;
232
- }
233
- const parent = dirname(dir);
234
- if (parent === dir) {
235
- break;
236
- }
237
- dir = parent;
238
- }
239
- return skillDirs;
212
+ // The same walk the trust check uses: home and above are never project skills (#3420 review).
213
+ return projectAgentsSkillDirs(startDir);
240
214
  }
241
215
  function collectAutoPromptEntries(dir) {
242
216
  const entries = [];
@@ -1964,9 +1938,7 @@ export class DefaultPackageManager {
1964
1938
  };
1965
1939
  const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
1966
1940
  const projectTrusted = this.settingsManager.isProjectTrusted();
1967
- const projectAgentsSkillDirs = projectTrusted
1968
- ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
1969
- : [];
1941
+ const projectAgentsSkillDirs = projectTrusted ? collectAncestorAgentsSkillDirs(this.cwd) : [];
1970
1942
  const addResources = (resourceType, paths, metadata, overrides, baseDir) => {
1971
1943
  const target = this.getTargetMap(accumulator, resourceType);
1972
1944
  for (const path of paths) {
@@ -2,6 +2,14 @@ import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/typ
2
2
  import type { DefaultProjectTrust } from "./settings-manager.ts";
3
3
  import { type ProjectTrustStore } from "./trust-manager.ts";
4
4
  export type AppMode = "interactive" | "print" | "json" | "rpc";
5
+ /**
6
+ * Full access means no prompt anywhere (sisyphuslabs/omo-desktop-app#2465): a session whose
7
+ * permission preset is explicitly `full-access` trusts its project for that session. Explicit
8
+ * means the session's `permission-preset` flag (CLI, or `open_session.permissionPreset` through
9
+ * the launch profile), else the user's global setting. Project settings never count: they are what
10
+ * trust gates. The implicit default never counts either, so a plain launch keeps asking.
11
+ */
12
+ export declare function presetTrustsProject(presetFlag: boolean | string | undefined, globalPreset: unknown): boolean;
5
13
  export interface ResolveProjectTrustedOptions {
6
14
  cwd: string;
7
15
  trustStore: ProjectTrustStore;
@@ -1,6 +1,18 @@
1
1
  import { APP_NAME, CONFIG_DIR_NAME } from "../config.js";
2
2
  import { emitProjectTrustEvent } from "./extensions/runner.js";
3
3
  import { getProjectTrustOptions, hasTrustRequiringProjectResources, } from "./trust-manager.js";
4
+ /**
5
+ * Full access means no prompt anywhere (sisyphuslabs/omo-desktop-app#2465): a session whose
6
+ * permission preset is explicitly `full-access` trusts its project for that session. Explicit
7
+ * means the session's `permission-preset` flag (CLI, or `open_session.permissionPreset` through
8
+ * the launch profile), else the user's global setting. Project settings never count: they are what
9
+ * trust gates. The implicit default never counts either, so a plain launch keeps asking.
10
+ */
11
+ export function presetTrustsProject(presetFlag, globalPreset) {
12
+ if (typeof presetFlag === "string")
13
+ return presetFlag === "full-access";
14
+ return globalPreset === "full-access";
15
+ }
4
16
  function formatProjectTrustPrompt(cwd) {
5
17
  return `Trust project folder?\n${cwd}\n\nThis allows ${APP_NAME} to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`;
6
18
  }
@@ -1,10 +1,18 @@
1
- import { type ApiKeyAuth, type ModelAuth, type Provider, type ProviderHeaders } from "@earendil-works/pi-ai";
1
+ import { type ApiKeyAuth, type AuthContext, type AuthResult, type ModelAuth, type Provider, type ProviderHeaders } from "@earendil-works/pi-ai";
2
2
  import type { ModelsJsonProvider } from "./model-config.ts";
3
+ interface AmbientAuthExtensionInput {
4
+ name?: string;
5
+ resolve(input: {
6
+ ctx: AuthContext;
7
+ signal: AbortSignal;
8
+ }): Promise<AuthResult | undefined>;
9
+ }
3
10
  interface ProviderAuthExtensionInput {
4
11
  apiKey?: string;
5
12
  headers?: Record<string, string>;
6
13
  authHeader?: boolean;
7
14
  oauth?: unknown;
15
+ ambientAuth?: AmbientAuthExtensionInput;
8
16
  }
9
17
  export declare function composeApiKeyAuth(providerId: string, base: Provider | undefined, config: ModelsJsonProvider | undefined, extension: ProviderAuthExtensionInput | undefined): ApiKeyAuth | undefined;
10
18
  export declare function configuredApiKey(config: ModelsJsonProvider | undefined, extension: ProviderAuthExtensionInput | undefined): string | undefined;
@@ -8,6 +8,14 @@ export function composeApiKeyAuth(providerId, base, config, extension) {
8
8
  const rawHeaders = configuredHeaders(config, extension);
9
9
  const headerSource = headerAuthResolutionSource(config?.headers, extension?.headers);
10
10
  const authHeader = extension?.authHeader ?? config?.authHeader ?? false;
11
+ const ambient = extension?.ambientAuth;
12
+ if (!inherited && rawKey === undefined && !headerSource && ambient) {
13
+ // A shared cloud credential chain (Google ADC, AWS env) owns the lane: an
14
+ // explicitly configured key, credential headers, or a base provider still
15
+ // win, and a resolution marked ambient never outranks a configured
16
+ // provider in automatic default selection (senpi#2327).
17
+ return ambientApiKeyAuth(providerId, ambient, rawHeaders, headerSource, authHeader);
18
+ }
11
19
  if (!inherited && rawKey === undefined && !headerSource && oauth) {
12
20
  // An OAuth provider with no key, no credential headers and no base normally
13
21
  // gets no api-key auth at all. Providers with ambient credentials still
@@ -101,6 +109,32 @@ function ambientOnlyAuth(providerId, oauth, rawHeaders, headerSource, authHeader
101
109
  resolve,
102
110
  };
103
111
  }
112
+ /**
113
+ * Api-key adapter for extension providers whose credentials live in a shared
114
+ * cloud credential chain (Google ADC, AWS env). No login is offered — the chain
115
+ * is the only source — and `Models.checkAuth` resolves this auth, so the
116
+ * `ambient` marker reaches the provider's auth status.
117
+ */
118
+ function ambientApiKeyAuth(providerId, ambient, rawHeaders, headerSource, authHeader) {
119
+ const resolve = async (input) => {
120
+ const result = await ambient.resolve({ ctx: input.ctx, signal: input.signal });
121
+ if (!result)
122
+ return undefined;
123
+ const explicitEnv = { ...(input.credential?.env ?? {}), ...(result.env ?? {}) };
124
+ const headerEnv = await configContextEnv(Object.values(rawHeaders ?? {}), input.ctx, explicitEnv);
125
+ const headers = await resolveHeadersOrThrow(rawHeaders, `provider "${providerId}"`, headerEnv);
126
+ return {
127
+ ...result,
128
+ auth: withConfiguredAuth(result.auth, headers, authHeader),
129
+ source: result.source ?? headerSource,
130
+ };
131
+ };
132
+ return {
133
+ name: ambient.name ?? "Ambient credentials",
134
+ ambientOnly: true,
135
+ resolve,
136
+ };
137
+ }
104
138
  export function configuredApiKey(config, extension) {
105
139
  return extension?.apiKey ?? config?.apiKey;
106
140
  }
@@ -80,6 +80,15 @@ export interface ProviderConfigInput {
80
80
  extraBody?: Record<string, unknown>;
81
81
  authHeader?: boolean;
82
82
  oauth?: ExtensionOAuthConfig;
83
+ /**
84
+ * Ambient api-key auth resolved from a shared cloud credential chain (Google
85
+ * ADC, AWS env) rather than a credential configured for this provider. The
86
+ * provider counts as configured only while `resolve` yields a result, and the
87
+ * ambient marker keeps it from outranking a provider the user configured when
88
+ * the automatic default model is chosen (senpi#2327). Ignored when `apiKey`,
89
+ * credential headers, or a base provider already supply auth.
90
+ */
91
+ ambientAuth?: ProviderAmbientAuth;
83
92
  retryPolicy?: RetryPolicyProfile;
84
93
  models?: ProviderModelConfig[];
85
94
  refreshModels?(context: RefreshModelsContext): Promise<ProviderModelConfig[]>;
@@ -92,6 +101,20 @@ export interface ProviderConfigInput {
92
101
  */
93
102
  fallbackEligible?(): boolean;
94
103
  }
104
+ /** Ambient api-key auth hook for extension providers (see `ProviderConfigInput.ambientAuth`). */
105
+ export interface ProviderAmbientAuth {
106
+ /** Display name, e.g. "Google Cloud credentials". */
107
+ name?: string;
108
+ /**
109
+ * Resolve from the ambient chain. `undefined` means the chain currently
110
+ * supplies no credential, so the provider stays unavailable. A returned
111
+ * result should carry `ambient: true`.
112
+ */
113
+ resolve(input: {
114
+ ctx: AuthContext;
115
+ signal: AbortSignal;
116
+ }): Promise<AuthResult | undefined>;
117
+ }
95
118
  export type AuthStatus = {
96
119
  configured: boolean;
97
120
  source?: "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command" | HeaderAuthStatusSource;
@@ -1,6 +1,8 @@
1
1
  import { type Theme } from "../modes/interactive/theme/theme.ts";
2
2
  import type { ResourceDiagnostic } from "./diagnostics.ts";
3
3
  export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
4
+ export { loadProjectContextFiles };
5
+ import { loadProjectContextFiles } from "./context-files.ts";
4
6
  import { type EventBus } from "./event-bus.ts";
5
7
  import type { HostMcpRegistry } from "./extensions/builtin/mcp/host-registry.ts";
6
8
  import type { ExtensionFactory, InlineExtension, LoadExtensionsResult, LoadedHookSources, SessionContext, SessionKind } from "./extensions/types.ts";
@@ -64,6 +66,7 @@ export interface ResourceLoader {
64
66
  path: string;
65
67
  content: string;
66
68
  }>;
69
+ diagnostics?: ResourceDiagnostic[];
67
70
  };
68
71
  getSystemPrompt(): string | undefined;
69
72
  getSystemPromptSource(): {
@@ -85,13 +88,6 @@ export interface ResourceLoader {
85
88
  * session. Canonicalizing collapses both spellings to one path.
86
89
  */
87
90
  export declare function canonicalizeGlobalDefaultExtensionModulePath(modulePath: string): string;
88
- export declare function loadProjectContextFiles(options: {
89
- cwd: string;
90
- agentDir: string;
91
- }): Array<{
92
- path: string;
93
- content: string;
94
- }>;
95
91
  export interface DefaultResourceLoaderOptions {
96
92
  cwd: string;
97
93
  agentDir: string;
@@ -185,6 +181,7 @@ export declare class DefaultResourceLoader implements ResourceLoader {
185
181
  private themes;
186
182
  private themeDiagnostics;
187
183
  private agentsFiles;
184
+ private contextDiagnostics;
188
185
  private systemPrompt?;
189
186
  private systemPromptSourcePath?;
190
187
  private appendSystemPrompt;
@@ -222,6 +219,7 @@ export declare class DefaultResourceLoader implements ResourceLoader {
222
219
  path: string;
223
220
  content: string;
224
221
  }>;
222
+ diagnostics: ResourceDiagnostic[];
225
223
  };
226
224
  getSystemPrompt(): string | undefined;
227
225
  getSystemPromptSource(): {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
- import { basename, dirname, join, resolve, sep } from "node:path";
2
+ import { dirname, join, resolve, sep } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { detectCapabilities, getTerminalColorMode } from "@earendil-works/pi-tui";
5
5
  import chalk from "chalk";
@@ -7,22 +7,24 @@ import { APP_NAME, CONFIG_DIR_NAME, findNodePackageDir, getAgentDir, getPackageD
7
7
  import { loadThemeFromPath } from "../modes/interactive/theme/theme.js";
8
8
  import { resolveInstallPath } from "../runtime-snapshot/marker.js";
9
9
  import { ACCEPTED_SHIM_BANNERS, GENERATED_SHIM_BANNER } from "./generated-shim-banner.js";
10
+ export { loadProjectContextFiles };
10
11
  import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.js";
11
12
  import { stripBom } from "../utils/text.js";
12
13
  import { bundledBuiltinExtensions } from "./bundled-resources.js";
14
+ import { loadProjectContextFiles } from "./context-files.js";
13
15
  import { createEventBus } from "./event-bus.js";
14
16
  import { builtinExtensions, globalDefaultExtensionFactories, globalDefaultExtensionIds, } from "./extensions/builtin/index.js";
15
17
  import { createMcpExtension } from "./extensions/builtin/mcp/index.js";
16
18
  import { McpService } from "./extensions/builtin/mcp/service.js";
17
19
  import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions, } from "./extensions/loader.js";
18
20
  import { EMPTY_SESSION_CONTEXT } from "./extensions/types.js";
19
- import { findGitPaths } from "./footer-data-provider.js";
20
21
  import { dedupePathsByPackageIdentity, findNearestPackageIdentity } from "./package-identity.js";
21
22
  import { DefaultPackageManager } from "./package-manager.js";
22
23
  import { loadPromptTemplates } from "./prompt-templates.js";
23
24
  import { memoizeResolvedPaths, resolvedPathsMemoKey } from "./resolved-paths-memo.js";
24
25
  import { SettingsManager } from "./settings-manager.js";
25
26
  import { loadSkills } from "./skills.js";
27
+ import { warnIfLargeSkillCatalog } from "./skills-prompt.js";
26
28
  import { BUILTIN_PATH_PREFIX, createSourceInfo, getSyntheticPathSource, isSyntheticPath, } from "./source-info.js";
27
29
  import { resetTimings, time } from "./timings.js";
28
30
  const HOST_PROVIDED_EXTENSION_PACKAGES = new Set([
@@ -256,86 +258,9 @@ function resolveGeneratedGlobalDefaultExtensionFactory(extensionPath, agentDir)
256
258
  return undefined;
257
259
  }
258
260
  }
259
- function loadContextFileFromDir(dir) {
260
- const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
261
- for (const filename of candidates) {
262
- const filePath = join(dir, filename);
263
- if (existsSync(filePath)) {
264
- try {
265
- if (!statSync(filePath).isFile()) {
266
- continue;
267
- }
268
- return {
269
- path: filePath,
270
- content: stripBom(readFileSync(filePath, "utf-8")),
271
- };
272
- }
273
- catch (error) {
274
- console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
275
- }
276
- }
277
- }
278
- return null;
279
- }
280
- /**
281
- * The main repo's context file that a nested linked worktree's own copy shadows: both
282
- * occupy the same logical repository scope, so loading both applies that context twice. Returns
283
- * undefined when nothing is shadowed, leaving normal ancestor inheritance alone.
284
- *
285
- * Returned canonicalized (realpath), because `git worktree add` writes the `.git`
286
- * file's `gitdir:` target in realpath form while cwd may still be symlinked
287
- * (macOS `/tmp` -> `/private/tmp`).
288
- */
289
- function findShadowedContextFile(cwd) {
290
- const gitPaths = findGitPaths(cwd);
291
- if (!gitPaths)
292
- return undefined;
293
- const commonGitDir = canonicalizePath(gitPaths.commonGitDir);
294
- const worktreeRoot = canonicalizePath(gitPaths.repoDir);
295
- const mainRepoRoot = dirname(commonGitDir);
296
- // False for an ordinary repo, where the two are the same dir, and for a sibling
297
- // worktree (`git worktree add ../feat`), whose main repo is not an ancestor.
298
- if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`))
299
- return undefined;
300
- // dirname of the common git dir is the main worktree root only when that dir is
301
- // itself checked out from the same repo. In a bare layout (`proj/.bare` +
302
- // `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a
303
- // submodule's gitdir has no `commondir`, so it lands under `.git/modules`.
304
- if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir)
305
- return undefined;
306
- const worktreeContextFile = loadContextFileFromDir(worktreeRoot);
307
- return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined;
308
- }
309
- export function loadProjectContextFiles(options) {
310
- const resolvedCwd = resolvePath(options.cwd);
311
- const resolvedAgentDir = resolvePath(options.agentDir);
312
- const contextFiles = [];
313
- const seenPaths = new Set();
314
- const globalContext = loadContextFileFromDir(resolvedAgentDir);
315
- if (globalContext) {
316
- contextFiles.push(globalContext);
317
- seenPaths.add(globalContext.path);
318
- }
319
- const ancestorContextFiles = [];
320
- const shadowedContextFile = findShadowedContextFile(resolvedCwd);
321
- let currentDir = resolvedCwd;
322
- while (true) {
323
- const contextFile = loadContextFileFromDir(currentDir);
324
- const isShadowed = shadowedContextFile !== undefined && canonicalizePath(contextFile?.path ?? "") === shadowedContextFile;
325
- if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
326
- ancestorContextFiles.unshift(contextFile);
327
- seenPaths.add(contextFile.path);
328
- }
329
- const parentDir = dirname(currentDir);
330
- if (parentDir === currentDir)
331
- break;
332
- currentDir = parentDir;
333
- }
334
- contextFiles.push(...ancestorContextFiles);
335
- return contextFiles;
336
- }
337
261
  export class DefaultResourceLoader {
338
262
  constructor(options) {
263
+ this.contextDiagnostics = [];
339
264
  this.cwd = resolvePath(options.cwd);
340
265
  this.agentDir = resolvePath(options.agentDir);
341
266
  this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);
@@ -421,7 +346,7 @@ export class DefaultResourceLoader {
421
346
  return { themes: this.themes, diagnostics: this.themeDiagnostics };
422
347
  }
423
348
  getAgentsFiles() {
424
- return { agentsFiles: this.agentsFiles };
349
+ return { agentsFiles: this.agentsFiles, diagnostics: this.contextDiagnostics };
425
350
  }
426
351
  getSystemPrompt() {
427
352
  return this.systemPrompt;
@@ -616,14 +541,16 @@ export class DefaultResourceLoader {
616
541
  }
617
542
  const agentsFiles = {
618
543
  agentsFiles: this.noContextFiles
619
- ? []
544
+ ? Object.assign([], { diagnostics: [] })
620
545
  : loadProjectContextFiles({
621
546
  cwd: this.cwd,
622
547
  agentDir: this.agentDir,
548
+ ...this.settingsManager.getSettings().contextFiles,
623
549
  }),
624
550
  };
625
551
  const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
626
- this.agentsFiles = resolvedAgentsFiles.agentsFiles;
552
+ this.agentsFiles = [...resolvedAgentsFiles.agentsFiles];
553
+ this.contextDiagnostics = agentsFiles.agentsFiles.diagnostics;
627
554
  time("contextFiles", "extensions");
628
555
  // SYSTEM.md / APPEND_SYSTEM.md file discovery was intentionally removed; the explicit
629
556
  // options are the only static prompt source (see packages/coding-agent/changes.md).
@@ -872,7 +799,7 @@ export class DefaultResourceLoader {
872
799
  skill.sourceInfo ??
873
800
  this.getDefaultSourceInfoForPath(skill.filePath),
874
801
  }));
875
- this.skillDiagnostics = resolvedSkills.diagnostics;
802
+ this.skillDiagnostics = [...resolvedSkills.diagnostics, ...warnIfLargeSkillCatalog(this.skills)];
876
803
  }
877
804
  updatePromptsFromPaths(promptPaths, metadataByPath) {
878
805
  let promptsResult;
@@ -16,6 +16,7 @@ export declare class CircuitProbes {
16
16
  constructor(circuits: FallbackCircuitAccess | undefined);
17
17
  isOpen(selector: string): boolean;
18
18
  governs(selector: string): boolean;
19
+ isExhausted(selector: string): boolean;
19
20
  admit(selector: string): CircuitAdmission["kind"];
20
21
  holds(selector: string): boolean;
21
22
  get probing(): string | undefined;
@@ -23,6 +23,9 @@ export class CircuitProbes {
23
23
  governs(selector) {
24
24
  return this.circuits?.governs(selector) ?? false;
25
25
  }
26
+ isExhausted(selector) {
27
+ return this.circuits?.isExhausted(selector) ?? false;
28
+ }
26
29
  admit(selector) {
27
30
  const admission = this.circuits?.admit(selector) ?? { kind: "closed" };
28
31
  if (admission.kind === "probe" && this.token?.selector !== selector) {
@@ -29,6 +29,8 @@ export interface CircuitOpenRequest {
29
29
  retryAfterMs?: number;
30
30
  /** The reset window a usage limit named for itself; when present it alone sets the open. */
31
31
  resetWindowMs?: number;
32
+ /** The failure is a verdict for every session: the entry is spent (billing, quota, an account limit). A provider-named wait is not. */
33
+ exhausted?: boolean;
32
34
  }
33
35
  export interface ProbeToken {
34
36
  readonly selector: string;
@@ -63,6 +65,7 @@ export declare class FallbackCircuitBreaker {
63
65
  releaseOwnersWithPrefix(prefix: string): void;
64
66
  close(selector: string): void;
65
67
  has(selector: string): boolean;
68
+ isExhausted(selector: string): boolean;
66
69
  sweep(now: number): void;
67
70
  }
68
71
  /**
@@ -80,6 +83,8 @@ export interface FallbackCircuitAccess {
80
83
  /** Whether the breaker holds any state for the selector, i.e. it, not a session cooldown, governs recovery. */
81
84
  governs(selector: string): boolean;
82
85
  isOpen(selector: string, lane?: CircuitLane): boolean;
86
+ /** Whether the open circuit records a verdict every session should act on: the entry is spent. */
87
+ isExhausted(selector: string): boolean;
83
88
  admit(selector: string, lane?: CircuitLane): CircuitAdmission;
84
89
  release(token: ProbeToken): void;
85
90
  releaseAll(): void;
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { parseRetryAfterMsMarker } from "@earendil-works/pi-ai/utils/retry-hint";
3
3
  import { usageLimitReset } from "../credential-pool/reset-time.js";
4
+ import { isHealthExhaustionFailure } from "./circuit-probes.js";
4
5
  import { usageLimitScope } from "./usage-limit.js";
5
6
  /**
6
7
  * Fallback-chain circuit breaker. An entry that fails out of a configured chain
@@ -34,8 +35,14 @@ export class FallbackCircuitBreaker {
34
35
  }
35
36
  open(selector, request) {
36
37
  const previous = this.circuits.get(selector);
37
- const consecutiveOpens = (previous?.consecutiveOpens ?? 0) + 1;
38
- const cooldownMs = Math.min(request.cooldownMs * 2 ** (consecutiveOpens - 1), request.maxCooldownMs);
38
+ // A failure while the circuit is still open (concurrent sessions failing together) belongs to
39
+ // the same open episode: it takes no backoff step. Only a failure once the open has elapsed, a
40
+ // failed half-open probe, advances it (senpi#3467).
41
+ const sameEpisode = previous !== undefined && request.now < previous.openUntil;
42
+ const consecutiveOpens = (previous?.consecutiveOpens ?? 0) + (sameEpisode ? 0 : 1);
43
+ const cooldownMs = sameEpisode
44
+ ? previous.cooldownMs
45
+ : Math.min(request.cooldownMs * 2 ** (consecutiveOpens - 1), request.maxCooldownMs);
39
46
  const retryAfterMs = request.retryAfterMs;
40
47
  const hinted = retryAfterMs !== undefined && Number.isFinite(retryAfterMs) && retryAfterMs > 0
41
48
  ? request.now + retryAfterMs
@@ -53,7 +60,7 @@ export class FallbackCircuitBreaker {
53
60
  const hintedUntil = Math.min(retryFloorUntil, request.now + request.maxCooldownMs);
54
61
  const openUntil = resetWindowMs !== undefined && Number.isFinite(resetWindowMs) && resetWindowMs > 0
55
62
  ? Math.max(request.now + Math.min(resetWindowMs, request.maxCooldownMs), hintedUntil)
56
- : Math.max(request.now + cooldownMs, hintedUntil);
63
+ : Math.max(sameEpisode ? previous.openUntil : request.now + cooldownMs, hintedUntil);
57
64
  this.circuits.set(selector, {
58
65
  openUntil,
59
66
  retryFloorUntil,
@@ -61,6 +68,7 @@ export class FallbackCircuitBreaker {
61
68
  cooldownMs,
62
69
  maxCooldownMs: request.maxCooldownMs,
63
70
  probe: undefined,
71
+ exhausted: request.exhausted === true || (sameEpisode && previous.exhausted),
64
72
  });
65
73
  return openUntil;
66
74
  }
@@ -111,6 +119,9 @@ export class FallbackCircuitBreaker {
111
119
  has(selector) {
112
120
  return this.circuits.has(selector);
113
121
  }
122
+ isExhausted(selector) {
123
+ return this.circuits.get(selector)?.exhausted === true;
124
+ }
114
125
  sweep(now) {
115
126
  for (const [selector, circuit] of this.circuits) {
116
127
  if (!circuit.probe && now >= circuit.openUntil + circuit.maxCooldownMs)
@@ -137,10 +148,22 @@ export function createFallbackCircuitAccess(deps) {
137
148
  : undefined;
138
149
  const resetWindowMs = reset !== undefined && (reset.kind === "absolute" || reset.ms >= cooldownMs) ? reset.ms : undefined;
139
150
  deps.breaker.sweep(now);
140
- const openUntil = deps.breaker.open(selector, { now, cooldownMs, maxCooldownMs, retryAfterMs, resetWindowMs });
151
+ // Only a verdict that the entry is spent for everyone (billing, quota, an account usage limit) moves
152
+ // sessions that never failed on it. A slow request, an overload or a rate limit with a Retry-After is
153
+ // this session's view: siblings keep their model and find out on their own request (senpi#3467).
154
+ const exhausted = message !== undefined && (isHealthExhaustionFailure(message) || usageLimitScope(message) === "account");
155
+ const openUntil = deps.breaker.open(selector, {
156
+ now,
157
+ cooldownMs,
158
+ maxCooldownMs,
159
+ retryAfterMs,
160
+ resetWindowMs,
161
+ exhausted,
162
+ });
141
163
  deps.logger.info("circuit_opened", { selector, durationMs: openUntil - now, retryAfterMs, resetWindowMs });
142
164
  },
143
165
  governs: (selector) => enabled() && deps.breaker.has(selector),
166
+ isExhausted: (selector) => enabled() && deps.breaker.isExhausted(selector),
144
167
  isOpen: (selector, lane = "turn") => enabled() && deps.breaker.isOpen(selector, deps.now(), laneOwner(deps.owner(), lane)),
145
168
  admit: (selector, lane = "turn") => enabled() ? deps.breaker.admit(selector, deps.now(), laneOwner(deps.owner(), lane)) : { kind: "closed" },
146
169
  release: (token) => deps.breaker.release(token),
@@ -10,6 +10,7 @@ export declare class RetryFallbackController {
10
10
  private lastExhaustedChainKey;
11
11
  private heldForNetworkBlip;
12
12
  private blipModel;
13
+ private readonly ownFailures;
13
14
  private readonly unusable;
14
15
  readonly probes: CircuitProbes;
15
16
  private canonicalCache;
@@ -56,6 +57,8 @@ export declare class RetryFallbackController {
56
57
  notifyCompactionApplied(): boolean;
57
58
  /** A user-driven model change abandons the fallback window entirely. */
58
59
  clearForManualModelChange(model: Model<Api>): void;
60
+ /** A response this session got from `selector` succeeded: the entry serves it again (senpi#3467). */
61
+ noteServed(selector: string): void;
59
62
  /**
60
63
  * Records a failure that makes the entry unusable for every session - billing
61
64
  * or quota exhaustion, or a transient failure that spent its budget or failed a
@@ -63,10 +66,12 @@ export declare class RetryFallbackController {
63
66
  */
64
67
  noteHealthFailure(model: Model<Api>, thinkingLevel: ThinkingLevel | undefined, failure: CircuitFailure): void;
65
68
  /**
66
- * Turn-boundary skip: when the current entry's circuit was opened by this or a
67
- * sibling session, move to the next closed entry without spending a request on
68
- * it. With no closed entry left the current one stays and probes, so the chain
69
- * never refuses a turn; a half-open current entry is claimed for this session.
69
+ * Turn-boundary skip: when the current entry's circuit is open because this session
70
+ * saw it fail (and has not been served by it since), or because its account is spent
71
+ * for everyone, move to the next closed entry without spending a request on it. A
72
+ * sibling's bare failure or Retry-After never moves this session (senpi#3467). With no
73
+ * closed entry left the current one stays and probes, so the chain never refuses a
74
+ * turn; a half-open current entry is claimed for this session.
70
75
  */
71
76
  rerouteAroundOpenCircuit(): Promise<boolean>;
72
77
  tryFallback(reason: FallbackReason, failure: {
@@ -11,6 +11,9 @@ export class RetryFallbackController {
11
11
  this.triedSelectors = new Set();
12
12
  // A fallback forced by a network blip serves out its turn; only the next turn boundary reverts it.
13
13
  this.heldForNetworkBlip = false;
14
+ // Entries this session itself saw fail out of a chain: only these (or an entry spent for everyone)
15
+ // may move the session off its model at a turn boundary (senpi#3467).
16
+ this.ownFailures = new Set();
14
17
  this.deps = deps;
15
18
  this.probes = new CircuitProbes(deps.circuits);
16
19
  this.unusable = new UnusableEntries(deps.cooldowns);
@@ -159,11 +162,17 @@ export class RetryFallbackController {
159
162
  }
160
163
  this.state = undefined;
161
164
  this.heldForNetworkBlip = false;
165
+ this.ownFailures.delete(formatSelector(model));
162
166
  this.deps.cooldowns.clearModel(formatSelector(model));
163
167
  this.unusable.clear(model);
164
168
  this.probes.release();
165
169
  this.deps.circuits?.close(formatSelector(model));
166
170
  }
171
+ /** A response this session got from `selector` succeeded: the entry serves it again (senpi#3467). */
172
+ noteServed(selector) {
173
+ this.ownFailures.delete(selector);
174
+ this.probes.accept(selector);
175
+ }
167
176
  /**
168
177
  * Records a failure that makes the entry unusable for every session - billing
169
178
  * or quota exhaustion, or a transient failure that spent its budget or failed a
@@ -176,21 +185,29 @@ export class RetryFallbackController {
176
185
  // A pure network fault is the transport's, not the model's: it never opens the shared circuit (senpi#3306).
177
186
  if (isPureNetworkTransient(failure.errorMessage))
178
187
  return;
188
+ this.ownFailures.add(formatSelector(model));
179
189
  this.probes.noteFailure(formatSelector(model), failure);
180
190
  }
181
191
  /**
182
- * Turn-boundary skip: when the current entry's circuit was opened by this or a
183
- * sibling session, move to the next closed entry without spending a request on
184
- * it. With no closed entry left the current one stays and probes, so the chain
185
- * never refuses a turn; a half-open current entry is claimed for this session.
192
+ * Turn-boundary skip: when the current entry's circuit is open because this session
193
+ * saw it fail (and has not been served by it since), or because its account is spent
194
+ * for everyone, move to the next closed entry without spending a request on it. A
195
+ * sibling's bare failure or Retry-After never moves this session (senpi#3467). With no
196
+ * closed entry left the current one stays and probes, so the chain never refuses a
197
+ * turn; a half-open current entry is claimed for this session.
186
198
  */
187
199
  async rerouteAroundOpenCircuit() {
188
200
  const current = this.deps.getCurrentSelector();
189
201
  if (!this.deps.circuits || !current)
190
202
  return false;
191
203
  const currentBase = formatSelector(current.model);
204
+ // A half-open entry is still claimed as this session's single probe whoever opened it.
192
205
  if (this.probes.admit(currentBase) !== "open")
193
206
  return false;
207
+ // Another session's bare failure says this entry was slow for that session, not that it is
208
+ // down for this one: a session that never failed on it keeps its model and finds out itself.
209
+ if (!this.ownFailures.has(currentBase) && !this.probes.isExhausted(currentBase))
210
+ return false;
194
211
  const candidate = this.nextCandidate(false, true);
195
212
  if (!candidate || candidate.circuitOpen)
196
213
  return false;