pi-advisor-flow 0.2.7 → 0.2.9

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
@@ -4,6 +4,24 @@ All notable changes to this project are documented here.
4
4
 
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.2.9
8
+
9
+ ### Fixed
10
+
11
+ - Preserved custom numeric values when stepping `/advisor-settings` controls instead of resetting off-preset budgets and disclosure caps.
12
+ - Kept recorded session blocks enforced after `/advisor-off`, restored configuration after failed activation, and charged Advisor-call budgets only when consultation execution begins.
13
+ - Kept conversation and repository disclosure within their configured caps and explicitly identified disabled repository context as withheld.
14
+ - Made concurrent outcome records append safely with one exclusively initialized digest salt, and stopped superseded manual consultations from entering session summaries.
15
+ - Preserved and ignored forward-compatible `advisor.json` fields with a warning, and made malformed configuration errors actionable in every Advisor command.
16
+ - Isolated call budgets, repeated-action counters, and safety blocks between concurrent same-process sessions.
17
+ - Kept the repository-context withheld warning visible when its disclosure budget is zero.
18
+
19
+ ## 0.2.8
20
+
21
+ ### Fixed
22
+
23
+ - Advisor session blocks are reported to Herdr again. Herdr grants each pane a single lifecycle status authority, which for Pi is Herdr's own `herdr-agent-state` integration, and silently drops semantic state from any other source. The Advisor block now signals that integration over Pi's in-process event bus (`herdr:blocked`) instead of relying on display metadata alone, so a blocked pane shows `blocked` rather than `working`. Only the unblocked-to-blocked edge emits, because the listener refcounts; clears are always delivered.
24
+
7
25
  ## 0.2.7
8
26
 
9
27
  ### Security
package/README.md CHANGED
@@ -8,17 +8,22 @@ A configurable second-opinion workflow for <a href="https://github.com/earendil-
8
8
 
9
9
  </div>
10
10
 
11
- This extension introduces a strategic "Executor/Advisor" workflow.
11
+ `pi-advisor-flow` keeps one model focused on execution and makes a second, smarter model available for consequential decisions, stalled work, and final reviews. The Executor still owns the work. The Advisor challenges assumptions, exposes risks, and suggests verification steps without taking over or running tools.
12
12
 
13
- `pi-advisor-flow` keeps one model focused on execution and makes a second, smarter model available for consequential decisions, stalled work, and final reviews. The Executor still owns the work. The Advisor provides a concise review, answers questions and can provide help; it does not take over planning or run tools.
13
+ The idea is simple: keep implementation on a fast model and borrow frontier reasoning only when decisions matter. [Read why this workflow is useful](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need).
14
14
 
15
- The concept is simple, keep implementation on a fast model, borrow frontier reasoning only when decisions actually matter. [Read more about Advisors here](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need).
15
+ ## Features
16
16
 
17
- ## Install
17
+ - **On-demand second opinions** through the `ask_advisor` tool or `/advisor-manual`.
18
+ - **Configurable review gates** before plans, after repeated failures, and before declaring completion.
19
+ - **Automatic loop detection** for repeated tool calls, with explicit proceed, revise, or blocked decisions.
20
+ - **Separate model and reasoning controls** for the Executor and Advisor.
21
+ - **Privacy controls** for conversation history, repository context, tool results, secret redaction, and outcome logging.
22
+ - **Optional persistent activation, Simple mode, session summaries, and Herdr integration.**
18
23
 
19
- Requires Pi 0.80.7 or later. The extension installs no dependencies of its own; Pi supplies the modules it uses at runtime.
24
+ ## Install
20
25
 
21
- Install into your Pi agent environment:
26
+ Current release: **0.2.9**. Requires Pi 0.80.7 or later. The extension installs no dependencies of its own; Pi supplies its runtime modules.
22
27
 
23
28
  ```bash
24
29
  # npm
@@ -27,197 +32,65 @@ pi install npm:pi-advisor-flow
27
32
  # GitHub
28
33
  pi install git:github.com/philipbrembeck/pi-advisor.git
29
34
 
30
- # local checkout, useful during development
35
+ # local checkout
31
36
  pi install /path/to/pi-advisor
32
37
  ```
33
38
 
34
- Restart or reload Pi after installation, then run `/advisor` in a session.
39
+ Restart or reload Pi after installation.
35
40
 
36
- ## Start using it
41
+ ## Quick start
37
42
 
38
43
  1. Run `/advisor` to enable the flow and register `ask_advisor`.
39
44
  2. Run `/advisor-models` to choose the Executor and Advisor models.
40
- 3. Run `/advisor-settings` to set Simple mode, persistent activation, context, gates, privacy controls, and output limits.
41
-
42
- Enable with models in one command when preferred:
43
-
44
- ```text
45
- /advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol
46
- ```
47
-
48
- `/advisor contextMaxChars=30000` sets the reconstructed-context limit for the current session. Use `0` for no history. The `ALL` option in settings represents the complete current branch and is still subject to the Advisor model's context limit.
49
-
50
- ## Commands
51
-
52
- | Command | Purpose |
53
- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
54
- | `/advisor` | Enable the flow, select the configured Executor, and register `ask_advisor`. Accepts `executor=`, `advisor=`, and `contextMaxChars=` overrides. |
55
- | `/advisor-manual [focus]` | Start a parallel Advisor consultation without interrupting the current Executor turn. |
56
- | `/advisor-models` | Choose Executor and Advisor models plus their reasoning effort. |
57
- | `/advisor-settings` | Open all Advisor settings in one keyboard-navigable screen. |
58
- | `/advisor-off` | Disable the flow and remove `ask_advisor` from the active session. |
59
-
60
- ### `ask_advisor`
61
-
62
- The Executor calls `ask_advisor({})` for a general review of the current task and reconstructed conversation. It can pass a `question` for a targeted review, or a concise `draft` for plan and completion reviews. A draft should name proposed work, validation, and remaining risks; it is an unverified claim, not evidence.
63
-
64
- Successful calls return an opaque `adviceId`. When global outcome logging is enabled, the Executor may voluntarily call `record_advisor_outcome` once with that ID, an adoption value, and a final validation status.
65
-
66
- Use the Advisor after the Executor has investigated and formed a candidate direction. It is intended to challenge assumptions, expose risks, and confirm the next verification step—not to replace the Executor's work.
67
-
68
- Normal consultations preserve the provider's final Markdown and never block execution. When the Advisor has no material concern or recommendation, it may begin with the exact first line `Verdict: sound`; Pi renders that response with the static `◆ ADVISOR · SOUND` header, for both `ask_advisor` results and `/advisor-manual`.
45
+ 3. Run `/advisor-settings` to configure review gates, context, privacy, and limits.
69
46
 
70
- ## Automatic loop gate
47
+ Unknown fields in `advisor.json` are preserved for forward compatibility and reported as non-blocking warnings. Invalid recognized values remain errors, and Advisor commands show the configuration problem without crashing their handlers.
71
48
 
72
- The optional loop gate detects consecutive calls with the same normalized tool signature. By default, it consults the Advisor after three repeats.
73
-
74
- Unlike ordinary consultations, a loop-gate reply must start with exactly one decision header:
49
+ You can also enable the flow and select both models at once:
75
50
 
76
51
  ```text
77
- Decision: proceed
78
- Decision: revise
79
- Decision: blocked
80
- ```
81
-
82
- | Decision | Effect |
83
- | --------- | --------------------------------------------------- |
84
- | `proceed` | Reset the repeat counter and allow the tool action. |
85
- | `revise` | Block the repeated tool action. |
86
- | `blocked` | Apply the configured gate-failure policy. |
87
-
88
- Malformed, missing, duplicate, or contradictory decisions are gate failures. The same policy also applies when the Advisor is unavailable or the shared call budget is exhausted.
89
-
90
- | Failure mode | Effect |
91
- | ------------------------- | ----------------------------------- |
92
- | `block-session` (default) | Block the session. |
93
- | `block-tool` | Block only the current tool action. |
94
- | `warn-and-continue` | Show a warning and continue. |
95
-
96
- | Condition | `block-session` | `block-tool` | `warn-and-continue` |
97
- | -------------------------------------------------------- | --------------- | ----------------- | ------------------- |
98
- | Advisor unavailable or timed out | Block session | Block tool action | Warn and continue |
99
- | Missing, malformed, duplicate, or contradictory decision | Block session | Block tool action | Warn and continue |
100
- | Shared budget exhausted | Block session | Block tool action | Warn and continue |
101
- | `Decision: blocked` | Block session | Block tool action | Warn and continue |
102
-
103
- `advisorBlockOnBlocked` controls whether a session block immediately aborts the active run. It never turns a session block into a tool-only block.
104
-
105
- ## Settings and configuration
106
-
107
- `/advisor-models` and `/advisor-settings` save to global `advisor.json` in the Pi agent directory. Repository-controlled project `advisor.json` files are not applied; models, prompts, gates, budgets, disclosure, redaction, integrations, and consent remain under the user's global configuration.
108
-
109
- All fields are optional. This example shows the available settings and their normal defaults:
110
-
111
- ```json
112
- {
113
- "executor": "openai/gpt-5.6-luna",
114
- "advisor": "anthropic/claude-fable-5",
115
- "executorEffort": "medium",
116
- "advisorEffort": "xhigh",
117
- "contextMaxChars": 25000,
118
-
119
- "advisorPlanGate": true,
120
- "advisorFailureGate": true,
121
- "advisorCompletionGate": true,
122
- "advisorCustomInvocation": "before changing a production deployment",
123
- "advisorCollapseResponses": false,
124
-
125
- "advisorAutoLoopGate": true,
126
- "advisorLoopThreshold": 3,
127
- "advisorMaxCallsPerSession": 5,
128
- "advisorBlockOnBlocked": true,
129
- "gateFailureMode": "block-session",
130
-
131
- "advisorSessionSummary": false,
132
- "advisorGitContext": "summary",
133
- "advisorGitContextMaxChars": 20000,
134
- "simpleMode": false,
135
- "alwaysOn": false,
136
- "advisorHerdrIntegration": true,
137
- "advisorToolResultMaxLines": 2000,
138
- "advisorToolResultMaxBytes": 51200,
139
-
140
- "advisorRedactSecrets": false,
141
- "advisorUntrackedContent": false,
142
- "advisorOutcomeLogging": false,
143
- "advisorToolPolicies": {
144
- "bash": "summary",
145
- "deploy": "exclude"
146
- }
147
- }
52
+ /advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol
148
53
  ```
149
54
 
150
- ### Simple mode and persistent activation
151
-
152
- - `simpleMode` defaults to `false`. When enabled, `ask_advisor` and `/advisor-manual` remain available for voluntary second opinions, while plan/failure/completion rules, loop gates, blocking, call budgets, and session summaries are disabled. Context limits, result caps, redaction, and tool disclosure policies still apply.
153
- - `alwaysOn` defaults to `false`. When enabled, Pi restores the configured Executor and activates `ask_advisor` for new, resumed, forked, and reloaded sessions. While the Advisor flow is active, an explicit `/model` selection becomes the persisted Executor for the next activation; a model restored with a session does not change the saved Executor. `/advisor-off` turns `alwaysOn` off so the flow stays disabled in later sessions.
154
- - In Simple mode, settings keeps the Context window/history slider alongside Simple mode and Always on; advanced values remain saved and take effect when Simple mode is disabled.
155
-
156
- ### Repository context
157
-
158
- - `advisorGitContext` defaults to `summary`. It controls how much of the working tree reaches the Advisor:
159
- - `off` sends no repository information.
160
- - `summary` sends changed file names, change status, and line counts. It never sends file contents.
161
- - `full` additionally sends the patch.
162
- - Changes are measured against the last commit and cover staged and unstaged work. Untracked files are always listed by name only; their contents are never sent by `gitContext: full`.
163
- - `advisorUntrackedContent` defaults to false. When enabled, `includeUntracked` can attach only exact named, repository-relative, untracked regular files. Files are redacted and capped before egress; sibling files remain withheld.
164
- - `advisorGitContextMaxChars` defaults to `20000`. Repository context may claim its own cap or half of `contextMaxChars`, whichever is smaller, so it cannot crowd out the conversation.
165
- - The Executor may pass `gitContext` to `ask_advisor` as `none`, `summary`, or `full`. `advisorGitContext` is the ceiling: a larger request is narrowed to the configured level and the Advisor is told that a fuller view was withheld, so it does not claim verification it could not perform.
166
- - `summary` deliberately excludes diff hunk headers. Git derives those from surrounding file content, so a hunk header can reproduce a line the change never touched, including a credential.
167
- - Redaction runs before the region is capped, and repository content is labelled as untrusted data in the request. Paths and patch text are escaped so a crafted path cannot close the region early and have the remainder read as instructions.
168
- - File names themselves can be sensitive. `summary` withholds file contents, not file names; use `off` when names must not leave the machine.
169
- - Collection shares a single overall time budget across its git commands and degrades to a stated failure rather than implying a clean tree.
170
-
171
- ### Project preferences and outcomes
55
+ ## How it works
172
56
 
173
- In a trusted project only, `.pi/advisor-preferences.md` may provide a short local brief. It is never written by pi-advisor, is treated as lower-priority untrusted text, and is redacted/capped before egress. Symlinks, unreadable files, and paths outside the project are ignored.
57
+ 1. The Executor investigates the task and forms its own candidate direction.
58
+ 2. For a consequential decision, stalled attempt, or final review, it calls `ask_advisor` with the reconstructed conversation and allowed repository context.
59
+ 3. The Advisor returns a concise review. It may challenge assumptions, identify risks, or recommend the next verification step.
60
+ 4. The Executor decides what to adopt, performs the work, and validates the result.
174
61
 
175
- `advisorOutcomeLogging` defaults to false and is global-only: a project config cannot enable it. When enabled, `~/.pi/agent/advisor-outcomes.jsonl` stores bounded rotating JSONL records with only a version, timestamp, salted truncated advice digest, trigger, adoption, and validation status. It stores no prompt, advice, paths, tool output, repository data, session ID, or advice ID.
62
+ A normal consultation never blocks execution. The optional automatic loop gate is different: it evaluates repeated tool calls and applies the configured failure policy when the Advisor says to revise, reports a block, is unavailable, or returns an invalid decision.
176
63
 
177
- ### Context and limits
64
+ Successful calls return an opaque `adviceId`. If global outcome logging is enabled, the Executor can call `record_advisor_outcome` once to record whether the advice was adopted and whether final validation passed.
178
65
 
179
- - `contextMaxChars` defaults to `15000`. It preserves complete semantic entries and adds an omission marker rather than splitting a message.
180
- - Set `contextMaxChars` to `0` to omit reconstructed history. `9007199254740991` is the persisted value for `ALL`.
181
- - Tool results default to Pi's `2000` lines and `50 KiB` limits. Oversized results preserve their beginning and end with an omission marker.
182
- - `advisorLoopThreshold` is an integer of at least `2`; its default is `3`.
183
- - Omit `advisorMaxCallsPerSession` for an unlimited shared budget. Otherwise it must be a non-negative safe integer.
184
-
185
- ### Privacy controls
186
-
187
- Advisor context can contain user messages, tool calls, and tool results. Configure disclosure deliberately:
188
-
189
- - `advisorRedactSecrets` defaults to `false`. When enabled, pi-advisor locally redacts common credential patterns before including context in an Advisor request.
190
- - `advisorToolPolicies` matches an **exact tool name**. Each tool may use `full`, `summary`, or `exclude`.
191
- - `full` includes the call arguments and capped result output.
192
- - `summary` omits call arguments and result output but includes result status and size metadata.
193
- - `exclude` omits both call details and output.
194
- - Tools not listed in `advisorToolPolicies`, including custom and newly added tools, use `full` for backward compatibility.
195
-
196
- Redaction and output limits reduce accidental disclosure; they are not a data-classification system and cannot guarantee every secret is found. Use tool policies for content that must not be sent to the Advisor.
66
+ ## Commands
197
67
 
198
- ### Session summary and Herdr
68
+ | Command | Purpose |
69
+ | --- | --- |
70
+ | `/advisor` | Enable the flow and optionally override the Executor, Advisor, or context limit. |
71
+ | `/advisor-manual [focus]` | Start a parallel consultation without interrupting the current Executor turn. |
72
+ | `/advisor-models` | Choose both models and their reasoning effort. |
73
+ | `/advisor-settings` | Configure behavior, context, gates, privacy, and output limits. |
74
+ | `/advisor-off` | Disable the flow and turn off persistent activation. |
199
75
 
200
- The optional Session Advisor Summary defaults to off. When enabled, it is local and in-memory only, appears after a non-blocked settled run, and is never persisted.
76
+ The Executor calls `ask_advisor({})` for a general review. It can pass a targeted `question` or a concise `draft` describing proposed work, validation, and remaining risks. Draft claims give the Advisor review context; they are not verification evidence.
201
77
 
202
- It distinguishes regular Markdown advice from gate decisions and records the trigger, model, usage/cost when available, failures, budget, and execution effect.
78
+ ## What gets sent to the Advisor
203
79
 
204
- [Herdr](https://github.com/ogulcancelik/herdr) integration is enabled by default. It reports Advisor activity and a bounded, redacted blocked-state summary through Herdr's metadata paths; disable it with `advisorHerdrIntegration`. Previously reported state is still cleared when integration is disabled.
80
+ Advisor context can include user messages, tool calls, tool results, and repository information. Secret redaction is off by default, and tools without an explicit disclosure policy default to full context. Review the privacy settings before using the extension with sensitive work.
205
81
 
206
- ## Development
82
+ Repository context is configurable from no access through changed-file summaries to a capped patch. When context is disabled or its budget is zero, the Advisor is told it was withheld rather than shown an apparently clean tree. Untracked file contents require a separate explicit opt-in.
207
83
 
208
- ```bash
209
- git clone git@github.com:philipbrembeck/pi-advisor.git
210
- cd pi-advisor
211
- bun install
84
+ ## Documentation
212
85
 
213
- bun test
214
- bun run typecheck
215
- bun run lint
216
- ```
86
+ - [Configuration and automatic loop gates](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/configuration.md)
87
+ - [Privacy and data handling](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/privacy.md)
88
+ - [Development](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/development.md)
89
+ - [Documentation index](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/README.md)
217
90
 
218
91
  ## Links
219
92
 
220
- - [MIT LICENSE](LICENSE)
93
+ - [MIT License](LICENSE)
221
94
  - [Changelog](CHANGELOG.md)
222
95
  - [npm package](https://www.npmjs.com/package/pi-advisor-flow)
223
96
  - [Why use an Advisor flow?](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need)
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerCommands } from "../src/commands.js";
3
+ import { setHerdrBlockedEmitter } from "../src/herdr.js";
4
+ import { AdvisorSessionState } from "../src/session-state.js";
3
5
  import {
4
6
  consultAdvisor as consultAdvisorImplementation,
5
7
  parseAutomaticDecision as parseAutomaticDecisionImplementation,
@@ -28,6 +30,10 @@ export const runAdvisorGate = (
28
30
  ) => runAdvisorGateImplementation(...args);
29
31
 
30
32
  export default function (pi: ExtensionAPI) {
31
- registerAdvisorTool(pi);
32
- registerCommands(pi);
33
+ const sessionState = new AdvisorSessionState();
34
+ setHerdrBlockedEmitter((active, label) =>
35
+ pi.events.emit("herdr:blocked", { active, label })
36
+ );
37
+ registerAdvisorTool(pi, sessionState);
38
+ registerCommands(pi, { sessionState });
33
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "author": "Philip Brembeck",
5
5
  "repository": {
6
6
  "type": "git",
package/src/commands.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  advisorMaxCallsPerSessionRef,
10
10
  advisorRef,
11
11
  alwaysOnRef,
12
+ contextMaxCharsRef,
12
13
  executorEffortRef,
13
14
  executorRef,
14
15
  getAdvisorSettings,
@@ -47,10 +48,11 @@ import {
47
48
  splitRef,
48
49
  } from "./config.js";
49
50
  import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "./herdr.js";
51
+ import type { AdvisorSessionState } from "./session-state.js";
50
52
  import {
51
53
  adviceForDisplay,
52
- advisorSessionState,
53
54
  consultAdvisor,
55
+ advisorSessionState as defaultAdvisorSessionState,
54
56
  hasSoundVerdict,
55
57
  renderAdvisorCallBox,
56
58
  renderAdvisorResponseHeader,
@@ -138,8 +140,13 @@ const findConfiguredModel = (ctx: ExtensionContext, ref: string) => {
138
140
 
139
141
  export const registerCommands = (
140
142
  pi: ExtensionAPI,
141
- dependencies: { consult?: ManualConsult } = {}
143
+ dependencies: {
144
+ consult?: ManualConsult;
145
+ sessionState?: AdvisorSessionState;
146
+ } = {}
142
147
  ) => {
148
+ const advisorSessionState =
149
+ dependencies.sessionState ?? defaultAdvisorSessionState;
143
150
  const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
144
151
  const requestAdvisor =
145
152
  dependencies.consult ??
@@ -155,15 +162,15 @@ export const registerCommands = (
155
162
  herdrAdvisorActivity.start();
156
163
  return requestAdvisor(ctx, question, controller.signal)
157
164
  .then(({ markdown }) => {
165
+ if (controller.signal.aborted) {
166
+ return;
167
+ }
158
168
  advisorSessionState.recordInvocation({
159
169
  executionEffect: "continued",
160
170
  kind: "markdown",
161
171
  model: advisorRef,
162
172
  trigger: "manual",
163
173
  });
164
- if (controller.signal.aborted) {
165
- return;
166
- }
167
174
  pi.sendMessage(
168
175
  {
169
176
  content: `Manual Advisor consultation${question ? ` (${question})` : ""}:\n\n${markdown}`,
@@ -232,19 +239,48 @@ export const registerCommands = (
232
239
  return {};
233
240
  };
234
241
 
242
+ const loadCommandConfig = (ctx: ExtensionContext) => {
243
+ try {
244
+ loadConfig(ctx);
245
+ return true;
246
+ } catch (error) {
247
+ const message = error instanceof Error ? error.message : String(error);
248
+ notify(
249
+ ctx,
250
+ `Advisor command could not load configuration: ${message} Fix advisor.json and retry.`,
251
+ "error"
252
+ );
253
+ return false;
254
+ }
255
+ };
256
+
235
257
  const activateAdvisor = async (
236
258
  args: string,
237
259
  ctx: ExtensionContext,
238
260
  announce = true
239
261
  ) => {
240
- loadConfig(ctx);
262
+ if (!loadCommandConfig(ctx)) {
263
+ return;
264
+ }
265
+ const previous = {
266
+ advisor: advisorRef,
267
+ contextMaxChars: contextMaxCharsRef,
268
+ executor: executorRef,
269
+ };
270
+ const restoreRefs = () => {
271
+ setAdvisorRef(previous.advisor);
272
+ setContextMaxCharsRef(previous.contextMaxChars);
273
+ setExecutorRef(previous.executor);
274
+ };
241
275
  const argumentError = parseArgs(args);
242
276
  if (argumentError) {
277
+ restoreRefs();
243
278
  notify(ctx, argumentError, "error");
244
279
  return;
245
280
  }
246
281
  const { error } = await resolveActivationModels(ctx);
247
282
  if (error) {
283
+ restoreRefs();
248
284
  notify(ctx, error, "error");
249
285
  return;
250
286
  }
@@ -356,7 +392,9 @@ export const registerCommands = (
356
392
  description:
357
393
  "Consult the Advisor in parallel; accepts an optional focused question and fans its response out to the Executor",
358
394
  handler: (args, ctx) => {
359
- loadConfig(ctx);
395
+ if (!loadCommandConfig(ctx)) {
396
+ return Promise.resolve();
397
+ }
360
398
  if (
361
399
  !(
362
400
  isSimpleMode() ||
@@ -396,8 +434,7 @@ export const registerCommands = (
396
434
  description:
397
435
  "Select and persist the Executor and Advisor models with reasoning levels",
398
436
  handler: async (_args, ctx) => {
399
- loadConfig(ctx);
400
- if (!ctx.hasUI) {
437
+ if (!(loadCommandConfig(ctx) && ctx.hasUI)) {
401
438
  return;
402
439
  }
403
440
  const refs = ctx.modelRegistry
@@ -475,8 +512,7 @@ export const registerCommands = (
475
512
  description: "Configure Advisor context and reasoning effort",
476
513
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one settings form maps every persisted control.
477
514
  handler: async (_args, ctx) => {
478
- loadConfig(ctx);
479
- if (!ctx.hasUI) {
515
+ if (!(loadCommandConfig(ctx) && ctx.hasUI)) {
480
516
  return;
481
517
  }
482
518
 
package/src/config.ts CHANGED
@@ -305,16 +305,10 @@ const invalidConfigValue = (
305
305
  );
306
306
  };
307
307
 
308
- const validateKnownKeys = (config: ConfigRecord, path: string) => {
309
- const unknownKeys = Object.keys(config).filter(
308
+ const unknownConfigKeys = (config: ConfigRecord) =>
309
+ Object.keys(config).filter(
310
310
  (key) => !CONFIG_KEYS.has(key as keyof AdvisorConfig)
311
311
  );
312
- if (unknownKeys.length > 0) {
313
- throw new TypeError(
314
- `Invalid advisor configuration at ${path}: unknown key(s) ${unknownKeys.map((key) => JSON.stringify(key)).join(", ")}. Remove them or upgrade pi-advisor.`
315
- );
316
- }
317
- };
318
312
 
319
313
  const validateStringValues = (config: ConfigRecord, path: string) => {
320
314
  for (const key of STRING_CONFIG_KEYS) {
@@ -406,7 +400,6 @@ export const validateConfig = (
406
400
  );
407
401
  }
408
402
  const config = value as ConfigRecord;
409
- validateKnownKeys(config, path);
410
403
  validateStringValues(config, path);
411
404
  validateBooleanValues(config, path);
412
405
  validateNumericValues(config, path);
@@ -587,10 +580,12 @@ const configCache = new Map<
587
580
  string,
588
581
  { config: AdvisorConfig; identity: string }
589
582
  >();
583
+ const warnedUnknownConfigIdentities = new Set<string>();
590
584
 
591
585
  /** Drops the parsed-configuration cache; the next load re-reads from disk. */
592
586
  export const resetConfigCache = () => {
593
587
  configCache.clear();
588
+ warnedUnknownConfigIdentities.clear();
594
589
  };
595
590
 
596
591
  /**
@@ -626,6 +621,19 @@ export const loadConfig = (_ctx: ExtensionContext) => {
626
621
  : undefined;
627
622
  if (globalConfig) {
628
623
  applyConfig(globalConfig);
624
+ const unknownKeys = unknownConfigKeys(globalConfig as ConfigRecord);
625
+ const warningIdentity = `${global}:${configIdentity(global)}`;
626
+ if (
627
+ unknownKeys.length > 0 &&
628
+ _ctx.hasUI &&
629
+ !warnedUnknownConfigIdentities.has(warningIdentity)
630
+ ) {
631
+ _ctx.ui.notify(
632
+ `Advisor configuration at ${global} contains unrecognized key(s) ${unknownKeys.map((key) => JSON.stringify(key)).join(", ")}. They were preserved but ignored; check for typos or upgrade pi-advisor.`,
633
+ "warning"
634
+ );
635
+ warnedUnknownConfigIdentities.add(warningIdentity);
636
+ }
629
637
  }
630
638
  // Repository-controlled project configuration is never applied. Models,
631
639
  // prompts, gates, budgets, disclosure, redaction, integrations, and consent
@@ -288,28 +288,41 @@ const selectRecentEntries = (entries: string[], maxChars: number): string => {
288
288
  if (joined.length <= maxChars || maxChars === Number.MAX_SAFE_INTEGER) {
289
289
  return joined;
290
290
  }
291
+ const newestTruncated = "[Newest entry truncated]";
292
+ if (entries.length === 1) {
293
+ const prefix = `${newestTruncated}${separator}`;
294
+ return `${prefix}${entries[0].slice(0, Math.max(0, maxChars - prefix.length))}`.slice(
295
+ 0,
296
+ maxChars
297
+ );
298
+ }
291
299
  const selected: string[] = [];
292
- // Track the joined length instead of re-joining the accumulator each step;
293
- // long branches with a large maxChars are otherwise quadratic in bytes.
294
300
  let selectedLength = 0;
295
301
  for (let index = entries.length - 1; index >= 0; index -= 1) {
296
302
  const entry = entries[index];
303
+ const candidateCount = selected.length + 1;
304
+ const omitted = entries.length - candidateCount;
305
+ const marker = `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]`;
297
306
  const candidateLength =
298
307
  selectedLength +
299
308
  entry.length +
300
- (selected.length === 0 ? 0 : separator.length);
301
- if (candidateLength <= maxChars || selected.length === 0) {
302
- selected.unshift(entry);
303
- selectedLength = candidateLength;
304
- } else {
309
+ (selected.length > 0 ? separator.length : 0);
310
+ if (marker.length + separator.length + candidateLength > maxChars) {
305
311
  break;
306
312
  }
313
+ selected.unshift(entry);
314
+ selectedLength = candidateLength;
307
315
  }
308
- const omitted = entries.length - selected.length;
309
- const suffix = selected.length
310
- ? `${separator}${selected.join(separator)}`
311
- : "";
312
- return `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]${suffix}`;
316
+ const omitted = entries.length - Math.max(1, selected.length);
317
+ const marker = `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]`;
318
+ if (selected.length > 0) {
319
+ return `${marker}${separator}${selected.join(separator)}`;
320
+ }
321
+ const prefix = `${marker}${separator}${newestTruncated}${separator}`;
322
+ return `${prefix}${entries.at(-1)?.slice(0, Math.max(0, maxChars - prefix.length)) ?? ""}`.slice(
323
+ 0,
324
+ maxChars
325
+ );
313
326
  };
314
327
 
315
328
  export const recentConversation = (
package/src/git.ts CHANGED
@@ -60,8 +60,12 @@ export const capRepositoryContext = (value: string, maxChars: number) => {
60
60
  if (value.length <= maxChars) {
61
61
  return { text: value, truncated: false };
62
62
  }
63
+ const contentChars = Math.max(0, maxChars - TRUNCATION_NOTICE.length);
63
64
  return {
64
- text: `${value.slice(0, Math.max(0, maxChars - TRUNCATION_NOTICE.length))}${TRUNCATION_NOTICE}`,
65
+ text:
66
+ maxChars < TRUNCATION_NOTICE.length
67
+ ? TRUNCATION_NOTICE.slice(0, maxChars)
68
+ : `${value.slice(0, contentChars)}${TRUNCATION_NOTICE}`,
65
69
  truncated: true,
66
70
  };
67
71
  };
package/src/herdr.ts CHANGED
@@ -38,6 +38,25 @@ export interface HerdrNotificationRequest {
38
38
  export type HerdrRequest = HerdrMetadataRequest | HerdrNotificationRequest;
39
39
  type Report = (request: HerdrRequest) => void;
40
40
 
41
+ // Herdr only accepts semantic state from the pane's lifecycle authority, which
42
+ // for pi is herdr's own herdr-agent-state extension (source "herdr:pi"). Socket
43
+ // reports from any other source are dropped, so the blocked state has to be
44
+ // signalled through the in-process pi event bus that integration listens on.
45
+ type BlockedEmitter = (active: boolean, label: string) => void;
46
+ let emitBlocked: BlockedEmitter | undefined;
47
+
48
+ export const setHerdrBlockedEmitter = (emitter: BlockedEmitter | undefined) => {
49
+ emitBlocked = emitter;
50
+ };
51
+
52
+ const safeEmitBlocked = (active: boolean, label = "Advisor blocked") => {
53
+ try {
54
+ emitBlocked?.(active, label);
55
+ } catch {
56
+ /* Herdr is optional. */
57
+ }
58
+ };
59
+
41
60
  const isControlCharacter = (character: string) =>
42
61
  character <= "\u001f" || character === "\u007f";
43
62
 
@@ -167,8 +186,14 @@ export class HerdrAdvisorBlock {
167
186
  if (!this.enabled()) {
168
187
  return;
169
188
  }
189
+ const label = cleanNotification(reason, 200);
190
+ const wasBlocked: boolean = this.#blocked;
191
+ if (!wasBlocked) {
192
+ // The listener refcounts, so only the false → true edge may emit.
193
+ safeEmitBlocked(true, label);
194
+ }
170
195
  this.#blocked = true;
171
- this.safeReport({ blocked: cleanNotification(reason, 200) });
196
+ this.safeReport({ blocked: label });
172
197
  }
173
198
 
174
199
  clear() {
@@ -179,6 +204,7 @@ export class HerdrAdvisorBlock {
179
204
  }
180
205
  // Clearing previously reported state is a de-escalation and must still be
181
206
  // delivered if integration was disabled after the block was reported.
207
+ safeEmitBlocked(false);
182
208
  try {
183
209
  this.report({
184
210
  id: `${BLOCK_SOURCE}:${nextSequence()}`,
package/src/outcomes.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { createHmac, randomBytes } from "node:crypto";
2
- import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import {
3
+ appendFile,
4
+ chmod,
5
+ link,
6
+ mkdir,
7
+ open,
8
+ readFile,
9
+ stat,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
3
13
  import { join } from "node:path";
4
14
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
15
 
@@ -35,18 +45,76 @@ export const outcomeLogPath = () =>
35
45
 
36
46
  const salt = async () => {
37
47
  const path = statePath();
38
- try {
39
- return await readFile(path);
40
- } catch {
41
- await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
48
+ await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
49
+ for (let attempt = 0; attempt < 20; attempt += 1) {
50
+ try {
51
+ // biome-ignore lint/performance/noAwaitInLoops: contenders retry sequentially until one salt is atomically published.
52
+ const existing = await readFile(path);
53
+ if (existing.length === 32) {
54
+ return existing;
55
+ }
56
+ // Recover a salt file left incomplete by an interrupted older writer.
57
+ await unlink(path);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
60
+ throw error;
61
+ }
62
+ }
42
63
  const value = randomBytes(32);
43
- const temporary = `${path}.${process.pid}.${Date.now()}`;
64
+ const temporary = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.${attempt}`;
44
65
  await writeFile(temporary, value, { mode: 0o600 });
45
- await rename(temporary, path);
46
- await chmod(path, 0o600);
47
- return value;
66
+ try {
67
+ await link(temporary, path);
68
+ return value;
69
+ } catch (error) {
70
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
71
+ throw error;
72
+ }
73
+ } finally {
74
+ await unlink(temporary).catch(() => undefined);
75
+ }
76
+ }
77
+ throw new Error("Advisor outcome salt initialization did not complete.");
78
+ };
79
+ const sameFile = (
80
+ left: { dev: number | bigint; ino: number | bigint },
81
+ right: { dev: number | bigint; ino: number | bigint }
82
+ ) => left.dev === right.dev && left.ino === right.ino;
83
+
84
+ const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
85
+ const lockPath = `${outcomeLogPath()}.lock`;
86
+ for (let attempt = 0; attempt < 200; attempt += 1) {
87
+ try {
88
+ // biome-ignore lint/performance/noAwaitInLoops: lock acquisition must retry sequentially across processes.
89
+ const lock = await open(lockPath, "wx", 0o600);
90
+ const identity = await lock.stat();
91
+ try {
92
+ return await run();
93
+ } finally {
94
+ await lock.close();
95
+ const current = await stat(lockPath).catch(() => undefined);
96
+ if (current && sameFile(identity, current)) {
97
+ await unlink(lockPath).catch(() => undefined);
98
+ }
99
+ }
100
+ } catch (error) {
101
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
102
+ throw error;
103
+ }
104
+ const observed = await stat(lockPath).catch(() => undefined);
105
+ if (observed && Date.now() - observed.mtimeMs > 30_000) {
106
+ const current = await stat(lockPath).catch(() => undefined);
107
+ if (current && sameFile(observed, current)) {
108
+ await unlink(lockPath).catch(() => undefined);
109
+ }
110
+ continue;
111
+ }
112
+ await new Promise((resolve) => setTimeout(resolve, 5));
113
+ }
48
114
  }
115
+ throw new Error("Timed out waiting to append an Advisor outcome.");
49
116
  };
117
+
50
118
  export const adviceDigest = (advice: string, key: Buffer) =>
51
119
  createHmac("sha256", key).update(advice).digest("hex").slice(0, 16);
52
120
 
@@ -58,12 +126,6 @@ export const appendOutcome = async (
58
126
  ) => {
59
127
  const path = outcomeLogPath();
60
128
  await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
61
- let previous = "";
62
- try {
63
- previous = await readFile(path, "utf8");
64
- } catch {
65
- /* new log */
66
- }
67
129
  const next: OutcomeRecord = {
68
130
  adoption: record.adoption,
69
131
  adviceHash: adviceDigest(record.advice, await salt()),
@@ -73,13 +135,21 @@ export const appendOutcome = async (
73
135
  validationStatus: record.validationStatus,
74
136
  };
75
137
  const line = `${JSON.stringify(next)}\n`;
76
- const retained =
77
- Buffer.byteLength(previous) + Buffer.byteLength(line) > MAX_LOG_BYTES
78
- ? ""
79
- : previous;
80
- const temporary = `${path}.${process.pid}.${Date.now()}`;
81
- await writeFile(temporary, retained + line, { mode: 0o600 });
82
- await rename(temporary, path);
83
- await chmod(path, 0o600);
138
+ await withOutcomeLock(async () => {
139
+ const currentBytes = await stat(path)
140
+ .then((value) => value.size)
141
+ .catch((error: NodeJS.ErrnoException) => {
142
+ if (error.code === "ENOENT") {
143
+ return 0;
144
+ }
145
+ throw error;
146
+ });
147
+ if (currentBytes + Buffer.byteLength(line) > MAX_LOG_BYTES) {
148
+ await writeFile(path, line, { encoding: "utf8", mode: 0o600 });
149
+ } else {
150
+ await appendFile(path, line, { encoding: "utf8", mode: 0o600 });
151
+ }
152
+ await chmod(path, 0o600);
153
+ });
84
154
  return next;
85
155
  };
package/src/tools.ts CHANGED
@@ -141,6 +141,8 @@ export const gitContextNote = (
141
141
  return `Repository context was limited to "${allowed}" by user configuration; a fuller view was requested but withheld.`;
142
142
  }
143
143
  switch (result.status) {
144
+ case "disabled":
145
+ return "Repository context was disabled or had no disclosure budget; it was withheld. Do not assume the working tree is clean.";
144
146
  case "no-changes":
145
147
  return "The working tree has no uncommitted changes.";
146
148
  case "not-a-repository":
@@ -157,6 +159,20 @@ const LEVEL_WITHHELD: Record<string, boolean> = {
157
159
  "no-changes": false,
158
160
  };
159
161
 
162
+ export const advisorRepositoryContext = (
163
+ result: GitContextResult,
164
+ requested: GitContextLevel,
165
+ allowed: GitContextLevel,
166
+ budget: number
167
+ ) => {
168
+ const note = gitContextNote(result, requested, allowed);
169
+ const payload = capRepositoryContext(
170
+ escapeRepositoryText(result.text),
171
+ budget
172
+ ).text;
173
+ return [note, payload].filter(Boolean).join("\n\n");
174
+ };
175
+
160
176
  /**
161
177
  * The conversation boundary for outgoing Advisor requests. Repository context is
162
178
  * the only other egress path; both are assembled by advisorMessageText and both
@@ -433,11 +449,14 @@ const collectAdvisorResponse = async (
433
449
  );
434
450
  // The note is placed first so a cap can never drop the statement that the
435
451
  // Advisor's view of the repository is limited.
436
- const note = gitContextNote(changes, gitContext ?? allowed, level);
437
- const changeText = capRepositoryContext(
438
- [note, escapeRepositoryText(changes.text)].filter(Boolean).join("\n\n"),
452
+ // The disclosure warning is control metadata, not repository payload. Keep it
453
+ // outside the zero-byte Git budget so disabling disclosure cannot erase it.
454
+ const changeText = advisorRepositoryContext(
455
+ changes,
456
+ gitContext ?? allowed,
457
+ level,
439
458
  gitBudget
440
- ).text;
459
+ );
441
460
  // Repository context spends part of the shared budget, so a large patch
442
461
  // cannot silently push the conversation past the model's context window.
443
462
  const conversation = advisorRequestConversation(
@@ -668,7 +687,6 @@ const reserveAdvisorCall = (
668
687
  notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
669
688
  return { block: true, reason: message };
670
689
  }
671
- session.consumeCall();
672
690
  reservedCalls.add(event.toolCallId);
673
691
  return {};
674
692
  };
@@ -934,8 +952,10 @@ const renderAdvisorResult = (
934
952
  return box;
935
953
  };
936
954
 
937
- export const registerAdvisorTool = (pi: ExtensionAPI) => {
938
- const session = advisorSessionState;
955
+ export const registerAdvisorTool = (
956
+ pi: ExtensionAPI,
957
+ session: AdvisorSessionState = advisorSessionState
958
+ ) => {
939
959
  const reservedCalls = new Set<string>();
940
960
 
941
961
  pi.registerMessageRenderer?.(
@@ -1021,16 +1041,16 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
1021
1041
  });
1022
1042
 
1023
1043
  pi.on("tool_call", (event, ctx) => {
1024
- if (!pi.getActiveTools().includes("ask_advisor")) {
1025
- return;
1026
- }
1027
- loadConfig(ctx);
1028
- if (!isSimpleMode() && session.blocked) {
1044
+ if (session.blocked) {
1029
1045
  return {
1030
1046
  block: true,
1031
1047
  reason: session.blockedReason ?? "Advisor session is blocked.",
1032
1048
  };
1033
1049
  }
1050
+ if (!pi.getActiveTools().includes("ask_advisor")) {
1051
+ return;
1052
+ }
1053
+ loadConfig(ctx);
1034
1054
  const reservation = reserveAdvisorCall(event, ctx, session, reservedCalls);
1035
1055
  if (event.toolName === "ask_advisor") {
1036
1056
  return reservation;
@@ -1039,6 +1059,9 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
1039
1059
  });
1040
1060
 
1041
1061
  pi.on("agent_settled", (_event, ctx) => {
1062
+ // Any reservation still present never reached execute (for example because
1063
+ // another handler blocked it or the turn was aborted).
1064
+ reservedCalls.clear();
1042
1065
  if (isSimpleMode() || session.blocked || !advisorSessionSummaryRef) {
1043
1066
  return;
1044
1067
  }
@@ -1057,7 +1080,8 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
1057
1080
  description:
1058
1081
  "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review.",
1059
1082
  async execute(_id, params, signal, onUpdate, ctx) {
1060
- if (!(reservedCalls.delete(_id) || isSimpleMode())) {
1083
+ reservedCalls.delete(_id);
1084
+ if (!isSimpleMode()) {
1061
1085
  if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
1062
1086
  throw new Error("Advisor call budget exhausted for this session.");
1063
1087
  }
package/src/ui.ts CHANGED
@@ -24,6 +24,18 @@ interface SearchableModelSelectorOptions {
24
24
  title: string;
25
25
  tui: RenderRequester;
26
26
  }
27
+ const stepNumericPreset = (
28
+ current: number,
29
+ presets: number[],
30
+ direction: number
31
+ ) => {
32
+ const values = presets.includes(current)
33
+ ? presets
34
+ : [...presets, current].sort((a, b) => a - b);
35
+ const index = values.indexOf(current);
36
+ return values[Math.max(0, Math.min(values.length - 1, index + direction))];
37
+ };
38
+
27
39
  interface AdvisorSettingsSelectorOptions {
28
40
  effortLevels: string[];
29
41
  initial: AdvisorSettings;
@@ -304,14 +316,24 @@ export class AdvisorSettingsSelector implements Component, Focusable {
304
316
  this.editingCustom = false;
305
317
  this.editingPolicies = false;
306
318
  this.policiesError = undefined;
307
- this.options = options;
308
319
  // Retain every advanced value even when Simple mode hides its controls.
309
320
  this.settings = { ...options.initial };
310
- this.contextIndex = Math.max(
311
- 0,
312
- options.presets.findIndex(
313
- (preset) => preset.value === this.settings.contextMaxChars
314
- )
321
+ const configuredContext = this.settings.contextMaxChars;
322
+ const presets = options.presets.some(
323
+ (preset) => preset.value === configuredContext
324
+ )
325
+ ? [...options.presets]
326
+ : [
327
+ ...options.presets,
328
+ {
329
+ description: "Custom configured value",
330
+ label: String(configuredContext),
331
+ value: configuredContext,
332
+ },
333
+ ].sort((a, b) => a.value - b.value);
334
+ this.options = { ...options, presets };
335
+ this.contextIndex = presets.findIndex(
336
+ (preset) => preset.value === configuredContext
315
337
  );
316
338
  this.effortIndex = Math.max(
317
339
  0,
@@ -746,6 +768,7 @@ export class AdvisorSettingsSelector implements Component, Focusable {
746
768
  tui.requestRender();
747
769
  }
748
770
 
771
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one keyboard dispatcher maps each settings row to its bounded adjustment.
749
772
  private adjust(direction: number): void {
750
773
  switch (this.selectedRowId()) {
751
774
  case "simpleMode":
@@ -808,11 +831,14 @@ export class AdvisorSettingsSelector implements Component, Focusable {
808
831
  );
809
832
  break;
810
833
  case "maxCallsPerSession": {
811
- const values = [undefined, 0, 1, 2, 3, 5, 10, 25, 50];
812
- const index = Math.max(
813
- 0,
814
- values.indexOf(this.settings.maxCallsPerSession)
815
- );
834
+ const current = this.settings.maxCallsPerSession;
835
+ const numeric = [0, 1, 2, 3, 5, 10, 25, 50];
836
+ const sorted =
837
+ current === undefined || numeric.includes(current)
838
+ ? numeric
839
+ : [...numeric, current].sort((a, b) => a - b);
840
+ const values: (number | undefined)[] = [undefined, ...sorted];
841
+ const index = values.indexOf(current);
816
842
  this.settings.maxCallsPerSession =
817
843
  values[Math.max(0, Math.min(values.length - 1, index + direction))];
818
844
  break;
@@ -865,33 +891,27 @@ export class AdvisorSettingsSelector implements Component, Focusable {
865
891
  break;
866
892
  }
867
893
  case "gitContextMaxChars": {
868
- const values = [0, 5000, 10_000, 20_000, 50_000, 100_000];
869
- const index = Math.max(
870
- 0,
871
- values.indexOf(this.settings.gitContextMaxChars ?? 20_000)
894
+ this.settings.gitContextMaxChars = stepNumericPreset(
895
+ this.settings.gitContextMaxChars ?? 20_000,
896
+ [0, 5000, 10_000, 20_000, 50_000, 100_000],
897
+ direction
872
898
  );
873
- this.settings.gitContextMaxChars =
874
- values[Math.max(0, Math.min(values.length - 1, index + direction))];
875
899
  break;
876
900
  }
877
901
  case "toolResultMaxLines": {
878
- const values = [0, 500, 1000, 2000, 5000, 10_000];
879
- const index = Math.max(
880
- 0,
881
- values.indexOf(this.settings.toolResultMaxLines ?? 2000)
902
+ this.settings.toolResultMaxLines = stepNumericPreset(
903
+ this.settings.toolResultMaxLines ?? 2000,
904
+ [0, 500, 1000, 2000, 5000, 10_000],
905
+ direction
882
906
  );
883
- this.settings.toolResultMaxLines =
884
- values[Math.max(0, Math.min(values.length - 1, index + direction))];
885
907
  break;
886
908
  }
887
909
  case "toolResultMaxBytes": {
888
- const values = [0, 10 * 1024, 50 * 1024, 100 * 1024, 500 * 1024];
889
- const index = Math.max(
890
- 0,
891
- values.indexOf(this.settings.toolResultMaxBytes ?? 50 * 1024)
910
+ this.settings.toolResultMaxBytes = stepNumericPreset(
911
+ this.settings.toolResultMaxBytes ?? 50 * 1024,
912
+ [0, 10 * 1024, 50 * 1024, 100 * 1024, 500 * 1024],
913
+ direction
892
914
  );
893
- this.settings.toolResultMaxBytes =
894
- values[Math.max(0, Math.min(values.length - 1, index + direction))];
895
915
  break;
896
916
  }
897
917
  default: