pi-advisor-flow 0.8.3 → 0.9.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -5
  3. package/dist/index.js +685 -96
  4. package/package.json +14 -14
  5. package/src/attachments.ts +21 -5
  6. package/src/commands/activation.ts +6 -1
  7. package/src/commands/lifecycle.ts +6 -0
  8. package/src/commands/manual-command.ts +44 -12
  9. package/src/commands/manual-consultation.ts +7 -3
  10. package/src/commands/model-commands.ts +1 -0
  11. package/src/commands/runtime.ts +36 -10
  12. package/src/commands/settings-commands.ts +2 -0
  13. package/src/commands/settings-persistence.ts +2 -0
  14. package/src/commands/types.ts +5 -0
  15. package/src/config/defaults.ts +7 -0
  16. package/src/config/schema.ts +7 -0
  17. package/src/config/state.ts +5 -0
  18. package/src/config/types.ts +1 -0
  19. package/src/conversation.ts +29 -7
  20. package/src/image-attachments.ts +118 -0
  21. package/src/image-validation.ts +288 -0
  22. package/src/images.ts +151 -0
  23. package/src/scout-context.ts +1 -0
  24. package/src/scout-curation.ts +11 -4
  25. package/src/scout-groups.ts +13 -3
  26. package/src/scout-types.ts +2 -0
  27. package/src/tools/consult-context.ts +78 -2
  28. package/src/tools/consultation.ts +36 -4
  29. package/src/tools/jev-turn-gate.ts +4 -1
  30. package/src/tools/model-access.ts +21 -2
  31. package/src/tools/prompts.ts +14 -2
  32. package/src/tools/register-ask-advisor.ts +28 -1
  33. package/src/tools/register-lifecycle.ts +8 -2
  34. package/src/tools/render-advisor-result.ts +33 -9
  35. package/src/tools/scout-status.ts +11 -8
  36. package/src/tools/types.ts +9 -0
  37. package/src/ui/settings-items.ts +17 -1
  38. package/src/ui/settings-mutations.ts +1 -0
  39. package/src/ui/types.ts +1 -0
  40. package/src/ui-guard.ts +31 -0
@@ -1,3 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type { ImageContent } from "@earendil-works/pi-ai/compat";
1
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
5
 
3
6
  import { readTrackedFiles, readUntrackedFiles } from "../attachments.ts";
@@ -7,12 +10,19 @@ import {
7
10
  advisorGitContextRef,
8
11
  advisorRedactSecretsRef,
9
12
  advisorScoutEnabledRef,
13
+ advisorToolPoliciesRef,
10
14
  advisorTrackedFileContentRef,
11
15
  advisorUntrackedContentRef,
12
16
  contextMaxCharsRef,
13
17
  } from "../config/state.ts";
14
18
  import { clampGitContextLevel, collectGitContext } from "../git.ts";
15
19
  import type { GitContextLevel } from "../git.ts";
20
+ import { readImageFiles } from "../image-attachments.ts";
21
+ import {
22
+ ADVISOR_IMAGES_MAX_COUNT,
23
+ ADVISOR_IMAGES_TOTAL_MAX_BYTES,
24
+ selectedConversationImages,
25
+ } from "../images.ts";
16
26
  import { readProjectPreferences } from "../preferences.ts";
17
27
  import { redactAndCapText, redactSecrets } from "../redaction.ts";
18
28
  import { curateAdvisorConversation } from "../scout-curation.ts";
@@ -38,6 +48,7 @@ export interface ConsultationContextOptions {
38
48
  includeUntracked?: string[];
39
49
  onScout?: (event: ScoutLifecycleEvent) => void;
40
50
  signal?: AbortSignal;
51
+ supportsImages?: boolean;
41
52
  }
42
53
 
43
54
  export interface ConsultationContext {
@@ -47,6 +58,10 @@ export interface ConsultationContext {
47
58
  conversation: string;
48
59
  /** Redacted draft text, if a draft was supplied. */
49
60
  draftText?: string;
61
+ imageOmissions: number;
62
+ imagePartsSeen: number;
63
+ images: { image: ImageContent; label: string }[];
64
+ supportsImages: boolean;
50
65
  /** Redacted project preferences, if present. */
51
66
  preferences?: { bytes: number; text: string };
52
67
  /** Non-cancelling Scout outcome, when Scout ran. */
@@ -92,9 +107,12 @@ export const assembleConsultationContext = async (
92
107
  0,
93
108
  contextMaxCharsRef - changeText.length
94
109
  );
110
+ const imageNonce = randomUUID();
95
111
  const legacyConversation = advisorRequestConversation(
96
112
  ctx,
97
- conversationBudget
113
+ conversationBudget,
114
+ true,
115
+ imageNonce
98
116
  );
99
117
  const curated = await curateAdvisorConversation(
100
118
  ctx,
@@ -104,7 +122,8 @@ export const assembleConsultationContext = async (
104
122
  advisorScoutEnabledRef,
105
123
  runAdvisorScout,
106
124
  options.currentInvocationId,
107
- conversationBudget
125
+ conversationBudget,
126
+ imageNonce
108
127
  );
109
128
  const preferences = await readProjectPreferences(
110
129
  ctx,
@@ -135,12 +154,69 @@ export const assembleConsultationContext = async (
135
154
  untracked.reduce((sum, item) => sum + item.bytes, 0)
136
155
  )
137
156
  );
157
+ const supportsImages = options.supportsImages ?? false;
158
+ const untrackedImages = await readImageFiles(
159
+ ctx.cwd,
160
+ options.includeUntracked ?? [],
161
+ supportsImages && advisorUntrackedContentRef,
162
+ "untracked",
163
+ ADVISOR_IMAGES_TOTAL_MAX_BYTES,
164
+ ADVISOR_IMAGES_MAX_COUNT
165
+ );
166
+ const trackedImages = await readImageFiles(
167
+ ctx.cwd,
168
+ options.includeTracked ?? [],
169
+ supportsImages && advisorTrackedFileContentRef,
170
+ "tracked",
171
+ ADVISOR_IMAGES_TOTAL_MAX_BYTES -
172
+ untrackedImages.images.reduce((sum, item) => sum + item.bytes, 0),
173
+ ADVISOR_IMAGES_MAX_COUNT - untrackedImages.images.length
174
+ );
175
+ const images = [...untrackedImages.images, ...trackedImages.images].map(
176
+ (item) => ({
177
+ image: item.image,
178
+ label: `File ${JSON.stringify(item.path)}`,
179
+ })
180
+ );
181
+ let remainingBytes =
182
+ ADVISOR_IMAGES_TOTAL_MAX_BYTES -
183
+ [...untrackedImages.images, ...trackedImages.images].reduce(
184
+ (sum, item) => sum + item.bytes,
185
+ 0
186
+ );
187
+ let imageOmissions = untrackedImages.omitted + trackedImages.omitted;
188
+ const census = selectedConversationImages(
189
+ ctx,
190
+ curated.conversation,
191
+ advisorToolPoliciesRef,
192
+ curated.scout?.ok === true
193
+ ? new Set<string>(curated.selectedEntryIds)
194
+ : undefined,
195
+ imageNonce
196
+ );
197
+ for (const item of census.selected) {
198
+ const bytes = Buffer.from(item.image.data, "base64").length;
199
+ if (
200
+ !supportsImages ||
201
+ images.length >= ADVISOR_IMAGES_MAX_COUNT ||
202
+ bytes > remainingBytes
203
+ ) {
204
+ imageOmissions += 1;
205
+ continue;
206
+ }
207
+ remainingBytes -= bytes;
208
+ images.push({ image: item.image, label: item.marker });
209
+ }
138
210
  return {
139
211
  changeText,
140
212
  conversation: curated.conversation,
141
213
  draftText,
214
+ imageOmissions,
215
+ imagePartsSeen: census.imagePartsSeen,
216
+ images,
142
217
  preferences,
143
218
  scout: curated.scout,
219
+ supportsImages,
144
220
  tracked,
145
221
  untracked,
146
222
  };
@@ -9,6 +9,7 @@ import {
9
9
  advisorRef,
10
10
  } from "../config/state.ts";
11
11
  import { loadConfig } from "../config/storage.ts";
12
+ import { escapeRepositoryText } from "../git.ts";
12
13
  import type { GitContextLevel } from "../git.ts";
13
14
  import { collectTextStream, resolveConfiguredModel } from "../model-stream.ts";
14
15
  import { redactSecrets } from "../redaction.ts";
@@ -16,7 +17,11 @@ import type { ScoutLifecycleEvent } from "../scout.ts";
16
17
  import type { ConsultationTrigger, GateTrigger } from "../session-state.ts";
17
18
  import { assembleConsultationContext } from "./consult-context.ts";
18
19
  import { parseAutomaticDecision } from "./gate-protocol.ts";
19
- import { advisorModelAccessReason } from "./model-access.ts";
20
+ import {
21
+ advisorModelAccessReason,
22
+ sameModelAdvisorDisabled,
23
+ sameModelAdvisorNotice,
24
+ } from "./model-access.ts";
20
25
  import {
21
26
  ADVISOR_DECISION_SYSTEM,
22
27
  ADVISOR_SYSTEM,
@@ -57,12 +62,18 @@ const collectAdvisorResponse = async (
57
62
  ) => {
58
63
  const { ctx, question, signal, systemPrompt } = options;
59
64
  loadConfig(ctx);
65
+ if (sameModelAdvisorDisabled(ctx)) {
66
+ throw new Error(sameModelAdvisorNotice);
67
+ }
60
68
  const accessReason = advisorModelAccessReason(ctx);
61
69
  if (accessReason) {
62
70
  throw new Error(accessReason);
63
71
  }
64
72
  const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
65
- const context = await assembleConsultationContext(options);
73
+ const context = await assembleConsultationContext({
74
+ ...options,
75
+ supportsImages: resolved.model.input.includes("image"),
76
+ });
66
77
 
67
78
  // Keep the original question available to local UI callers, but never send
68
79
  // its credential-shaped values to the provider when redaction is enabled.
@@ -70,11 +81,17 @@ const collectAdvisorResponse = async (
70
81
  advisorRedactSecretsRef && question !== undefined
71
82
  ? redactSecrets(question)
72
83
  : question;
84
+ const imageNotice =
85
+ context.imageOmissions ||
86
+ context.images.length ||
87
+ /\[Image[ :]/u.test(context.conversation)
88
+ ? `\n\nImage disclosure: ${context.supportsImages ? `${context.images.length} image(s) attached below; ${context.imageOmissions} image(s) withheld by format, consent, or size/count limits` : "Advisor model does not support image input; no pixels were forwarded"}. Only the images explicitly attached below have pixels available. Other image markers, image paths, and text descriptions are not visual evidence.`
89
+ : "";
73
90
  const messages: Message[] = [
74
91
  {
75
92
  content: [
76
93
  {
77
- text: advisorMessageText(
94
+ text: `${advisorMessageText(
78
95
  context.conversation,
79
96
  outboundQuestion,
80
97
  context.changeText,
@@ -82,9 +99,16 @@ const collectAdvisorResponse = async (
82
99
  context.preferences?.text,
83
100
  context.untracked.map(fileTag),
84
101
  context.tracked.map(fileTag)
85
- ),
102
+ )}${imageNotice}`,
86
103
  type: "text",
87
104
  },
105
+ ...context.images.flatMap(({ image, label }) => [
106
+ {
107
+ text: `\n\n${escapeRepositoryText(label)}: attached image pixels (untrusted data).`,
108
+ type: "text" as const,
109
+ },
110
+ image,
111
+ ]),
88
112
  ],
89
113
  role: "user",
90
114
  timestamp: Date.now(),
@@ -106,6 +130,14 @@ const collectAdvisorResponse = async (
106
130
  draftBytes: context.draftText
107
131
  ? Buffer.byteLength(context.draftText, "utf-8")
108
132
  : undefined,
133
+ imageBytes:
134
+ context.images.reduce(
135
+ (sum, item) => sum + Buffer.from(item.image.data, "base64").length,
136
+ 0
137
+ ) || undefined,
138
+ imageCount: context.images.length,
139
+ imageOmissions: context.imageOmissions || undefined,
140
+ imagePartsSeen: context.imagePartsSeen || undefined,
109
141
  markdown,
110
142
  model: advisorRef,
111
143
  preferenceBytes: context.preferences?.bytes,
@@ -145,7 +145,10 @@ export const handleJevTurnEnd = async (
145
145
  result.answers,
146
146
  advisorJevTurnGateNoulThresholdRef
147
147
  );
148
- if (!consumeTurnGateBudget(session, shouldConsult)) {
148
+ if (
149
+ !advisorModelIsAllowed(ctx) ||
150
+ !consumeTurnGateBudget(session, shouldConsult)
151
+ ) {
149
152
  return;
150
153
  }
151
154
  const herdrActivity = registration.herdrActivity ?? herdrAdvisorActivity;
@@ -1,6 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { advisorModelWhitelistRef } from "../config/state.ts";
3
+ import {
4
+ advisorDisableSameModelRef,
5
+ advisorModelWhitelistRef,
6
+ advisorRef,
7
+ splitRef,
8
+ } from "../config/state.ts";
4
9
 
5
10
  type AdvisorModelAccess =
6
11
  | { allowed: true; modelRef?: string }
@@ -34,9 +39,23 @@ export const advisorModelAccess = (
34
39
  return denial;
35
40
  };
36
41
 
42
+ export const sameModelAdvisorDisabled = (
43
+ ctx: Pick<ExtensionContext, "model">,
44
+ model: { id: string; provider: string } | undefined = ctx.model
45
+ ): boolean => {
46
+ if (!(advisorDisableSameModelRef && advisorRef && model)) {
47
+ return false;
48
+ }
49
+ const [provider, id] = splitRef(advisorRef);
50
+ return provider === model.provider && id === model.id;
51
+ };
52
+
53
+ export const sameModelAdvisorNotice =
54
+ "Advisor disabled: executor and advisor are the same model.";
55
+
37
56
  export const advisorModelIsAllowed = (
38
57
  ctx: Pick<ExtensionContext, "model">
39
- ): boolean => advisorModelAccess(ctx).allowed;
58
+ ): boolean => advisorModelAccess(ctx).allowed && !sameModelAdvisorDisabled(ctx);
40
59
 
41
60
  export const advisorModelAccessReason = (
42
61
  ctx: Pick<ExtensionContext, "model">
@@ -97,8 +97,20 @@ export const advisorRepositoryContext = (
97
97
  */
98
98
  export const advisorRequestConversation = (
99
99
  ctx: ExtensionContext,
100
- maxChars = contextMaxCharsRef
101
- ) => recentConversation(ctx, maxChars);
100
+ maxChars = contextMaxCharsRef,
101
+ describeImages = false,
102
+ imageNonce = ""
103
+ ) =>
104
+ recentConversation(
105
+ ctx,
106
+ maxChars,
107
+ undefined,
108
+ undefined,
109
+ undefined,
110
+ undefined,
111
+ describeImages,
112
+ imageNonce
113
+ );
102
114
 
103
115
  export const advisorInvocationGuidelines = () => {
104
116
  if (isSimpleMode()) {
@@ -10,6 +10,7 @@ import {
10
10
  getAdvisorSettings,
11
11
  isSimpleMode,
12
12
  } from "../config/state.ts";
13
+ import { loadConfig } from "../config/storage.ts";
13
14
  import { notifyHerdrAdvisorFailure } from "../herdr.ts";
14
15
  import {
15
16
  ADVISOR_STREAM_UPDATE_INTERVAL_MS,
@@ -22,7 +23,11 @@ import {
22
23
  } from "../usage.ts";
23
24
  import { notifyLocalFailure, updateAdvisorUsageStatus } from "./gate-policy.ts";
24
25
  import { normalizeScreeningQuestion, screeningSkipText } from "./jev-filter.ts";
25
- import { advisorModelAccessReason } from "./model-access.ts";
26
+ import {
27
+ advisorModelAccessReason,
28
+ sameModelAdvisorDisabled,
29
+ sameModelAdvisorNotice,
30
+ } from "./model-access.ts";
26
31
  import { renderAdvisorResult } from "./render-advisor-result.ts";
27
32
  import {
28
33
  renderAdvisorCallBox,
@@ -73,7 +78,22 @@ export const registerAskAdvisorTool = ({
73
78
  description:
74
79
  "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review. If the Advisor explicitly names a missing file, you may make a sequential follow-up call with includeTrackedFiles when enabled and relevant.",
75
80
  async execute(_id, params, signal, onUpdate, ctx) {
81
+ const skipSameModel = () => {
82
+ session.releaseCall(_id);
83
+ reservedCalls.delete(_id);
84
+ return {
85
+ content: [{ text: sameModelAdvisorNotice, type: "text" as const }],
86
+ details: {
87
+ skipReason: sameModelAdvisorNotice,
88
+ text: sameModelAdvisorNotice,
89
+ },
90
+ };
91
+ };
76
92
  try {
93
+ loadConfig(ctx);
94
+ if (sameModelAdvisorDisabled(ctx)) {
95
+ return skipSameModel();
96
+ }
77
97
  assertAdvisorModelAccess(ctx);
78
98
  } catch (error) {
79
99
  session.releaseCall(_id);
@@ -128,6 +148,9 @@ export const registerAskAdvisorTool = ({
128
148
  },
129
149
  };
130
150
  }
151
+ if (sameModelAdvisorDisabled(ctx)) {
152
+ return skipSameModel();
153
+ }
131
154
  if (
132
155
  !simpleMode &&
133
156
  !session.canConsult(getAdvisorMaxCallsPerSession(), _id)
@@ -217,6 +240,10 @@ export const registerAskAdvisorTool = ({
217
240
  adviceId: result.adviceId,
218
241
  advisor: result.model,
219
242
  draftBytes: result.draftBytes,
243
+ imageBytes: result.imageBytes,
244
+ imageCount: result.imageCount,
245
+ imageOmissions: result.imageOmissions,
246
+ imagePartsSeen: result.imagePartsSeen,
220
247
  preferenceBytes: result.preferenceBytes,
221
248
  question: resolveAdvisorRequest(params.question),
222
249
  scout: scoutDetails,
@@ -6,7 +6,10 @@ import {
6
6
  import { loadConfig } from "../config/storage.ts";
7
7
  import { reserveAdvisorCall } from "./gate-policy.ts";
8
8
  import { handleAutomaticGate } from "./loop-gate.ts";
9
- import { advisorModelAccess } from "./model-access.ts";
9
+ import {
10
+ advisorModelAccess,
11
+ sameModelAdvisorDisabled,
12
+ } from "./model-access.ts";
10
13
  import { advisorInvocationGuidelines } from "./prompts.ts";
11
14
  import type { ToolRegistrationContext } from "./types.ts";
12
15
 
@@ -73,7 +76,7 @@ export const registerToolLifecycle = ({
73
76
  return;
74
77
  }
75
78
  loadConfig(ctx);
76
- if (!advisorModelAccess(ctx).allowed) {
79
+ if (!advisorModelAccess(ctx).allowed || sameModelAdvisorDisabled(ctx)) {
77
80
  return;
78
81
  }
79
82
  const guidelines = advisorInvocationGuidelines();
@@ -107,6 +110,9 @@ export const registerToolLifecycle = ({
107
110
  // tool errors; every other tool must proceed without Advisor gating.
108
111
  return;
109
112
  }
113
+ if (sameModelAdvisorDisabled(ctx)) {
114
+ return;
115
+ }
110
116
  const accessBlock = modelAccessBlock(event.toolName, ctx);
111
117
  if (accessBlock) {
112
118
  return accessBlock;
@@ -30,23 +30,39 @@ const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
30
30
  context.state.phase = phase;
31
31
  };
32
32
 
33
+ const formatByteSize = (bytes: number): string => {
34
+ if (bytes >= 1024 ** 3) {
35
+ return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
36
+ }
37
+ if (bytes >= 1024 ** 2) {
38
+ return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
39
+ }
40
+ if (bytes >= 1024) {
41
+ return `${Math.round(bytes / 1024)} KiB`;
42
+ }
43
+ return `${bytes} B`;
44
+ };
45
+
33
46
  const attachmentLabels = (details: AdvisorToolDetails | undefined) =>
34
47
  [
35
48
  details?.draftBytes
36
- ? `Draft attached · ${details.draftBytes} B`
49
+ ? `Draft attached · ${formatByteSize(details.draftBytes)}`
37
50
  : undefined,
38
51
  details?.preferenceBytes
39
- ? `Project preferences attached · ${details.preferenceBytes} B`
52
+ ? `Project preferences attached · ${formatByteSize(details.preferenceBytes)}`
40
53
  : undefined,
41
54
  details?.trackedBytes
42
- ? `Tracked files attached · ${details.trackedBytes} B`
55
+ ? `Tracked files attached · ${formatByteSize(details.trackedBytes)}`
43
56
  : undefined,
44
57
  details?.untrackedBytes
45
- ? `Untracked files attached · ${details.untrackedBytes} B`
58
+ ? `Untracked files attached · ${formatByteSize(details.untrackedBytes)}`
59
+ : undefined,
60
+ details?.imageCount
61
+ ? `${details.imageCount} image${details.imageCount === 1 ? "" : "s"} attached${details.imageBytes ? ` · ${formatByteSize(details.imageBytes)}` : ""}${details.imageOmissions ? ` · ${details.imageOmissions} withheld` : ""}`
46
62
  : undefined,
47
63
  ].filter((label): label is string => label !== undefined);
48
64
 
49
- const renderJevSkipBox = (
65
+ const renderSkipBox = (
50
66
  box: Box,
51
67
  result: AgentToolResult<AdvisorToolDetails>,
52
68
  expanded: boolean,
@@ -55,7 +71,7 @@ const renderJevSkipBox = (
55
71
  const details = advisorResultDetails(result);
56
72
  const lines = [
57
73
  theme.fg("dim", theme.bold("◆ ADVISOR · SKIPPED")),
58
- theme.fg("dim", ` ${details?.jev?.reason ?? ""}`),
74
+ theme.fg("dim", ` ${details?.skipReason ?? details?.jev?.reason ?? ""}`),
59
75
  ];
60
76
  box.addChild(new Text(lines.join("\n"), 0, 0));
61
77
  box.addChild(
@@ -84,7 +100,15 @@ const renderPartialAdvisorResult = (
84
100
  scout?.status === "calling" || scout?.status === "streaming";
85
101
  syncRenderPhase(context, scoutActive ? "scout" : "advisor");
86
102
  if (!context.state.timerId) {
87
- context.state.timerId = setInterval(() => context.invalidate(), 80);
103
+ context.state.timerId = setInterval(() => {
104
+ try {
105
+ context.invalidate();
106
+ } catch {
107
+ // Renderer context is gone; stop repainting and drop the timer.
108
+ clearInterval(context.state.timerId);
109
+ context.state.timerId = undefined;
110
+ }
111
+ }, 80);
88
112
  }
89
113
  if (scout) {
90
114
  renderScoutDetails(box, scout, expanded, theme);
@@ -157,8 +181,8 @@ const renderFinalAdvisorResult = (
157
181
  context.state.timerId = undefined;
158
182
  }
159
183
  const details = advisorResultDetails(result);
160
- if (details?.jev?.skipped) {
161
- renderJevSkipBox(box, result, expanded, theme);
184
+ if (details?.jev?.skipped || details?.skipReason) {
185
+ renderSkipBox(box, result, expanded, theme);
162
186
  return;
163
187
  }
164
188
  if (details?.scout) {
@@ -8,6 +8,7 @@ import type { Box } from "@earendil-works/pi-tui";
8
8
 
9
9
  import { executorRef, getAdvisorSettings } from "../config/state.ts";
10
10
  import type { ScoutLifecycleEvent } from "../scout.ts";
11
+ import { uiAction, uiAvailable } from "../ui-guard.ts";
11
12
  import { formatAdvisorUsage, snapshotAdvisorUsage } from "../usage.ts";
12
13
  import { renderThinkingMarkdown, SPINNER_FRAMES } from "./render-common.ts";
13
14
  import type { ScoutToolDetails } from "./types.ts";
@@ -91,14 +92,14 @@ export class ScoutStatusManager {
91
92
  }
92
93
 
93
94
  update(ctx: ExtensionContext, token: symbol, event: ScoutLifecycleEvent) {
94
- if (this.#retired.has(token) || !ctx.hasUI) {
95
+ if (this.#retired.has(token) || !uiAvailable(ctx)) {
95
96
  return;
96
97
  }
97
98
  this.#known.add(token);
98
99
  if (event.type === "call" || event.type === "chunk") {
99
100
  this.#active.add(token);
100
101
  if (this.showStatus) {
101
- ctx.ui.setStatus("advisor-scout", "Scout curating…");
102
+ uiAction(ctx, (ui) => ui.setStatus("advisor-scout", "Scout curating…"));
102
103
  }
103
104
  return;
104
105
  }
@@ -109,12 +110,14 @@ export class ScoutStatusManager {
109
110
  this.#active.delete(token);
110
111
  this.#known.delete(token);
111
112
  this.#retired.add(token);
112
- if (!(ctx.hasUI && this.showStatus)) {
113
+ if (!(uiAvailable(ctx) && this.showStatus)) {
113
114
  return;
114
115
  }
115
- ctx.ui.setStatus(
116
- "advisor-scout",
117
- this.#active.size > 0 ? "Scout curating…" : undefined
116
+ uiAction(ctx, (ui) =>
117
+ ui.setStatus(
118
+ "advisor-scout",
119
+ this.#active.size > 0 ? "Scout curating…" : undefined
120
+ )
118
121
  );
119
122
  }
120
123
 
@@ -124,8 +127,8 @@ export class ScoutStatusManager {
124
127
  }
125
128
  this.#known.clear();
126
129
  this.#active.clear();
127
- if (ctx.hasUI && this.showStatus) {
128
- ctx.ui.setStatus("advisor-scout", undefined);
130
+ if (this.showStatus) {
131
+ uiAction(ctx, (ui) => ui.setStatus("advisor-scout", undefined));
129
132
  }
130
133
  }
131
134
  }
@@ -47,6 +47,10 @@ export interface AdvisorGateFailure {
47
47
  export interface AdvisorConsultationResult {
48
48
  adviceId: string;
49
49
  draftBytes?: number;
50
+ imageBytes?: number;
51
+ imageCount?: number;
52
+ imageOmissions?: number;
53
+ imagePartsSeen?: number;
50
54
  markdown: string;
51
55
  model: string;
52
56
  preferenceBytes?: number;
@@ -83,6 +87,10 @@ export interface AdvisorToolDetails {
83
87
  adviceId?: string;
84
88
  advisor?: string;
85
89
  draftBytes?: number;
90
+ imageBytes?: number;
91
+ imageCount?: number;
92
+ imageOmissions?: number;
93
+ imagePartsSeen?: number;
86
94
  jev?: {
87
95
  kind: JevSkipKind;
88
96
  reason: string;
@@ -91,6 +99,7 @@ export interface AdvisorToolDetails {
91
99
  preferenceBytes?: number;
92
100
  question?: string;
93
101
  scout?: ScoutToolDetails;
102
+ skipReason?: string;
94
103
  text?: string;
95
104
  thinking?: string;
96
105
  trackedBytes?: number;
@@ -284,7 +284,16 @@ export const createSettingsItems = ({
284
284
  },
285
285
  ];
286
286
  if (settings.simpleMode) {
287
- items.push(modelWhitelist);
287
+ items.push(
288
+ modelWhitelist,
289
+ toggle(
290
+ "disableSameModel",
291
+ "Disable same-model Advisor",
292
+ "Skip advice when the active Executor and Advisor use the same provider/model; turn off to allow higher-effort same-model reviews.",
293
+ settings.disableSameModel,
294
+ true
295
+ )
296
+ );
288
297
  return items;
289
298
  }
290
299
 
@@ -297,6 +306,13 @@ export const createSettingsItems = ({
297
306
  values: withCurrentValue(currentEffort(settings.effort), effortLevels),
298
307
  },
299
308
  modelWhitelist,
309
+ toggle(
310
+ "disableSameModel",
311
+ "Disable same-model Advisor",
312
+ "Skip advice when the active Executor and Advisor use the same provider/model; turn off to allow higher-effort same-model reviews.",
313
+ settings.disableSameModel,
314
+ true
315
+ ),
300
316
  toggle(
301
317
  "scoutEnabled",
302
318
  "Experimental Advisor Scout",
@@ -6,6 +6,7 @@ const BOOLEAN_SETTING_FIELDS = [
6
6
  "blockOnBlocked",
7
7
  "collapseResponses",
8
8
  "completionGate",
9
+ "disableSameModel",
9
10
  "failureGate",
10
11
  "herdrIntegration",
11
12
  "outcomeLogging",
package/src/ui/types.ts CHANGED
@@ -74,6 +74,7 @@ export interface AdvisorSettings {
74
74
  completionGate: boolean;
75
75
  contextMaxChars: number;
76
76
  customRule?: string;
77
+ disableSameModel?: boolean;
77
78
  effort?: string;
78
79
  failureGate: boolean;
79
80
  failureMode?: GateFailureMode;
@@ -0,0 +1,31 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // Pi 0.99 asserts ctx liveness on UI access; post-session reads must no-op.
4
+ const isStaleContext = (error: unknown): error is Error =>
5
+ error instanceof Error && /stale/iu.test(error.message);
6
+
7
+ export const uiAvailable = (ctx: ExtensionContext): boolean => {
8
+ try {
9
+ return ctx.hasUI;
10
+ } catch (error) {
11
+ if (isStaleContext(error)) {
12
+ return false;
13
+ }
14
+ throw error;
15
+ }
16
+ };
17
+
18
+ export const uiAction = (
19
+ ctx: ExtensionContext,
20
+ action: (ui: ExtensionContext["ui"]) => void
21
+ ): void => {
22
+ try {
23
+ if (ctx.hasUI) {
24
+ action(ctx.ui);
25
+ }
26
+ } catch (error) {
27
+ if (!isStaleContext(error)) {
28
+ throw error;
29
+ }
30
+ }
31
+ };