pi-advisor-flow 0.7.0 → 0.8.0

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 (92) hide show
  1. package/CHANGELOG.md +19 -18
  2. package/README.md +9 -8
  3. package/dist/index.js +1390 -1068
  4. package/extensions/index.ts +2 -1
  5. package/package.json +48 -35
  6. package/src/attachments.ts +11 -10
  7. package/src/commands/activation-preparation.ts +1 -0
  8. package/src/commands/activation.ts +2 -0
  9. package/src/commands/lifecycle.ts +1 -0
  10. package/src/commands/manual-command.ts +7 -1
  11. package/src/commands/manual-consultation.ts +102 -92
  12. package/src/commands/manual-progress.ts +7 -5
  13. package/src/commands/model-commands.ts +1 -1
  14. package/src/commands/model-options.ts +14 -3
  15. package/src/commands/model-picker.ts +1 -0
  16. package/src/commands/registration.ts +1 -0
  17. package/src/commands/renderers.ts +67 -55
  18. package/src/commands/runtime.ts +16 -14
  19. package/src/commands/settings-commands.ts +10 -4
  20. package/src/commands/settings-persistence.ts +16 -2
  21. package/src/commands/types.ts +1 -0
  22. package/src/commands.ts +0 -1
  23. package/src/config/args.ts +1 -1
  24. package/src/config/defaults.ts +8 -1
  25. package/src/config/schema.ts +42 -17
  26. package/src/config/state.ts +12 -3
  27. package/src/config/storage.ts +34 -23
  28. package/src/config/types.ts +16 -3
  29. package/src/config/validation.ts +33 -21
  30. package/src/config.ts +3 -0
  31. package/src/content-utils.ts +24 -4
  32. package/src/conversation.ts +16 -15
  33. package/src/git.ts +10 -3
  34. package/src/herdr-block.ts +83 -0
  35. package/src/herdr-shared.ts +108 -0
  36. package/src/herdr.ts +30 -192
  37. package/src/jev/client.ts +75 -85
  38. package/src/jev/failure.ts +19 -0
  39. package/src/jev/key-store.ts +26 -11
  40. package/src/jev/ledger.ts +58 -64
  41. package/src/jev/questions.ts +26 -23
  42. package/src/jev/state.ts +15 -12
  43. package/src/jev/transport.ts +8 -3
  44. package/src/model-stream.ts +35 -37
  45. package/src/outcomes.ts +15 -11
  46. package/src/pi-settings.ts +4 -9
  47. package/src/preferences.ts +4 -2
  48. package/src/redaction.ts +10 -12
  49. package/src/scout-context.ts +59 -60
  50. package/src/scout-curation.ts +4 -13
  51. package/src/scout-groups.ts +178 -175
  52. package/src/scout-protocol.ts +74 -70
  53. package/src/scout-reconstruct.ts +1 -1
  54. package/src/scout.ts +115 -73
  55. package/src/session-state.ts +64 -49
  56. package/src/tool-result-cap.ts +18 -17
  57. package/src/tools/consult-context.ts +7 -15
  58. package/src/tools/consultation.ts +14 -4
  59. package/src/tools/gate-policy.ts +1 -0
  60. package/src/tools/gate-protocol.ts +36 -23
  61. package/src/tools/jev-filter.ts +43 -41
  62. package/src/tools/jev-turn-gate.ts +29 -9
  63. package/src/tools/loop-gate.ts +31 -20
  64. package/src/tools/model-access.ts +46 -0
  65. package/src/tools/prompts.ts +20 -25
  66. package/src/tools/register-ask-advisor.ts +36 -16
  67. package/src/tools/register-lifecycle.ts +22 -0
  68. package/src/tools/register-outcome.ts +2 -0
  69. package/src/tools/register-renderers.ts +146 -128
  70. package/src/tools/registration.ts +12 -14
  71. package/src/tools/render-advisor-result.ts +35 -22
  72. package/src/tools/render-common.ts +16 -19
  73. package/src/tools/scout-status.ts +42 -27
  74. package/src/tools/types.ts +1 -0
  75. package/src/tools.ts +0 -1
  76. package/src/ui/jev-setup-submenu.ts +65 -59
  77. package/src/ui/manual-dialog-render.ts +1 -0
  78. package/src/ui/manual-dialog.ts +39 -43
  79. package/src/ui/masked-input.ts +5 -11
  80. package/src/ui/model-multi-selector.ts +9 -0
  81. package/src/ui/model-selector-adapter.ts +31 -0
  82. package/src/ui/model-selector.ts +14 -161
  83. package/src/ui/searchable-model-list.ts +230 -0
  84. package/src/ui/settings-formatting.ts +24 -14
  85. package/src/ui/settings-items.ts +38 -1
  86. package/src/ui/settings-list-adapter.ts +15 -12
  87. package/src/ui/settings-mutations.ts +157 -70
  88. package/src/ui/settings-selector.ts +18 -9
  89. package/src/ui/text-setting-submenu.ts +3 -6
  90. package/src/ui/types.ts +14 -0
  91. package/src/ui.ts +0 -1
  92. package/src/usage.ts +26 -19
@@ -14,6 +14,24 @@ export interface ToolResultTruncation {
14
14
 
15
15
  const OMITTED_MARKER = "[... omitted tool-result section ...]";
16
16
 
17
+ const collect = (
18
+ candidates: string[],
19
+ maxEntries: number,
20
+ maxContentBytes: number
21
+ ) => {
22
+ const selected: string[] = [];
23
+ let used = 0;
24
+ for (const line of candidates.slice(0, maxEntries)) {
25
+ const next = used + byteLength(line) + (selected.length ? 1 : 0);
26
+ if (next > maxContentBytes) {
27
+ break;
28
+ }
29
+ selected.push(line);
30
+ used = next;
31
+ }
32
+ return selected;
33
+ };
34
+
17
35
  export const capToolResult = (
18
36
  value: string,
19
37
  maxLines = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
@@ -53,23 +71,6 @@ export const capToolResult = (
53
71
  }
54
72
  const headCount = Math.floor((maxLines - 1) / 2);
55
73
  const tailCount = maxLines - 1 - headCount;
56
- const collect = (
57
- candidates: string[],
58
- maxEntries: number,
59
- maxContentBytes: number
60
- ) => {
61
- const selected: string[] = [];
62
- let used = 0;
63
- for (const line of candidates.slice(0, maxEntries)) {
64
- const next = used + byteLength(line) + (selected.length ? 1 : 0);
65
- if (next > maxContentBytes) {
66
- break;
67
- }
68
- selected.push(line);
69
- used = next;
70
- }
71
- return selected;
72
- };
73
74
  const availableBytes = maxBytes - markerBytes - 2;
74
75
  const head = collect(lines, headCount, Math.floor(availableBytes / 2));
75
76
  const tail = collect(
@@ -1,9 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import {
3
- readTrackedFiles,
4
- readUntrackedFiles,
5
- type UntrackedAttachment,
6
- } from "../attachments.ts";
2
+
3
+ import { readTrackedFiles, readUntrackedFiles } from "../attachments.ts";
4
+ import type { UntrackedAttachment } from "../attachments.ts";
7
5
  import {
8
6
  advisorGitContextMaxCharsRef,
9
7
  advisorGitContextRef,
@@ -13,19 +11,13 @@ import {
13
11
  advisorUntrackedContentRef,
14
12
  contextMaxCharsRef,
15
13
  } from "../config/state.ts";
16
- import {
17
- clampGitContextLevel,
18
- collectGitContext,
19
- type GitContextLevel,
20
- } from "../git.ts";
14
+ import { clampGitContextLevel, collectGitContext } from "../git.ts";
15
+ import type { GitContextLevel } from "../git.ts";
21
16
  import { readProjectPreferences } from "../preferences.ts";
22
17
  import { redactAndCapText, redactSecrets } from "../redaction.ts";
23
- import {
24
- runAdvisorScout,
25
- type ScoutLifecycleEvent,
26
- type ScoutOutcome,
27
- } from "../scout.ts";
28
18
  import { curateAdvisorConversation } from "../scout-curation.ts";
19
+ import { runAdvisorScout } from "../scout.ts";
20
+ import type { ScoutLifecycleEvent, ScoutOutcome } from "../scout.ts";
29
21
  import {
30
22
  advisorGitContextBudget,
31
23
  advisorRepositoryContext,
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import type { Message } from "@earendil-works/pi-ai/compat";
3
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
4
6
  import {
5
7
  advisorEffortRef,
6
8
  advisorRedactSecretsRef,
@@ -14,6 +16,7 @@ import type { ScoutLifecycleEvent } from "../scout.ts";
14
16
  import type { ConsultationTrigger, GateTrigger } from "../session-state.ts";
15
17
  import { assembleConsultationContext } from "./consult-context.ts";
16
18
  import { parseAutomaticDecision } from "./gate-protocol.ts";
19
+ import { advisorModelAccessReason } from "./model-access.ts";
17
20
  import {
18
21
  ADVISOR_DECISION_SYSTEM,
19
22
  ADVISOR_SYSTEM,
@@ -21,7 +24,7 @@ import {
21
24
  } from "./prompts.ts";
22
25
  import type { AdvisorConsultationResult, AdvisorGateOutcome } from "./types.ts";
23
26
 
24
- // biome-ignore lint/performance/noBarrelFile: preserves the tools facade's historical re-export of the moved curation entry point.
27
+ // Re-export preserves the tools facade's historical curation entry point.
25
28
  export { curateAdvisorConversation } from "../scout-curation.ts";
26
29
 
27
30
  /** Thrown when the Advisor produced an empty response body. */
@@ -54,6 +57,10 @@ const collectAdvisorResponse = async (
54
57
  ) => {
55
58
  const { ctx, question, signal, systemPrompt } = options;
56
59
  loadConfig(ctx);
60
+ const accessReason = advisorModelAccessReason(ctx);
61
+ if (accessReason) {
62
+ throw new Error(accessReason);
63
+ }
57
64
  const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
58
65
  const context = await assembleConsultationContext(options);
59
66
 
@@ -95,9 +102,9 @@ const collectAdvisorResponse = async (
95
102
  if (!markdown.trim()) {
96
103
  throw new AdvisorNoAdviceError();
97
104
  }
98
- return {
105
+ const response: Omit<AdvisorConsultationResult, "adviceId" | "trigger"> = {
99
106
  draftBytes: context.draftText
100
- ? Buffer.byteLength(context.draftText, "utf8")
107
+ ? Buffer.byteLength(context.draftText, "utf-8")
101
108
  : undefined,
102
109
  markdown,
103
110
  model: advisorRef,
@@ -108,8 +115,11 @@ const collectAdvisorResponse = async (
108
115
  untrackedBytes:
109
116
  context.untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
110
117
  usage: streamed.usage,
111
- ...(context.scout ? { scout: context.scout } : {}),
112
118
  };
119
+ if (context.scout) {
120
+ response.scout = context.scout;
121
+ }
122
+ return response;
113
123
  };
114
124
 
115
125
  export const consultAdvisor = async (
@@ -3,6 +3,7 @@ import type {
3
3
  ToolCallEvent,
4
4
  ToolCallEventResult,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+
6
7
  import {
7
8
  advisorBlockOnBlockedRef,
8
9
  getAdvisorMaxCallsPerSession,
@@ -1,20 +1,30 @@
1
1
  import type { GateDecision } from "../session-state.ts";
2
2
  import type { AdvisorGateFailure, AdvisorGateResult } from "./types.ts";
3
3
 
4
- const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i;
5
- const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
6
- const LINE_BREAK = /\r?\n/;
4
+ /** Named-group accessor; exec matches of these regexes always populate groups. */
5
+ const namedGroups = (match: RegExpExecArray): Record<string, string> =>
6
+ match.groups ?? {};
7
+
8
+ const DECISION_LINE =
9
+ /^Decision\s*:\s*(?<decision>proceed|revise|blocked)\s*$/iu;
10
+ const CODE_FENCE = /^ {0,3}(?<marker>`{3,}|~{3,})(?<suffix>.*)$/u;
11
+ const LINE_BREAK = /\r?\n/u;
7
12
 
8
13
  interface FenceState {
9
14
  character: string;
10
15
  length: number;
11
16
  }
12
17
 
18
+ interface FenceAdvance {
19
+ closed: boolean;
20
+ openingFence: FenceState | undefined;
21
+ }
22
+
13
23
  const advanceFence = (
14
24
  openingFence: FenceState | undefined,
15
25
  marker: string,
16
26
  suffix: string
17
- ): { closed: boolean; openingFence: FenceState | undefined } => {
27
+ ): FenceAdvance => {
18
28
  if (!openingFence) {
19
29
  if (marker[0] === "`" && suffix.includes("`")) {
20
30
  return { closed: false, openingFence: undefined };
@@ -59,7 +69,8 @@ export const parseAutomaticDecision = (
59
69
  ok: false,
60
70
  };
61
71
  }
62
- const decision = match[1].toLowerCase() as GateDecision;
72
+ // SAFETY: DECISION_LINE's decision group only matches proceed, revise, or blocked.
73
+ const decision = namedGroups(match).decision.toLowerCase() as GateDecision;
63
74
  let openingFence: FenceState | undefined;
64
75
  const decisions: string[] = [];
65
76
  let pendingFencedDecisions: string[] = [];
@@ -72,10 +83,11 @@ export const parseAutomaticDecision = (
72
83
  // open.
73
84
  const fence = CODE_FENCE.exec(line);
74
85
  if (fence) {
86
+ const groups = namedGroups(fence);
75
87
  const { closed, openingFence: nextOpeningFence } = advanceFence(
76
88
  openingFence,
77
- fence[1],
78
- fence[2]
89
+ groups.marker,
90
+ groups.suffix
79
91
  );
80
92
  openingFence = nextOpeningFence;
81
93
  if (closed) {
@@ -87,7 +99,7 @@ export const parseAutomaticDecision = (
87
99
  if (!subsequent) {
88
100
  continue;
89
101
  }
90
- const repeated = subsequent[1].trim().toLowerCase();
102
+ const repeated = namedGroups(subsequent).decision.trim().toLowerCase();
91
103
  if (openingFence) {
92
104
  pendingFencedDecisions.push(repeated);
93
105
  } else {
@@ -97,21 +109,22 @@ export const parseAutomaticDecision = (
97
109
  if (openingFence) {
98
110
  decisions.push(...pendingFencedDecisions);
99
111
  }
100
- for (const repeated of decisions) {
101
- if (repeated === decision) {
102
- return {
103
- category: "duplicate-decision",
104
- markdown: text,
105
- message: "Advisor gate response contains duplicate decision lines.",
106
- ok: false,
107
- };
108
- }
109
- return {
110
- category: "contradictory-decision",
111
- markdown: text,
112
- message: "Advisor gate response contains contradictory decision lines.",
113
- ok: false,
114
- };
112
+ const [repeated] = decisions;
113
+ if (repeated !== undefined) {
114
+ return repeated === decision
115
+ ? {
116
+ category: "duplicate-decision",
117
+ markdown: text,
118
+ message: "Advisor gate response contains duplicate decision lines.",
119
+ ok: false,
120
+ }
121
+ : {
122
+ category: "contradictory-decision",
123
+ markdown: text,
124
+ message:
125
+ "Advisor gate response contains contradictory decision lines.",
126
+ ok: false,
127
+ };
115
128
  }
116
129
  return {
117
130
  decision,
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Fetch } from "@typesafe-ai/sdk";
3
+
3
4
  import {
4
5
  advisorJevFilterEnabledRef,
5
6
  advisorJevFilterNoulMarginRef,
@@ -14,7 +15,8 @@ import {
14
15
  screeningQuestions,
15
16
  } from "../jev/questions.ts";
16
17
  import { buildJevState } from "../jev/state.ts";
17
- import { type JevCredentials, resolveJevTransport } from "../jev/transport.ts";
18
+ import { resolveJevTransport } from "../jev/transport.ts";
19
+ import type { JevCredentials } from "../jev/transport.ts";
18
20
  import type { AdvisorSessionState } from "../session-state.ts";
19
21
  import { createOutageNotifier } from "./outage-notifier.ts";
20
22
 
@@ -42,7 +44,7 @@ export interface ScreeningDeps {
42
44
  }
43
45
 
44
46
  export const normalizeScreeningQuestion = (question: string | undefined) =>
45
- question?.trim().toLowerCase().replace(/\s+/g, " ") || undefined;
47
+ question?.trim().toLowerCase().replaceAll(/\s+/gu, " ") || undefined;
46
48
 
47
49
  const REATTACHED_ADVICE_CAP_BYTES = 4 * 1024;
48
50
 
@@ -64,45 +66,6 @@ export const resetJevOutageNotification = outageNotifier.reset;
64
66
 
65
67
  const allow = (): ScreeningOutcome => ({ decision: "allow" });
66
68
 
67
- /**
68
- * Fail-open screening: decides run/skip for one ask_advisor call. Disabled or
69
- * simple mode, missing credentials, and every Jev failure allow the
70
- * consultation; only the hard conjunction (negligible-mass ≥ skip confidence
71
- * AND confidently self-answerable) or an exact repeat skips.
72
- */
73
- export const screenConsultation = (
74
- ctx: ExtensionContext,
75
- session: AdvisorSessionState,
76
- options: ScreenConsultationOptions,
77
- deps: ScreeningDeps = {}
78
- ): Promise<ScreeningOutcome> => {
79
- if (isSimpleMode()) {
80
- return Promise.resolve(allow());
81
- }
82
- // Repeat reattachment and its force/passthrough escape hatch are code-side
83
- // and free: they run regardless of the Jev enable flag, which only gates
84
- // the (paid, network) Jev screening call.
85
- const normalizedQuestion = normalizeScreeningQuestion(options.question);
86
- const bypass = bypassOutcome(session, options, normalizedQuestion);
87
- if (bypass) {
88
- return Promise.resolve(bypass);
89
- }
90
- const reattached = session.reattachedAdviceFor(normalizedQuestion);
91
- if (reattached) {
92
- session.recordJevFilterSkipped(true, normalizedQuestion);
93
- return Promise.resolve({
94
- decision: "skip",
95
- kind: "repeat",
96
- reason: "already answered earlier in this session",
97
- reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
98
- });
99
- }
100
- if (!advisorJevFilterEnabledRef) {
101
- return Promise.resolve(allow());
102
- }
103
- return screenWithJev(ctx, session, options, deps, normalizedQuestion);
104
- };
105
-
106
69
  const bypassOutcome = (
107
70
  session: AdvisorSessionState,
108
71
  options: ScreenConsultationOptions,
@@ -193,6 +156,45 @@ const screenWithJev = async (
193
156
  }
194
157
  };
195
158
 
159
+ /**
160
+ * Fail-open screening: decides run/skip for one ask_advisor call. Disabled or
161
+ * simple mode, missing credentials, and every Jev failure allow the
162
+ * consultation; only the hard conjunction (negligible-mass ≥ skip confidence
163
+ * AND confidently self-answerable) or an exact repeat skips.
164
+ */
165
+ export const screenConsultation = (
166
+ ctx: ExtensionContext,
167
+ session: AdvisorSessionState,
168
+ options: ScreenConsultationOptions,
169
+ deps: ScreeningDeps = {}
170
+ ): Promise<ScreeningOutcome> => {
171
+ if (isSimpleMode()) {
172
+ return Promise.resolve(allow());
173
+ }
174
+ // Repeat reattachment and its force/passthrough escape hatch are code-side
175
+ // and free: they run regardless of the Jev enable flag, which only gates
176
+ // the (paid, network) Jev screening call.
177
+ const normalizedQuestion = normalizeScreeningQuestion(options.question);
178
+ const bypass = bypassOutcome(session, options, normalizedQuestion);
179
+ if (bypass) {
180
+ return Promise.resolve(bypass);
181
+ }
182
+ const reattached = session.reattachedAdviceFor(normalizedQuestion);
183
+ if (reattached) {
184
+ session.recordJevFilterSkipped(true, normalizedQuestion);
185
+ return Promise.resolve({
186
+ decision: "skip",
187
+ kind: "repeat",
188
+ reason: "already answered earlier in this session",
189
+ reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
190
+ });
191
+ }
192
+ if (!advisorJevFilterEnabledRef) {
193
+ return Promise.resolve(allow());
194
+ }
195
+ return screenWithJev(ctx, session, options, deps, normalizedQuestion);
196
+ };
197
+
196
198
  export const screeningSkipText = (outcome: {
197
199
  kind: JevSkipKind;
198
200
  reattachedAdvice?: string;
@@ -1,5 +1,9 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionContext,
3
+ TurnEndEvent,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import type { Fetch } from "@typesafe-ai/sdk";
6
+
3
7
  import {
4
8
  advisorJevTurnGateEveryTurnsRef,
5
9
  advisorJevTurnGateNoulThresholdRef,
@@ -10,11 +14,13 @@ import { herdrAdvisorActivity } from "../herdr.ts";
10
14
  import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
11
15
  import { composeTurnGateVerdict } from "../jev/questions.ts";
12
16
  import { buildJevState } from "../jev/state.ts";
13
- import { type JevCredentials, resolveJevTransport } from "../jev/transport.ts";
17
+ import { resolveJevTransport } from "../jev/transport.ts";
18
+ import type { JevCredentials } from "../jev/transport.ts";
14
19
  import type { AdvisorSessionState } from "../session-state.ts";
15
20
  import { advisorUsageCost } from "../usage.ts";
16
21
  import type { consultAdvisor } from "./consultation.ts";
17
22
  import { updateAdvisorUsageStatus } from "./gate-policy.ts";
23
+ import { advisorModelIsAllowed } from "./model-access.ts";
18
24
  import { createOutageNotifier } from "./outage-notifier.ts";
19
25
 
20
26
  export const turnGateQuestion = {
@@ -34,6 +40,17 @@ export interface JevTurnGateDeps {
34
40
  resolveTransport?: () => Promise<JevCredentials | undefined>;
35
41
  }
36
42
 
43
+ export interface TurnGateCallDetails {
44
+ question: string;
45
+ turn: number;
46
+ }
47
+
48
+ export interface TurnGateResultDetails {
49
+ advisor: string;
50
+ text: string;
51
+ usage?: unknown;
52
+ }
53
+
37
54
  export interface JevTurnGateRegistration {
38
55
  activeTools: () => string[];
39
56
  consult: typeof consultAdvisor;
@@ -41,7 +58,7 @@ export interface JevTurnGateRegistration {
41
58
  send: (message: {
42
59
  content: string;
43
60
  customType: string;
44
- details: Record<string, unknown>;
61
+ details: TurnGateCallDetails | TurnGateResultDetails;
45
62
  display: boolean;
46
63
  }) => void;
47
64
  session: AdvisorSessionState;
@@ -81,6 +98,7 @@ export const handleJevTurnEnd = async (
81
98
  isSimpleMode() ||
82
99
  session.blocked ||
83
100
  !registration.activeTools().includes("ask_advisor") ||
101
+ !advisorModelIsAllowed(ctx) ||
84
102
  !session.canConsult(getAdvisorMaxCallsPerSession())
85
103
  ) {
86
104
  return;
@@ -171,13 +189,15 @@ export const handleJevTurnEnd = async (
171
189
  }
172
190
  };
173
191
 
192
+ type TurnEndRegistrar = (
193
+ event: "turn_end",
194
+ handler: (event: TurnEndEvent, ctx: ExtensionContext) => void | Promise<void>
195
+ ) => void;
196
+
174
197
  /** Wires the turn_end handler on an ExtensionAPI-like surface. */
175
- export const registerJevTurnGate = <T extends string>(
176
- on: (
177
- event: T,
178
- handler: (event: unknown, ctx: ExtensionContext) => unknown
179
- ) => void,
198
+ export const registerJevTurnGate = (
199
+ on: TurnEndRegistrar,
180
200
  registration: JevTurnGateRegistration
181
201
  ): void => {
182
- on("turn_end" as T, (_event, ctx) => handleJevTurnEnd(registration, ctx));
202
+ on("turn_end", (_event, ctx) => handleJevTurnEnd(registration, ctx));
183
203
  };
@@ -4,6 +4,7 @@ import type {
4
4
  ToolCallEvent,
5
5
  ToolCallEventResult,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+
7
8
  import {
8
9
  advisorAutoLoopGateRef,
9
10
  advisorFailureModeRef,
@@ -15,8 +16,9 @@ import {
15
16
  } from "../config/state.ts";
16
17
  import type { GateFailureMode } from "../config/types.ts";
17
18
  import { herdrAdvisorActivity } from "../herdr.ts";
18
- import type { AdvisorSessionState } from "../session-state.ts";
19
+ import type { AdvisorSessionState, GateDecision } from "../session-state.ts";
19
20
  import { advisorUsageCost, snapshotAdvisorUsage } from "../usage.ts";
21
+ import type { AdvisorUsageSnapshot } from "../usage.ts";
20
22
  import type { runAdvisorGate } from "./consultation.ts";
21
23
  import {
22
24
  blockedDecisionEffect,
@@ -26,10 +28,9 @@ import {
26
28
  updateAdvisorUsageStatus,
27
29
  } from "./gate-policy.ts";
28
30
  import { adviceForGateText } from "./gate-protocol.ts";
29
- import {
30
- appendScoutLifecycleEntry,
31
- type ScoutStatusManager,
32
- } from "./scout-status.ts";
31
+ import { advisorModelIsAllowed } from "./model-access.ts";
32
+ import { appendScoutLifecycleEntry } from "./scout-status.ts";
33
+ import type { ScoutStatusManager } from "./scout-status.ts";
33
34
  import type { AdvisorGateResult, ScoutToolDetails } from "./types.ts";
34
35
 
35
36
  const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
@@ -46,20 +47,27 @@ const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
46
47
  );
47
48
  };
48
49
 
50
+ interface LoopResultDetails {
51
+ advisor?: string;
52
+ decision?: GateDecision;
53
+ text: string;
54
+ usage?: AdvisorUsageSnapshot;
55
+ }
56
+
49
57
  const sendAutomaticGateFailure = (
50
58
  pi: ExtensionAPI,
51
59
  markdown: string,
52
- usage?: unknown
60
+ usage?: AdvisorUsageSnapshot
53
61
  ) => {
54
- const normalizedUsage = snapshotAdvisorUsage(usage);
62
+ const details: LoopResultDetails = { text: markdown };
63
+ if (usage) {
64
+ details.usage = usage;
65
+ }
55
66
  pi.sendMessage(
56
67
  {
57
68
  content: markdown,
58
69
  customType: "advisor-loop-result",
59
- details: {
60
- text: markdown,
61
- ...(normalizedUsage ? { usage: normalizedUsage } : {}),
62
- },
70
+ details,
63
71
  display: true,
64
72
  },
65
73
  { deliverAs: "steer" }
@@ -70,18 +78,20 @@ const sendAutomaticGateResult = (
70
78
  pi: ExtensionAPI,
71
79
  result: AdvisorGateResult
72
80
  ) => {
81
+ const details: LoopResultDetails = {
82
+ advisor: result.model,
83
+ decision: result.decision,
84
+ text: result.markdown,
85
+ };
86
+ const normalizedUsage = snapshotAdvisorUsage(result.usage);
87
+ if (normalizedUsage) {
88
+ details.usage = normalizedUsage;
89
+ }
73
90
  pi.sendMessage(
74
91
  {
75
92
  content: adviceForGateText(result),
76
93
  customType: "advisor-loop-result",
77
- details: {
78
- advisor: result.model,
79
- decision: result.decision,
80
- text: result.markdown,
81
- ...(snapshotAdvisorUsage(result.usage)
82
- ? { usage: snapshotAdvisorUsage(result.usage) }
83
- : {}),
84
- },
94
+ details,
85
95
  display: true,
86
96
  },
87
97
  { deliverAs: "steer" }
@@ -118,7 +128,7 @@ const applyGateDecision = (
118
128
  sendAutomaticGateFailure(
119
129
  pi,
120
130
  `**Advisor gate failure (${result.category}):** ${result.message}`,
121
- result.usage
131
+ snapshotAdvisorUsage(result.usage)
122
132
  );
123
133
  return failure.block
124
134
  ? { block: true, reason: `${reason}\n${failure.reason}` }
@@ -158,6 +168,7 @@ export const handleAutomaticGate = async (
158
168
  if (
159
169
  isSimpleMode() ||
160
170
  event.toolName === "ask_advisor" ||
171
+ !advisorModelIsAllowed(ctx) ||
161
172
  !advisorAutoLoopGateRef ||
162
173
  !session.recordToolCall(
163
174
  event.toolName,
@@ -0,0 +1,46 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { advisorModelWhitelistRef } from "../config/state.ts";
4
+
5
+ type AdvisorModelAccess =
6
+ | { allowed: true; modelRef?: string }
7
+ | { allowed: false; modelRef?: string; reason: string };
8
+
9
+ const currentModelRef = (
10
+ ctx: Pick<ExtensionContext, "model">
11
+ ): string | undefined => {
12
+ const { model } = ctx;
13
+ return model ? `${model.provider}/${model.id}` : undefined;
14
+ };
15
+
16
+ export const advisorModelAccess = (
17
+ ctx: Pick<ExtensionContext, "model">
18
+ ): AdvisorModelAccess => {
19
+ const modelRef = currentModelRef(ctx);
20
+ if (advisorModelWhitelistRef.length === 0) {
21
+ return modelRef ? { allowed: true, modelRef } : { allowed: true };
22
+ }
23
+ if (modelRef && advisorModelWhitelistRef.includes(modelRef)) {
24
+ return { allowed: true, modelRef };
25
+ }
26
+ const current = modelRef ?? "no current model";
27
+ const denial: AdvisorModelAccess = {
28
+ allowed: false,
29
+ reason: `Advisor calls are restricted to the configured model whitelist (${advisorModelWhitelistRef.join(", ")}). Current model: ${current}.`,
30
+ };
31
+ if (modelRef) {
32
+ denial.modelRef = modelRef;
33
+ }
34
+ return denial;
35
+ };
36
+
37
+ export const advisorModelIsAllowed = (
38
+ ctx: Pick<ExtensionContext, "model">
39
+ ): boolean => advisorModelAccess(ctx).allowed;
40
+
41
+ export const advisorModelAccessReason = (
42
+ ctx: Pick<ExtensionContext, "model">
43
+ ): string | undefined => {
44
+ const access = advisorModelAccess(ctx);
45
+ return access.allowed ? undefined : access.reason;
46
+ };