@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.
- package/CHANGELOG.md +28 -0
- package/dist/bundle/chunks/{chunk-I5ZJEXTM.js → chunk-2NO5ZOYE.js} +6 -6
- package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
- package/dist/bundle/chunks/{chunk-X5VCHHXR.js → chunk-GY5O7NIQ.js} +1 -1
- package/dist/bundle/chunks/{chunk-AVALXFDW.js → chunk-JD4CV3GH.js} +1 -1
- package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
- package/dist/bundle/chunks/{chunk-FMYLLJBQ.js → chunk-KWC3MCLP.js} +1 -1
- package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
- package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
- package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
- package/dist/bundle/chunks/{chunk-HJPX7PBL.js → chunk-XCRGEMY2.js} +125 -118
- package/dist/bundle/chunks/{cli-main-JVPFJGP5.js → cli-main-I6JF2GPS.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
- package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-BROZRY34.js → interactive-mode-4Q3TIXOZ.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-SMQR5SCJ.js → multi-session-host-XEZB2XVR.js} +5 -4
- package/dist/bundle/chunks/{package-manager-cli-2QFJY2AS.js → package-manager-cli-YWZHOZOY.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-Z45T3NZO.js → rpc-mode-SLWXVXXE.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-H7FFWF2A.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
- package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
- package/dist/bundle/chunks/session-worker.js +243 -235
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-session-runtime.d.ts +15 -0
- package/dist/core/agent-session-runtime.js +53 -2
- package/dist/core/agent-session.d.ts +12 -0
- package/dist/core/agent-session.js +42 -1
- package/dist/core/context-files.d.ts +21 -0
- package/dist/core/context-files.js +175 -0
- package/dist/core/diagnostics.d.ts +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
- package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
- package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
- package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
- package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
- package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
- package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
- package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
- package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
- package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
- package/dist/core/extensions/builtin/permission-system/index.js +3 -1
- package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
- package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
- package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
- package/dist/core/extensions/builtin/permission-system/service.js +1 -1
- package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
- package/dist/core/package-manager.js +4 -32
- package/dist/core/project-trust.d.ts +8 -0
- package/dist/core/project-trust.js +12 -0
- package/dist/core/resource-loader.d.ts +5 -7
- package/dist/core/resource-loader.js +11 -84
- package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
- package/dist/core/retry-fallback/circuit-probes.js +3 -0
- package/dist/core/retry-fallback/circuit.d.ts +5 -0
- package/dist/core/retry-fallback/circuit.js +27 -4
- package/dist/core/retry-fallback/controller.d.ts +9 -4
- package/dist/core/retry-fallback/controller.js +21 -4
- package/dist/core/settings-manager.d.ts +15 -0
- package/dist/core/settings-manager.js +21 -1
- package/dist/core/skills-prompt.d.ts +18 -0
- package/dist/core/skills-prompt.js +131 -0
- package/dist/core/skills.d.ts +1 -9
- package/dist/core/skills.js +1 -59
- package/dist/core/trust-manager.d.ts +13 -4
- package/dist/core/trust-manager.js +52 -21
- package/dist/main.js +23 -4
- package/dist/modes/interactive/interactive-mode.js +10 -3
- package/dist/modes/rpc/abort-grace.d.ts +9 -0
- package/dist/modes/rpc/abort-grace.js +11 -0
- package/dist/modes/rpc/effect-commands.d.ts +0 -2
- package/dist/modes/rpc/effect-commands.js +2 -3
- package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
- package/dist/modes/rpc/host-handover-parked.js +1 -0
- package/dist/modes/rpc/host-handover-reopen.js +11 -2
- package/dist/modes/rpc/rpc-client.d.ts +28 -2
- package/dist/modes/rpc/rpc-client.js +24 -11
- package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
- package/dist/modes/rpc/rpc-request-deadline.js +15 -0
- package/dist/modes/rpc/session-command-router.js +7 -4
- package/dist/modes/rpc/session-registry-attach.js +6 -1
- package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
- package/dist/modes/rpc/session-registry-claims.js +68 -0
- package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
- package/dist/modes/rpc/session-registry-opening.js +57 -0
- package/dist/modes/rpc/session-registry.d.ts +1 -0
- package/dist/modes/rpc/session-registry.js +48 -60
- package/docs/rpc.md +6 -2
- package/docs/settings.md +13 -0
- package/package.json +6 -6
- package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
- package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
- 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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
|
|
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, "&")
|
|
126
|
+
.replace(/</g, "<")
|
|
127
|
+
.replace(/>/g, ">")
|
|
128
|
+
.replace(/"/g, """)
|
|
129
|
+
.replace(/'/g, "'");
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=skills-prompt.js.map
|
package/dist/core/skills.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/skills.js
CHANGED
|
@@ -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, "&")
|
|
323
|
-
.replace(/</g, "<")
|
|
324
|
-
.replace(/>/g, ">")
|
|
325
|
-
.replace(/"/g, """)
|
|
326
|
-
.replace(/'/g, "'");
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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 {
|