pi-advisor-flow 0.4.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/src/commands.ts CHANGED
@@ -2,8 +2,9 @@ import {
2
2
  type ExtensionAPI,
3
3
  type ExtensionContext,
4
4
  getMarkdownTheme,
5
+ type Theme,
5
6
  } from "@earendil-works/pi-coding-agent";
6
- import { Box, Markdown, Text } from "@earendil-works/pi-tui";
7
+ import { Box, type Component, Markdown, Text } from "@earendil-works/pi-tui";
7
8
  import {
8
9
  advisorEffortRef,
9
10
  advisorRef,
@@ -13,6 +14,7 @@ import {
13
14
  executorRef,
14
15
  getAdvisorMaxCallsPerSession,
15
16
  getAdvisorSettings,
17
+ getPersistedModelRefs,
16
18
  isSimpleMode,
17
19
  loadConfig,
18
20
  parseArgs,
@@ -46,9 +48,12 @@ import {
46
48
  setContextMaxCharsRef,
47
49
  setExecutorEffortRef,
48
50
  setExecutorRef,
51
+ setShowUsageDetailsRef,
52
+ setShowUsageFooterRef,
49
53
  setSimpleModeRef,
50
54
  splitRef,
51
55
  } from "./config.js";
56
+ import type { GitContextLevel } from "./git.js";
52
57
  import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "./herdr.js";
53
58
  import type { ScoutLifecycleEvent } from "./scout.js";
54
59
  import type { AdvisorSessionState } from "./session-state.js";
@@ -60,13 +65,19 @@ import {
60
65
  hasSoundVerdict,
61
66
  renderAdvisorCallBox,
62
67
  renderAdvisorResponseHeader,
68
+ renderScoutDetails,
69
+ renderThinkingMarkdown,
63
70
  resolveAdvisorRequest,
64
71
  ScoutStatusManager,
72
+ type ScoutToolDetails,
73
+ SPINNER_FRAMES,
65
74
  } from "./tools.js";
66
75
  import {
67
76
  type AdvisorSettings,
68
77
  AdvisorSettingsSelector,
69
78
  type ContextPreset,
79
+ ManualAdvisorDialog,
80
+ type ManualAdvisorRequest,
70
81
  SearchableModelSelector,
71
82
  } from "./ui.js";
72
83
  import {
@@ -102,6 +113,20 @@ const selectedEffort = (choice: string): string | undefined => {
102
113
  : choice;
103
114
  return effort === DEFAULT_EFFORT_LEVEL ? undefined : effort;
104
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");
105
130
 
106
131
  const CONTEXT_PRESETS: ContextPreset[] = [
107
132
  {
@@ -143,7 +168,8 @@ type ManualConsult = (
143
168
  question?: string,
144
169
  signal?: AbortSignal,
145
170
  onChunk?: (thinking: string, text: string) => void,
146
- onScout?: (event: ScoutLifecycleEvent) => void
171
+ onScout?: (event: ScoutLifecycleEvent) => void,
172
+ gitContext?: GitContextLevel
147
173
  ) => Promise<{
148
174
  markdown: string;
149
175
  thinkingText: string;
@@ -163,11 +189,348 @@ const notify = (
163
189
  }
164
190
  };
165
191
 
166
- const findConfiguredModel = (ctx: ExtensionContext, ref: string) => {
192
+ type ManualAdvisorProgressPhase =
193
+ | "preparing"
194
+ | "active"
195
+ | "complete"
196
+ | "cancelled"
197
+ | "error";
198
+ interface ManualAdvisorProgressState {
199
+ phase: ManualAdvisorProgressPhase;
200
+ scout?: ScoutToolDetails;
201
+ text?: string;
202
+ thinking?: string;
203
+ }
204
+
205
+ class ManualAdvisorProgressComponent implements Component {
206
+ private readonly question: string | undefined;
207
+ private readonly state: ManualAdvisorProgressState;
208
+ private readonly expanded: boolean;
209
+ private readonly theme: Theme;
210
+
211
+ constructor(
212
+ question: string | undefined,
213
+ state: ManualAdvisorProgressState,
214
+ expanded: boolean,
215
+ theme: Theme
216
+ ) {
217
+ this.question = question;
218
+ this.state = state;
219
+ this.expanded = expanded;
220
+ this.theme = theme;
221
+ }
222
+
223
+ render(width: number): string[] {
224
+ const box = renderAdvisorCallBox(this.question, this.theme);
225
+ if (this.state.phase === "complete" || this.state.phase === "cancelled") {
226
+ return box.render(width);
227
+ }
228
+
229
+ const { scout } = this.state;
230
+ const scoutActive =
231
+ scout?.status === "calling" || scout?.status === "streaming";
232
+ if (scoutActive) {
233
+ renderScoutDetails(box, scout, this.expanded, this.theme);
234
+ return box.render(width);
235
+ }
236
+ if (scout?.status === "cancelled") {
237
+ return box.render(width);
238
+ }
239
+ if (this.state.phase === "error") {
240
+ box.addChild(
241
+ new Text(
242
+ this.theme.fg("error", this.theme.bold("◆ ADVISOR · FAILED")),
243
+ 0,
244
+ 0
245
+ )
246
+ );
247
+ return box.render(width);
248
+ }
249
+
250
+ const frame =
251
+ SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
252
+ let status = "Working…";
253
+ if (this.state.phase === "preparing") {
254
+ status = "Preparing…";
255
+ } else if (this.state.text?.trim()) {
256
+ status = "Responding…";
257
+ }
258
+ box.addChild(
259
+ new Text(
260
+ `${this.theme.fg("warning", this.theme.bold(`◆ ADVISOR ${frame}`))} ${this.theme.fg("dim", `· ${status}`)}`,
261
+ 0,
262
+ 0
263
+ )
264
+ );
265
+ if (this.state.thinking?.trim()) {
266
+ box.addChild(
267
+ renderThinkingMarkdown(this.state.thinking.slice(-200), this.theme)
268
+ );
269
+ }
270
+ if (this.state.text) {
271
+ box.addChild(
272
+ new Markdown(
273
+ adviceForDisplay(this.state.text, this.expanded),
274
+ 0,
275
+ 0,
276
+ getMarkdownTheme()
277
+ )
278
+ );
279
+ }
280
+ return box.render(width);
281
+ }
282
+
283
+ invalidate(): void {
284
+ // The live progress state is read during each render.
285
+ }
286
+ }
287
+
288
+ const findConfiguredModel = (
289
+ ctx: ExtensionContext,
290
+ ref: string | undefined
291
+ ) => {
292
+ if (!ref) {
293
+ return;
294
+ }
167
295
  const [provider, modelId] = splitRef(ref);
168
296
  return ctx.modelRegistry.find(provider, modelId);
169
297
  };
170
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
+
487
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one settings form maps every persisted control.
488
+ const applyAdvisorSettings = (settings: AdvisorSettings) => {
489
+ setAdvisorEffortRef(
490
+ settings.effort === "Default (Model Default)" ? undefined : settings.effort
491
+ );
492
+ setContextMaxCharsRef(settings.contextMaxChars);
493
+ setAdvisorPlanGateRef(settings.planGate);
494
+ setAdvisorFailureGateRef(settings.failureGate);
495
+ setAdvisorCompletionGateRef(settings.completionGate);
496
+ setAdvisorCollapseResponsesRef(settings.collapseResponses);
497
+ setAdvisorCustomInvocationRef(settings.customRule);
498
+ setAdvisorBlockOnBlockedRef(settings.blockOnBlocked ?? true);
499
+ setAdvisorAutoLoopGateRef(settings.autoLoopGate ?? true);
500
+ setAdvisorLoopThresholdRef(settings.loopThreshold ?? 3);
501
+ setAdvisorMaxCallsPerSessionRef(settings.maxCallsPerSession);
502
+ setAdvisorSessionSummaryRef(settings.sessionSummary ?? false);
503
+ setAdvisorScoutEnabledRef(settings.scoutEnabled ?? false);
504
+ setShowUsageDetailsRef(settings.showUsageDetails ?? true);
505
+ setShowUsageFooterRef(settings.showUsageFooter ?? false);
506
+ setSimpleModeRef(settings.simpleMode ?? false);
507
+ setAlwaysOnRef(settings.alwaysOn ?? false);
508
+ setAdvisorFailureModeRef(settings.failureMode ?? "block-session");
509
+ setAdvisorHerdrIntegrationRef(settings.herdrIntegration ?? true);
510
+ setAdvisorToolResultMaxLinesRef(settings.toolResultMaxLines ?? 2000);
511
+ setAdvisorToolResultMaxBytesRef(settings.toolResultMaxBytes ?? 50 * 1024);
512
+ setAdvisorRedactSecretsRef(settings.redactSecrets ?? false);
513
+ setAdvisorGitContextRef(settings.gitContext ?? "summary");
514
+ setAdvisorGitContextMaxCharsRef(settings.gitContextMaxChars ?? 20_000);
515
+ setAdvisorToolPoliciesRef(settings.toolPolicies ?? {});
516
+ setAdvisorTrackedFileContentRef(settings.trackedFileContent ?? false);
517
+ setAdvisorUntrackedContentRef(settings.untrackedContent ?? false);
518
+ setAdvisorOutcomeLoggingRef(settings.outcomeLogging ?? false);
519
+ };
520
+
521
+ const saveAdvisorSettings = (
522
+ ctx: ExtensionContext,
523
+ settings: AdvisorSettings
524
+ ) => {
525
+ applyAdvisorSettings(settings);
526
+ const persisted = getPersistedModelRefs();
527
+ saveConfig(ctx, {
528
+ persistAdvisor: Boolean(persisted.advisor),
529
+ persistExecutor: Boolean(persisted.executor),
530
+ });
531
+ saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
532
+ };
533
+
171
534
  export const registerCommands = (
172
535
  pi: ExtensionAPI,
173
536
  dependencies: {
@@ -178,42 +541,70 @@ export const registerCommands = (
178
541
  ) => {
179
542
  const advisorSessionState =
180
543
  dependencies.sessionState ?? defaultAdvisorSessionState;
181
- const scoutStatus = dependencies.statusManager ?? new ScoutStatusManager();
544
+ const scoutStatus =
545
+ dependencies.statusManager ?? new ScoutStatusManager(false);
182
546
  const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
183
547
  const requestAdvisor =
184
548
  dependencies.consult ??
185
- ((ctx, question, signal, onChunk, onScout) =>
549
+ ((ctx, question, signal, onChunk, onScout, gitContext) =>
186
550
  consultAdvisor(
187
551
  ctx,
188
552
  question,
189
553
  signal,
190
554
  onChunk,
191
555
  "manual",
192
- undefined,
556
+ gitContext,
193
557
  undefined,
194
558
  undefined,
195
559
  undefined,
196
560
  onScout,
197
561
  undefined
198
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
+ };
199
579
  const manualConsultations = new Map<AbortController, symbol>();
580
+ const manualProgressTimers = new Map<
581
+ AbortController,
582
+ ReturnType<typeof setInterval>
583
+ >();
584
+ const manualProgress = new Map<string, ManualAdvisorProgressState>();
585
+ let manualProgressSequence = 0;
200
586
  const updateAdvisorUsageStatus = (ctx: ExtensionContext) => {
201
587
  if (ctx.hasUI) {
202
- ctx.ui.setStatus("advisor-usage", advisorSessionState.usageStatus());
588
+ ctx.ui.setStatus(
589
+ "advisor-usage",
590
+ getAdvisorSettings().showUsageFooter
591
+ ? advisorSessionState.usageStatus()
592
+ : undefined
593
+ );
203
594
  }
204
595
  };
205
- const setManualStatus = (
206
- ctx: ExtensionContext,
207
- controller: AbortController,
208
- token: symbol,
209
- status: string | undefined
210
- ) => {
211
- if (
212
- ctx.hasUI &&
213
- manualConsultations.get(controller) === token &&
214
- !controller.signal.aborted
215
- ) {
216
- ctx.ui.setStatus("advisor-manual", status);
596
+ const reportManualBudgetExhausted = (ctx: ExtensionContext) => {
597
+ const message = "Advisor call budget exhausted for this session.";
598
+ notify(ctx, message, "warning");
599
+ notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
600
+ };
601
+
602
+ // A status update with an undefined value is an invisible render pulse. The
603
+ // transcript owns manual progress now, so no consultation text is kept in the
604
+ // footer while this pulse still drives the live spinner.
605
+ const requestManualRender = (ctx: ExtensionContext) => {
606
+ if (ctx.hasUI) {
607
+ ctx.ui.setStatus("advisor-manual", undefined);
217
608
  }
218
609
  };
219
610
 
@@ -221,10 +612,26 @@ export const registerCommands = (
221
612
  ctx: ExtensionContext,
222
613
  question: string | undefined,
223
614
  controller: AbortController,
224
- scoutStatusToken: symbol
615
+ scoutStatusToken: symbol,
616
+ progress: ManualAdvisorProgressState,
617
+ gitContext?: GitContextLevel
225
618
  ) => {
226
619
  herdrAdvisorActivity.start();
227
- setManualStatus(ctx, controller, scoutStatusToken, "Advisor preparing…");
620
+ progress.phase = "preparing";
621
+ requestManualRender(ctx);
622
+ if (ctx.hasUI) {
623
+ const timer = setInterval(() => {
624
+ if (
625
+ controller.signal.aborted ||
626
+ manualConsultations.get(controller) !== scoutStatusToken
627
+ ) {
628
+ clearInterval(timer);
629
+ return;
630
+ }
631
+ requestManualRender(ctx);
632
+ }, 80);
633
+ manualProgressTimers.set(controller, timer);
634
+ }
228
635
  let scoutDetails: Parameters<typeof appendScoutLifecycleEntry>[2];
229
636
  return requestAdvisor(
230
637
  ctx,
@@ -234,41 +641,27 @@ export const registerCommands = (
234
641
  if (controller.signal.aborted) {
235
642
  return;
236
643
  }
237
- let status = "Advisor working…";
238
- if (thinking.trim()) {
239
- status = "Advisor thinking…";
240
- }
241
- if (text.trim()) {
242
- status = "Advisor responding…";
243
- }
244
- setManualStatus(ctx, controller, scoutStatusToken, status);
644
+ progress.phase = "active";
645
+ progress.thinking = thinking;
646
+ progress.text = text;
647
+ requestManualRender(ctx);
245
648
  },
246
649
  (event) => {
247
650
  if (!controller.signal.aborted) {
248
651
  scoutStatus.update(ctx, scoutStatusToken, event);
249
- if (event.type === "call" || event.type === "chunk") {
250
- setManualStatus(
251
- ctx,
252
- controller,
253
- scoutStatusToken,
254
- "Advisor Scout curating…"
255
- );
256
- } else if (event.type === "success" || event.type === "fallback") {
257
- setManualStatus(
258
- ctx,
259
- controller,
260
- scoutStatusToken,
261
- "Advisor working…"
262
- );
263
- }
264
652
  scoutDetails = appendScoutLifecycleEntry(pi, event, scoutDetails);
653
+ progress.scout = scoutDetails;
654
+ progress.phase = "active";
655
+ requestManualRender(ctx);
265
656
  }
266
- }
657
+ },
658
+ gitContext
267
659
  )
268
660
  .then(({ markdown, usage }) => {
269
661
  if (controller.signal.aborted) {
270
662
  return;
271
663
  }
664
+ progress.phase = "complete";
272
665
  advisorSessionState.recordInvocation({
273
666
  cost: advisorUsageCost(usage),
274
667
  executionEffect: "continued",
@@ -303,6 +696,7 @@ export const registerCommands = (
303
696
  if (controller.signal.aborted) {
304
697
  return;
305
698
  }
699
+ progress.phase = "error";
306
700
  const message = error instanceof Error ? error.message : String(error);
307
701
  advisorSessionState.recordInvocation({
308
702
  executionEffect: "continued",
@@ -328,12 +722,15 @@ export const registerCommands = (
328
722
  notifyHerdrAdvisorFailure("Advisor consultation failed", message);
329
723
  })
330
724
  .finally(() => {
331
- if (
332
- ctx.hasUI &&
333
- manualConsultations.get(controller) === scoutStatusToken
334
- ) {
335
- ctx.ui.setStatus("advisor-manual", undefined);
725
+ if (controller.signal.aborted) {
726
+ progress.phase = "cancelled";
727
+ }
728
+ const timer = manualProgressTimers.get(controller);
729
+ if (timer) {
730
+ clearInterval(timer);
731
+ manualProgressTimers.delete(controller);
336
732
  }
733
+ requestManualRender(ctx);
337
734
  scoutStatus.release(ctx, scoutStatusToken);
338
735
  manualConsultations.delete(controller);
339
736
  herdrAdvisorActivity.finish();
@@ -344,17 +741,25 @@ export const registerCommands = (
344
741
  const resolveActivationModels = async (ctx: ExtensionContext) => {
345
742
  const executor = findConfiguredModel(ctx, executorRef);
346
743
  if (!executor) {
347
- return { error: `Executor model not found: ${executorRef}` };
744
+ return {
745
+ error: executorRef
746
+ ? `Executor model not found: ${executorRef}`
747
+ : "Executor model not configured",
748
+ };
348
749
  }
349
750
  const advisor = findConfiguredModel(ctx, advisorRef);
350
751
  if (!advisor) {
351
- return { error: `Advisor model not found: ${advisorRef}` };
752
+ return {
753
+ error: advisorRef
754
+ ? `Advisor model not found: ${advisorRef}`
755
+ : "Advisor model not configured",
756
+ };
352
757
  }
353
758
  const advisorAuth = await ctx.modelRegistry.getApiKeyAndHeaders(advisor);
354
759
  if (!(advisorAuth.ok && advisorAuth.apiKey)) {
355
760
  return { error: `No API key for Advisor ${advisorRef}` };
356
761
  }
357
- if (!(await pi.setModel(executor))) {
762
+ if (!(await setExecutorModel(executor))) {
358
763
  return { error: `No API key for Executor ${executorRef}` };
359
764
  }
360
765
  return {};
@@ -375,6 +780,83 @@ export const registerCommands = (
375
780
  }
376
781
  };
377
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
+
378
860
  const activateAdvisor = async (
379
861
  args: string,
380
862
  ctx: ExtensionContext,
@@ -385,32 +867,53 @@ export const registerCommands = (
385
867
  }
386
868
  const previous = {
387
869
  advisor: advisorRef,
870
+ advisorEffort: advisorEffortRef,
388
871
  contextMaxChars: contextMaxCharsRef,
389
872
  executor: executorRef,
873
+ executorEffort: executorEffortRef,
390
874
  };
391
875
  const restoreRefs = () => {
392
876
  setAdvisorRef(previous.advisor);
877
+ setAdvisorEffortRef(previous.advisorEffort);
393
878
  setContextMaxCharsRef(previous.contextMaxChars);
394
879
  setExecutorRef(previous.executor);
880
+ setExecutorEffortRef(previous.executorEffort);
395
881
  };
882
+ const executorOverride = hasExecutorOverride(args);
883
+ const advisorOverride = hasAdvisorOverride(args);
396
884
  const argumentError = parseArgs(args);
397
885
  if (argumentError) {
398
886
  restoreRefs();
399
887
  notify(ctx, argumentError, "error");
400
888
  return;
401
889
  }
890
+
891
+ const prepared = await prepareActivationModels(
892
+ ctx,
893
+ announce,
894
+ executorOverride,
895
+ advisorOverride
896
+ );
897
+ if (!prepared) {
898
+ restoreRefs();
899
+ return;
900
+ }
402
901
  const { error } = await resolveActivationModels(ctx);
403
902
  if (error) {
404
903
  restoreRefs();
405
904
  notify(ctx, error, "error");
406
905
  return;
407
906
  }
408
- // parseArgs only mutates in-memory refs, and every later loadConfig resets
409
- // 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,
410
909
  // so an unusable model reference is never written to the configuration.
411
- if (args.trim()) {
412
- saveConfig(ctx);
910
+ if (args.trim() || prepared.pickedModels || prepared.pendingExecutor) {
911
+ saveConfig(ctx, { persistAdvisor: true, persistExecutor: true });
413
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;
414
917
  if (executorEffortRef) {
415
918
  pi.setThinkingLevel(executorEffortRef as ThinkingLevel);
416
919
  }
@@ -424,7 +927,7 @@ export const registerCommands = (
424
927
  if (announce) {
425
928
  notify(
426
929
  ctx,
427
- `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"})`,
428
931
  "info"
429
932
  );
430
933
  }
@@ -432,9 +935,20 @@ export const registerCommands = (
432
935
 
433
936
  pi.registerEntryRenderer?.(
434
937
  "advisor-manual-call",
435
- (entry, _options, theme) => {
436
- const { question } = (entry.data ?? {}) as { question?: string };
437
- return renderAdvisorCallBox(question, theme);
938
+ (entry, { expanded }, theme) => {
939
+ const { progressId, question } = (entry.data ?? {}) as {
940
+ progressId?: string;
941
+ question?: string;
942
+ };
943
+ const progress = progressId ? manualProgress.get(progressId) : undefined;
944
+ return progress
945
+ ? new ManualAdvisorProgressComponent(
946
+ question,
947
+ progress,
948
+ Boolean(expanded),
949
+ theme
950
+ )
951
+ : renderAdvisorCallBox(question, theme);
438
952
  }
439
953
  );
440
954
 
@@ -461,9 +975,11 @@ export const registerCommands = (
461
975
  if (details?.advisor) {
462
976
  box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
463
977
  }
464
- const usage = formatAdvisorUsage(details?.usage);
465
- if (usage) {
466
- box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
978
+ if (getAdvisorSettings().showUsageDetails) {
979
+ const usage = formatAdvisorUsage(details?.usage);
980
+ if (usage) {
981
+ box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
982
+ }
467
983
  }
468
984
  box.addChild(
469
985
  new Markdown(
@@ -478,6 +994,7 @@ export const registerCommands = (
478
994
  );
479
995
 
480
996
  pi.on("session_start", async (_event, ctx) => {
997
+ pendingExecutorModelRef = undefined;
481
998
  // A malformed advisor.json or a provider auth failure must not reject a
482
999
  // lifecycle handler and break session startup.
483
1000
  try {
@@ -492,39 +1009,57 @@ export const registerCommands = (
492
1009
  });
493
1010
 
494
1011
  pi.on("model_select", (event, ctx) => {
495
- // Only an explicit user selection redefines the Executor. "restore" replays a
496
- // stored session model and would otherwise overwrite saved configuration.
497
- 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) {
498
1016
  return;
499
1017
  }
500
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;
501
1026
  if (selected === executorRef) {
502
1027
  return;
503
1028
  }
1029
+ const persisted = getPersistedModelRefs();
504
1030
  setExecutorRef(selected);
505
- saveConfig(ctx);
1031
+ saveConfig(ctx, {
1032
+ persistAdvisor: Boolean(persisted.advisor),
1033
+ persistExecutor: true,
1034
+ });
506
1035
  });
507
1036
 
508
1037
  pi.on("session_shutdown", (_event, ctx) => {
509
1038
  if (ctx.hasUI) {
510
- ctx.ui.setStatus("advisor-manual", undefined);
511
1039
  ctx.ui.setStatus("advisor-usage", undefined);
512
1040
  }
513
1041
  for (const [controller, token] of manualConsultations) {
514
1042
  controller.abort();
1043
+ const timer = manualProgressTimers.get(controller);
1044
+ if (timer) {
1045
+ clearInterval(timer);
1046
+ manualProgressTimers.delete(controller);
1047
+ }
515
1048
  scoutStatus.release(ctx, token);
516
1049
  }
517
1050
  scoutStatus.clear(ctx);
518
1051
  manualConsultations.clear();
1052
+ manualProgressTimers.clear();
1053
+ manualProgress.clear();
519
1054
  herdrAdvisorActivity.clear();
520
1055
  });
521
1056
 
522
1057
  pi.registerCommand("advisor-manual", {
523
1058
  description:
524
1059
  "Consult the Advisor in parallel; accepts an optional focused question and fans its response out to the Executor",
525
- handler: (args, ctx) => {
1060
+ handler: async (args, ctx) => {
526
1061
  if (!loadCommandConfig(ctx)) {
527
- return Promise.resolve();
1062
+ return;
528
1063
  }
529
1064
  if (
530
1065
  !(
@@ -532,15 +1067,57 @@ export const registerCommands = (
532
1067
  advisorSessionState.canConsult(getAdvisorMaxCallsPerSession())
533
1068
  )
534
1069
  ) {
535
- const message = "Advisor call budget exhausted for this session.";
536
- notify(ctx, message, "warning");
537
- notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
538
- return Promise.resolve();
1070
+ reportManualBudgetExhausted(ctx);
1071
+ return;
539
1072
  }
1073
+
1074
+ let gitContext: GitContextLevel | undefined;
1075
+ let question: string | undefined;
1076
+ if (ctx.mode === "tui") {
1077
+ const request = await ctx.ui.custom<ManualAdvisorRequest | undefined>(
1078
+ (tui, theme, keybindings, done) =>
1079
+ new ManualAdvisorDialog({
1080
+ gitContext: getAdvisorSettings().gitContext,
1081
+ initialMessage: args,
1082
+ keybindings,
1083
+ onCancel: () => done(undefined),
1084
+ onSubmit: done,
1085
+ theme,
1086
+ tui,
1087
+ }),
1088
+ {
1089
+ overlay: true,
1090
+ overlayOptions: {
1091
+ anchor: "center",
1092
+ margin: 2,
1093
+ maxHeight: "80%",
1094
+ minWidth: 56,
1095
+ width: 76,
1096
+ },
1097
+ }
1098
+ );
1099
+ if (!request) {
1100
+ return;
1101
+ }
1102
+ if (
1103
+ !(
1104
+ isSimpleMode() ||
1105
+ advisorSessionState.canConsult(getAdvisorMaxCallsPerSession())
1106
+ )
1107
+ ) {
1108
+ reportManualBudgetExhausted(ctx);
1109
+ return;
1110
+ }
1111
+ const { gitContext: selectedGitContext, message } = request;
1112
+ gitContext = selectedGitContext;
1113
+ question = resolveAdvisorRequest(message);
1114
+ } else {
1115
+ question = resolveAdvisorRequest(args);
1116
+ }
1117
+
540
1118
  if (!isSimpleMode()) {
541
1119
  advisorSessionState.consumeCall();
542
1120
  }
543
- const question = resolveAdvisorRequest(args);
544
1121
  // A single visible progress surface avoids competing consultations overwriting
545
1122
  // each other's streamed state. A newer manual request replaces the previous one.
546
1123
  for (const [pending, token] of manualConsultations) {
@@ -550,17 +1127,27 @@ export const registerCommands = (
550
1127
  manualConsultations.clear();
551
1128
  const controller = new AbortController();
552
1129
  const scoutStatusToken = Symbol("manual-scout");
1130
+ manualProgressSequence += 1;
1131
+ const progressId = `manual-${manualProgressSequence}`;
1132
+ const progress: ManualAdvisorProgressState = { phase: "preparing" };
1133
+ manualProgress.set(progressId, progress);
553
1134
  scoutStatus.register(scoutStatusToken);
554
1135
  manualConsultations.set(controller, scoutStatusToken);
555
- pi.appendEntry?.("advisor-manual-call", { question });
556
- startManualConsultation(ctx, question, controller, scoutStatusToken);
557
- return Promise.resolve();
1136
+ pi.appendEntry?.("advisor-manual-call", { progressId, question });
1137
+ startManualConsultation(
1138
+ ctx,
1139
+ question,
1140
+ controller,
1141
+ scoutStatusToken,
1142
+ progress,
1143
+ gitContext
1144
+ );
558
1145
  },
559
1146
  });
560
1147
 
561
1148
  pi.registerCommand("advisor", {
562
1149
  description:
563
- "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",
564
1151
  handler: (args, ctx) => activateAdvisor(args, ctx),
565
1152
  });
566
1153
 
@@ -571,66 +1158,33 @@ export const registerCommands = (
571
1158
  if (!(loadCommandConfig(ctx) && ctx.hasUI)) {
572
1159
  return;
573
1160
  }
574
- const refs = ctx.modelRegistry
575
- .getAvailable()
576
- .map((m) => `${m.provider}/${m.id}`);
577
-
578
- const executor = await ctx.ui.custom<string | undefined>(
579
- (tui, theme, keybindings, done) =>
580
- new SearchableModelSelector({
581
- allOptions: refs,
582
- currentOption: executorRef,
583
- keybindings,
584
- onCancel: () => done(undefined),
585
- onSelect: done,
586
- theme,
587
- title: "Select Executor Model",
588
- tui,
589
- })
590
- );
591
- if (!executor) {
592
- return;
593
- }
594
-
595
- const executorEffort = await ctx.ui.select(
596
- "Select Executor Reasoning/Thinking Level",
597
- effortChoices(executorEffortRef)
598
- );
599
- if (!executorEffort) {
600
- return;
601
- }
602
-
603
- const advisor = await ctx.ui.custom<string | undefined>(
604
- (tui, theme, keybindings, done) =>
605
- new SearchableModelSelector({
606
- allOptions: refs,
607
- currentOption: advisorRef,
608
- keybindings,
609
- onCancel: () => done(undefined),
610
- onSelect: done,
611
- theme,
612
- title: "Select Advisor Model",
613
- tui,
614
- })
615
- );
616
- if (!advisor) {
617
- return;
618
- }
619
-
620
- const advisorEffort = await ctx.ui.select(
621
- "Select Advisor Reasoning/Thinking Level",
622
- effortChoices(advisorEffortRef)
623
- );
624
- 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) {
625
1175
  return;
626
1176
  }
627
1177
 
628
- setExecutorRef(executor);
629
- setAdvisorRef(advisor);
630
- setExecutorEffortRef(selectedEffort(executorEffort));
631
- setAdvisorEffortRef(selectedEffort(advisorEffort));
1178
+ setExecutorRef(selection.executor);
1179
+ setAdvisorRef(selection.advisor);
1180
+ setExecutorEffortRef(selection.executorEffort);
1181
+ setAdvisorEffortRef(selection.advisorEffort);
632
1182
 
633
- const path = saveConfig(ctx);
1183
+ const path = saveConfig(ctx, {
1184
+ persistAdvisor: true,
1185
+ persistExecutor: true,
1186
+ });
1187
+ pendingExecutorModelRef = undefined;
634
1188
  ctx.ui.notify(
635
1189
  `Saved Executor + Advisor configurations to ${path}`,
636
1190
  "info"
@@ -639,68 +1193,37 @@ export const registerCommands = (
639
1193
  });
640
1194
 
641
1195
  pi.registerCommand("advisor-settings", {
642
- description: "Configure Advisor context and reasoning effort",
643
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one settings form maps every persisted control.
1196
+ description: "Configure Advisor settings",
644
1197
  handler: async (_args, ctx) => {
645
1198
  if (!(loadCommandConfig(ctx) && ctx.hasUI)) {
646
1199
  return;
647
1200
  }
648
1201
 
649
1202
  const initial: AdvisorSettings = getAdvisorSettings();
650
- const settings = await ctx.ui.custom<AdvisorSettings | undefined>(
1203
+ await ctx.ui.custom<void>(
651
1204
  (tui, theme, _keybindings, done) =>
652
1205
  new AdvisorSettingsSelector({
653
1206
  effortLevels: EFFORT_LEVELS,
654
1207
  initial,
655
- onCancel: () => done(undefined),
656
- onSave: done,
1208
+ onCancel: () => done(),
1209
+ onChange: (settings) => {
1210
+ try {
1211
+ saveAdvisorSettings(ctx, settings);
1212
+ updateAdvisorUsageStatus(ctx);
1213
+ } catch (error) {
1214
+ const message =
1215
+ error instanceof Error ? error.message : String(error);
1216
+ ctx.ui.notify(
1217
+ `Could not save Advisor settings: ${message}`,
1218
+ "error"
1219
+ );
1220
+ }
1221
+ },
657
1222
  presets: CONTEXT_PRESETS,
658
1223
  theme,
659
1224
  tui,
660
1225
  })
661
1226
  );
662
- if (!settings) {
663
- return;
664
- }
665
-
666
- setAdvisorEffortRef(
667
- settings.effort === "Default (Model Default)"
668
- ? undefined
669
- : settings.effort
670
- );
671
- setContextMaxCharsRef(settings.contextMaxChars);
672
- setAdvisorPlanGateRef(settings.planGate);
673
- setAdvisorFailureGateRef(settings.failureGate);
674
- setAdvisorCompletionGateRef(settings.completionGate);
675
- setAdvisorCollapseResponsesRef(settings.collapseResponses);
676
- setAdvisorCustomInvocationRef(settings.customRule);
677
- setAdvisorBlockOnBlockedRef(settings.blockOnBlocked ?? true);
678
- setAdvisorAutoLoopGateRef(settings.autoLoopGate ?? true);
679
- setAdvisorLoopThresholdRef(settings.loopThreshold ?? 3);
680
- setAdvisorMaxCallsPerSessionRef(settings.maxCallsPerSession);
681
- setAdvisorSessionSummaryRef(settings.sessionSummary ?? false);
682
- setAdvisorScoutEnabledRef(settings.scoutEnabled ?? false);
683
- setSimpleModeRef(settings.simpleMode ?? false);
684
- setAlwaysOnRef(settings.alwaysOn ?? false);
685
- setAdvisorFailureModeRef(settings.failureMode ?? "block-session");
686
- setAdvisorHerdrIntegrationRef(settings.herdrIntegration ?? true);
687
- setAdvisorToolResultMaxLinesRef(settings.toolResultMaxLines ?? 2000);
688
- setAdvisorToolResultMaxBytesRef(settings.toolResultMaxBytes ?? 50 * 1024);
689
- setAdvisorRedactSecretsRef(settings.redactSecrets ?? false);
690
- setAdvisorGitContextRef(settings.gitContext ?? "summary");
691
- setAdvisorGitContextMaxCharsRef(settings.gitContextMaxChars ?? 20_000);
692
- setAdvisorToolPoliciesRef(settings.toolPolicies ?? {});
693
- setAdvisorTrackedFileContentRef(settings.trackedFileContent ?? false);
694
- setAdvisorUntrackedContentRef(settings.untrackedContent ?? false);
695
- setAdvisorOutcomeLoggingRef(settings.outcomeLogging ?? false);
696
- const path = saveConfig(ctx);
697
- const globalPath = saveGlobalOutcomeLogging(
698
- settings.outcomeLogging ?? false
699
- );
700
- ctx.ui.notify(
701
- `Saved Advisor settings to ${path}; outcome logging globally to ${globalPath}`,
702
- "info"
703
- );
704
1227
  },
705
1228
  });
706
1229
 
@@ -718,8 +1241,12 @@ export const registerCommands = (
718
1241
  // Leaving alwaysOn set would silently reactivate the flow next session.
719
1242
  const wasAlwaysOn = alwaysOnRef;
720
1243
  if (wasAlwaysOn) {
1244
+ const persisted = getPersistedModelRefs();
721
1245
  setAlwaysOnRef(false);
722
- saveConfig(ctx);
1246
+ saveConfig(ctx, {
1247
+ persistAdvisor: Boolean(persisted.advisor),
1248
+ persistExecutor: Boolean(persisted.executor),
1249
+ });
723
1250
  }
724
1251
  notify(
725
1252
  ctx,