pi-advisor-flow 0.3.0 → 0.3.3

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/tools.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import {
3
- type AssistantMessage,
4
- type Message,
5
- stream,
6
- } from "@earendil-works/pi-ai/compat";
2
+ import type { Message } from "@earendil-works/pi-ai/compat";
7
3
  import {
8
4
  type AgentToolResult,
9
5
  type ExtensionAPI,
@@ -33,13 +29,14 @@ import {
33
29
  advisorPlanGateRef,
34
30
  advisorRedactSecretsRef,
35
31
  advisorRef,
32
+ advisorScoutEnabledRef,
36
33
  advisorSessionSummaryRef,
37
34
  advisorTrackedFileContentRef,
38
35
  advisorUntrackedContentRef,
39
36
  contextMaxCharsRef,
37
+ executorRef,
40
38
  isSimpleMode,
41
39
  loadConfig,
42
- splitRef,
43
40
  } from "./config.js";
44
41
  import {
45
42
  recentConversation,
@@ -60,8 +57,18 @@ import {
60
57
  herdrAdvisorBlock,
61
58
  notifyHerdrAdvisorFailure,
62
59
  } from "./herdr.js";
60
+ import { collectTextStream, resolveConfiguredModel } from "./model-stream.js";
63
61
  import { ADOPTIONS, appendOutcome, VALIDATIONS } from "./outcomes.js";
64
62
  import { readProjectPreferences } from "./preferences.js";
63
+ import {
64
+ runAdvisorScout,
65
+ type ScoutLifecycleEvent,
66
+ type ScoutOutcome,
67
+ } from "./scout.js";
68
+ import {
69
+ buildScoutManifest,
70
+ reconstructScoutConversation,
71
+ } from "./scout-context.js";
65
72
  import {
66
73
  AdvisorSessionState,
67
74
  type ConsultationTrigger,
@@ -301,6 +308,7 @@ export interface AdvisorConsultationResult {
301
308
  markdown: string;
302
309
  model: string;
303
310
  preferenceBytes?: number;
311
+ scout?: Exclude<ScoutOutcome, { cancelled: true }>;
304
312
  thinkingText: string;
305
313
  trackedBytes?: number;
306
314
  trigger: ConsultationTrigger;
@@ -414,6 +422,67 @@ export const parseAutomaticDecision = (
414
422
  const adviceForText = (result: AdvisorGateResult) =>
415
423
  `**Decision: ${result.decision}**\n\n${result.markdown}`;
416
424
 
425
+ export const curateAdvisorConversation = async (
426
+ ctx: ExtensionContext,
427
+ legacyConversation: string,
428
+ signal?: AbortSignal,
429
+ onScout?: (event: ScoutLifecycleEvent) => void,
430
+ enabled = advisorScoutEnabledRef,
431
+ runScout: typeof runAdvisorScout = runAdvisorScout,
432
+ currentInvocationId?: string,
433
+ maxChars?: number
434
+ ): Promise<{
435
+ conversation: string;
436
+ scout?: Exclude<ScoutOutcome, { cancelled: true }>;
437
+ }> => {
438
+ if (!enabled) {
439
+ return { conversation: legacyConversation };
440
+ }
441
+ if (maxChars !== undefined && maxChars <= 0) {
442
+ return { conversation: "" };
443
+ }
444
+ const built = buildScoutManifest(ctx, {
445
+ currentInvocationId,
446
+ maxBytes: maxChars,
447
+ });
448
+ if (!built.ok) {
449
+ const scout: Exclude<ScoutOutcome, { cancelled: true }> = {
450
+ category: built.reason,
451
+ message: built.message,
452
+ metrics: {
453
+ availableCount: 0,
454
+ inputBytes: 0,
455
+ latencyMs: 0,
456
+ omittedBeforeScout: 0,
457
+ selectedCount: 0,
458
+ },
459
+ model: executorRef,
460
+ ok: false,
461
+ };
462
+ onScout?.({ outcome: scout, type: "fallback" });
463
+ return { conversation: legacyConversation, scout };
464
+ }
465
+ const outcome = await runScout(ctx, built.manifest, signal, onScout);
466
+ if (!outcome.ok && outcome.cancelled) {
467
+ throw signal?.reason instanceof Error
468
+ ? signal.reason
469
+ : new Error("Advisor operation cancelled during Scout.");
470
+ }
471
+ let conversation = legacyConversation;
472
+ if (outcome.ok) {
473
+ conversation =
474
+ maxChars === undefined
475
+ ? outcome.conversation
476
+ : reconstructScoutConversation(
477
+ built.manifest,
478
+ outcome.selection.selectedIds,
479
+ outcome.selection.synthesis,
480
+ maxChars
481
+ );
482
+ }
483
+ return { conversation, scout: outcome };
484
+ };
485
+
417
486
  const collectAdvisorResponse = async (
418
487
  ctx: ExtensionContext,
419
488
  systemPrompt: string,
@@ -423,21 +492,12 @@ const collectAdvisorResponse = async (
423
492
  gitContext?: GitContextLevel,
424
493
  draft?: string,
425
494
  includeUntracked?: string[],
426
- includeTracked?: string[]
495
+ includeTracked?: string[],
496
+ onScout?: (event: ScoutLifecycleEvent) => void,
497
+ currentInvocationId?: string
427
498
  ) => {
428
499
  loadConfig(ctx);
429
- const [provider, modelId] = splitRef(advisorRef);
430
- const model = ctx.modelRegistry.find(provider, modelId);
431
- if (!model) {
432
- throw new Error(`Advisor model not found: ${advisorRef}`);
433
- }
434
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
435
- if (!auth.ok) {
436
- throw new Error((auth as { error: string }).error);
437
- }
438
- if (!auth.apiKey) {
439
- throw new Error(`No API key for ${advisorRef}`);
440
- }
500
+ const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
441
501
 
442
502
  // The user setting is the ceiling; the Executor may only narrow it.
443
503
  const allowed = advisorGitContextRef;
@@ -464,10 +524,25 @@ const collectAdvisorResponse = async (
464
524
  );
465
525
  // Repository context spends part of the shared budget, so a large patch
466
526
  // cannot silently push the conversation past the model's context window.
467
- const conversation = advisorRequestConversation(
527
+ const conversationBudget = Math.max(
528
+ 0,
529
+ contextMaxCharsRef - changeText.length
530
+ );
531
+ const legacyConversation = advisorRequestConversation(
468
532
  ctx,
469
- Math.max(0, contextMaxCharsRef - changeText.length)
533
+ conversationBudget
470
534
  );
535
+ const curated = await curateAdvisorConversation(
536
+ ctx,
537
+ legacyConversation,
538
+ signal,
539
+ onScout,
540
+ advisorScoutEnabledRef,
541
+ runAdvisorScout,
542
+ currentInvocationId,
543
+ conversationBudget
544
+ );
545
+ const { conversation, scout } = curated;
471
546
  const preferences = await readProjectPreferences(
472
547
  ctx,
473
548
  8 * 1024,
@@ -519,41 +594,14 @@ const collectAdvisorResponse = async (
519
594
  },
520
595
  ];
521
596
 
522
- let thinkingText = "";
523
- let responseText = "";
524
- const eventStream = stream(
525
- model,
526
- { messages, systemPrompt },
527
- {
528
- apiKey: auth.apiKey,
529
- env: auth.env,
530
- headers: auth.headers,
531
- reasoning: advisorEffortRef as never,
532
- signal,
533
- }
534
- );
535
-
536
- for await (const event of eventStream) {
537
- if (event.type === "thinking_delta") {
538
- thinkingText += event.delta;
539
- onChunk?.(thinkingText, responseText);
540
- } else if (event.type === "text_delta") {
541
- responseText += event.delta;
542
- onChunk?.(thinkingText, responseText);
543
- }
544
- }
545
-
546
- const response = await eventStream.result();
547
- const lastAssistant = [response].find(
548
- (m): m is AssistantMessage => m.role === "assistant"
549
- );
550
- const markdown =
551
- lastAssistant?.content
552
- .filter(
553
- (part): part is { type: "text"; text: string } => part.type === "text"
554
- )
555
- .map((part) => part.text)
556
- .join("\n") || responseText;
597
+ const streamed = await collectTextStream(resolved, {
598
+ messages,
599
+ onChunk,
600
+ reasoning: advisorEffortRef,
601
+ signal,
602
+ systemPrompt,
603
+ });
604
+ const markdown = streamed.text;
557
605
  if (!markdown.trim()) {
558
606
  throw new Error("Advisor returned no advice.");
559
607
  }
@@ -562,14 +610,13 @@ const collectAdvisorResponse = async (
562
610
  markdown,
563
611
  model: advisorRef,
564
612
  preferenceBytes: preferences?.bytes,
565
- thinkingText,
613
+ thinkingText: streamed.thinking,
566
614
  trackedBytes:
567
615
  tracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
568
616
  untrackedBytes:
569
617
  untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
570
- usage: (
571
- lastAssistant as (AssistantMessage & { usage?: unknown }) | undefined
572
- )?.usage,
618
+ usage: streamed.usage,
619
+ ...(scout ? { scout } : {}),
573
620
  };
574
621
  };
575
622
 
@@ -582,7 +629,9 @@ export const consultAdvisor = async (
582
629
  gitContext?: GitContextLevel,
583
630
  draft?: string,
584
631
  includeUntracked?: string[],
585
- includeTracked?: string[]
632
+ includeTracked?: string[],
633
+ onScout?: (event: ScoutLifecycleEvent) => void,
634
+ currentInvocationId?: string
586
635
  ): Promise<AdvisorConsultationResult> => {
587
636
  const result = await collectAdvisorResponse(
588
637
  ctx,
@@ -593,7 +642,9 @@ export const consultAdvisor = async (
593
642
  gitContext,
594
643
  draft,
595
644
  includeUntracked,
596
- includeTracked
645
+ includeTracked,
646
+ onScout,
647
+ currentInvocationId
597
648
  );
598
649
  return { ...result, adviceId: randomUUID(), trigger };
599
650
  };
@@ -603,7 +654,9 @@ export const runAdvisorGate = async (
603
654
  question: string,
604
655
  trigger: GateTrigger = "repeated-tool-call",
605
656
  signal?: AbortSignal,
606
- onChunk?: (thinking: string, text: string) => void
657
+ onChunk?: (thinking: string, text: string) => void,
658
+ onScout?: (event: ScoutLifecycleEvent) => void,
659
+ currentInvocationId?: string
607
660
  ): Promise<AdvisorGateOutcome> => {
608
661
  try {
609
662
  const result = await collectAdvisorResponse(
@@ -611,7 +664,13 @@ export const runAdvisorGate = async (
611
664
  ADVISOR_DECISION_SYSTEM,
612
665
  question,
613
666
  signal,
614
- onChunk
667
+ onChunk,
668
+ undefined,
669
+ undefined,
670
+ undefined,
671
+ undefined,
672
+ onScout,
673
+ currentInvocationId
615
674
  );
616
675
  const parsed = parseAutomaticDecision(result.markdown);
617
676
  if (!parsed.ok) {
@@ -625,6 +684,9 @@ export const runAdvisorGate = async (
625
684
  usage: result.usage,
626
685
  };
627
686
  } catch (error) {
687
+ if (signal?.aborted) {
688
+ throw error;
689
+ }
628
690
  const message = error instanceof Error ? error.message : String(error);
629
691
  return {
630
692
  category:
@@ -763,7 +825,9 @@ const handleAutomaticGate = async (
763
825
  pi: ExtensionAPI,
764
826
  event: ToolCallEvent,
765
827
  ctx: ExtensionContext,
766
- session: AdvisorSessionState
828
+ session: AdvisorSessionState,
829
+ runGate: typeof runAdvisorGate,
830
+ scoutStatus: ScoutStatusManager
767
831
  ): Promise<ToolCallEventResult | undefined> => {
768
832
  if (
769
833
  isSimpleMode() ||
@@ -789,12 +853,36 @@ const handleAutomaticGate = async (
789
853
  }
790
854
  session.consumeCall();
791
855
  herdrAdvisorActivity.start();
792
- sendAutomaticGateCall(pi, event);
856
+ let scoutDetails: ScoutToolDetails | undefined;
857
+ const scoutStatusToken = Symbol("automatic-gate-scout");
858
+ scoutStatus.register(scoutStatusToken);
859
+ let gateCallSent = false;
860
+ const ensureGateCall = () => {
861
+ if (!gateCallSent) {
862
+ sendAutomaticGateCall(pi, event);
863
+ gateCallSent = true;
864
+ }
865
+ };
866
+ if (!advisorScoutEnabledRef) {
867
+ ensureGateCall();
868
+ }
793
869
  try {
794
- const result = await runAdvisorGate(
870
+ const result = await runGate(
795
871
  ctx,
796
- `${reason} Review the repeated actions and recommend the smallest safe next step.`
872
+ `${reason} Review the repeated actions and recommend the smallest safe next step.`,
873
+ "repeated-tool-call",
874
+ ctx.signal,
875
+ undefined,
876
+ (scoutEvent) => {
877
+ scoutStatus.update(ctx, scoutStatusToken, scoutEvent);
878
+ scoutDetails = appendScoutLifecycleEntry(pi, scoutEvent, scoutDetails);
879
+ if (scoutEvent.type === "success" || scoutEvent.type === "fallback") {
880
+ ensureGateCall();
881
+ }
882
+ },
883
+ event.toolCallId
797
884
  );
885
+ ensureGateCall();
798
886
  if (!result.ok) {
799
887
  session.recordInvocation({
800
888
  executionEffect: gateFailureEffectForMode(advisorFailureModeRef),
@@ -841,22 +929,40 @@ const handleAutomaticGate = async (
841
929
  }
842
930
  return { block: true, reason: gateReason };
843
931
  } finally {
932
+ scoutStatus.release(ctx, scoutStatusToken);
844
933
  herdrAdvisorActivity.finish();
845
934
  }
846
935
  };
847
936
 
937
+ export interface ScoutToolDetails {
938
+ availableCount?: number;
939
+ fallbackReason?: string;
940
+ latencyMs?: number;
941
+ model: string;
942
+ omittedBeforeScout?: number;
943
+ selectedCount?: number;
944
+ selectedLabels?: string[];
945
+ status: "calling" | "streaming" | "curated" | "fallback" | "cancelled";
946
+ synthesis?: string;
947
+ text?: string;
948
+ thinking?: string;
949
+ usage?: unknown;
950
+ }
848
951
  interface AdvisorToolDetails {
849
952
  adviceId?: string;
850
953
  advisor?: string;
851
954
  draftBytes?: number;
852
955
  preferenceBytes?: number;
853
956
  question?: string;
957
+ scout?: ScoutToolDetails;
854
958
  text?: string;
855
959
  thinking?: string;
856
960
  trackedBytes?: number;
857
961
  untrackedBytes?: number;
858
962
  }
859
963
  interface AdvisorRenderState {
964
+ phase?: string;
965
+ scout?: ScoutToolDetails;
860
966
  timerId?: ReturnType<typeof setInterval>;
861
967
  }
862
968
  interface AdvisorToolContext {
@@ -868,6 +974,196 @@ interface AdvisorToolContext {
868
974
  const advisorResultDetails = (result: AgentToolResult<AdvisorToolDetails>) =>
869
975
  result.details;
870
976
 
977
+ export const scoutDetailsFromEvent = (
978
+ event: ScoutLifecycleEvent,
979
+ previous?: ScoutToolDetails
980
+ ): ScoutToolDetails => {
981
+ if (event.type === "call") {
982
+ return { model: event.model, status: "calling" };
983
+ }
984
+ if (event.type === "chunk") {
985
+ return {
986
+ ...previous,
987
+ model: event.model,
988
+ status: "streaming",
989
+ text: event.text,
990
+ thinking: event.thinking,
991
+ };
992
+ }
993
+ if (event.type === "cancelled") {
994
+ return {
995
+ model: previous ? previous.model : executorRef,
996
+ status: "cancelled",
997
+ };
998
+ }
999
+ const { outcome } = event;
1000
+ return outcome.ok
1001
+ ? {
1002
+ availableCount: outcome.metrics.availableCount,
1003
+ latencyMs: outcome.metrics.latencyMs,
1004
+ model: outcome.model,
1005
+ omittedBeforeScout: outcome.metrics.omittedBeforeScout,
1006
+ selectedCount: outcome.metrics.selectedCount,
1007
+ selectedLabels: outcome.selectedLabels,
1008
+ status: "curated",
1009
+ synthesis: outcome.selection.synthesis,
1010
+ usage: outcome.metrics.usage,
1011
+ }
1012
+ : {
1013
+ availableCount: outcome.metrics.availableCount,
1014
+ fallbackReason: `${outcome.category}: ${outcome.message}`,
1015
+ latencyMs: outcome.metrics.latencyMs,
1016
+ model: outcome.model,
1017
+ omittedBeforeScout: outcome.metrics.omittedBeforeScout,
1018
+ selectedCount: 0,
1019
+ status: "fallback",
1020
+ usage: outcome.metrics.usage,
1021
+ };
1022
+ };
1023
+
1024
+ export const appendScoutLifecycleEntry = (
1025
+ pi: ExtensionAPI,
1026
+ event: ScoutLifecycleEvent,
1027
+ previous?: ScoutToolDetails
1028
+ ) => {
1029
+ const scout = scoutDetailsFromEvent(event, previous);
1030
+ if (
1031
+ event.type === "success" ||
1032
+ event.type === "fallback" ||
1033
+ event.type === "cancelled"
1034
+ ) {
1035
+ pi.appendEntry?.("advisor-scout-result", scout);
1036
+ }
1037
+ return scout;
1038
+ };
1039
+
1040
+ export class ScoutStatusManager {
1041
+ readonly #active = new Set<symbol>();
1042
+ readonly #known = new Set<symbol>();
1043
+ readonly #retired = new Set<symbol>();
1044
+
1045
+ register(token: symbol) {
1046
+ if (!this.#retired.has(token)) {
1047
+ this.#known.add(token);
1048
+ }
1049
+ }
1050
+
1051
+ update(ctx: ExtensionContext, token: symbol, event: ScoutLifecycleEvent) {
1052
+ if (this.#retired.has(token) || !ctx.hasUI) {
1053
+ return;
1054
+ }
1055
+ this.#known.add(token);
1056
+ if (event.type === "call" || event.type === "chunk") {
1057
+ this.#active.add(token);
1058
+ ctx.ui.setStatus("advisor-scout", "Scout curating…");
1059
+ return;
1060
+ }
1061
+ this.release(ctx, token);
1062
+ }
1063
+
1064
+ release(ctx: ExtensionContext, token: symbol) {
1065
+ this.#active.delete(token);
1066
+ this.#known.delete(token);
1067
+ this.#retired.add(token);
1068
+ if (!ctx.hasUI) {
1069
+ return;
1070
+ }
1071
+ ctx.ui.setStatus(
1072
+ "advisor-scout",
1073
+ this.#active.size > 0 ? "Scout curating…" : undefined
1074
+ );
1075
+ }
1076
+
1077
+ clear(ctx: ExtensionContext) {
1078
+ for (const token of this.#known) {
1079
+ this.#retired.add(token);
1080
+ }
1081
+ this.#known.clear();
1082
+ this.#active.clear();
1083
+ if (ctx.hasUI) {
1084
+ ctx.ui.setStatus("advisor-scout", undefined);
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ const scoutTitle = (scout: ScoutToolDetails, frame: string) => {
1090
+ if (scout.status === "calling" || scout.status === "streaming") {
1091
+ return `◆ SCOUT ${frame} · CURATING…`;
1092
+ }
1093
+ if (scout.status === "curated") {
1094
+ return "◆ SCOUT · CURATED";
1095
+ }
1096
+ if (scout.status === "cancelled") {
1097
+ return "◆ SCOUT · CANCELLED";
1098
+ }
1099
+ return "◆ SCOUT · FALLBACK";
1100
+ };
1101
+
1102
+ export const renderScoutDetails = (
1103
+ box: Box,
1104
+ scout: ScoutToolDetails,
1105
+ expanded: boolean,
1106
+ theme: Theme
1107
+ ) => {
1108
+ const active = scout.status === "calling" || scout.status === "streaming";
1109
+ const frame =
1110
+ SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
1111
+ const title = scoutTitle(scout, frame);
1112
+ const lines = [
1113
+ theme.fg(
1114
+ scout.status === "fallback" || scout.status === "cancelled"
1115
+ ? "warning"
1116
+ : "accent",
1117
+ theme.bold(title)
1118
+ ),
1119
+ theme.fg(
1120
+ "dim",
1121
+ ` ${scout.model}${scout.selectedCount === undefined ? "" : ` · ${scout.selectedCount} kept / ${Math.max(0, (scout.availableCount ?? 0) - scout.selectedCount)} omitted`}${scout.latencyMs === undefined ? "" : ` · ${(scout.latencyMs / 1000).toFixed(1)}s`}`
1122
+ ),
1123
+ ];
1124
+ if (scout.fallbackReason) {
1125
+ lines.push(theme.fg("warning", ` ${scout.fallbackReason}`));
1126
+ }
1127
+ if (scout.thinking && active) {
1128
+ lines.push(
1129
+ theme.fg(
1130
+ "thinkingText",
1131
+ ` 💭 ${scout.thinking.replace(/\n/g, " ").slice(-200)}`
1132
+ )
1133
+ );
1134
+ }
1135
+ if (expanded && scout.selectedLabels?.length) {
1136
+ lines.push(
1137
+ theme.fg("dim", ` Selected: ${scout.selectedLabels.join("; ")}`)
1138
+ );
1139
+ }
1140
+ if (expanded && scout.synthesis) {
1141
+ lines.push(
1142
+ theme.fg(
1143
+ "dim",
1144
+ ` Scout synthesis (untrusted inference): ${scout.synthesis}`
1145
+ )
1146
+ );
1147
+ }
1148
+ if (expanded && scout.omittedBeforeScout) {
1149
+ lines.push(
1150
+ theme.fg(
1151
+ "dim",
1152
+ ` ${scout.omittedBeforeScout} group(s) omitted before Scout`
1153
+ )
1154
+ );
1155
+ }
1156
+ box.addChild(new Text(lines.join("\n"), 0, 0));
1157
+ };
1158
+
1159
+ const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
1160
+ if (context.state.phase !== phase && context.state.timerId) {
1161
+ clearInterval(context.state.timerId);
1162
+ context.state.timerId = undefined;
1163
+ }
1164
+ context.state.phase = phase;
1165
+ };
1166
+
871
1167
  const renderPartialAdvisorResult = (
872
1168
  box: Box,
873
1169
  result: AgentToolResult<AdvisorToolDetails>,
@@ -875,12 +1171,25 @@ const renderPartialAdvisorResult = (
875
1171
  theme: Theme,
876
1172
  context: AdvisorToolContext
877
1173
  ) => {
1174
+ const details = advisorResultDetails(result);
1175
+ if (details?.scout) {
1176
+ context.state.scout = details.scout;
1177
+ }
1178
+ const scout = details?.scout ?? context.state.scout;
1179
+ const scoutActive =
1180
+ scout?.status === "calling" || scout?.status === "streaming";
1181
+ syncRenderPhase(context, scoutActive ? "scout" : "advisor");
878
1182
  if (!context.state.timerId) {
879
1183
  context.state.timerId = setInterval(() => context.invalidate(), 80);
880
1184
  }
1185
+ if (scout) {
1186
+ renderScoutDetails(box, scout, expanded, theme);
1187
+ }
1188
+ if (scoutActive || scout?.status === "cancelled") {
1189
+ return;
1190
+ }
881
1191
  const frame =
882
1192
  SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
883
- const details = advisorResultDetails(result);
884
1193
  const lines = [
885
1194
  `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`,
886
1195
  ];
@@ -911,11 +1220,22 @@ const renderFinalAdvisorResult = (
911
1220
  theme: Theme,
912
1221
  context: AdvisorToolContext
913
1222
  ) => {
1223
+ syncRenderPhase(context, "final");
914
1224
  if (context.state.timerId) {
915
1225
  clearInterval(context.state.timerId);
916
1226
  context.state.timerId = undefined;
917
1227
  }
918
1228
  const details = advisorResultDetails(result);
1229
+ if (details?.scout) {
1230
+ context.state.scout = details.scout;
1231
+ }
1232
+ const scout = details?.scout ?? context.state.scout;
1233
+ if (scout) {
1234
+ renderScoutDetails(box, scout, expanded, theme);
1235
+ }
1236
+ if (scout?.status === "cancelled") {
1237
+ return;
1238
+ }
919
1239
  const advice = details?.text || textFrom(result.content);
920
1240
  const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)];
921
1241
  if (details?.advisor) {
@@ -981,9 +1301,24 @@ const renderAdvisorResult = (
981
1301
 
982
1302
  export const registerAdvisorTool = (
983
1303
  pi: ExtensionAPI,
984
- session: AdvisorSessionState = advisorSessionState
1304
+ session: AdvisorSessionState = advisorSessionState,
1305
+ dependencies: {
1306
+ runGate?: typeof runAdvisorGate;
1307
+ statusManager?: ScoutStatusManager;
1308
+ } = {}
985
1309
  ) => {
986
1310
  const reservedCalls = new Set<string>();
1311
+ const scoutStatus = dependencies.statusManager ?? new ScoutStatusManager();
1312
+
1313
+ pi.registerEntryRenderer?.(
1314
+ "advisor-scout-result",
1315
+ (entry, { expanded }, theme) => {
1316
+ const scout = entry.data as ScoutToolDetails;
1317
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
1318
+ renderScoutDetails(box, scout, Boolean(expanded), theme);
1319
+ return box;
1320
+ }
1321
+ );
987
1322
 
988
1323
  pi.registerMessageRenderer?.(
989
1324
  "advisor-loop-call",
@@ -1082,7 +1417,14 @@ export const registerAdvisorTool = (
1082
1417
  if (event.toolName === "ask_advisor") {
1083
1418
  return reservation;
1084
1419
  }
1085
- return handleAutomaticGate(pi, event, ctx, session);
1420
+ return handleAutomaticGate(
1421
+ pi,
1422
+ event,
1423
+ ctx,
1424
+ session,
1425
+ dependencies.runGate ?? runAdvisorGate,
1426
+ scoutStatus
1427
+ );
1086
1428
  });
1087
1429
 
1088
1430
  pi.on("agent_settled", (_event, ctx) => {
@@ -1098,8 +1440,9 @@ export const registerAdvisorTool = (
1098
1440
  }
1099
1441
  });
1100
1442
 
1101
- pi.on("session_shutdown", () => {
1443
+ pi.on("session_shutdown", (_event, ctx) => {
1102
1444
  reservedCalls.clear();
1445
+ scoutStatus.clear(ctx);
1103
1446
  herdrAdvisorBlock.clear();
1104
1447
  });
1105
1448
 
@@ -1123,6 +1466,7 @@ export const registerAdvisorTool = (
1123
1466
  session.consumeCall();
1124
1467
  }
1125
1468
  herdrAdvisorActivity.start();
1469
+ let scoutDetails: ScoutToolDetails | undefined;
1126
1470
  try {
1127
1471
  const result = await consultAdvisor(
1128
1472
  ctx,
@@ -1134,6 +1478,7 @@ export const registerAdvisorTool = (
1134
1478
  details: {
1135
1479
  advisor: advisorRef,
1136
1480
  question: resolveAdvisorRequest(params.question),
1481
+ scout: scoutDetails,
1137
1482
  text: tx,
1138
1483
  thinking: t,
1139
1484
  },
@@ -1143,7 +1488,19 @@ export const registerAdvisorTool = (
1143
1488
  params.gitContext === "none" ? "off" : params.gitContext,
1144
1489
  params.draft,
1145
1490
  params.includeUntracked,
1146
- params.includeTrackedFiles
1491
+ params.includeTrackedFiles,
1492
+ (event) => {
1493
+ scoutDetails = scoutDetailsFromEvent(event, scoutDetails);
1494
+ onUpdate?.({
1495
+ content: [{ text: scoutDetails.text ?? "", type: "text" }],
1496
+ details: {
1497
+ advisor: advisorRef,
1498
+ question: resolveAdvisorRequest(params.question),
1499
+ scout: scoutDetails,
1500
+ },
1501
+ });
1502
+ },
1503
+ _id
1147
1504
  );
1148
1505
  session.issueAdvice(
1149
1506
  result.adviceId,
@@ -1172,6 +1529,7 @@ export const registerAdvisorTool = (
1172
1529
  draftBytes: result.draftBytes,
1173
1530
  preferenceBytes: result.preferenceBytes,
1174
1531
  question: resolveAdvisorRequest(params.question),
1532
+ scout: scoutDetails,
1175
1533
  text: result.markdown,
1176
1534
  thinking: result.thinkingText,
1177
1535
  trackedBytes: result.trackedBytes,