pi-advisor-flow 0.5.1 → 0.5.2

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -2
  3. package/extensions/index.ts +7 -7
  4. package/package.json +2 -1
  5. package/src/commands/activation-preparation.ts +117 -0
  6. package/src/commands/activation.ts +135 -0
  7. package/src/commands/lifecycle.ts +83 -0
  8. package/src/commands/manual-command.ts +107 -0
  9. package/src/commands/manual-consultation.ts +144 -0
  10. package/src/commands/manual-progress.ts +93 -0
  11. package/src/commands/model-commands.ts +66 -0
  12. package/src/commands/model-options.ts +181 -0
  13. package/src/commands/model-picker.ts +110 -0
  14. package/src/commands/registration.ts +27 -0
  15. package/src/commands/renderers.ts +76 -0
  16. package/src/commands/runtime.ts +114 -0
  17. package/src/commands/settings-commands.ts +80 -0
  18. package/src/commands/settings-persistence.ts +81 -0
  19. package/src/commands/types.ts +78 -0
  20. package/src/commands.ts +9 -1259
  21. package/src/config/args.ts +37 -0
  22. package/src/config/defaults.ts +190 -0
  23. package/src/config/state.ts +199 -0
  24. package/src/config/storage.ts +224 -0
  25. package/src/config/types.ts +63 -0
  26. package/src/config/validation.ts +234 -0
  27. package/src/config.ts +104 -814
  28. package/src/conversation.ts +4 -2
  29. package/src/herdr.ts +1 -1
  30. package/src/model-stream.ts +15 -1
  31. package/src/scout-context.ts +2 -2
  32. package/src/scout.ts +1 -1
  33. package/src/tools/consultation.ts +337 -0
  34. package/src/tools/gate-policy.ts +132 -0
  35. package/src/tools/gate-protocol.ts +127 -0
  36. package/src/tools/loop-gate.ts +210 -0
  37. package/src/tools/prompts.ts +159 -0
  38. package/src/tools/register-ask-advisor.ts +225 -0
  39. package/src/tools/register-lifecycle.ts +89 -0
  40. package/src/tools/register-outcome.ts +75 -0
  41. package/src/tools/register-renderers.ts +88 -0
  42. package/src/tools/registration.ts +35 -0
  43. package/src/tools/render-advisor-result.ts +168 -0
  44. package/src/tools/render-common.ts +120 -0
  45. package/src/tools/scout-status.ts +206 -0
  46. package/src/tools/session.ts +3 -0
  47. package/src/tools/types.ts +119 -0
  48. package/src/tools.ts +42 -1865
  49. package/src/ui/manual-dialog-render.ts +150 -0
  50. package/src/ui/manual-dialog.ts +305 -0
  51. package/src/ui/model-selector.ts +166 -0
  52. package/src/ui/settings-formatting.ts +133 -0
  53. package/src/ui/settings-items.ts +323 -0
  54. package/src/ui/settings-list-adapter.ts +103 -0
  55. package/src/ui/settings-mutations.ts +80 -0
  56. package/src/ui/settings-selector.ts +161 -0
  57. package/src/ui/text-setting-submenu.ts +66 -0
  58. package/src/ui/types.ts +111 -0
  59. package/src/ui.ts +9 -1418
@@ -0,0 +1,75 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import { Type } from "typebox";
3
+ import { advisorOutcomeLoggingRef } from "../config/state.js";
4
+ import { loadConfig } from "../config/storage.js";
5
+ import { textFrom } from "../conversation.js";
6
+ import { ADOPTIONS, VALIDATIONS } from "../outcomes.js";
7
+ import type { ToolRegistrationContext } from "./types.js";
8
+
9
+ export const registerOutcomeTool = ({
10
+ appendOutcome: appendAdvisorOutcome,
11
+ pi,
12
+ session,
13
+ }: ToolRegistrationContext): void => {
14
+ pi.registerTool({
15
+ description:
16
+ "Voluntarily record the settled adoption and validation outcome for a displayed adviceId when global outcome logging is enabled.",
17
+ async execute(_id, params, _signal, _update, ctx) {
18
+ loadConfig(ctx);
19
+ if (!advisorOutcomeLoggingRef) {
20
+ return {
21
+ content: [
22
+ { text: "Outcome logging is disabled globally.", type: "text" },
23
+ ],
24
+ details: { recorded: false },
25
+ };
26
+ }
27
+ const advice = session.reserveAdvice(params.adviceId);
28
+ if (!advice) {
29
+ throw new Error("Unknown, already recorded, or pending adviceId.");
30
+ }
31
+ try {
32
+ await appendAdvisorOutcome({
33
+ adoption: params.adoption as (typeof ADOPTIONS)[number],
34
+ advice: advice.advice,
35
+ trigger: advice.trigger,
36
+ validationStatus:
37
+ params.validationStatus as (typeof VALIDATIONS)[number],
38
+ });
39
+ session.commitAdvice(params.adviceId);
40
+ return {
41
+ content: [
42
+ { text: "Advisor outcome recorded locally.", type: "text" },
43
+ ],
44
+ details: { recorded: true },
45
+ };
46
+ } catch {
47
+ session.releaseAdvice(params.adviceId);
48
+ if (ctx.hasUI) {
49
+ ctx.ui.notify(
50
+ "Advisor outcome could not be recorded locally.",
51
+ "warning"
52
+ );
53
+ }
54
+ return {
55
+ content: [
56
+ {
57
+ text: "Advisor outcome was not recorded; Advisor execution remains usable.",
58
+ type: "text",
59
+ },
60
+ ],
61
+ details: { recorded: false },
62
+ };
63
+ }
64
+ },
65
+ label: "Record Advisor Outcome",
66
+ name: "record_advisor_outcome",
67
+ parameters: Type.Object({
68
+ adoption: Type.String({ enum: ADOPTIONS }),
69
+ adviceId: Type.String(),
70
+ validationStatus: Type.String({ enum: VALIDATIONS }),
71
+ }),
72
+ renderCall: () => new Text("[advisor] Record outcome", 0, 0),
73
+ renderResult: (result) => new Text(textFrom(result.content), 0, 0),
74
+ });
75
+ };
@@ -0,0 +1,88 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ getMarkdownTheme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
6
+ import { getAdvisorSettings } from "../config/state.js";
7
+ import { formatAdvisorUsage } from "../usage.js";
8
+ import { adviceForDisplay, renderAdvisorCallBox } from "./render-common.js";
9
+ import { renderScoutDetails } from "./scout-status.js";
10
+ import type { GateDecision, ScoutToolDetails } from "./types.js";
11
+
12
+ export const registerToolRenderers = (pi: ExtensionAPI): void => {
13
+ pi.registerEntryRenderer?.(
14
+ "advisor-scout-result",
15
+ (entry, { expanded }, theme) => {
16
+ const scout = entry.data as ScoutToolDetails;
17
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
18
+ renderScoutDetails(box, scout, Boolean(expanded), theme);
19
+ return box;
20
+ }
21
+ );
22
+
23
+ pi.registerMessageRenderer?.(
24
+ "advisor-loop-call",
25
+ (message, _options, theme) => {
26
+ const details = message.details as { question?: string } | undefined;
27
+ return renderAdvisorCallBox(details?.question, theme);
28
+ }
29
+ );
30
+
31
+ pi.registerMessageRenderer?.(
32
+ "advisor-loop-result",
33
+ (message, { expanded }, theme) => {
34
+ const details = message.details as
35
+ | {
36
+ advisor?: string;
37
+ decision?: GateDecision;
38
+ text?: string;
39
+ usage?: unknown;
40
+ }
41
+ | undefined;
42
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
43
+ box.addChild(
44
+ new Text(
45
+ theme.fg(
46
+ "warning",
47
+ theme.bold(`◆ ADVISOR GATE: ${details?.decision ?? "failure"}`)
48
+ ),
49
+ 0,
50
+ 0
51
+ )
52
+ );
53
+ if (details?.advisor) {
54
+ box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
55
+ }
56
+ if (getAdvisorSettings().showUsageDetails) {
57
+ const usage = formatAdvisorUsage(details?.usage);
58
+ if (usage) {
59
+ box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
60
+ }
61
+ }
62
+ if (details?.text) {
63
+ box.addChild(
64
+ new Markdown(
65
+ adviceForDisplay(details.text, Boolean(expanded)),
66
+ 0,
67
+ 0,
68
+ getMarkdownTheme()
69
+ )
70
+ );
71
+ } else {
72
+ box.addChild(
73
+ new Text(
74
+ theme.fg(
75
+ "error",
76
+ typeof message.content === "string"
77
+ ? message.content
78
+ : "Advisor gate failed."
79
+ ),
80
+ 0,
81
+ 0
82
+ )
83
+ );
84
+ }
85
+ return box;
86
+ }
87
+ );
88
+ };
@@ -0,0 +1,35 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { appendOutcome } from "../outcomes.js";
3
+ import type { AdvisorSessionState } from "../session-state.js";
4
+ import { consultAdvisor, runAdvisorGate } from "./consultation.js";
5
+ import { registerAskAdvisorTool } from "./register-ask-advisor.js";
6
+ import { registerToolLifecycle } from "./register-lifecycle.js";
7
+ import { registerOutcomeTool } from "./register-outcome.js";
8
+ import { registerToolRenderers } from "./register-renderers.js";
9
+ import { ScoutStatusManager } from "./scout-status.js";
10
+ import { advisorSessionState } from "./session.js";
11
+ import type {
12
+ ToolRegistrationContext,
13
+ ToolRegistrationDependencies,
14
+ } from "./types.js";
15
+
16
+ export const registerAdvisorTool = (
17
+ pi: ExtensionAPI,
18
+ session: AdvisorSessionState = advisorSessionState,
19
+ dependencies: ToolRegistrationDependencies = {}
20
+ ): void => {
21
+ const registration: ToolRegistrationContext = {
22
+ appendOutcome: dependencies.appendOutcome ?? appendOutcome,
23
+ consult: dependencies.consult ?? consultAdvisor,
24
+ pi,
25
+ reservedCalls: new Set<string>(),
26
+ runGate: dependencies.runGate ?? runAdvisorGate,
27
+ scoutStatus: dependencies.statusManager ?? new ScoutStatusManager(),
28
+ session,
29
+ };
30
+
31
+ registerToolRenderers(pi);
32
+ registerToolLifecycle(registration);
33
+ registerAskAdvisorTool(registration);
34
+ registerOutcomeTool(registration);
35
+ };
@@ -0,0 +1,168 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ getMarkdownTheme,
4
+ type Theme,
5
+ type ToolRenderResultOptions,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
8
+ import { getAdvisorSettings } from "../config/state.js";
9
+ import { textFrom } from "../conversation.js";
10
+ import { formatAdvisorUsage } from "../usage.js";
11
+ import {
12
+ adviceForDisplay,
13
+ hasSoundVerdict,
14
+ renderAdvisorResponseHeader,
15
+ renderThinkingMarkdown,
16
+ SPINNER_FRAMES,
17
+ } from "./render-common.js";
18
+ import { renderScoutDetails } from "./scout-status.js";
19
+ import type { AdvisorToolContext, AdvisorToolDetails } from "./types.js";
20
+
21
+ const advisorResultDetails = (result: AgentToolResult<AdvisorToolDetails>) =>
22
+ result.details;
23
+
24
+ const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
25
+ if (context.state.phase !== phase && context.state.timerId) {
26
+ clearInterval(context.state.timerId);
27
+ context.state.timerId = undefined;
28
+ }
29
+ context.state.phase = phase;
30
+ };
31
+
32
+ const renderPartialAdvisorResult = (
33
+ box: Box,
34
+ result: AgentToolResult<AdvisorToolDetails>,
35
+ expanded: boolean,
36
+ theme: Theme,
37
+ context: AdvisorToolContext
38
+ ) => {
39
+ const details = advisorResultDetails(result);
40
+ if (details?.scout) {
41
+ context.state.scout = details.scout;
42
+ }
43
+ const scout = details?.scout ?? context.state.scout;
44
+ const scoutActive =
45
+ scout?.status === "calling" || scout?.status === "streaming";
46
+ syncRenderPhase(context, scoutActive ? "scout" : "advisor");
47
+ if (!context.state.timerId) {
48
+ context.state.timerId = setInterval(() => context.invalidate(), 80);
49
+ }
50
+ if (scout) {
51
+ renderScoutDetails(box, scout, expanded, theme);
52
+ }
53
+ if (scoutActive || scout?.status === "cancelled") {
54
+ return;
55
+ }
56
+ const frame =
57
+ SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
58
+ const lines = [
59
+ `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`,
60
+ ];
61
+ box.addChild(new Text(lines.join("\n"), 0, 0));
62
+ if (details?.thinking?.trim()) {
63
+ const thought =
64
+ details.thinking.length > 200
65
+ ? details.thinking.slice(-200)
66
+ : details.thinking;
67
+ box.addChild(renderThinkingMarkdown(thought, theme));
68
+ }
69
+ if (details?.text) {
70
+ box.addChild(
71
+ new Markdown(
72
+ adviceForDisplay(details.text, expanded),
73
+ 0,
74
+ 0,
75
+ getMarkdownTheme()
76
+ )
77
+ );
78
+ }
79
+ };
80
+
81
+ const renderFinalAdvisorResult = (
82
+ box: Box,
83
+ result: AgentToolResult<AdvisorToolDetails>,
84
+ expanded: boolean,
85
+ theme: Theme,
86
+ context: AdvisorToolContext
87
+ ) => {
88
+ syncRenderPhase(context, "final");
89
+ if (context.state.timerId) {
90
+ clearInterval(context.state.timerId);
91
+ context.state.timerId = undefined;
92
+ }
93
+ const details = advisorResultDetails(result);
94
+ if (details?.scout) {
95
+ context.state.scout = details.scout;
96
+ }
97
+ const scout = details?.scout ?? context.state.scout;
98
+ if (scout) {
99
+ renderScoutDetails(box, scout, expanded, theme);
100
+ }
101
+ if (scout?.status === "cancelled") {
102
+ return;
103
+ }
104
+ const advice = details?.text || textFrom(result.content);
105
+ const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)];
106
+ if (details?.advisor) {
107
+ lines.push(theme.fg("dim", ` ${details.advisor}`));
108
+ }
109
+ if (getAdvisorSettings().showUsageDetails) {
110
+ const usage = formatAdvisorUsage(details?.usage);
111
+ if (usage) {
112
+ lines.push(theme.fg("dim", ` Usage: ${usage}`));
113
+ }
114
+ }
115
+ const attachments = [
116
+ details?.draftBytes
117
+ ? `Draft attached · ${details.draftBytes} B`
118
+ : undefined,
119
+ details?.preferenceBytes
120
+ ? `Project preferences attached · ${details.preferenceBytes} B`
121
+ : undefined,
122
+ details?.trackedBytes
123
+ ? `Tracked files attached · ${details.trackedBytes} B`
124
+ : undefined,
125
+ details?.untrackedBytes
126
+ ? `Untracked files attached · ${details.untrackedBytes} B`
127
+ : undefined,
128
+ ].filter(Boolean);
129
+ if (attachments.length) {
130
+ lines.push(theme.fg("dim", ` ${attachments.join(" · ")}`));
131
+ }
132
+ const thinking = details?.thinking?.trim()
133
+ ? `${details.thinking.slice(0, 300)}${details.thinking.length > 300 ? "…" : ""}`
134
+ : "";
135
+ const displayAdvice = advice || "(Advisor returned no advice.)";
136
+ box.addChild(new Text(lines.join("\n"), 0, 0));
137
+ if (thinking) {
138
+ box.addChild(renderThinkingMarkdown(thinking, theme));
139
+ }
140
+ box.addChild(
141
+ new Markdown(
142
+ adviceForDisplay(displayAdvice, expanded),
143
+ 0,
144
+ 0,
145
+ getMarkdownTheme()
146
+ )
147
+ );
148
+ };
149
+
150
+ export const renderAdvisorResult = (
151
+ result: AgentToolResult<AdvisorToolDetails>,
152
+ { isPartial, expanded }: ToolRenderResultOptions,
153
+ theme: Theme,
154
+ context: AdvisorToolContext
155
+ ) => {
156
+ const box =
157
+ context.lastComponent instanceof Box
158
+ ? context.lastComponent
159
+ : new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
160
+ box.setBgFn((text) => theme.bg("customMessageBg", text));
161
+ box.clear();
162
+ if (isPartial) {
163
+ renderPartialAdvisorResult(box, result, expanded, theme, context);
164
+ } else {
165
+ renderFinalAdvisorResult(box, result, expanded, theme, context);
166
+ }
167
+ return box;
168
+ };
@@ -0,0 +1,120 @@
1
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Box,
4
+ type Component,
5
+ Markdown,
6
+ Text,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { advisorCollapseResponsesRef } from "../config/state.js";
11
+
12
+ export const SPINNER_FRAMES = [
13
+ "⠋",
14
+ "⠙",
15
+ "⠹",
16
+ "⠸",
17
+ "⠼",
18
+ "⠴",
19
+ "⠦",
20
+ "⠧",
21
+ "⠇",
22
+ "⠏",
23
+ ];
24
+
25
+ const THINKING_PREFIX = " 💭 ";
26
+ const THINKING_PREFIX_WIDTH = visibleWidth(THINKING_PREFIX);
27
+ export type ThinkingTheme = Pick<Theme, "fg">;
28
+
29
+ /**
30
+ * Renders visible nested-model thinking with the same Markdown semantics as
31
+ * Pi's assistant thinking blocks while keeping the compact speech-bubble cue.
32
+ * The prefix is added after Markdown parsing so it cannot change block syntax.
33
+ *
34
+ * Note: During streaming, incomplete Markdown (e.g., `**text` without closing **)
35
+ * displays raw markers transiently until delimiters arrive. This mirrors expected
36
+ * behavior when typing Markdown incrementally and is acceptable in the context
37
+ * of brief thinking previews. When thinking is complete, all markers render.
38
+ */
39
+ class ThinkingMarkdown implements Component {
40
+ private readonly markdown: Markdown;
41
+ private readonly prefix: string;
42
+
43
+ constructor(thinking: string, theme: ThinkingTheme) {
44
+ this.markdown = new Markdown(thinking.trim(), 0, 0, getMarkdownTheme(), {
45
+ color: (text) => theme.fg("thinkingText", text),
46
+ italic: true,
47
+ });
48
+ this.prefix = theme.fg("thinkingText", THINKING_PREFIX);
49
+ }
50
+
51
+ render(width: number): string[] {
52
+ const renderWidth = Math.max(1, Math.floor(width));
53
+ const contentWidth = Math.max(1, renderWidth - THINKING_PREFIX_WIDTH);
54
+ const lines = this.markdown.render(contentWidth);
55
+ return lines.map((line, index) =>
56
+ truncateToWidth(
57
+ index === 0 ? `${this.prefix}${line}` : line,
58
+ renderWidth,
59
+ ""
60
+ )
61
+ );
62
+ }
63
+
64
+ invalidate(): void {
65
+ this.markdown.invalidate();
66
+ }
67
+ }
68
+
69
+ export const renderThinkingMarkdown = (
70
+ thinking: string,
71
+ theme: ThinkingTheme
72
+ ): Component => new ThinkingMarkdown(thinking, theme);
73
+
74
+ export const resolveAdvisorRequest = (question?: string) =>
75
+ question?.trim() || undefined;
76
+
77
+ export const renderAdvisorCallBox = (
78
+ question: string | undefined,
79
+ theme: Theme
80
+ ) => {
81
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
82
+ const label = theme.fg("customMessageLabel", theme.bold("[advisor]"));
83
+ const title = theme.fg("customMessageText", "Executor → Advisor");
84
+ box.addChild(
85
+ new Text(
86
+ question
87
+ ? `${label} ${title}\n${theme.fg("dim", ` ${question}`)}`
88
+ : `${label} ${title}`,
89
+ 0,
90
+ 0
91
+ )
92
+ );
93
+ return box;
94
+ };
95
+
96
+ const COLLAPSED_ADVICE_LINES = 12;
97
+ // The system prompt requires this exact first line, so the match is exact too.
98
+ const SOUND_VERDICT = /^Verdict:\s*sound$/;
99
+
100
+ export const hasSoundVerdict = (advice: string) =>
101
+ SOUND_VERDICT.test(
102
+ (advice.split("\n").find((line) => line.trim()) ?? "").trim()
103
+ );
104
+
105
+ /** The single Advisor response header shared by tool and manual renderers. */
106
+ export const renderAdvisorResponseHeader = (sound: boolean, theme: Theme) =>
107
+ sound
108
+ ? theme.fg("accent", theme.bold("◆ ADVISOR · SOUND"))
109
+ : theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"));
110
+
111
+ export const adviceForDisplay = (advice: string, expanded: boolean) => {
112
+ if (!advisorCollapseResponsesRef || expanded) {
113
+ return advice;
114
+ }
115
+ const lines = advice.split("\n");
116
+ if (lines.length <= COLLAPSED_ADVICE_LINES) {
117
+ return advice;
118
+ }
119
+ return `${lines.slice(0, COLLAPSED_ADVICE_LINES).join("\n")}\n\n… (${lines.length - COLLAPSED_ADVICE_LINES} more lines, Ctrl+O to expand)`;
120
+ };
@@ -0,0 +1,206 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { type Box, Text } from "@earendil-works/pi-tui";
7
+ import { executorRef, getAdvisorSettings } from "../config/state.js";
8
+ import type { ScoutLifecycleEvent } from "../scout.js";
9
+ import { formatAdvisorUsage, snapshotAdvisorUsage } from "../usage.js";
10
+ import { renderThinkingMarkdown, SPINNER_FRAMES } from "./render-common.js";
11
+ import type { ScoutToolDetails } from "./types.js";
12
+
13
+ export const scoutDetailsFromEvent = (
14
+ event: ScoutLifecycleEvent,
15
+ previous?: ScoutToolDetails
16
+ ): ScoutToolDetails => {
17
+ if (event.type === "call") {
18
+ return { model: event.model, status: "calling" };
19
+ }
20
+ if (event.type === "chunk") {
21
+ return {
22
+ ...previous,
23
+ model: event.model,
24
+ status: "streaming",
25
+ text: event.text,
26
+ thinking: event.thinking,
27
+ };
28
+ }
29
+ if (event.type === "cancelled") {
30
+ return {
31
+ model: previous ? previous.model : executorRef,
32
+ status: "cancelled",
33
+ };
34
+ }
35
+ const { outcome } = event;
36
+ return outcome.ok
37
+ ? {
38
+ availableCount: outcome.metrics.availableCount,
39
+ latencyMs: outcome.metrics.latencyMs,
40
+ model: outcome.model,
41
+ omittedBeforeScout: outcome.metrics.omittedBeforeScout,
42
+ selectedCount: outcome.metrics.selectedCount,
43
+ selectedLabels: outcome.selectedLabels,
44
+ status: "curated",
45
+ synthesis: outcome.selection.synthesis,
46
+ usage: snapshotAdvisorUsage(outcome.metrics.usage),
47
+ }
48
+ : {
49
+ availableCount: outcome.metrics.availableCount,
50
+ fallbackReason: `${outcome.category}: ${outcome.message}`,
51
+ latencyMs: outcome.metrics.latencyMs,
52
+ model: outcome.model,
53
+ omittedBeforeScout: outcome.metrics.omittedBeforeScout,
54
+ selectedCount: 0,
55
+ status: "fallback",
56
+ usage: snapshotAdvisorUsage(outcome.metrics.usage),
57
+ };
58
+ };
59
+
60
+ export const appendScoutLifecycleEntry = (
61
+ pi: ExtensionAPI,
62
+ event: ScoutLifecycleEvent,
63
+ previous?: ScoutToolDetails
64
+ ) => {
65
+ const scout = scoutDetailsFromEvent(event, previous);
66
+ if (
67
+ event.type === "success" ||
68
+ event.type === "fallback" ||
69
+ event.type === "cancelled"
70
+ ) {
71
+ pi.appendEntry?.("advisor-scout-result", scout);
72
+ }
73
+ return scout;
74
+ };
75
+
76
+ export class ScoutStatusManager {
77
+ readonly #active = new Set<symbol>();
78
+ readonly #known = new Set<symbol>();
79
+ readonly #retired = new Set<symbol>();
80
+ private readonly showStatus: boolean;
81
+
82
+ constructor(showStatus = true) {
83
+ this.showStatus = showStatus;
84
+ }
85
+
86
+ register(token: symbol) {
87
+ if (!this.#retired.has(token)) {
88
+ this.#known.add(token);
89
+ }
90
+ }
91
+
92
+ update(ctx: ExtensionContext, token: symbol, event: ScoutLifecycleEvent) {
93
+ if (this.#retired.has(token) || !ctx.hasUI) {
94
+ return;
95
+ }
96
+ this.#known.add(token);
97
+ if (event.type === "call" || event.type === "chunk") {
98
+ this.#active.add(token);
99
+ if (this.showStatus) {
100
+ ctx.ui.setStatus("advisor-scout", "Scout curating…");
101
+ }
102
+ return;
103
+ }
104
+ this.release(ctx, token);
105
+ }
106
+
107
+ release(ctx: ExtensionContext, token: symbol) {
108
+ this.#active.delete(token);
109
+ this.#known.delete(token);
110
+ this.#retired.add(token);
111
+ if (!(ctx.hasUI && this.showStatus)) {
112
+ return;
113
+ }
114
+ ctx.ui.setStatus(
115
+ "advisor-scout",
116
+ this.#active.size > 0 ? "Scout curating…" : undefined
117
+ );
118
+ }
119
+
120
+ clear(ctx: ExtensionContext) {
121
+ for (const token of this.#known) {
122
+ this.#retired.add(token);
123
+ }
124
+ this.#known.clear();
125
+ this.#active.clear();
126
+ if (ctx.hasUI && this.showStatus) {
127
+ ctx.ui.setStatus("advisor-scout", undefined);
128
+ }
129
+ }
130
+ }
131
+
132
+ const scoutTitle = (scout: ScoutToolDetails, frame: string) => {
133
+ if (scout.status === "calling" || scout.status === "streaming") {
134
+ return `◆ SCOUT ${frame} · CURATING…`;
135
+ }
136
+ if (scout.status === "curated") {
137
+ return "◆ SCOUT · CURATED";
138
+ }
139
+ if (scout.status === "cancelled") {
140
+ return "◆ SCOUT · CANCELLED";
141
+ }
142
+ return "◆ SCOUT · FALLBACK";
143
+ };
144
+
145
+ export const renderScoutDetails = (
146
+ box: Box,
147
+ scout: ScoutToolDetails,
148
+ expanded: boolean,
149
+ theme: Theme
150
+ ) => {
151
+ const active = scout.status === "calling" || scout.status === "streaming";
152
+ const frame =
153
+ SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
154
+ const title = scoutTitle(scout, frame);
155
+ const lines = [
156
+ theme.fg(
157
+ scout.status === "fallback" || scout.status === "cancelled"
158
+ ? "warning"
159
+ : "accent",
160
+ theme.bold(title)
161
+ ),
162
+ theme.fg(
163
+ "dim",
164
+ ` ${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`}`
165
+ ),
166
+ ];
167
+ if (getAdvisorSettings().showUsageDetails) {
168
+ const usage = formatAdvisorUsage(scout.usage);
169
+ if (usage) {
170
+ lines.push(theme.fg("dim", ` Usage: ${usage}`));
171
+ }
172
+ }
173
+ if (scout.fallbackReason) {
174
+ lines.push(theme.fg("warning", ` ${scout.fallbackReason}`));
175
+ }
176
+ const thinking = scout.thinking && active ? scout.thinking.slice(-200) : "";
177
+ box.addChild(new Text(lines.join("\n"), 0, 0));
178
+ if (thinking.trim()) {
179
+ box.addChild(renderThinkingMarkdown(thinking, theme));
180
+ }
181
+ const expandedLines: string[] = [];
182
+ if (expanded && scout.selectedLabels?.length) {
183
+ expandedLines.push(
184
+ theme.fg("dim", ` Selected: ${scout.selectedLabels.join("; ")}`)
185
+ );
186
+ }
187
+ if (expanded && scout.synthesis) {
188
+ expandedLines.push(
189
+ theme.fg(
190
+ "dim",
191
+ ` Scout synthesis (untrusted inference): ${scout.synthesis}`
192
+ )
193
+ );
194
+ }
195
+ if (expanded && scout.omittedBeforeScout) {
196
+ expandedLines.push(
197
+ theme.fg(
198
+ "dim",
199
+ ` ${scout.omittedBeforeScout} group(s) omitted before Scout`
200
+ )
201
+ );
202
+ }
203
+ if (expandedLines.length > 0) {
204
+ box.addChild(new Text(expandedLines.join("\n"), 0, 0));
205
+ }
206
+ };
@@ -0,0 +1,3 @@
1
+ import { AdvisorSessionState } from "../session-state.js";
2
+
3
+ export const advisorSessionState = new AdvisorSessionState();