pi-advisor-flow 0.5.0 → 0.5.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
@@ -4,6 +4,46 @@ 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.5.1
8
+
9
+ ### Changed
10
+
11
+ - Updated CI and local validation to Bun 1.4.1 and kept the Pi compatibility packages at 0.84.4; Pi 0.85.0 currently adds an eager `/server` import without declaring the `@earendil-works/pi-server` dependency required by its coding-agent entry point.
12
+ - Kept benchmark tests out of the normal test run; use `bun run test:bench` to run them explicitly.
13
+ - Added Socket security dependency overrides and a `socket` script for `bunx socket optimize`.
14
+
15
+ ### Added
16
+
17
+ - Added a repository-only failsafe benchmark with an offline replay tier, a
18
+ 24-item decision-point corpus, a fail-closed Pi/Harbor ReactBench adapter,
19
+ pinned live-tier controls, hard budget limits, and task-level
20
+ uplift/dominance reporting. See [Benchmarking](docs/benchmark.md).
21
+ - Added bounded local Harbor runtime controls for Apple Container experiments,
22
+ Docker build-cache cleanup, and a recorded one-hour agent timeout for complex
23
+ ReactBench tasks.
24
+ - Added a disposable, credential-free GitHub build compatibility layer for
25
+ pinned ReactBench Dockerfiles whose Git 2.39 transport is rejected by the
26
+ GitHub endpoint, with bounded pre-agent Harbor infrastructure retries and
27
+ per-attempt artifacts for transient build and transport failures.
28
+ - Derived the host Harbor command timeout from the bounded agent timeout and
29
+ made timeout cleanup terminate the full Harbor process group, including the
30
+ broker and descendants.
31
+ - Ensured configured Advisor reasoning effort reaches the provider-facing
32
+ request field used by current Pi AI adapters.
33
+
34
+ ### Fixed
35
+
36
+ - Made `/advisor` open the available-model picker on first use or when either saved model is missing or unavailable; activation never silently selects a model outside the user's available catalog.
37
+ - Added a concise `/advisor` explanation of the Advisor's second-opinion role.
38
+ - Preserved Markdown formatting in visible Scout and nested Advisor thinking previews while retaining their speech-bubble cue. During streaming, incomplete Markdown delimiters display transiently until they close, which is expected behavior when thinking arrives incrementally.
39
+ - Adopted an explicit `/model` selection made before `/advisor` as the Executor on the next successful activation, without persisting ordinary model changes while the flow is off.
40
+
41
+ ## [5.1.0] - 2026-09-04 [YANKED]
42
+
43
+ ### Release status
44
+
45
+ - Published by mistake instead of `0.5.1`; withdrawn from npm and removed from Git, with `latest` restored to `0.5.0`.
46
+
7
47
  ## 0.5.0
8
48
 
9
49
  ### Changed
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # pi-advisor
1
+ # [pi-advisor](https://github.com/philipbrembeck/pi-advisor)
2
2
 
3
3
  <div align="center">
4
4
 
@@ -8,114 +8,79 @@ A configurable second-opinion workflow for <a href="https://github.com/earendil-
8
8
 
9
9
  </div>
10
10
 
11
-
12
11
  ![d18m Downloads](https://img.shields.io/npm/d18m/pi-advisor-flow?style=flat) ![NPM Version](https://img.shields.io/npm/v/pi-advisor-flow?style=flat)
13
12
 
14
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 challenges assumptions, exposes risks, and suggests verification steps without taking over or running tools.
15
14
 
16
- 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).
15
+ 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).
16
+
17
+ ## How it works
17
18
 
18
- ## Features
19
+ 1. The Executor works on your task as usual.
20
+ 2. It calls `ask_advisor`, or an enabled gate starts a review.
21
+ 3. pi-advisor reconstructs the relevant conversation and allowed repository context.
22
+ 4. The Advisor returns an opinion. The Executor decides what to adopt, changes the code, and validates it.
19
23
 
20
- - **On-demand second opinions** through the `ask_advisor` tool or `/advisor-manual`.
21
- - **Configurable review gates** before plans, after repeated failures, and before declaring completion.
22
- - **Automatic loop detection** for repeated tool calls, with explicit proceed, revise, or blocked decisions.
23
- - **Separate model and reasoning controls** for the Executor and Advisor.
24
- - **Advisor usage accounting** with per-response token/cost details and optional cumulative direct usage in the Pi footer and session summary. Per-response usage and cost details are shown by default and can be hidden independently from the footer in `/advisor-settings`.
25
- - **Privacy controls** for conversation history, repository context, explicit tracked/untracked file handoff, tool results, secret redaction, and outcome logging.
26
- - **Optional persistent activation, Simple mode, session summaries, and Herdr integration.**
27
- - **Compact searchable `/advisor-settings` controls** that match Pi's settings list and save changes immediately.
28
- - **EXPERIMENTAL Advisor Scout** that uses the configured Executor model to curate conversation evidence before every Advisor call.
24
+ Regular consultations do not block execution. Automatic loop gates are different: they can stop a repeated tool action or session based on your configured failure policy.
29
25
 
30
26
  ## Install
31
27
 
32
- Requires Pi 0.84.1 or later and is compatible with Herdr 0.8.0.
28
+ Requires Pi 0.84.1 or later.
33
29
 
34
30
  ```bash
35
- # npm
36
31
  pi install npm:pi-advisor-flow
32
+ ```
37
33
 
38
- # GitHub
39
- pi install git:github.com/philipbrembeck/pi-advisor.git
34
+ You can also install from GitHub:
40
35
 
41
- # local checkout
42
- pi install /path/to/pi-advisor
36
+ ```bash
37
+ pi install git:github.com/philipbrembeck/pi-advisor.git
43
38
  ```
44
39
 
45
- Restart or reload Pi after installation.
40
+ Reload Pi after installing.
46
41
 
47
42
  ## Quick start
48
43
 
49
- 1. Run `/advisor` to enable the flow and register `ask_advisor`.
50
- 2. Run `/advisor-models` to choose the Executor and Advisor models. Current model and thinking-level selections appear first and ticked, so pressing Enter keeps them.
51
- 3. Run `/advisor-settings` to configure review gates, context, privacy, and limits. Type to fuzzy-search settings; changes save immediately.
52
-
53
- ![Advisor Settings](https://raw.githubusercontent.com/philipbrembeck/pi-advisor/refs/heads/main/assets/settings.png)
54
-
55
- 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.
56
-
57
- You can also enable the flow and select both models at once:
58
-
59
44
  ```text
60
- /advisor executor=openai-codex/gpt-5.6-luna advisor=openai-codex/gpt-5.6-sol
45
+ /advisor # Enable the Advisor Flow
46
+ /advisor-models # Choose the Executor and Advisor models
47
+ /advisor-settings # Configure behavior, modes, etc.
61
48
  ```
62
49
 
63
- ## How it works
64
-
65
- 1. The Executor investigates the task and forms its own candidate direction.
66
- 2. For a consequential decision, stalled attempt, or final review, it calls `ask_advisor` with the reconstructed conversation and allowed repository context.
67
- 3. When Experimental Advisor Scout is enabled, the configured Executor model selects relevant conversation groups and writes a short, explicitly untrusted synthesis.
68
- 4. The Advisor receives selected verbatim evidence, required current-request context, and the unchanged deterministic repository, preference, draft, and attachment regions.
69
- 5. The Executor decides what to adopt, performs the work, and validates the result.
50
+ On first use, or whenever a saved model is unavailable, `/advisor` opens the same available-model picker as `/advisor-models`; it never silently chooses an unconfigured model. After activation, `/advisor` explains that the Advisor reviews the Executor's context without changing files or running tools.
70
51
 
71
- 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.
52
+ From the Executor, `ask_advisor({})` requests a general review. A targeted `question` or concise `draft` can focus the review on a particular decision.
72
53
 
73
- Advisor responses show provider-reported input, output, cache, and cost details when available. Successful `ask_advisor` tool results also carry normalized usage into Pi's built-in `Tools/summaries` and `/cost` totals. Manual consultations and automatic gates remain in the separate session-local direct Advisor accounting because they are custom messages, so they are not double-counted in Pi's Executor totals. Missing or partial provider usage is shown as unavailable rather than fabricated as zero usage. `/advisor-settings` independently controls per-response usage details and the cumulative Advisor footer without disabling this accounting; the footer is off by default.
74
-
75
- 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.
76
-
77
- ### Experimental Advisor Scout
78
-
79
- Experimental Advisor Scout is off by default. Enable `Experimental Advisor Scout` in the advanced `/advisor-settings` screen or set `"advisorScoutEnabled": true` in the global `advisor.json`.
80
-
81
- Scout runs before `ask_advisor`, `/advisor-manual`, and automatic Advisor gates. It uses the configured Executor model and Executor reasoning effort in a separate model call. This adds cost and latency, but can reduce cost in the Advisor call. The compact result shows the model, selection counts, elapsed time, and usage/cost details; `Ctrl+O` shows bounded selected labels and the synthesis. Usage and cost details can be hidden in `/advisor-settings`.
82
-
83
- Scout receives a bounded manifest of conversation and tool-history groups after the normal tool disclosure, result-cap, and redaction policies are applied. The Scout manifest has its own fixed transport limit, while the reconstructed conversation remains bounded by the Advisor's remaining context budget after repository context; manifest metadata no longer consumes that Advisor conversation budget. A zero remaining budget produces no history groups. For a pending `ask_advisor` call, Scout receives only the allowlisted question and Git-context preference, never the draft or explicit attachment paths. Scout does not receive the deterministic Git context, draft, project preferences, or explicit tracked and untracked attachments. Those regions are appended later through their existing consent and cap rules.
84
-
85
- This experiment adapts the context-boundary idea from Zhang et al., ["FastContext: Training Efficient Repository Explorer for Coding Agents"](https://arxiv.org/html/2606.14066v1). It is not a reproduction of FastContext. pi-advisor Scout curates conversation history only.
54
+ In the Settings, enable the Simple Mode for a quick start.
55
+ ![Pi Advisor Settings Panel](https://raw.githubusercontent.com/philipbrembeck/pi-advisor/refs/heads/main/assets/settings.png)
86
56
 
87
57
  ## Commands
88
58
 
89
- | Command | Purpose |
90
- | --- | --- |
91
- | `/advisor` | Enable the flow and optionally override the Executor, Advisor, or context limit. |
92
- | `/advisor-manual [focus]` | Open a TUI form for a parallel consultation, or consult immediately in non-TUI modes; shows progress in the footer. |
93
- | `/advisor-models` | Choose both models and their reasoning effort; current models are preselected. |
94
- | `/advisor-settings` | Configure behavior, context, gates, privacy, and output limits. |
95
- | `/advisor-off` | Disable the flow and turn off persistent activation. |
59
+ | Command | What it does |
60
+ | ------------------------- | ------------------------------------------------- |
61
+ | `/advisor` | Enable the flow; choose available models when needed. |
62
+ | `/advisor-manual [focus]` | Ask for an immediate second opinion. |
63
+ | `/advisor-models` | Choose the Executor and Advisor models. |
64
+ | `/advisor-settings` | Configure behavior, context, privacy, and limits. |
65
+ | `/advisor-off` | Disable the flow and persistent activation. |
96
66
 
97
- In the interactive TUI, `/advisor-manual [focus]` opens a centered overlay. The optional focus text is prefilled and editable; Enter submits, Shift+Enter inserts a newline, Tab/Shift+Tab moves focus, and Escape cancels. The form lets you choose the permitted Git context level, with choices above the configured ceiling hidden. `None` withholds Git data only; it does not remove configured conversation history. After submission, the call, Scout, and Advisor streaming progress appear immediately in the transcript. Canceling has no consultation side effects. RPC, print, and JSON invocations retain their immediate argument-driven behavior.
98
-
99
- 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. If the Advisor explicitly says it cannot review a specifically named file, the Executor may make a sequential follow-up call with `includeTrackedFiles` when global consent is enabled and the file is relevant. Draft claims give the Advisor review context; they are not verification evidence.
67
+ ### Experimental Advisor Scout
100
68
 
101
- ## What gets sent to the Advisor
69
+ Advisor Scout is off by default. When enabled, the Executor model first selects relevant conversation history before the Advisor sees it. This adds a model call, latency, and cost. See the [configuration guide](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/configuration.md) for details.
102
70
 
103
- 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.
71
+ ## Privacy
104
72
 
105
- When Experimental Advisor Scout is enabled, the Executor model provider also receives bounded Advisor-eligible conversation history. Scout does not receive the deterministic repository, draft, preference, or explicit-file regions described below.
73
+ Advisor requests can include user messages, tool calls, tool results, and repository information. `/advisor-settings` controls context, tool disclosure, redaction, and explicit file handoff. Secret redaction is off by default, and tools without an explicit policy use full context. Settings are global, so a project cannot silently change them.
106
74
 
107
- 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. Explicit tracked and untracked file contents require separate global opt-ins; attachments are capped, redacted when configured, and sent as untrusted data.
75
+ Read [Privacy and data handling](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/privacy.md) before using pi-advisor with sensitive work.
108
76
 
109
77
  ## Documentation
110
78
 
111
79
  - [Configuration and automatic loop gates](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/configuration.md)
112
80
  - [Privacy and data handling](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/privacy.md)
113
81
  - [Development](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/development.md)
114
- - [Documentation index](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/README.md)
115
-
116
- ## Links
117
-
118
- - [MIT License](LICENSE)
82
+ - [Benchmarking](docs/benchmark.md)
119
83
  - [Changelog](CHANGELOG.md)
84
+ - [MIT License](LICENSE)
120
85
  - [npm package](https://www.npmjs.com/package/pi-advisor-flow)
121
86
  - [Why use an Advisor flow?](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Advanced Executor/Advisor flow for Pi, fully configurable and extendable.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,33 +28,45 @@
28
28
  "README.md"
29
29
  ],
30
30
  "scripts": {
31
+ "bench:decisions": "bun bench/src/cli.ts decisions",
32
+ "bench:evaluate": "bun bench/src/cli.ts evaluate",
33
+ "bench:replay": "bun bench/src/cli.ts replay",
34
+ "bench:screen": "bun bench/src/cli.ts screen",
31
35
  "format": "bunx ultracite fix --linter-enabled=false",
32
36
  "lint": "bunx ultracite check",
33
37
  "lint:fix": "bunx ultracite fix",
34
38
  "package:check": "node scripts/check-package.mjs",
35
39
  "prepare": "husky",
40
+ "socket": "bunx socket optimize",
36
41
  "test": "bun test",
42
+ "test:bench": "bun --config=./bunfig.bench.toml test ./bench/test/",
37
43
  "typecheck": "tsc --noEmit"
38
44
  },
39
45
  "lint-staged": {
40
46
  "*.{json,jsonc,ts}": "bun run lint:fix --"
41
47
  },
48
+ "resolutions": {
49
+ "is-unicode-supported": "npm:@socketregistry/is-unicode-supported@^1",
50
+ "safe-buffer": "npm:@socketregistry/safe-buffer@^1"
51
+ },
42
52
  "overrides": {
43
53
  "brace-expansion": "5.0.9",
54
+ "is-unicode-supported": "npm:@socketregistry/is-unicode-supported@^1",
55
+ "safe-buffer": "npm:@socketregistry/safe-buffer@^1",
44
56
  "undici": "8.9.0"
45
57
  },
46
58
  "devDependencies": {
47
- "@biomejs/biome": "2.5.11",
59
+ "@biomejs/biome": "2.5.12",
48
60
  "@earendil-works/pi-ai": "^0.84.4",
49
61
  "@earendil-works/pi-coding-agent": "^0.84.4",
50
62
  "@earendil-works/pi-tui": "^0.84.4",
51
- "@types/node": "^26.4.0",
52
- "bun-types": "1.3.14",
63
+ "@types/node": "^26.4.1",
64
+ "bun-types": "1.4.1",
53
65
  "husky": "^9.1.7",
54
66
  "lint-staged": "^17.4.1",
55
- "typebox": "^1.3.21",
67
+ "typebox": "^1.3.25",
56
68
  "typescript": "^7.0.2",
57
- "ultracite": "7.10.7"
69
+ "ultracite": "7.10.8"
58
70
  },
59
71
  "peerDependencies": {
60
72
  "@earendil-works/pi-ai": "^0.84.1",
package/src/commands.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  executorRef,
15
15
  getAdvisorMaxCallsPerSession,
16
16
  getAdvisorSettings,
17
+ getPersistedModelRefs,
17
18
  isSimpleMode,
18
19
  loadConfig,
19
20
  parseArgs,
@@ -65,6 +66,7 @@ import {
65
66
  renderAdvisorCallBox,
66
67
  renderAdvisorResponseHeader,
67
68
  renderScoutDetails,
69
+ renderThinkingMarkdown,
68
70
  resolveAdvisorRequest,
69
71
  ScoutStatusManager,
70
72
  type ScoutToolDetails,
@@ -111,6 +113,20 @@ const selectedEffort = (choice: string): string | undefined => {
111
113
  : choice;
112
114
  return effort === DEFAULT_EFFORT_LEVEL ? undefined : effort;
113
115
  };
116
+ const ADVISOR_ACTIVATION_EXPLANATION =
117
+ "The Advisor is a second-opinion model that reviews the Executor's context and returns risks, alternatives, and verification steps without changing files or running tools.";
118
+ const ARGUMENT_WHITESPACE = /\s+/;
119
+ const hasModelOverride = (args: string, key: "advisor" | "executor") =>
120
+ args
121
+ .trim()
122
+ .split(ARGUMENT_WHITESPACE)
123
+ .some((token) => {
124
+ const [tokenKey, value] = token.split("=");
125
+ return tokenKey === key && Boolean(value);
126
+ });
127
+ const hasExecutorOverride = (args: string) =>
128
+ hasModelOverride(args, "executor");
129
+ const hasAdvisorOverride = (args: string) => hasModelOverride(args, "advisor");
114
130
 
115
131
  const CONTEXT_PRESETS: ContextPreset[] = [
116
132
  {
@@ -246,16 +262,9 @@ class ManualAdvisorProgressComponent implements Component {
246
262
  0
247
263
  )
248
264
  );
249
- if (this.state.thinking) {
265
+ if (this.state.thinking?.trim()) {
250
266
  box.addChild(
251
- new Text(
252
- this.theme.fg(
253
- "thinkingText",
254
- ` 💭 ${this.state.thinking.replace(/\n/g, " ").slice(-200)}`
255
- ),
256
- 0,
257
- 0
258
- )
267
+ renderThinkingMarkdown(this.state.thinking.slice(-200), this.theme)
259
268
  );
260
269
  }
261
270
  if (this.state.text) {
@@ -276,11 +285,205 @@ class ManualAdvisorProgressComponent implements Component {
276
285
  }
277
286
  }
278
287
 
279
- const findConfiguredModel = (ctx: ExtensionContext, ref: string) => {
288
+ const findConfiguredModel = (
289
+ ctx: ExtensionContext,
290
+ ref: string | undefined
291
+ ) => {
292
+ if (!ref) {
293
+ return;
294
+ }
280
295
  const [provider, modelId] = splitRef(ref);
281
296
  return ctx.modelRegistry.find(provider, modelId);
282
297
  };
283
298
 
299
+ const getAvailableModelRefs = (ctx: ExtensionContext): string[] | undefined => {
300
+ if (typeof ctx.modelRegistry.getAvailable !== "function") {
301
+ return undefined;
302
+ }
303
+ return ctx.modelRegistry
304
+ .getAvailable()
305
+ .map((model) => `${model.provider}/${model.id}`);
306
+ };
307
+
308
+ const isSelectableModel = (
309
+ ctx: ExtensionContext,
310
+ ref: string | undefined,
311
+ availableRefs: Set<string> | undefined
312
+ ) => {
313
+ if (!ref) {
314
+ return false;
315
+ }
316
+ if (availableRefs) {
317
+ const [provider, modelId] = splitRef(ref);
318
+ return availableRefs.has(`${provider}/${modelId}`);
319
+ }
320
+ return Boolean(findConfiguredModel(ctx, ref));
321
+ };
322
+
323
+ const getExplicitModelError = (
324
+ ctx: ExtensionContext,
325
+ ref: string | undefined,
326
+ label: "Advisor" | "Executor",
327
+ overridden: boolean,
328
+ availableRefs: Set<string> | undefined
329
+ ) => {
330
+ if (overridden) {
331
+ if (!ref) {
332
+ return `${label} model not configured`;
333
+ }
334
+ if (!findConfiguredModel(ctx, ref)) {
335
+ return `${label} model not found: ${ref}`;
336
+ }
337
+ if (availableRefs && !isSelectableModel(ctx, ref, availableRefs)) {
338
+ return `${label} model unavailable: ${ref}`;
339
+ }
340
+ }
341
+ };
342
+
343
+ interface ActivationModelPlan {
344
+ pendingExecutor: string | undefined;
345
+ selectAdvisor: boolean;
346
+ selectExecutor: boolean;
347
+ }
348
+
349
+ const planActivationModels = (
350
+ ctx: ExtensionContext,
351
+ executor: string | undefined,
352
+ advisor: string | undefined,
353
+ pendingExecutorRef: string | undefined,
354
+ persisted: ReturnType<typeof getPersistedModelRefs>,
355
+ executorOverride: boolean,
356
+ advisorOverride: boolean,
357
+ availableRefs: Set<string> | undefined
358
+ ): ActivationModelPlan => {
359
+ const pendingExecutor =
360
+ !executorOverride &&
361
+ pendingExecutorRef &&
362
+ isSelectableModel(ctx, pendingExecutorRef, availableRefs)
363
+ ? pendingExecutorRef
364
+ : undefined;
365
+ const executorConfigured =
366
+ executorOverride ||
367
+ Boolean(pendingExecutor) ||
368
+ Boolean(
369
+ persisted.executor && isSelectableModel(ctx, executor, availableRefs)
370
+ );
371
+ const advisorConfigured =
372
+ advisorOverride ||
373
+ Boolean(
374
+ persisted.advisor && isSelectableModel(ctx, advisor, availableRefs)
375
+ );
376
+ return {
377
+ pendingExecutor,
378
+ selectAdvisor: !advisorConfigured,
379
+ selectExecutor: !executorConfigured,
380
+ };
381
+ };
382
+
383
+ interface AdvisorModelSelection {
384
+ advisor: string;
385
+ advisorEffort: string | undefined;
386
+ executor: string;
387
+ executorEffort: string | undefined;
388
+ }
389
+
390
+ interface AdvisorModelPickerOptions {
391
+ advisor: string | undefined;
392
+ advisorEffort: string | undefined;
393
+ executor: string | undefined;
394
+ executorEffort: string | undefined;
395
+ selectAdvisor: boolean;
396
+ selectExecutor: boolean;
397
+ }
398
+
399
+ const selectAdvisorModels = async (
400
+ ctx: ExtensionContext,
401
+ options: AdvisorModelPickerOptions
402
+ ): Promise<AdvisorModelSelection | undefined> => {
403
+ if (!ctx.hasUI) {
404
+ return undefined;
405
+ }
406
+ const refs = getAvailableModelRefs(ctx);
407
+ if (refs && refs.length === 0) {
408
+ notify(
409
+ ctx,
410
+ "No selectable models are available. Configure a provider with /login or models.json, then retry.",
411
+ "error"
412
+ );
413
+ return undefined;
414
+ }
415
+ const allOptions = [
416
+ ...new Set(
417
+ refs ??
418
+ [options.executor, options.advisor].filter((ref): ref is string =>
419
+ Boolean(ref)
420
+ )
421
+ ),
422
+ ];
423
+ let { advisor, advisorEffort, executor, executorEffort } = options;
424
+
425
+ if (options.selectExecutor) {
426
+ const selectedExecutor = await ctx.ui.custom<string | undefined>(
427
+ (tui, theme, keybindings, done) =>
428
+ new SearchableModelSelector({
429
+ allOptions,
430
+ currentOption: executor || undefined,
431
+ keybindings,
432
+ onCancel: () => done(undefined),
433
+ onSelect: done,
434
+ theme,
435
+ title: "Select Executor Model",
436
+ tui,
437
+ })
438
+ );
439
+ if (!selectedExecutor) {
440
+ return undefined;
441
+ }
442
+ const selectedExecutorEffort = await ctx.ui.select(
443
+ "Select Executor Reasoning/Thinking Level",
444
+ effortChoices(executorEffort)
445
+ );
446
+ if (!selectedExecutorEffort) {
447
+ return undefined;
448
+ }
449
+ executor = selectedExecutor;
450
+ executorEffort = selectedEffort(selectedExecutorEffort);
451
+ }
452
+
453
+ if (options.selectAdvisor) {
454
+ const selectedAdvisor = await ctx.ui.custom<string | undefined>(
455
+ (tui, theme, keybindings, done) =>
456
+ new SearchableModelSelector({
457
+ allOptions,
458
+ currentOption: advisor || undefined,
459
+ keybindings,
460
+ onCancel: () => done(undefined),
461
+ onSelect: done,
462
+ theme,
463
+ title: "Select Advisor Model",
464
+ tui,
465
+ })
466
+ );
467
+ if (!selectedAdvisor) {
468
+ return undefined;
469
+ }
470
+ const selectedAdvisorEffort = await ctx.ui.select(
471
+ "Select Advisor Reasoning/Thinking Level",
472
+ effortChoices(advisorEffort)
473
+ );
474
+ if (!selectedAdvisorEffort) {
475
+ return undefined;
476
+ }
477
+ advisor = selectedAdvisor;
478
+ advisorEffort = selectedEffort(selectedAdvisorEffort);
479
+ }
480
+
481
+ if (!(advisor && executor)) {
482
+ return undefined;
483
+ }
484
+ return { advisor, advisorEffort, executor, executorEffort };
485
+ };
486
+
284
487
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one settings form maps every persisted control.
285
488
  const applyAdvisorSettings = (settings: AdvisorSettings) => {
286
489
  setAdvisorEffortRef(
@@ -320,7 +523,11 @@ const saveAdvisorSettings = (
320
523
  settings: AdvisorSettings
321
524
  ) => {
322
525
  applyAdvisorSettings(settings);
323
- saveConfig(ctx);
526
+ const persisted = getPersistedModelRefs();
527
+ saveConfig(ctx, {
528
+ persistAdvisor: Boolean(persisted.advisor),
529
+ persistExecutor: Boolean(persisted.executor),
530
+ });
324
531
  saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
325
532
  };
326
533
 
@@ -353,6 +560,22 @@ export const registerCommands = (
353
560
  onScout,
354
561
  undefined
355
562
  ));
563
+ // Pi's model_select event reports both built-in `/model` changes and direct
564
+ // pi.setModel() calls. Keep an inactive user selection transiently so
565
+ // `/advisor` can adopt it without treating every normal model change as a
566
+ // global default. The suppression flag covers this extension's own restore.
567
+ let pendingExecutorModelRef: string | undefined;
568
+ let suppressModelSelectionSync = false;
569
+ const setExecutorModel = async (
570
+ model: Parameters<ExtensionAPI["setModel"]>[0]
571
+ ) => {
572
+ suppressModelSelectionSync = true;
573
+ try {
574
+ return await pi.setModel(model);
575
+ } finally {
576
+ suppressModelSelectionSync = false;
577
+ }
578
+ };
356
579
  const manualConsultations = new Map<AbortController, symbol>();
357
580
  const manualProgressTimers = new Map<
358
581
  AbortController,
@@ -518,17 +741,25 @@ export const registerCommands = (
518
741
  const resolveActivationModels = async (ctx: ExtensionContext) => {
519
742
  const executor = findConfiguredModel(ctx, executorRef);
520
743
  if (!executor) {
521
- return { error: `Executor model not found: ${executorRef}` };
744
+ return {
745
+ error: executorRef
746
+ ? `Executor model not found: ${executorRef}`
747
+ : "Executor model not configured",
748
+ };
522
749
  }
523
750
  const advisor = findConfiguredModel(ctx, advisorRef);
524
751
  if (!advisor) {
525
- return { error: `Advisor model not found: ${advisorRef}` };
752
+ return {
753
+ error: advisorRef
754
+ ? `Advisor model not found: ${advisorRef}`
755
+ : "Advisor model not configured",
756
+ };
526
757
  }
527
758
  const advisorAuth = await ctx.modelRegistry.getApiKeyAndHeaders(advisor);
528
759
  if (!(advisorAuth.ok && advisorAuth.apiKey)) {
529
760
  return { error: `No API key for Advisor ${advisorRef}` };
530
761
  }
531
- if (!(await pi.setModel(executor))) {
762
+ if (!(await setExecutorModel(executor))) {
532
763
  return { error: `No API key for Executor ${executorRef}` };
533
764
  }
534
765
  return {};
@@ -549,6 +780,83 @@ export const registerCommands = (
549
780
  }
550
781
  };
551
782
 
783
+ const prepareActivationModels = async (
784
+ ctx: ExtensionContext,
785
+ announce: boolean,
786
+ executorOverride: boolean,
787
+ advisorOverride: boolean
788
+ ): Promise<
789
+ { pickedModels: boolean; pendingExecutor?: string } | undefined
790
+ > => {
791
+ const persisted = getPersistedModelRefs();
792
+ const availableRefs = getAvailableModelRefs(ctx);
793
+ const availableRefSet = availableRefs ? new Set(availableRefs) : undefined;
794
+ const explicitError =
795
+ getExplicitModelError(
796
+ ctx,
797
+ executorRef,
798
+ "Executor",
799
+ executorOverride,
800
+ availableRefSet
801
+ ) ??
802
+ getExplicitModelError(
803
+ ctx,
804
+ advisorRef,
805
+ "Advisor",
806
+ advisorOverride,
807
+ availableRefSet
808
+ );
809
+ if (explicitError) {
810
+ notify(ctx, explicitError, "error");
811
+ return;
812
+ }
813
+
814
+ const plan = planActivationModels(
815
+ ctx,
816
+ executorRef,
817
+ advisorRef,
818
+ pendingExecutorModelRef,
819
+ persisted,
820
+ executorOverride,
821
+ advisorOverride,
822
+ availableRefSet
823
+ );
824
+ setExecutorRef(plan.pendingExecutor ?? executorRef);
825
+ if (!(plan.selectExecutor || plan.selectAdvisor)) {
826
+ return { pendingExecutor: plan.pendingExecutor, pickedModels: false };
827
+ }
828
+
829
+ // Always-on startup cannot open an interactive picker, so it leaves the
830
+ // flow disabled until the user selects both models with `/advisor`.
831
+ if (!announce) {
832
+ notify(
833
+ ctx,
834
+ "Advisor models are not configured or available. Run /advisor to choose them.",
835
+ "error"
836
+ );
837
+ return;
838
+ }
839
+ const selection = await selectAdvisorModels(ctx, {
840
+ advisor: advisorOverride || persisted.advisor ? advisorRef : "",
841
+ advisorEffort: advisorEffortRef,
842
+ executor:
843
+ executorOverride || plan.pendingExecutor || persisted.executor
844
+ ? executorRef
845
+ : "",
846
+ executorEffort: executorEffortRef,
847
+ selectAdvisor: plan.selectAdvisor,
848
+ selectExecutor: plan.selectExecutor,
849
+ });
850
+ if (!selection) {
851
+ return;
852
+ }
853
+ setAdvisorRef(selection.advisor);
854
+ setAdvisorEffortRef(selection.advisorEffort);
855
+ setExecutorRef(selection.executor);
856
+ setExecutorEffortRef(selection.executorEffort);
857
+ return { pendingExecutor: plan.pendingExecutor, pickedModels: true };
858
+ };
859
+
552
860
  const activateAdvisor = async (
553
861
  args: string,
554
862
  ctx: ExtensionContext,
@@ -559,32 +867,53 @@ export const registerCommands = (
559
867
  }
560
868
  const previous = {
561
869
  advisor: advisorRef,
870
+ advisorEffort: advisorEffortRef,
562
871
  contextMaxChars: contextMaxCharsRef,
563
872
  executor: executorRef,
873
+ executorEffort: executorEffortRef,
564
874
  };
565
875
  const restoreRefs = () => {
566
876
  setAdvisorRef(previous.advisor);
877
+ setAdvisorEffortRef(previous.advisorEffort);
567
878
  setContextMaxCharsRef(previous.contextMaxChars);
568
879
  setExecutorRef(previous.executor);
880
+ setExecutorEffortRef(previous.executorEffort);
569
881
  };
882
+ const executorOverride = hasExecutorOverride(args);
883
+ const advisorOverride = hasAdvisorOverride(args);
570
884
  const argumentError = parseArgs(args);
571
885
  if (argumentError) {
572
886
  restoreRefs();
573
887
  notify(ctx, argumentError, "error");
574
888
  return;
575
889
  }
890
+
891
+ const prepared = await prepareActivationModels(
892
+ ctx,
893
+ announce,
894
+ executorOverride,
895
+ advisorOverride
896
+ );
897
+ if (!prepared) {
898
+ restoreRefs();
899
+ return;
900
+ }
576
901
  const { error } = await resolveActivationModels(ctx);
577
902
  if (error) {
578
903
  restoreRefs();
579
904
  notify(ctx, error, "error");
580
905
  return;
581
906
  }
582
- // parseArgs only mutates in-memory refs, and every later loadConfig resets
583
- // them from disk. Persist supplied arguments once they are known to resolve,
907
+ // parseArgs, model picking, and an inactive `/model` selection only mutate
908
+ // in-memory refs. Persist them once both models are known and authenticated,
584
909
  // so an unusable model reference is never written to the configuration.
585
- if (args.trim()) {
586
- saveConfig(ctx);
910
+ if (args.trim() || prepared.pickedModels || prepared.pendingExecutor) {
911
+ saveConfig(ctx, { persistAdvisor: true, persistExecutor: true });
587
912
  }
913
+ // A successful activation has committed the effective Executor. Do not let
914
+ // an older inactive selection override an explicit activation argument on a
915
+ // later attempt.
916
+ pendingExecutorModelRef = undefined;
588
917
  if (executorEffortRef) {
589
918
  pi.setThinkingLevel(executorEffortRef as ThinkingLevel);
590
919
  }
@@ -598,7 +927,7 @@ export const registerCommands = (
598
927
  if (announce) {
599
928
  notify(
600
929
  ctx,
601
- `Advisor flow ready — Executor: ${executorRef} (thinking: ${executorEffortRef || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`,
930
+ `${ADVISOR_ACTIVATION_EXPLANATION}\n\nAdvisor flow ready — Executor: ${executorRef} (thinking: ${executorEffortRef || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`,
602
931
  "info"
603
932
  );
604
933
  }
@@ -665,6 +994,7 @@ export const registerCommands = (
665
994
  );
666
995
 
667
996
  pi.on("session_start", async (_event, ctx) => {
997
+ pendingExecutorModelRef = undefined;
668
998
  // A malformed advisor.json or a provider auth failure must not reject a
669
999
  // lifecycle handler and break session startup.
670
1000
  try {
@@ -679,17 +1009,29 @@ export const registerCommands = (
679
1009
  });
680
1010
 
681
1011
  pi.on("model_select", (event, ctx) => {
682
- // Only an explicit user selection redefines the Executor. "restore" replays a
683
- // stored session model and would otherwise overwrite saved configuration.
684
- if (event.source !== "set" || !flowEnabled()) {
1012
+ // "restore" replays a stored session model and "cycle" changes the active
1013
+ // model without an explicit `/model` choice. Neither should redefine the
1014
+ // configured Executor.
1015
+ if (event.source !== "set" || suppressModelSelectionSync) {
685
1016
  return;
686
1017
  }
687
1018
  const selected = `${event.model.provider}/${event.model.id}`;
1019
+ if (!flowEnabled()) {
1020
+ // Defer persistence until `/advisor` succeeds. This keeps ordinary model
1021
+ // selection global defaults untouched when the flow is not enabled.
1022
+ pendingExecutorModelRef = selected;
1023
+ return;
1024
+ }
1025
+ pendingExecutorModelRef = undefined;
688
1026
  if (selected === executorRef) {
689
1027
  return;
690
1028
  }
1029
+ const persisted = getPersistedModelRefs();
691
1030
  setExecutorRef(selected);
692
- saveConfig(ctx);
1031
+ saveConfig(ctx, {
1032
+ persistAdvisor: Boolean(persisted.advisor),
1033
+ persistExecutor: true,
1034
+ });
693
1035
  });
694
1036
 
695
1037
  pi.on("session_shutdown", (_event, ctx) => {
@@ -805,7 +1147,7 @@ export const registerCommands = (
805
1147
 
806
1148
  pi.registerCommand("advisor", {
807
1149
  description:
808
- "Enable the Executor/Advisor flow and switch to the configured Executor model; accepts contextMaxChars=N",
1150
+ "Enable the Executor/Advisor flow and switch to the configured or explicitly selected Executor model; accepts contextMaxChars=N",
809
1151
  handler: (args, ctx) => activateAdvisor(args, ctx),
810
1152
  });
811
1153
 
@@ -816,66 +1158,33 @@ export const registerCommands = (
816
1158
  if (!(loadCommandConfig(ctx) && ctx.hasUI)) {
817
1159
  return;
818
1160
  }
819
- const refs = ctx.modelRegistry
820
- .getAvailable()
821
- .map((m) => `${m.provider}/${m.id}`);
822
-
823
- const executor = await ctx.ui.custom<string | undefined>(
824
- (tui, theme, keybindings, done) =>
825
- new SearchableModelSelector({
826
- allOptions: refs,
827
- currentOption: executorRef,
828
- keybindings,
829
- onCancel: () => done(undefined),
830
- onSelect: done,
831
- theme,
832
- title: "Select Executor Model",
833
- tui,
834
- })
835
- );
836
- if (!executor) {
837
- return;
838
- }
839
-
840
- const executorEffort = await ctx.ui.select(
841
- "Select Executor Reasoning/Thinking Level",
842
- effortChoices(executorEffortRef)
843
- );
844
- if (!executorEffort) {
845
- return;
846
- }
847
-
848
- const advisor = await ctx.ui.custom<string | undefined>(
849
- (tui, theme, keybindings, done) =>
850
- new SearchableModelSelector({
851
- allOptions: refs,
852
- currentOption: advisorRef,
853
- keybindings,
854
- onCancel: () => done(undefined),
855
- onSelect: done,
856
- theme,
857
- title: "Select Advisor Model",
858
- tui,
859
- })
860
- );
861
- if (!advisor) {
862
- return;
863
- }
864
-
865
- const advisorEffort = await ctx.ui.select(
866
- "Select Advisor Reasoning/Thinking Level",
867
- effortChoices(advisorEffortRef)
868
- );
869
- if (!advisorEffort) {
1161
+ // When `/model` was used before activation, show that session choice as
1162
+ // the Executor's current option instead of making the persisted Executor
1163
+ // look like the active selection.
1164
+ const persisted = getPersistedModelRefs();
1165
+ const selection = await selectAdvisorModels(ctx, {
1166
+ advisor: persisted.advisor ? advisorRef : "",
1167
+ advisorEffort: advisorEffortRef,
1168
+ executor:
1169
+ pendingExecutorModelRef ?? (persisted.executor ? executorRef : ""),
1170
+ executorEffort: executorEffortRef,
1171
+ selectAdvisor: true,
1172
+ selectExecutor: true,
1173
+ });
1174
+ if (!selection) {
870
1175
  return;
871
1176
  }
872
1177
 
873
- setExecutorRef(executor);
874
- setAdvisorRef(advisor);
875
- setExecutorEffortRef(selectedEffort(executorEffort));
876
- setAdvisorEffortRef(selectedEffort(advisorEffort));
1178
+ setExecutorRef(selection.executor);
1179
+ setAdvisorRef(selection.advisor);
1180
+ setExecutorEffortRef(selection.executorEffort);
1181
+ setAdvisorEffortRef(selection.advisorEffort);
877
1182
 
878
- const path = saveConfig(ctx);
1183
+ const path = saveConfig(ctx, {
1184
+ persistAdvisor: true,
1185
+ persistExecutor: true,
1186
+ });
1187
+ pendingExecutorModelRef = undefined;
879
1188
  ctx.ui.notify(
880
1189
  `Saved Executor + Advisor configurations to ${path}`,
881
1190
  "info"
@@ -932,8 +1241,12 @@ export const registerCommands = (
932
1241
  // Leaving alwaysOn set would silently reactivate the flow next session.
933
1242
  const wasAlwaysOn = alwaysOnRef;
934
1243
  if (wasAlwaysOn) {
1244
+ const persisted = getPersistedModelRefs();
935
1245
  setAlwaysOnRef(false);
936
- saveConfig(ctx);
1246
+ saveConfig(ctx, {
1247
+ persistAdvisor: Boolean(persisted.advisor),
1248
+ persistExecutor: Boolean(persisted.executor),
1249
+ });
937
1250
  }
938
1251
  notify(
939
1252
  ctx,
package/src/config.ts CHANGED
@@ -13,8 +13,6 @@ import {
13
13
  isValidGitContextLevel,
14
14
  } from "./git.js";
15
15
 
16
- export const FALLBACK_EXECUTOR = "openai-codex/gpt-5.6-luna";
17
- export const FALLBACK_ADVISOR = "openai-codex/gpt-5.6-sol";
18
16
  export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
19
17
  export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
20
18
  export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = DEFAULT_MAX_LINES;
@@ -37,8 +35,11 @@ export const GATE_FAILURE_MODES: GateFailureMode[] = [
37
35
  "warn-and-continue",
38
36
  ];
39
37
 
40
- export let executorRef = FALLBACK_EXECUTOR;
41
- export let advisorRef = FALLBACK_ADVISOR;
38
+ // An empty ref means no model has been selected yet.
39
+ export let executorRef = "";
40
+ export let advisorRef = "";
41
+ let persistedExecutorRef: string | undefined;
42
+ let persistedAdvisorRef: string | undefined;
42
43
  export let executorEffortRef: string | undefined;
43
44
  export let advisorEffortRef: string | undefined;
44
45
  export let contextMaxCharsRef = DEFAULT_CONTEXT_MAX_CHARS;
@@ -75,6 +76,11 @@ export const setExecutorRef = (ref: string) => {
75
76
  export const setAdvisorRef = (ref: string) => {
76
77
  advisorRef = ref;
77
78
  };
79
+ /** Returns model refs explicitly persisted in the global Advisor config. */
80
+ export const getPersistedModelRefs = () => ({
81
+ advisor: persistedAdvisorRef,
82
+ executor: persistedExecutorRef,
83
+ });
78
84
  export const setExecutorEffortRef = (effort: string | undefined) => {
79
85
  executorEffortRef = effort;
80
86
  };
@@ -469,8 +475,10 @@ export const validateConfig = (
469
475
  };
470
476
 
471
477
  const resetDefaults = () => {
472
- executorRef = FALLBACK_EXECUTOR;
473
- advisorRef = FALLBACK_ADVISOR;
478
+ executorRef = "";
479
+ advisorRef = "";
480
+ persistedExecutorRef = undefined;
481
+ persistedAdvisorRef = undefined;
474
482
  executorEffortRef = undefined;
475
483
  advisorEffortRef = undefined;
476
484
  contextMaxCharsRef = DEFAULT_CONTEXT_MAX_CHARS;
@@ -522,6 +530,9 @@ const applyNonEmptyStringConfig = (
522
530
  }
523
531
  };
524
532
 
533
+ const configuredModelRef = (value: string | undefined): string | undefined =>
534
+ value?.trim() || undefined;
535
+
525
536
  const applyConfig = (config: AdvisorConfig) => {
526
537
  applyNonEmptyStringConfig(config.executor, setExecutorRef);
527
538
  applyNonEmptyStringConfig(config.advisor, setAdvisorRef);
@@ -667,6 +678,8 @@ export const loadConfig = (_ctx: ExtensionContext) => {
667
678
  const globalConfig = existsSync(global)
668
679
  ? readConfigCached(global)
669
680
  : undefined;
681
+ persistedExecutorRef = configuredModelRef(globalConfig?.executor);
682
+ persistedAdvisorRef = configuredModelRef(globalConfig?.advisor);
670
683
  if (globalConfig) {
671
684
  applyConfig(globalConfig);
672
685
  const unknownKeys = unknownConfigKeys(globalConfig as ConfigRecord);
@@ -691,8 +704,18 @@ export const loadConfig = (_ctx: ExtensionContext) => {
691
704
  };
692
705
 
693
706
  /** Saves user-controlled configuration globally without outcome consent. */
694
- export const saveConfig = (_ctx: ExtensionContext) => {
707
+ export interface SaveConfigOptions {
708
+ persistAdvisor?: boolean;
709
+ persistExecutor?: boolean;
710
+ }
711
+
712
+ export const saveConfig = (
713
+ _ctx: ExtensionContext,
714
+ options: SaveConfigOptions = {}
715
+ ) => {
695
716
  const path = join(getAgentDir(), "advisor.json");
717
+ const persistAdvisor = options.persistAdvisor ?? true;
718
+ const persistExecutor = options.persistExecutor ?? true;
696
719
  let existing: Record<string, unknown> = {};
697
720
  try {
698
721
  const parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -707,7 +730,7 @@ export const saveConfig = (_ctx: ExtensionContext) => {
707
730
  }
708
731
  const data = {
709
732
  ...existing,
710
- advisor: advisorRef,
733
+ ...(persistAdvisor && advisorRef ? { advisor: advisorRef } : {}),
711
734
  advisorAutoLoopGate: advisorAutoLoopGateRef,
712
735
  advisorBlockOnBlocked: advisorBlockOnBlockedRef,
713
736
  advisorCollapseResponses: advisorCollapseResponsesRef,
@@ -718,7 +741,7 @@ export const saveConfig = (_ctx: ExtensionContext) => {
718
741
  advisorLoopThreshold: advisorLoopThresholdRef,
719
742
  advisorPlanGate: advisorPlanGateRef,
720
743
  contextMaxChars: contextMaxCharsRef,
721
- executor: executorRef,
744
+ ...(persistExecutor && executorRef ? { executor: executorRef } : {}),
722
745
  executorEffort: executorEffortRef,
723
746
  ...(advisorMaxCallsPerSessionRef === undefined
724
747
  ? {}
@@ -18,9 +18,12 @@ export interface ResolvedConfiguredModel {
18
18
 
19
19
  export const resolveConfiguredModel = async (
20
20
  ctx: ExtensionContext,
21
- ref: string,
21
+ ref: string | undefined,
22
22
  label: string
23
23
  ): Promise<ResolvedConfiguredModel> => {
24
+ if (!ref) {
25
+ throw new Error(`${label} model not configured`);
26
+ }
24
27
  const [provider, modelId] = splitRef(ref);
25
28
  const model = ctx.modelRegistry.find(provider, modelId);
26
29
  if (!model) {
@@ -193,7 +196,13 @@ export const collectTextStream = async (
193
196
  apiKey: resolved.apiKey,
194
197
  env: resolved.env,
195
198
  headers: resolved.headers,
199
+ // `stream()` uses the provider-facing name while the extension's public
200
+ // option keeps the Pi-facing `reasoning` name. Preserve both so the
201
+ // configured effort reaches providers that serialize reasoning_effort.
196
202
  reasoning: options.reasoning as never,
203
+ ...(options.reasoning === undefined
204
+ ? {}
205
+ : { reasoningEffort: options.reasoning as never }),
197
206
  signal: options.signal,
198
207
  }
199
208
  );
package/src/tools.ts CHANGED
@@ -10,7 +10,14 @@ import {
10
10
  type ToolCallEventResult,
11
11
  type ToolRenderResultOptions,
12
12
  } from "@earendil-works/pi-coding-agent";
13
- import { Box, Markdown, Text } from "@earendil-works/pi-tui";
13
+ import {
14
+ Box,
15
+ type Component,
16
+ Markdown,
17
+ Text,
18
+ truncateToWidth,
19
+ visibleWidth,
20
+ } from "@earendil-works/pi-tui";
14
21
  import { Type } from "typebox";
15
22
  import {
16
23
  advisorAutoLoopGateRef,
@@ -111,6 +118,56 @@ export const SPINNER_FRAMES = [
111
118
  "⠇",
112
119
  "⠏",
113
120
  ];
121
+
122
+ const THINKING_PREFIX = " 💭 ";
123
+ const THINKING_PREFIX_WIDTH = visibleWidth(THINKING_PREFIX);
124
+ type ThinkingTheme = Pick<Theme, "fg">;
125
+
126
+ /**
127
+ * Renders visible nested-model thinking with the same Markdown semantics as
128
+ * Pi's assistant thinking blocks while keeping the compact speech-bubble cue.
129
+ * The prefix is added after Markdown parsing so it cannot change block syntax.
130
+ *
131
+ * Note: During streaming, incomplete Markdown (e.g., `**text` without closing `**`)
132
+ * displays raw markers transiently until delimiters arrive. This mirrors expected
133
+ * behavior when typing Markdown incrementally and is acceptable in the context
134
+ * of brief thinking previews. When thinking is complete, all markers render.
135
+ */
136
+ class ThinkingMarkdown implements Component {
137
+ private readonly markdown: Markdown;
138
+ private readonly prefix: string;
139
+
140
+ constructor(thinking: string, theme: ThinkingTheme) {
141
+ this.markdown = new Markdown(thinking.trim(), 0, 0, getMarkdownTheme(), {
142
+ color: (text) => theme.fg("thinkingText", text),
143
+ italic: true,
144
+ });
145
+ this.prefix = theme.fg("thinkingText", THINKING_PREFIX);
146
+ }
147
+
148
+ render(width: number): string[] {
149
+ const renderWidth = Math.max(1, Math.floor(width));
150
+ const contentWidth = Math.max(1, renderWidth - THINKING_PREFIX_WIDTH);
151
+ const lines = this.markdown.render(contentWidth);
152
+ return lines.map((line, index) =>
153
+ truncateToWidth(
154
+ index === 0 ? `${this.prefix}${line}` : line,
155
+ renderWidth,
156
+ ""
157
+ )
158
+ );
159
+ }
160
+
161
+ invalidate(): void {
162
+ this.markdown.invalidate();
163
+ }
164
+ }
165
+
166
+ export const renderThinkingMarkdown = (
167
+ thinking: string,
168
+ theme: ThinkingTheme
169
+ ): Component => new ThinkingMarkdown(thinking, theme);
170
+
114
171
  export const resolveAdvisorRequest = (question?: string) =>
115
172
  question?.trim() || undefined;
116
173
  export const advisorMessageText = (
@@ -1214,21 +1271,19 @@ export const renderScoutDetails = (
1214
1271
  if (scout.fallbackReason) {
1215
1272
  lines.push(theme.fg("warning", ` ${scout.fallbackReason}`));
1216
1273
  }
1217
- if (scout.thinking && active) {
1218
- lines.push(
1219
- theme.fg(
1220
- "thinkingText",
1221
- ` 💭 ${scout.thinking.replace(/\n/g, " ").slice(-200)}`
1222
- )
1223
- );
1274
+ const thinking = scout.thinking && active ? scout.thinking.slice(-200) : "";
1275
+ box.addChild(new Text(lines.join("\n"), 0, 0));
1276
+ if (thinking.trim()) {
1277
+ box.addChild(renderThinkingMarkdown(thinking, theme));
1224
1278
  }
1279
+ const expandedLines: string[] = [];
1225
1280
  if (expanded && scout.selectedLabels?.length) {
1226
- lines.push(
1281
+ expandedLines.push(
1227
1282
  theme.fg("dim", ` Selected: ${scout.selectedLabels.join("; ")}`)
1228
1283
  );
1229
1284
  }
1230
1285
  if (expanded && scout.synthesis) {
1231
- lines.push(
1286
+ expandedLines.push(
1232
1287
  theme.fg(
1233
1288
  "dim",
1234
1289
  ` Scout synthesis (untrusted inference): ${scout.synthesis}`
@@ -1236,14 +1291,16 @@ export const renderScoutDetails = (
1236
1291
  );
1237
1292
  }
1238
1293
  if (expanded && scout.omittedBeforeScout) {
1239
- lines.push(
1294
+ expandedLines.push(
1240
1295
  theme.fg(
1241
1296
  "dim",
1242
1297
  ` ${scout.omittedBeforeScout} group(s) omitted before Scout`
1243
1298
  )
1244
1299
  );
1245
1300
  }
1246
- box.addChild(new Text(lines.join("\n"), 0, 0));
1301
+ if (expandedLines.length > 0) {
1302
+ box.addChild(new Text(expandedLines.join("\n"), 0, 0));
1303
+ }
1247
1304
  };
1248
1305
 
1249
1306
  const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
@@ -1283,14 +1340,14 @@ const renderPartialAdvisorResult = (
1283
1340
  const lines = [
1284
1341
  `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`,
1285
1342
  ];
1286
- if (details?.thinking) {
1343
+ box.addChild(new Text(lines.join("\n"), 0, 0));
1344
+ if (details?.thinking?.trim()) {
1287
1345
  const thought =
1288
1346
  details.thinking.length > 200
1289
1347
  ? details.thinking.slice(-200)
1290
1348
  : details.thinking;
1291
- lines.push(theme.fg("thinkingText", ` 💭 ${thought.replace(/\n/g, " ")}`));
1349
+ box.addChild(renderThinkingMarkdown(thought, theme));
1292
1350
  }
1293
- box.addChild(new Text(lines.join("\n"), 0, 0));
1294
1351
  if (details?.text) {
1295
1352
  box.addChild(
1296
1353
  new Markdown(
@@ -1354,17 +1411,14 @@ const renderFinalAdvisorResult = (
1354
1411
  if (attachments.length) {
1355
1412
  lines.push(theme.fg("dim", ` ${attachments.join(" · ")}`));
1356
1413
  }
1357
- if (details?.thinking) {
1358
- const thought = details.thinking.replace(/\n/g, " ").slice(0, 300);
1359
- lines.push(
1360
- theme.fg(
1361
- "thinkingText",
1362
- ` 💭 ${thought}${details.thinking.length > 300 ? "…" : ""}`
1363
- )
1364
- );
1365
- }
1414
+ const thinking = details?.thinking?.trim()
1415
+ ? `${details.thinking.slice(0, 300)}${details.thinking.length > 300 ? "…" : ""}`
1416
+ : "";
1366
1417
  const displayAdvice = advice || "(Advisor returned no advice.)";
1367
1418
  box.addChild(new Text(lines.join("\n"), 0, 0));
1419
+ if (thinking) {
1420
+ box.addChild(renderThinkingMarkdown(thinking, theme));
1421
+ }
1368
1422
  box.addChild(
1369
1423
  new Markdown(
1370
1424
  adviceForDisplay(displayAdvice, expanded),