pi-delegation-policy 0.9.0 → 0.9.1

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 CHANGED
@@ -2,7 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- No unreleased changes.
5
+ ## 0.9.1 - 2026-09-08
6
+
7
+ ### Fixed
8
+
9
+ - Make ContextShunt declared-read limits line-based, measure known textual results with real UTF-8 bytes and shared newline handling, and keep rejected requests out of the admitted-request window.
10
+ - Keep the one-time `allow TOKEN MAX_LINES MAX_BYTES` command while binding its authorization to one tool call and immutable input snapshot; enforce its byte maximum against the real result.
11
+ - Continue accepting the deprecated schema-4 `readerOutputBytes` field for compatibility while ignoring it, omitting it from new saves, and removing its inert panel control.
6
12
 
7
13
  ## 0.9.0 - 2026-09-07
8
14
 
package/CONTRIBUTING.md CHANGED
@@ -19,7 +19,8 @@ Pull requests should:
19
19
  - use fictional examples and provider-agnostic documentation;
20
20
  - preserve exact role references and the absence of model fallbacks;
21
21
  - preserve the boundary: this package does not create, launch, route, or supervise subagents; ContextShunt can only redirect the main agent and never starts a worker from a hook;
22
- - preserve ContextShunt defaults and controls: `off` performs no classification, metrics, I/O, or interception; preserve originals when an artifact, permission, or recognized contract is unavailable;
22
+ - preserve ContextShunt defaults and controls: `off` performs no classification, metrics, I/O, or interception; declared preflight limits count lines, post-result limits use returned UTF-8 bytes and lines, and rejected requests do not consume the admitted-request window;
23
+ - preserve originals when an artifact, permission, or recognized contract is unavailable; keep one-time exceptions user-authorized, short-lived, and bound to the exact tool call and input, with byte limits checked against the returned result;
23
24
  - avoid project configuration, credential handling, telemetry, and network requests;
24
25
  - run `npm run format:check`, `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build`.
25
26
 
package/README.md CHANGED
@@ -39,7 +39,7 @@ pi install npm:pi-delegation-policy
39
39
  4. Run `/delegate status`. `disabled`, `not configured`, and exact references remain distinct. `D:ERR` means no policy is injected.
40
40
  5. The applied state affects the **next** agent run.
41
41
 
42
- Global defaults are stored at `~/.pi/agent/delegation-policy.json` and new values use schema version 4. Schema 2 and 3 defaults and session entries are read and normalized in memory without rewriting them. Schema 3 stores `null` for an explicitly disabled ordinary role. Session changes write a schema 2 `off` guard before the schema 4 state; saving defaults changes only the global file.
42
+ Global defaults are stored at `~/.pi/agent/delegation-policy.json` and new values use schema version 4. The legacy positive `contextShunt.limits.readerOutputBytes` field remains accepted when reading schema 4, but is ignored and omitted from new saves. Schema 2 and 3 defaults and session entries are read and normalized in memory without rewriting them. Schema 3 stores `null` for an explicitly disabled ordinary role. Session changes write a schema 2 `off` guard before the schema 4 state; saving defaults changes only the global file.
43
43
 
44
44
  Before downgrading to a package that does not read schema 4, set global and branch ContextShunt to `off`; the guarded branch write already presents schema 2 `off` to older versions. Before downgrading to `0.6.0`, also change the global intensity to `off`, `normal`, or `aggressive` and run `/delegate off` in every active branch. For `<=0.5.0`, convert global defaults to schema 2 and replace ordinary `null` values with exact model references. Schema 2 never accepts `orchestrator`. See the configuration reference for details.
45
45
 
@@ -63,7 +63,7 @@ See the [getting-started guide](https://yivas.github.io/pi-delegation-policy/get
63
63
 
64
64
  The editor is a bounded, keyboard-first panel. Every model selector pins **Use global default** and **Disable for this session** before searchable models. It shows model ID first and `[provider]` last, fuzzy-searches provider, model ID, and display name, and shows at most 10 model rows. It also shows a compact effective-policy preview, field explanations, and public model metadata when Pi supplies it. Changes are drafts until **Apply changes**; saving effective configuration as defaults updates only the global file without applying the draft, and closing a modified draft requires explicit discard.
65
65
 
66
- **Context advanced** in `/delegate` edits the requested reader role, limits, and comma-separated patterns, with per-field global inheritance and a ContextShunt draft reset. In `enforce`, an oversized recognized read receives a bounded-read or user-confirmed one-time exception path. Large known text results are compacted only after their original is stored in a private, session-only artifact with a quota and an absolute 30-minute TTL from creation; scheduled cleanup runs while the process is active, recovery does not renew it, and shutdown removes the temporary directory. Crashes or OS suspension can delay deletion. `context_shunt_recover` accepts either a bounded line range or byte range. Errors, valid JSON of every root type, images, mixed content, and unknown tool contracts remain unchanged. The package declares `agents/pi-delegation-policy.bulk-reader.md` for discovery by a compatible executor. It allows only `read`, `grep`, `find`, and `ls`; it remains a guided profile, not an automatic bridge or a claim of executor isolation. It is not copied into user agent directories or launched automatically. If an executor cannot discover a path-based profile, use the guided redirection only.
66
+ **Context advanced** in `/delegate` edits the requested reader role, limits, and comma-separated patterns, with per-field global inheritance and a ContextShunt draft reset. In `enforce`, an oversized recognized read receives a bounded-read or user-confirmed one-time exception path. Preguards use declared lines, while postguards use real UTF-8 bytes and returned line counts; rejected reads do not consume the shared declared-request window. Large known text results are compacted only after their original is stored in a private, session-only artifact with a quota and an absolute 30-minute TTL from creation; scheduled cleanup runs while the process is active, recovery does not renew it, and shutdown removes the temporary directory. Crashes or OS suspension can delay deletion. `context_shunt_recover` accepts either a bounded line range or byte range. Errors, valid JSON of every root type, images, mixed content, and unknown tool contracts remain unchanged. The package declares `agents/pi-delegation-policy.bulk-reader.md` for discovery by a compatible executor. It allows only `read`, `grep`, `find`, and `ls`; it remains a guided profile, not an automatic bridge or a claim of executor isolation. It is not copied into user agent directories or launched automatically. If an executor cannot discover a path-based profile, use the guided redirection only.
67
67
 
68
68
  ## Development
69
69
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  This project is a local Pi extension. It stores delegation policy data and model identifiers in global defaults and session entries. ContextShunt is off by default. When explicitly enforced, it may keep a known successful text result in a private, session-only temporary file to serve bounded recovery; the file has an opaque ID, quota, cancellation check, and an absolute 30-minute TTL from creation. Cleanup is scheduled while the process is active and runs at shutdown, but crashes or OS suspension can delay deletion. It does not store credentials, execute subagents, or make network requests.
6
6
 
7
- ContextShunt is not a sandbox or a worker bridge. It preserves permissions and backends, does not inspect files before tool authorization, and leaves errors, structured/mixed results, images, binaries, and unknown contracts unchanged. The policy guides the main agent. It cannot guarantee that another system will follow a configured role or thinking choice. Review local configuration before using it.
7
+ ContextShunt is not a sandbox or a worker bridge. It preserves permissions and backends, does not inspect files before tool authorization, and leaves errors, structured/mixed results, images, binaries, invalid inputs, and unknown contracts unchanged. One-time exceptions are user-authorized, short-lived, bound to one call and immutable input snapshot, and capped by declared lines plus real returned UTF-8 bytes. The policy guides the main agent. It cannot guarantee that another system will follow a configured role or thinking choice. Review local configuration before using it.
8
8
 
9
9
  ## Reporting
10
10
 
@@ -22,8 +22,7 @@
22
22
  "fullReadLines": 350,
23
23
  "fullReadBytes": 16384,
24
24
  "targetedReadLines": 250,
25
- "targetedReadBytes": 16384,
26
- "readerOutputBytes": 8192
25
+ "targetedReadBytes": 16384
27
26
  },
28
27
  "shell": "conservative",
29
28
  "metrics": "memory",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-delegation-policy",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": false,
5
5
  "description": "A Pi extension for configurable delegation intensity and exact subagent role model references.",
6
6
  "type": "module",
@@ -48,7 +48,11 @@
48
48
  "fullReadBytes": { "$ref": "#/$defs/positiveLimit" },
49
49
  "targetedReadLines": { "$ref": "#/$defs/positiveLimit" },
50
50
  "targetedReadBytes": { "$ref": "#/$defs/positiveLimit" },
51
- "readerOutputBytes": { "$ref": "#/$defs/positiveLimit" }
51
+ "readerOutputBytes": {
52
+ "$ref": "#/$defs/positiveLimit",
53
+ "deprecated": true,
54
+ "description": "Legacy accepted field. It is validated then ignored and is not written by current versions."
55
+ }
52
56
  },
53
57
  "additionalProperties": false
54
58
  },
package/src/config.ts CHANGED
@@ -46,8 +46,8 @@ const DEFAULT_LIMITS = {
46
46
  fullReadBytes: 16384,
47
47
  targetedReadLines: 250,
48
48
  targetedReadBytes: 16384,
49
- readerOutputBytes: 8192,
50
49
  } as const;
50
+ const LEGACY_LIMIT_KEYS = [...Object.keys(DEFAULT_LIMITS), "readerOutputBytes"] as const;
51
51
  const MAX_LIMIT = 1024 * 1024;
52
52
 
53
53
  export type ConfigDiagnostic = { message: string; reportWhenOff?: boolean };
@@ -154,10 +154,11 @@ function parseShunt(value: unknown): ContextShuntSettings | undefined {
154
154
  if (value.metrics !== undefined && value.metrics !== "memory") return undefined;
155
155
  let limits: ContextShuntLimits | undefined;
156
156
  if (value.limits !== undefined) {
157
- if (!isRecord(value.limits) || !only(value.limits, Object.keys(DEFAULT_LIMITS)))
158
- return undefined;
157
+ if (!isRecord(value.limits) || !only(value.limits, LEGACY_LIMIT_KEYS)) return undefined;
159
158
  if (Object.values(value.limits).some((item) => !validLimit(item))) return undefined;
160
- limits = { ...value.limits } as ContextShuntLimits;
159
+ const effectiveLimits = { ...value.limits };
160
+ delete effectiveLimits.readerOutputBytes;
161
+ if (Object.keys(effectiveLimits).length) limits = effectiveLimits as ContextShuntLimits;
161
162
  }
162
163
  const exceptionPatterns =
163
164
  value.exceptionPatterns === undefined ? undefined : patterns(value.exceptionPatterns);
@@ -36,10 +36,13 @@ export class ContextShuntAdapter {
36
36
  settings: EffectiveContextShunt,
37
37
  isBuiltinTool = true,
38
38
  ): { block: true; reason: string } | undefined {
39
- if (settings.mode === "off" || (settings.mode === "enforce" && !isBuiltinTool))
39
+ if (settings.mode === "off") {
40
+ this.clearPending();
40
41
  return undefined;
42
+ }
43
+ if (settings.mode === "enforce" && !isBuiltinTool) return undefined;
41
44
 
42
- const decision = this.engine.decide(event.toolName, event.input, settings);
45
+ const decision = this.engine.decide(event.toolName, event.input, settings, event.toolCallId);
43
46
  if (decision.action !== "block") return undefined;
44
47
 
45
48
  this.expirePending();
@@ -73,7 +76,13 @@ export class ContextShuntAdapter {
73
76
  const pending = this.pending.get(token);
74
77
  this.pending.delete(token);
75
78
  if (!pending || pending.expiresAt <= this.now()) return false;
76
- return this.engine.allowOnce(pending.toolName, pending.input, maxLines, maxBytes);
79
+ return this.engine.allowOnce(
80
+ pending.toolName,
81
+ pending.input,
82
+ maxLines,
83
+ maxBytes,
84
+ pending.expiresAt,
85
+ );
77
86
  }
78
87
 
79
88
  async onToolResult(
@@ -82,12 +91,29 @@ export class ContextShuntAdapter {
82
91
  signal?: AbortSignal,
83
92
  isBuiltinTool = true,
84
93
  ): Promise<ToolResultPatch | undefined> {
85
- if (settings.mode !== "enforce" || !isBuiltinTool || signal?.aborted) return undefined;
94
+ if (settings.mode !== "enforce" || !isBuiltinTool || signal?.aborted) {
95
+ this.engine.clearCall(event.toolCallId);
96
+ return undefined;
97
+ }
86
98
 
87
- const text = shouldCompact(event.toolName, event.isError, event.content, settings);
88
- if (!text) return undefined;
89
- const artifactId = await this.artifacts.archive(text, signal);
90
- if (!artifactId) return undefined;
99
+ const exception = this.engine.takeConsumed(event.toolCallId, event.toolName, event.input);
100
+ const decision = shouldCompact(
101
+ event.toolName,
102
+ event.input,
103
+ event.isError,
104
+ event.content,
105
+ settings,
106
+ exception,
107
+ );
108
+ if (decision.kind === "skip") {
109
+ if (decision.reason.startsWith("uncovered")) this.engine.metrics.uncoveredResults += 1;
110
+ return undefined;
111
+ }
112
+ const artifactId = await this.artifacts.archive(decision.text, signal);
113
+ if (!artifactId) {
114
+ this.engine.metrics.archiveFailures += 1;
115
+ return undefined;
116
+ }
91
117
 
92
118
  this.engine.metrics.boundedResults += 1;
93
119
  return {
@@ -113,9 +139,17 @@ export class ContextShuntAdapter {
113
139
  return "error" in result ? result : { text: result.text, range: result.range };
114
140
  }
115
141
 
116
- async close(): Promise<void> {
142
+ clearConsumed(): void {
143
+ this.engine.clearConsumed();
144
+ }
145
+
146
+ clearPending(): void {
117
147
  this.pending.clear();
118
148
  this.engine.clear();
149
+ }
150
+
151
+ async close(): Promise<void> {
152
+ this.clearPending();
119
153
  await this.artifacts.close();
120
154
  }
121
155
 
@@ -10,6 +10,7 @@ const MAX_ARTIFACTS = 8;
10
10
  const ARTIFACT_TTL_MS = 30 * 60 * 1000;
11
11
  const WINDOW_TTL_MS = 60_000;
12
12
  const EXCEPTION_TTL_MS = 60_000;
13
+ const MAX_CONSUMED_EXCEPTIONS = 8;
13
14
 
14
15
  export type ShuntDecision = { action: "allow" | "block" | "skip"; reason: string };
15
16
  export type ShuntMetrics = {
@@ -17,16 +18,19 @@ export type ShuntMetrics = {
17
18
  wouldBlock: number;
18
19
  boundedResults: number;
19
20
  manualOverrides: number;
21
+ archiveFailures: number;
22
+ uncoveredResults: number;
20
23
  };
21
- type Window = { requestedLines: number; requestedBytes: number; updatedAt: number };
24
+ type Window = { requestedLines: number; updatedAt: number };
22
25
  type Exception = { expiresAt: number; maxLines: number; maxBytes: number };
26
+ export type ConsumedException = Exception & { toolName: string; input: string };
23
27
  type Clock = () => number;
24
28
 
25
29
  function positiveInteger(value: unknown): value is number {
26
- return typeof value === "number" && Number.isInteger(value) && value > 0;
30
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
27
31
  }
28
32
 
29
- type ReadInput = { path: string; offset?: number; limit?: number };
33
+ type ReadInput = { path: string; offset?: number; limit?: number; targeted: boolean };
30
34
 
31
35
  function readInput(value: unknown): ReadInput | undefined {
32
36
  if (!value || typeof value !== "object") return undefined;
@@ -34,7 +38,7 @@ function readInput(value: unknown): ReadInput | undefined {
34
38
  if (typeof input.path !== "string" || !input.path) return undefined;
35
39
  if (
36
40
  input.offset !== undefined &&
37
- (typeof input.offset !== "number" || !Number.isInteger(input.offset) || input.offset < 0)
41
+ (typeof input.offset !== "number" || !Number.isSafeInteger(input.offset) || input.offset < 1)
38
42
  ) {
39
43
  return undefined;
40
44
  }
@@ -43,6 +47,7 @@ function readInput(value: unknown): ReadInput | undefined {
43
47
  path: input.path,
44
48
  ...(typeof input.offset === "number" ? { offset: input.offset } : {}),
45
49
  ...(typeof input.limit === "number" ? { limit: input.limit } : {}),
50
+ targeted: input.offset !== undefined,
46
51
  };
47
52
  }
48
53
 
@@ -59,7 +64,7 @@ function shellReadInput(toolName: string, value: unknown): ReadInput | undefined
59
64
  if (!match?.groups?.path || !match.groups.limit) return undefined;
60
65
  const limit = Number(match.groups.limit);
61
66
  return Number.isSafeInteger(limit) && positiveInteger(limit)
62
- ? { path: `shell:${match.groups.path}`, limit }
67
+ ? { path: `shell:${match.groups.path}`, limit, targeted: false }
63
68
  : undefined;
64
69
  }
65
70
 
@@ -79,107 +84,143 @@ function matchesPattern(path: string, patterns: readonly string[]): boolean {
79
84
  });
80
85
  }
81
86
 
82
- export function inputFingerprint(toolName: string, input: unknown): string {
83
- return `${toolName}\u0000${JSON.stringify(input)}`;
87
+ export function inputFingerprint(toolName: string, input: unknown): string | undefined {
88
+ try {
89
+ const serialized = JSON.stringify(input);
90
+ return serialized === undefined ? undefined : `${toolName}\u0000${serialized}`;
91
+ } catch {
92
+ return undefined;
93
+ }
84
94
  }
85
95
 
86
96
  export class ContextShuntEngine {
87
97
  private readonly windows = new Map<string, Window>();
88
98
  private readonly exceptions = new Map<string, Exception>();
99
+ private readonly consumed = new Map<string, ConsumedException>();
89
100
  private readonly now: Clock;
90
101
  readonly metrics: ShuntMetrics = {
91
102
  blocked: 0,
92
103
  wouldBlock: 0,
93
104
  boundedResults: 0,
94
105
  manualOverrides: 0,
106
+ archiveFailures: 0,
107
+ uncoveredResults: 0,
95
108
  };
96
109
 
97
110
  constructor(now: Clock = Date.now) {
98
111
  this.now = now;
99
112
  }
100
113
 
101
- decide(toolName: string, input: unknown, settings: EffectiveContextShunt): ShuntDecision {
114
+ decide(
115
+ toolName: string,
116
+ input: unknown,
117
+ settings: EffectiveContextShunt,
118
+ toolCallId?: string,
119
+ ): ShuntDecision {
102
120
  if (settings.mode === "off") return { action: "skip", reason: "off" };
103
121
 
104
122
  const read = declaredRead(toolName, input);
105
- if (!read) return { action: "skip", reason: "unsupported or unknown tool contract" };
123
+ if (!read) return { action: "skip", reason: "unknown-contract" };
106
124
 
107
- this.expire(this.now());
125
+ const now = this.now();
126
+ this.expire(now);
108
127
  const fingerprint = inputFingerprint(toolName, input);
109
- const exception = this.exceptions.get(fingerprint);
110
- if (exception) {
128
+ const exception = fingerprint ? this.exceptions.get(fingerprint) : undefined;
129
+ if (exception && fingerprint && read.limit !== undefined) {
111
130
  this.exceptions.delete(fingerprint);
112
- const declaredLines = read.limit ?? 0;
113
- const declaredBytes = declaredLines * 80;
114
- if (
115
- exception.expiresAt > this.now() &&
116
- declaredLines <= exception.maxLines &&
117
- declaredBytes <= exception.maxBytes
118
- ) {
131
+ if (exception.expiresAt > now && read.limit <= exception.maxLines && toolCallId) {
132
+ this.consumed.delete(toolCallId);
133
+ if (this.consumed.size >= MAX_CONSUMED_EXCEPTIONS) {
134
+ const oldestToolCallId = this.consumed.keys().next().value;
135
+ if (oldestToolCallId) this.consumed.delete(oldestToolCallId);
136
+ }
137
+ this.consumed.set(toolCallId, { ...exception, toolName, input: fingerprint });
119
138
  this.metrics.manualOverrides += 1;
120
- return { action: "allow", reason: "one-time user exception" };
139
+ return { action: "allow", reason: "one-time-exception" };
121
140
  }
122
141
  }
123
142
 
124
143
  if (matchesPattern(read.path, settings.exceptionPatterns)) {
125
- return {
126
- action: "allow",
127
- reason: "matches ContextShunt exemption; tool permissions unchanged",
128
- };
129
- }
130
-
131
- if (read.limit === undefined) {
132
- return { action: "allow", reason: "size unknown; post-result guard applies" };
144
+ return { action: "allow", reason: "configured-exemption" };
133
145
  }
146
+ if (read.limit === undefined) return { action: "allow", reason: "size-unknown" };
134
147
 
135
- const requestedBytes = read.limit * 80;
136
- const targeted = read.offset !== undefined;
137
- const lineBudget = targeted ? settings.limits.targetedReadLines : settings.limits.fullReadLines;
138
- const byteBudget = targeted ? settings.limits.targetedReadBytes : settings.limits.fullReadBytes;
148
+ const lineBudget = read.targeted
149
+ ? settings.limits.targetedReadLines
150
+ : settings.limits.fullReadLines;
139
151
  const windowKey = `${toolName}\u0000${read.path}`;
140
152
  const previous = this.windows.get(windowKey);
141
153
  const totalLines = (previous?.requestedLines ?? 0) + read.limit;
142
- const totalBytes = (previous?.requestedBytes ?? 0) + requestedBytes;
143
- const excessive =
144
- read.limit > lineBudget ||
145
- requestedBytes > byteBudget ||
146
- totalLines > settings.limits.fullReadLines ||
147
- totalBytes > settings.limits.fullReadBytes;
148
- this.windows.set(windowKey, {
149
- requestedLines: totalLines,
150
- requestedBytes: totalBytes,
151
- updatedAt: this.now(),
152
- });
154
+ const excessive = read.limit > lineBudget || totalLines > settings.limits.fullReadLines;
155
+
153
156
  if (settings.mode === "observe") {
154
- if (excessive) this.metrics.wouldBlock += 1;
157
+ if (excessive) {
158
+ this.metrics.wouldBlock += 1;
159
+ return { action: "allow", reason: "would-block-lines" };
160
+ }
161
+ this.windows.set(windowKey, { requestedLines: totalLines, updatedAt: now });
162
+ return { action: "allow", reason: "declared-bounded" };
163
+ }
164
+ if (excessive) {
165
+ this.metrics.blocked += 1;
155
166
  return {
156
- action: "allow",
157
- reason: excessive ? "would block declared excessive range" : "declared bounded range",
167
+ action: "block",
168
+ reason: `${matchesPattern(read.path, settings.delegationHintPatterns) ? "delegation-hinted-" : ""}lines-exceed-budget`,
158
169
  };
159
170
  }
160
- if (!excessive) return { action: "allow", reason: "declared bounded range" };
161
- this.metrics.blocked += 1;
162
- return {
163
- action: "block",
164
- reason: `${
165
- matchesPattern(read.path, settings.delegationHintPatterns) ? "delegation-hinted " : ""
166
- }declared range exceeds ContextShunt budget; use a bounded read or a user-authorized one-time exception`,
167
- };
171
+ this.windows.set(windowKey, { requestedLines: totalLines, updatedAt: now });
172
+ return { action: "allow", reason: "declared-bounded" };
168
173
  }
169
174
 
170
- allowOnce(toolName: string, input: unknown, maxLines: number, maxBytes: number): boolean {
171
- if (!positiveInteger(maxLines) || !positiveInteger(maxBytes)) return false;
172
- this.exceptions.set(inputFingerprint(toolName, input), {
173
- expiresAt: this.now() + EXCEPTION_TTL_MS,
174
- maxLines,
175
- maxBytes,
176
- });
175
+ allowOnce(
176
+ toolName: string,
177
+ input: unknown,
178
+ maxLines: number,
179
+ maxBytes: number,
180
+ expiresAt = this.now() + EXCEPTION_TTL_MS,
181
+ ): boolean {
182
+ const fingerprint = inputFingerprint(toolName, input);
183
+ if (
184
+ !positiveInteger(maxLines) ||
185
+ !positiveInteger(maxBytes) ||
186
+ !fingerprint ||
187
+ expiresAt <= this.now()
188
+ )
189
+ return false;
190
+ this.exceptions.set(fingerprint, { expiresAt, maxLines, maxBytes });
177
191
  return true;
178
192
  }
179
193
 
194
+ takeConsumed(
195
+ toolCallId: string,
196
+ toolName: string,
197
+ input: unknown,
198
+ ): ConsumedException | undefined {
199
+ const exception = this.consumed.get(toolCallId);
200
+ this.consumed.delete(toolCallId);
201
+ if (
202
+ !exception ||
203
+ exception.expiresAt <= this.now() ||
204
+ exception.toolName !== toolName ||
205
+ exception.input !== inputFingerprint(toolName, input)
206
+ ) {
207
+ return undefined;
208
+ }
209
+ return exception;
210
+ }
211
+
212
+ clearCall(toolCallId: string): void {
213
+ this.consumed.delete(toolCallId);
214
+ }
215
+
216
+ clearConsumed(): void {
217
+ this.consumed.clear();
218
+ }
219
+
180
220
  clear(): void {
181
221
  this.windows.clear();
182
222
  this.exceptions.clear();
223
+ this.consumed.clear();
183
224
  }
184
225
 
185
226
  private expire(now: number): void {
@@ -189,6 +230,9 @@ export class ContextShuntEngine {
189
230
  for (const [key, exception] of this.exceptions) {
190
231
  if (exception.expiresAt <= now) this.exceptions.delete(key);
191
232
  }
233
+ for (const [key, exception] of this.consumed) {
234
+ if (exception.expiresAt <= now) this.consumed.delete(key);
235
+ }
192
236
  }
193
237
  }
194
238
 
@@ -206,7 +250,7 @@ function isUtf8Boundary(source: Buffer, offset: number): boolean {
206
250
  return byte !== undefined && (byte & 0b1100_0000) !== 0b1000_0000;
207
251
  }
208
252
 
209
- function splitLines(text: string): string[] {
253
+ export function splitLines(text: string): string[] {
210
254
  const lines: string[] = [];
211
255
  let start = 0;
212
256
  const endings = /\r\n|\n|\r/g;
@@ -435,23 +479,50 @@ function isJsonDocument(text: string): boolean {
435
479
  }
436
480
  }
437
481
 
482
+ export type CompactionDecision =
483
+ | { kind: "compact"; text: string; reason: "result-exceeds-budget" | "exception-exceeds-budget" }
484
+ | {
485
+ kind: "skip";
486
+ reason:
487
+ | "off"
488
+ | "uncovered-contract"
489
+ | "uncovered-result"
490
+ | "within-budget"
491
+ | "exception-within-budget";
492
+ };
493
+
438
494
  export function shouldCompact(
439
495
  toolName: string,
496
+ input: unknown,
440
497
  isError: unknown,
441
498
  content: unknown,
442
499
  settings: EffectiveContextShunt,
443
- ): string | undefined {
444
- if (
445
- settings.mode !== "enforce" ||
446
- isError ||
447
- !["read", "bash", "powershell", "grep"].includes(toolName)
448
- ) {
449
- return undefined;
450
- }
500
+ exception?: Pick<ConsumedException, "maxLines" | "maxBytes">,
501
+ ): CompactionDecision {
502
+ if (settings.mode !== "enforce") return { kind: "skip", reason: "off" };
503
+ if (!["read", "bash", "powershell", "grep"].includes(toolName))
504
+ return { kind: "skip", reason: "uncovered-contract" };
505
+ const read = declaredRead(toolName, input);
506
+ if (!read) return { kind: "skip", reason: "uncovered-contract" };
451
507
  const text = textResult(content);
452
- if (text === undefined || isJsonDocument(text) || text.includes("\u0000")) return undefined;
453
- return Buffer.byteLength(text, "utf8") > settings.limits.fullReadBytes ||
454
- text.split(/\r?\n/).length > settings.limits.fullReadLines
455
- ? text
456
- : undefined;
508
+ if (isError || text === undefined || isJsonDocument(text) || text.includes("\u0000"))
509
+ return { kind: "skip", reason: "uncovered-result" };
510
+
511
+ const targeted = read.targeted;
512
+ const lineBudget =
513
+ exception?.maxLines ??
514
+ (targeted ? settings.limits.targetedReadLines : settings.limits.fullReadLines);
515
+ const byteBudget =
516
+ exception?.maxBytes ??
517
+ (targeted ? settings.limits.targetedReadBytes : settings.limits.fullReadBytes);
518
+ if (text.length === 0) return { kind: "skip", reason: "within-budget" };
519
+ const exceeds =
520
+ Buffer.byteLength(text, "utf8") > byteBudget || splitLines(text).length > lineBudget;
521
+ if (!exceeds)
522
+ return { kind: "skip", reason: exception ? "exception-within-budget" : "within-budget" };
523
+ return {
524
+ kind: "compact",
525
+ text,
526
+ reason: exception ? "exception-exceeds-budget" : "result-exceeds-budget",
527
+ };
457
528
  }
@@ -51,7 +51,6 @@ const ADVANCED_FIELDS = [
51
51
  "fullReadBytes",
52
52
  "targetedReadLines",
53
53
  "targetedReadBytes",
54
- "readerOutputBytes",
55
54
  "exceptionPatterns",
56
55
  "delegationHintPatterns",
57
56
  "resetContextShunt",
@@ -63,7 +62,6 @@ const LIMIT_FIELDS = [
63
62
  "fullReadBytes",
64
63
  "targetedReadLines",
65
64
  "targetedReadBytes",
66
- "readerOutputBytes",
67
65
  ] as const;
68
66
  type LimitField = (typeof LIMIT_FIELDS)[number];
69
67
  type EditableAdvancedField = Exclude<AdvancedField, "readerRole" | "resetContextShunt" | "back">;
@@ -718,7 +716,6 @@ export class DelegatePanel implements Component, Focusable {
718
716
  case "fullReadBytes":
719
717
  case "targetedReadLines":
720
718
  case "targetedReadBytes":
721
- case "readerOutputBytes":
722
719
  return String(limit(field));
723
720
  case "exceptionPatterns":
724
721
  return effective.exceptionPatterns.length
@@ -757,7 +754,6 @@ export class DelegatePanel implements Component, Focusable {
757
754
  fullReadBytes: "Full read bytes",
758
755
  targetedReadLines: "Targeted read lines",
759
756
  targetedReadBytes: "Targeted read bytes",
760
- readerOutputBytes: "Reader output bytes",
761
757
  exceptionPatterns: "Exempt patterns",
762
758
  delegationHintPatterns: "Delegation hint patterns",
763
759
  resetContextShunt: "Reset ContextShunt draft",
@@ -807,7 +803,6 @@ export class DelegatePanel implements Component, Focusable {
807
803
  fullReadBytes: "Full read bytes",
808
804
  targetedReadLines: "Targeted read lines",
809
805
  targetedReadBytes: "Targeted read bytes",
810
- readerOutputBytes: "Reader output bytes",
811
806
  exceptionPatterns: "Exempt patterns",
812
807
  delegationHintPatterns: "Delegation hint patterns",
813
808
  };
package/src/index.ts CHANGED
@@ -80,19 +80,38 @@ function updateStatus(ctx: ExtensionContext, state: RuntimeState): void {
80
80
  ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", statusLabel(state)));
81
81
  }
82
82
 
83
+ function contextStatusText(state: RuntimeState, shunt: ContextShuntAdapter): string {
84
+ const context = state.effective.contextShunt;
85
+ const limits = context.limits;
86
+ const metrics = shunt.engine.metrics;
87
+ return `${statusText(state)} | context-limits=preflight-lines full=${limits.fullReadLines} targeted=${limits.targetedReadLines}; postresult-utf8-bytes full=${limits.fullReadBytes} targeted=${limits.targetedReadBytes} | context-coverage=known builtin text only; unknown contracts and invalid inputs are unchanged | context-events=blocked:${metrics.blocked},would-block:${metrics.wouldBlock},bounded:${metrics.boundedResults},exceptions:${metrics.manualOverrides},archive-failures:${metrics.archiveFailures},uncovered:${metrics.uncoveredResults}`;
88
+ }
89
+
83
90
  function isBuiltinTool(pi: ExtensionAPI, toolName: string): boolean {
84
91
  return pi
85
92
  .getAllTools()
86
93
  .some((tool) => tool.name === toolName && tool.sourceInfo.source === "builtin");
87
94
  }
95
+ function isContextShuntDisabled(state: RuntimeState): boolean {
96
+ return (
97
+ state.effective.intensity === "off" ||
98
+ state.effective.contextShunt.mode === "off" ||
99
+ state.effective.contextShunt.suspended
100
+ );
101
+ }
102
+ function synchronizeContextShunt(shunt: ContextShuntAdapter, state: RuntimeState): void {
103
+ if (isContextShuntDisabled(state)) shunt.clearPending();
104
+ }
88
105
  async function openEditor(
89
106
  pi: ExtensionAPI,
90
107
  ctx: ExtensionContext,
91
108
  setRuntime: (state: RuntimeState) => void,
109
+ shunt: ContextShuntAdapter,
92
110
  ): Promise<void> {
93
111
  await openDelegateEditor(ctx, pi);
94
112
  const state = await loadRuntime(ctx);
95
113
  setRuntime(state);
114
+ synchronizeContextShunt(shunt, state);
96
115
  updateStatus(ctx, state);
97
116
  }
98
117
  export function statusText(state: RuntimeState): string {
@@ -233,27 +252,36 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
233
252
  getArgumentCompletions,
234
253
  handler: async (args, ctx) => {
235
254
  const action = parseCommand(args);
236
- if (action.kind === "open") return openEditor(pi, ctx, rememberRuntime);
255
+ if (action.kind === "open") return openEditor(pi, ctx, rememberRuntime, shunt);
237
256
  if (action.kind === "status" || action.kind === "context-status") {
238
257
  const state = await refreshRuntime(ctx);
239
- ctx.ui.notify(statusText(state), hasRuntimeError(state) ? "error" : "info");
258
+ ctx.ui.notify(
259
+ action.kind === "context-status" ? contextStatusText(state, shunt) : statusText(state),
260
+ hasRuntimeError(state) ? "error" : "info",
261
+ );
240
262
  return;
241
263
  }
242
264
  if (action.kind === "reset") {
243
- rememberRuntime(await resetSession(pi, ctx));
265
+ const state = rememberRuntime(await resetSession(pi, ctx));
266
+ synchronizeContextShunt(shunt, state);
244
267
  return;
245
268
  }
246
269
  if (action.kind === "intensity") {
247
- rememberRuntime(await setSessionIntensity(pi, ctx, action.intensity));
270
+ const state = rememberRuntime(await setSessionIntensity(pi, ctx, action.intensity));
271
+ synchronizeContextShunt(shunt, state);
248
272
  return;
249
273
  }
250
274
  if (action.kind === "context-mode") {
251
- rememberRuntime(await setContextMode(pi, ctx, action.mode));
275
+ const state = rememberRuntime(await setContextMode(pi, ctx, action.mode));
276
+ synchronizeContextShunt(shunt, state);
252
277
  return;
253
278
  }
254
279
  if (action.kind === "context-allow") {
255
280
  const state = await refreshRuntime(ctx);
256
- const allowed = shunt.allowPending(action.token, action.maxLines, action.maxBytes);
281
+ synchronizeContextShunt(shunt, state);
282
+ const allowed =
283
+ !isContextShuntDisabled(state) &&
284
+ shunt.allowPending(action.token, action.maxLines, action.maxBytes);
257
285
  ctx.ui.notify(
258
286
  allowed
259
287
  ? "One-time ContextShunt exception is ready for the matching next call."
@@ -271,12 +299,13 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
271
299
  });
272
300
  pi.registerShortcut("alt+g", {
273
301
  description: "Open delegation policy",
274
- handler: async (ctx) => openEditor(pi, ctx, rememberRuntime),
302
+ handler: async (ctx) => openEditor(pi, ctx, rememberRuntime, shunt),
275
303
  });
276
304
  pi.on("session_start", async (_event, ctx) => {
277
305
  await refreshRuntime(ctx);
278
306
  });
279
307
  pi.on("session_tree", async (_event, ctx) => {
308
+ shunt.clearPending();
280
309
  await refreshRuntime(ctx);
281
310
  });
282
311
  pi.on("before_agent_start", async (event, ctx) => {
@@ -284,14 +313,23 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
284
313
  const policy = buildDelegationPolicy(state);
285
314
  return policy ? { systemPrompt: `${event.systemPrompt}\n\n${policy}` } : undefined;
286
315
  });
316
+ pi.on("agent_end", async () => {
317
+ shunt.clearConsumed();
318
+ });
287
319
  pi.on("tool_call", async (event) => {
288
320
  const settings = latestRuntime?.effective.contextShunt;
289
- if (!settings || settings.mode === "off") return undefined;
321
+ if (!settings || settings.mode === "off") {
322
+ shunt.clearPending();
323
+ return undefined;
324
+ }
290
325
  return shunt.onToolCall(event, settings, isBuiltinTool(pi, event.toolName));
291
326
  });
292
327
  pi.on("tool_result", async (event, ctx) => {
293
328
  const settings = latestRuntime?.effective.contextShunt;
294
- if (!settings || settings.mode === "off") return undefined;
329
+ if (!settings || settings.mode === "off") {
330
+ shunt.clearPending();
331
+ return undefined;
332
+ }
295
333
  return shunt.onToolResult(event, settings, ctx.signal, isBuiltinTool(pi, event.toolName));
296
334
  });
297
335
  pi.on("session_shutdown", async (_event, ctx) => {
package/src/types.ts CHANGED
@@ -25,7 +25,6 @@ export type ContextShuntLimits = {
25
25
  fullReadBytes?: number;
26
26
  targetedReadLines?: number;
27
27
  targetedReadBytes?: number;
28
- readerOutputBytes?: number;
29
28
  };
30
29
  export type ContextShuntSettings = {
31
30
  mode?: ContextShuntMode;