@code-yeongyu/senpi 2026.10.16-3 → 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 (99) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/bundle/chunks/{chunk-I5ZJEXTM.js → chunk-2NO5ZOYE.js} +6 -6
  3. package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-X5VCHHXR.js → chunk-GY5O7NIQ.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-AVALXFDW.js → chunk-JD4CV3GH.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-FMYLLJBQ.js → chunk-KWC3MCLP.js} +1 -1
  8. package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
  9. package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
  10. package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-HJPX7PBL.js → chunk-XCRGEMY2.js} +125 -118
  12. package/dist/bundle/chunks/{cli-main-JVPFJGP5.js → cli-main-I6JF2GPS.js} +1 -1
  13. package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
  14. package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
  15. package/dist/bundle/chunks/{interactive-mode-BROZRY34.js → interactive-mode-4Q3TIXOZ.js} +1 -1
  16. package/dist/bundle/chunks/{multi-session-host-SMQR5SCJ.js → multi-session-host-XEZB2XVR.js} +5 -4
  17. package/dist/bundle/chunks/{package-manager-cli-2QFJY2AS.js → package-manager-cli-YWZHOZOY.js} +1 -1
  18. package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
  19. package/dist/bundle/chunks/{rpc-mode-Z45T3NZO.js → rpc-mode-SLWXVXXE.js} +1 -1
  20. package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
  21. package/dist/bundle/chunks/{session-control-endpoint-H7FFWF2A.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
  22. package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
  23. package/dist/bundle/chunks/session-worker.js +243 -235
  24. package/dist/bundle/cli.js +2 -2
  25. package/dist/bundle/index.js +1 -1
  26. package/dist/bundle/rpc-entry.js +1 -1
  27. package/dist/bundle/runtime-manifest.json +1 -1
  28. package/dist/core/agent-session-runtime.d.ts +15 -0
  29. package/dist/core/agent-session-runtime.js +53 -2
  30. package/dist/core/agent-session.d.ts +12 -0
  31. package/dist/core/agent-session.js +42 -1
  32. package/dist/core/context-files.d.ts +21 -0
  33. package/dist/core/context-files.js +175 -0
  34. package/dist/core/diagnostics.d.ts +1 -0
  35. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
  36. package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
  37. package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
  38. package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
  39. package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
  40. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
  41. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
  42. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
  43. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
  44. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
  45. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
  46. package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
  47. package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
  48. package/dist/core/extensions/builtin/permission-system/index.js +3 -1
  49. package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
  50. package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
  51. package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
  52. package/dist/core/extensions/builtin/permission-system/service.js +1 -1
  53. package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
  54. package/dist/core/package-manager.js +4 -32
  55. package/dist/core/project-trust.d.ts +8 -0
  56. package/dist/core/project-trust.js +12 -0
  57. package/dist/core/resource-loader.d.ts +5 -7
  58. package/dist/core/resource-loader.js +11 -84
  59. package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
  60. package/dist/core/retry-fallback/circuit-probes.js +3 -0
  61. package/dist/core/retry-fallback/circuit.d.ts +5 -0
  62. package/dist/core/retry-fallback/circuit.js +27 -4
  63. package/dist/core/retry-fallback/controller.d.ts +9 -4
  64. package/dist/core/retry-fallback/controller.js +21 -4
  65. package/dist/core/settings-manager.d.ts +15 -0
  66. package/dist/core/settings-manager.js +21 -1
  67. package/dist/core/skills-prompt.d.ts +18 -0
  68. package/dist/core/skills-prompt.js +131 -0
  69. package/dist/core/skills.d.ts +1 -9
  70. package/dist/core/skills.js +1 -59
  71. package/dist/core/trust-manager.d.ts +13 -4
  72. package/dist/core/trust-manager.js +52 -21
  73. package/dist/main.js +23 -4
  74. package/dist/modes/interactive/interactive-mode.js +10 -3
  75. package/dist/modes/rpc/abort-grace.d.ts +9 -0
  76. package/dist/modes/rpc/abort-grace.js +11 -0
  77. package/dist/modes/rpc/effect-commands.d.ts +0 -2
  78. package/dist/modes/rpc/effect-commands.js +2 -3
  79. package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
  80. package/dist/modes/rpc/host-handover-parked.js +1 -0
  81. package/dist/modes/rpc/host-handover-reopen.js +11 -2
  82. package/dist/modes/rpc/rpc-client.d.ts +28 -2
  83. package/dist/modes/rpc/rpc-client.js +24 -11
  84. package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
  85. package/dist/modes/rpc/rpc-request-deadline.js +15 -0
  86. package/dist/modes/rpc/session-command-router.js +7 -4
  87. package/dist/modes/rpc/session-registry-attach.js +6 -1
  88. package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
  89. package/dist/modes/rpc/session-registry-claims.js +68 -0
  90. package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
  91. package/dist/modes/rpc/session-registry-opening.js +57 -0
  92. package/dist/modes/rpc/session-registry.d.ts +1 -0
  93. package/dist/modes/rpc/session-registry.js +48 -60
  94. package/docs/rpc.md +6 -2
  95. package/docs/settings.md +13 -0
  96. package/package.json +6 -6
  97. package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
  98. package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
  99. package/dist/bundle/chunks/session-picker-2O2BNBVE.js +0 -2
@@ -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;
@@ -4,6 +4,7 @@ import type { RetryPolicyProfile } from "@earendil-works/pi-ai/utils/retry-profi
4
4
  import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities, WheelScrollLines } from "@earendil-works/pi-tui";
5
5
  import type { CompactionModelSelector, CompactionSettings } from "./compaction-settings-access.ts";
6
6
  import { type ResolvedCompactionSettings } from "./compaction-settings-resolver.ts";
7
+ import type { ContextFileLimits } from "./context-files.ts";
7
8
  import { type FallbackCircuitSettings, type ResolvedFallbackCircuitSettings } from "./retry-fallback/circuit.ts";
8
9
  import { type ResolvedHintPolicySettings, type ResolvedRetryFallbackSettings, type RetrySettings as RetrySettingsConfig } from "./retry-fallback/settings.ts";
9
10
  import { parseSettingsJson } from "./settings-json.ts";
@@ -68,6 +69,7 @@ export interface Settings {
68
69
  compaction?: CompactionSettings & {
69
70
  model?: string;
70
71
  };
72
+ contextFiles?: ContextFileLimits;
71
73
  branchSummary?: BranchSummarySettings;
72
74
  retry?: RetrySettingsConfig;
73
75
  fallback?: FallbackCircuitSettings;
@@ -167,6 +169,8 @@ export declare function getSettingsPath(cwd: string, agentDir: string, scope: Se
167
169
  export declare function getInMemorySettingsPath(scope: SettingsScope): string;
168
170
  export interface SettingsManagerCreateOptions {
169
171
  projectTrusted?: boolean;
172
+ /** The project trust exists only because the session runs Full access (senpi#3414): leaving it withdraws the trust. */
173
+ trustFromFullAccess?: boolean;
170
174
  }
171
175
  export interface SettingsStorage {
172
176
  withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
@@ -281,6 +285,17 @@ export declare class SettingsManager {
281
285
  getTodoFirstTurnPlan(): TodoFirstTurnPlan;
282
286
  getTodoTurnEndBackstop(): boolean;
283
287
  isProjectTrusted(): boolean;
288
+ /**
289
+ * True when this session's project trust came only from its Full access preset (never asked, never
290
+ * saved): leaving Full access withdraws it, and going back restores it (senpi#3414, #3420).
291
+ */
292
+ private trustFromFullAccess;
293
+ isTrustFromFullAccess(): boolean;
294
+ /** True when a Full-access-only trust was withdrawn in this session and Full access may restore it. */
295
+ canRestoreFullAccessTrust(): boolean;
296
+ /** Counts project-trust changes; a reload compares it before installing what it loaded (#3420). */
297
+ private trustEpoch;
298
+ getTrustEpoch(): number;
284
299
  setProjectTrusted(trusted: boolean): void;
285
300
  reload(): Promise<void>;
286
301
  getSelectedSettingsSources(): SettingsSourceSelection[];
@@ -465,6 +465,13 @@ export class SettingsManager {
465
465
  this.selectedSources = new Map();
466
466
  this.sourceListeners = [];
467
467
  this.providerSettingsListeners = new Set();
468
+ /**
469
+ * True when this session's project trust came only from its Full access preset (never asked, never
470
+ * saved): leaving Full access withdraws it, and going back restores it (senpi#3414, #3420).
471
+ */
472
+ this.trustFromFullAccess = false;
473
+ /** Counts project-trust changes; a reload compares it before installing what it loaded (#3420). */
474
+ this.trustEpoch = 0;
468
475
  this.storage = storage;
469
476
  this.globalSettings = initialGlobal;
470
477
  this.projectSettings = initialProject;
@@ -506,7 +513,9 @@ export class SettingsManager {
506
513
  if (projectLoad.error) {
507
514
  initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project));
508
515
  }
509
- return new SettingsManager(storage, globalLoad.settings, projectLoad.settings, globalLoad.error, projectLoad.error, initialErrors, projectTrusted, initialSources, settingsPaths);
516
+ const manager = new SettingsManager(storage, globalLoad.settings, projectLoad.settings, globalLoad.error, projectLoad.error, initialErrors, projectTrusted, initialSources, settingsPaths);
517
+ manager.trustFromFullAccess = options.trustFromFullAccess === true;
518
+ return manager;
510
519
  }
511
520
  /** Create an in-memory SettingsManager (no file I/O) */
512
521
  static inMemory(settings = {}, options = {}) {
@@ -734,11 +743,22 @@ export class SettingsManager {
734
743
  isProjectTrusted() {
735
744
  return this.projectTrusted;
736
745
  }
746
+ isTrustFromFullAccess() {
747
+ return this.trustFromFullAccess && this.projectTrusted;
748
+ }
749
+ /** True when a Full-access-only trust was withdrawn in this session and Full access may restore it. */
750
+ canRestoreFullAccessTrust() {
751
+ return this.trustFromFullAccess && !this.projectTrusted;
752
+ }
753
+ getTrustEpoch() {
754
+ return this.trustEpoch;
755
+ }
737
756
  setProjectTrusted(trusted) {
738
757
  if (this.projectTrusted === trusted) {
739
758
  return;
740
759
  }
741
760
  this.projectTrusted = trusted;
761
+ this.trustEpoch += 1;
742
762
  this.modifiedProjectFields.clear();
743
763
  this.modifiedProjectNestedFields.clear();
744
764
  if (!trusted) {
@@ -0,0 +1,18 @@
1
+ import type { ResourceDiagnostic } from "./diagnostics.ts";
2
+ import type { Skill } from "./skills.ts";
3
+ export declare const COMPACT_SKILL_CATALOG_COUNT = 100;
4
+ export declare const COMPACT_SKILL_CATALOG_DESCRIPTION_BYTES: number;
5
+ export declare const MAX_SKILL_CATALOG_BYTES: number;
6
+ /** A catalog this large is listed by name plus a one-line description, since it rides on every request. */
7
+ export declare function isLargeSkillCatalog(skills: readonly Skill[]): boolean;
8
+ export declare function warnIfLargeSkillCatalog(skills: readonly Skill[]): ResourceDiagnostic[];
9
+ /**
10
+ * Format skills for inclusion in a system prompt.
11
+ * Uses XML format per Agent Skills standard.
12
+ * See: https://agentskills.io/integrate-skills
13
+ *
14
+ * Skills with disableModelInvocation=true are excluded from the prompt
15
+ * (they can only be invoked explicitly via /skill:name commands).
16
+ */
17
+ export declare function formatSkillsForPrompt(skills: Skill[], fileReadTool?: "read" | "bash"): string;
18
+ //# sourceMappingURL=skills-prompt.d.ts.map
@@ -0,0 +1,131 @@
1
+ export const COMPACT_SKILL_CATALOG_COUNT = 100;
2
+ export const COMPACT_SKILL_CATALOG_DESCRIPTION_BYTES = 64 * 1024;
3
+ export const MAX_SKILL_CATALOG_BYTES = 48 * 1024;
4
+ const ONE_LINE_DESCRIPTION_MAX_CHARS = 160;
5
+ /** A catalog this large is listed by name plus a one-line description, since it rides on every request. */
6
+ export function isLargeSkillCatalog(skills) {
7
+ if (skills.length > COMPACT_SKILL_CATALOG_COUNT)
8
+ return true;
9
+ const bytes = skills.reduce((sum, skill) => sum + Buffer.byteLength(skill.description, "utf8"), 0);
10
+ return bytes > COMPACT_SKILL_CATALOG_DESCRIPTION_BYTES;
11
+ }
12
+ export function warnIfLargeSkillCatalog(skills) {
13
+ const catalog = buildSkillCatalog(skills, "read");
14
+ if (!catalog.compact && !catalog.namesOnly)
15
+ return [];
16
+ return [
17
+ {
18
+ type: "warning",
19
+ category: "size-limit",
20
+ message: catalog.namesOnly
21
+ ? `The skills section is limited to ${MAX_SKILL_CATALOG_BYTES / 1024} KiB: descriptions were dropped and ${catalog.omitted} skills were omitted. Remove unused skills to shrink every request.`
22
+ : `The skills catalog uses one-line descriptions to limit request size (above ${COMPACT_SKILL_CATALOG_COUNT} skills, ${COMPACT_SKILL_CATALOG_DESCRIPTION_BYTES / 1024} KiB of descriptions, or ${MAX_SKILL_CATALOG_BYTES / 1024} KiB rendered). Remove unused skills to shrink every request.`,
23
+ },
24
+ ];
25
+ }
26
+ function buildSkillCatalog(skills, fileReadTool) {
27
+ const visible = skills.filter((skill) => !skill.disableModelInvocation);
28
+ const compact = isLargeSkillCatalog(visible);
29
+ const options = { fileReadTool, compact, namesOnly: false, omitted: 0 };
30
+ const prompt = renderSkills(visible, options);
31
+ if (Buffer.byteLength(prompt) <= MAX_SKILL_CATALOG_BYTES)
32
+ return { prompt, compact, namesOnly: false, omitted: 0 };
33
+ const compactPrompt = compact ? prompt : renderSkills(visible, { ...options, compact: true });
34
+ if (Buffer.byteLength(compactPrompt) <= MAX_SKILL_CATALOG_BYTES) {
35
+ return { prompt: compactPrompt, compact: true, namesOnly: false, omitted: 0 };
36
+ }
37
+ const minimal = { ...options, namesOnly: true };
38
+ const namesPrompt = renderSkills(visible, minimal);
39
+ if (Buffer.byteLength(namesPrompt) <= MAX_SKILL_CATALOG_BYTES) {
40
+ return { prompt: namesPrompt, compact, namesOnly: true, omitted: 0 };
41
+ }
42
+ let low = 0;
43
+ let high = visible.length;
44
+ while (low < high) {
45
+ const count = Math.ceil((low + high) / 2);
46
+ const candidate = renderSkills(visible.slice(0, count), { ...minimal, omitted: visible.length - count });
47
+ if (Buffer.byteLength(candidate) <= MAX_SKILL_CATALOG_BYTES)
48
+ low = count;
49
+ else
50
+ high = count - 1;
51
+ }
52
+ const omitted = visible.length - low;
53
+ return {
54
+ prompt: renderSkills(visible.slice(0, low), { ...minimal, omitted }),
55
+ compact,
56
+ namesOnly: true,
57
+ omitted,
58
+ };
59
+ }
60
+ function oneLineDescription(description) {
61
+ const firstLine = description.trim().split("\n", 1)[0]?.trim() ?? "";
62
+ return firstLine.length <= ONE_LINE_DESCRIPTION_MAX_CHARS
63
+ ? firstLine
64
+ : `${firstLine.slice(0, ONE_LINE_DESCRIPTION_MAX_CHARS - 1).trimEnd()}…`;
65
+ }
66
+ /**
67
+ * Format skills for inclusion in a system prompt.
68
+ * Uses XML format per Agent Skills standard.
69
+ * See: https://agentskills.io/integrate-skills
70
+ *
71
+ * Skills with disableModelInvocation=true are excluded from the prompt
72
+ * (they can only be invoked explicitly via /skill:name commands).
73
+ */
74
+ // Skill roots share long absolute prefixes across every skill under them; rendering
75
+ // that prefix per skill bills it once per skill. The roots table pays it once per
76
+ // distinct root and each location becomes a short alias/relative path. A one-line
77
+ // rule tells the model to expand aliases by joining them back to the root.
78
+ // `fileReadTool` lets a bash-only tool set still load skills: the loading rule names
79
+ // the tool the session actually has.
80
+ export function formatSkillsForPrompt(skills, fileReadTool = "read") {
81
+ return buildSkillCatalog(skills, fileReadTool).prompt;
82
+ }
83
+ function renderSkills(visibleSkills, options) {
84
+ if (visibleSkills.length === 0 && options.omitted === 0) {
85
+ return "";
86
+ }
87
+ const rootByPath = new Map();
88
+ for (const skill of visibleSkills) {
89
+ const dir = skill.filePath.split("/").slice(0, -2).join("/"); // parent of <name>/SKILL.md
90
+ if (!rootByPath.has(dir))
91
+ rootByPath.set(dir, `r${rootByPath.size}`);
92
+ }
93
+ const lines = [
94
+ "\n\nThe following skills provide specialized instructions for specific tasks.",
95
+ options.fileReadTool === "read"
96
+ ? "Use the read tool to load a skill's file when its description matches the task and its instructions would change the work; keyword overlap or mere availability is not a reason."
97
+ : "Use bash to load a skill's file when its description matches the task and its instructions would change the work; keyword overlap or mere availability is not a reason.",
98
+ "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
99
+ "",
100
+ "<skill_roots>",
101
+ ];
102
+ for (const [dir, alias] of rootByPath) {
103
+ lines.push(` <${alias}>${escapeXml(dir)}</${alias}>`);
104
+ }
105
+ lines.push("</skill_roots>", "A location's `rN/` prefix expands to the matching root above.", "", "<available_skills>");
106
+ for (const skill of visibleSkills) {
107
+ const dir = skill.filePath.split("/").slice(0, -2).join("/");
108
+ const alias = rootByPath.get(dir);
109
+ const relative = skill.filePath.startsWith(`${dir}/`) ? skill.filePath.slice(dir.length + 1) : skill.filePath;
110
+ lines.push(" <skill>");
111
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
112
+ if (!options.namesOnly) {
113
+ lines.push(` <description>${escapeXml(options.compact ? oneLineDescription(skill.description) : skill.description)}</description>`);
114
+ }
115
+ lines.push(` <location>${escapeXml(`${alias}/${relative}`)}</location>`);
116
+ lines.push(" </skill>");
117
+ }
118
+ if (options.omitted > 0)
119
+ lines.push(` <omitted_skills>${options.omitted}</omitted_skills>`);
120
+ lines.push("</available_skills>");
121
+ return lines.join("\n");
122
+ }
123
+ function escapeXml(str) {
124
+ return str
125
+ .replace(/&/g, "&amp;")
126
+ .replace(/</g, "&lt;")
127
+ .replace(/>/g, "&gt;")
128
+ .replace(/"/g, "&quot;")
129
+ .replace(/'/g, "&apos;");
130
+ }
131
+ //# sourceMappingURL=skills-prompt.js.map
@@ -1,5 +1,6 @@
1
1
  import type { ResourceDiagnostic } from "./diagnostics.ts";
2
2
  import { type SourceInfo } from "./source-info.ts";
3
+ export { formatSkillsForPrompt } from "./skills-prompt.ts";
3
4
  export interface SkillFrontmatter {
4
5
  name?: string;
5
6
  description?: string;
@@ -39,15 +40,6 @@ export interface LoadSkillsFromDirOptions {
39
40
  * - recurse into subdirectories to find SKILL.md
40
41
  */
41
42
  export declare function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult;
42
- /**
43
- * Format skills for inclusion in a system prompt.
44
- * Uses XML format per Agent Skills standard.
45
- * See: https://agentskills.io/integrate-skills
46
- *
47
- * Skills with disableModelInvocation=true are excluded from the prompt
48
- * (they can only be invoked explicitly via /skill:name commands).
49
- */
50
- export declare function formatSkillsForPrompt(skills: Skill[], fileReadTool?: "read" | "bash"): string;
51
43
  export interface LoadSkillsOptions {
52
44
  /** Working directory for project-local skills. */
53
45
  cwd: string;
@@ -6,6 +6,7 @@ import { parseFrontmatter } from "../utils/frontmatter.js";
6
6
  import { canonicalizePath, resolvePath } from "../utils/paths.js";
7
7
  import { readSkillMarkdownSource, shouldSkipSkillWalkDirectoryName } from "./skill-discovery.js";
8
8
  import { createSyntheticSourceInfo } from "./source-info.js";
9
+ export { formatSkillsForPrompt } from "./skills-prompt.js";
9
10
  /** Max name length per spec */
10
11
  const MAX_NAME_LENGTH = 64;
11
12
  /** Max description length per spec */
@@ -266,65 +267,6 @@ function loadSkillFromFile(filePath, source) {
266
267
  diagnostics,
267
268
  };
268
269
  }
269
- /**
270
- * Format skills for inclusion in a system prompt.
271
- * Uses XML format per Agent Skills standard.
272
- * See: https://agentskills.io/integrate-skills
273
- *
274
- * Skills with disableModelInvocation=true are excluded from the prompt
275
- * (they can only be invoked explicitly via /skill:name commands).
276
- */
277
- // Skill roots share long absolute prefixes across every skill under them; rendering
278
- // that prefix per skill bills it once per skill. The roots table pays it once per
279
- // distinct root and each location becomes a short alias/relative path. A one-line
280
- // rule tells the model to expand aliases by joining them back to the root.
281
- // `fileReadTool` lets a bash-only tool set still load skills: the loading rule names
282
- // the tool the session actually has.
283
- export function formatSkillsForPrompt(skills, fileReadTool = "read") {
284
- const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
285
- if (visibleSkills.length === 0) {
286
- return "";
287
- }
288
- const rootByPath = new Map();
289
- for (const skill of visibleSkills) {
290
- const dir = skill.filePath.split("/").slice(0, -2).join("/"); // parent of <name>/SKILL.md
291
- if (!rootByPath.has(dir))
292
- rootByPath.set(dir, `r${rootByPath.size}`);
293
- }
294
- const lines = [
295
- "\n\nThe following skills provide specialized instructions for specific tasks.",
296
- fileReadTool === "read"
297
- ? "Use the read tool to load a skill's file when its description matches the task and its instructions would change the work; keyword overlap or mere availability is not a reason."
298
- : "Use bash to load a skill's file when its description matches the task and its instructions would change the work; keyword overlap or mere availability is not a reason.",
299
- "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
300
- "",
301
- "<skill_roots>",
302
- ];
303
- for (const [dir, alias] of rootByPath) {
304
- lines.push(` <${alias}>${escapeXml(dir)}</${alias}>`);
305
- }
306
- lines.push("</skill_roots>", "A location's `rN/` prefix expands to the matching root above.", "", "<available_skills>");
307
- for (const skill of visibleSkills) {
308
- const dir = skill.filePath.split("/").slice(0, -2).join("/");
309
- const alias = rootByPath.get(dir);
310
- const relative = skill.filePath.startsWith(`${dir}/`) ? skill.filePath.slice(dir.length + 1) : skill.filePath;
311
- lines.push(" <skill>");
312
- lines.push(` <name>${escapeXml(skill.name)}</name>`);
313
- lines.push(` <description>${escapeXml(skill.description)}</description>`);
314
- lines.push(` <location>${escapeXml(`${alias}/${relative}`)}</location>`);
315
- lines.push(" </skill>");
316
- }
317
- lines.push("</available_skills>");
318
- return lines.join("\n");
319
- }
320
- function escapeXml(str) {
321
- return str
322
- .replace(/&/g, "&amp;")
323
- .replace(/</g, "&lt;")
324
- .replace(/>/g, "&gt;")
325
- .replace(/"/g, "&quot;")
326
- .replace(/'/g, "&apos;");
327
- }
328
270
  /**
329
271
  * Load skills from all configured locations.
330
272
  * Returns skills and any validation diagnostics.
@@ -17,13 +17,22 @@ export declare function getProjectTrustParentPath(cwd: string): string | undefin
17
17
  export declare function getProjectTrustOptions(cwd: string, options?: {
18
18
  includeSessionOnly?: boolean;
19
19
  }): ProjectTrustOption[];
20
+ /**
21
+ * The project `.agents/skills` directories for `cwd`, nearest first: one per folder from cwd up to
22
+ * the git root (or the filesystem root), stopping below the user's home. Home and every folder
23
+ * above it are never a project; `~/.agents/skills` is the user's own global skills. The ONE walk
24
+ * both the trust check and the loader use, over the same spelling and the same git root (#3420
25
+ * review): paths stay as spelled (resources are compared as strings), and each folder's real path
26
+ * decides whether it is home or above, so a symlink can't carry the walk past home either way.
27
+ */
28
+ export declare function projectAgentsSkillDirs(cwd: string): string[];
20
29
  /**
21
30
  * Returns true when cwd has project-local resources that must be gated by
22
31
  * project trust: trust-requiring entries under the project config dir or the
23
- * legacy cwd/.pi dir (whose resources are also discovered), or .agents/skills in
24
- * cwd or one of its ancestors. Returns false when no such project resources
25
- * exist. The user/global ~/.agents/skills directory is always treated as a
26
- * trusted user resource and is ignored here, even when cwd is $HOME.
32
+ * legacy cwd/.pi dir (whose resources are also discovered), or a project
33
+ * `.agents/skills` (`projectAgentsSkillDirs`, the same walk the loader uses).
34
+ * The user's home and every folder above it are never a project: their config
35
+ * dirs and .agents/skills are the user's own global resources (#2465).
27
36
  */
28
37
  export declare function hasTrustRequiringProjectResources(cwd: string): boolean;
29
38
  export declare class ProjectTrustStore {