pi-advisor-flow 0.8.2 → 0.8.3-dev.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.
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { registerCommands } from "../src/commands/registration.ts";
4
- import { setHerdrBlockedEmitter } from "../src/herdr.ts";
4
+ import { getAdvisorSettings } from "../src/config/state.ts";
5
+ import { HerdrAdvisorBlock, herdrAdvisorActivity } from "../src/herdr.ts";
5
6
  import { AdvisorSessionState } from "../src/session-state.ts";
6
7
  import {
7
8
  consultAdvisor as consultAdvisorImplementation,
@@ -34,13 +35,21 @@ export const runAdvisorGate = (
34
35
  export default function registerPiAdvisor(pi: ExtensionAPI) {
35
36
  const sessionState = new AdvisorSessionState();
36
37
  const scoutStatus = new ScoutStatusManager();
37
- setHerdrBlockedEmitter((active, label) =>
38
- pi.events.emit("herdr:blocked", { active, label })
38
+ const herdrActivity = herdrAdvisorActivity.createScope();
39
+ const herdrBlock = new HerdrAdvisorBlock(
40
+ undefined,
41
+ () => getAdvisorSettings().herdrIntegration,
42
+ (active, label) => {
43
+ pi.events.emit("herdr:blocked", { active, label });
44
+ }
39
45
  );
40
46
  registerAdvisorTool(pi, sessionState, {
47
+ herdrActivity,
48
+ herdrBlock,
41
49
  statusManager: scoutStatus,
42
50
  });
43
51
  registerCommands(pi, {
52
+ herdrActivity,
44
53
  sessionState,
45
54
  statusManager: scoutStatus,
46
55
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.8.2",
3
+ "version": "0.8.3-dev.2",
4
4
  "description": "Advanced Executor/Advisor flow for Pi, fully configurable and extendable.",
5
5
  "keywords": [
6
6
  "advisor",
@@ -55,12 +55,12 @@
55
55
  "@earendil-works/pi-coding-agent": "0.87.1",
56
56
  "@earendil-works/pi-tui": "0.87.1",
57
57
  "@oxlint/plugins": "1.85.0",
58
- "@types/node": "^26.6.2",
58
+ "@types/node": "^26.6.3",
59
59
  "@typesafe-ai/sdk": "^0.6.0",
60
60
  "bun-types": "1.4.2",
61
61
  "husky": "^9.1.7",
62
- "knip": "^6.37.0",
63
- "lint-staged": "^17.5.1",
62
+ "knip": "^6.38.0",
63
+ "lint-staged": "^17.6.0",
64
64
  "oxfmt": "0.70.0",
65
65
  "oxlint": "1.85.0",
66
66
  "typebox": "^1.3.34",
@@ -8,7 +8,6 @@ import {
8
8
  setExecutorRef,
9
9
  } from "../config/state.ts";
10
10
  import { loadConfig, saveConfig } from "../config/storage.ts";
11
- import { herdrAdvisorActivity } from "../herdr.ts";
12
11
  import { notify } from "./runtime.ts";
13
12
  import type { CommandRuntime } from "./types.ts";
14
13
 
@@ -84,6 +83,6 @@ export const registerCommandLifecycle = (
84
83
  runtime.manualConsultations.clear();
85
84
  runtime.manualProgressTimers.clear();
86
85
  runtime.manualProgress.clear();
87
- herdrAdvisorActivity.clear();
86
+ runtime.herdrActivity.clear();
88
87
  });
89
88
  };
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { advisorRef } from "../config/state.ts";
4
4
  import { isString } from "../content-utils.ts";
5
5
  import type { GitContextLevel } from "../git.ts";
6
- import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "../herdr.ts";
6
+ import { notifyHerdrAdvisorFailure } from "../herdr.ts";
7
7
  import { normalizeScreeningQuestion } from "../tools/jev-filter.ts";
8
8
  import { appendScoutLifecycleEntry } from "../tools/scout-status.ts";
9
9
  import type { ScoutToolDetails } from "../tools/types.ts";
@@ -28,7 +28,6 @@ export const startManualConsultation = async (
28
28
  progress: ManualAdvisorProgressState,
29
29
  gitContext?: GitContextLevel
30
30
  ) => {
31
- herdrAdvisorActivity.start();
32
31
  progress.phase = "preparing";
33
32
  runtime.requestManualRender(ctx);
34
33
  if (ctx.hasUI) {
@@ -44,6 +43,7 @@ export const startManualConsultation = async (
44
43
  }, 80);
45
44
  runtime.manualProgressTimers.set(controller, timer);
46
45
  }
46
+ const finishHerdrActivity = runtime.herdrActivity.start();
47
47
  let scoutDetails: ScoutToolDetails | undefined;
48
48
  try {
49
49
  const { adviceId, markdown, usage } = await runtime.requestAdvisor(
@@ -152,6 +152,7 @@ export const startManualConsultation = async (
152
152
  notify(ctx, `Advisor consultation failed: ${message}`, "error");
153
153
  notifyHerdrAdvisorFailure("Advisor consultation failed", message);
154
154
  } finally {
155
+ finishHerdrActivity();
155
156
  if (controller.signal.aborted) {
156
157
  progress.phase = "cancelled";
157
158
  }
@@ -163,6 +164,5 @@ export const startManualConsultation = async (
163
164
  runtime.requestManualRender(ctx);
164
165
  runtime.scoutStatus.release(ctx, scoutStatusToken);
165
166
  runtime.manualConsultations.delete(controller);
166
- herdrAdvisorActivity.finish();
167
167
  }
168
168
  };
@@ -4,7 +4,7 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  import { getAdvisorSettings } from "../config/state.ts";
7
- import { notifyHerdrAdvisorFailure } from "../herdr.ts";
7
+ import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "../herdr.ts";
8
8
  import type { AdvisorSessionState } from "../session-state.ts";
9
9
  import { consultAdvisor } from "../tools/consultation.ts";
10
10
  import { ScoutStatusManager } from "../tools/scout-status.ts";
@@ -40,6 +40,7 @@ const requestManualRender = (ctx: ExtensionContext) => {
40
40
 
41
41
  class CommandRuntime implements CommandRuntimeContract {
42
42
  readonly advisorSessionState: AdvisorSessionState;
43
+ readonly herdrActivity: NonNullable<CommandDependencies["herdrActivity"]>;
43
44
  readonly manualConsultations = new Map<AbortController, symbol>();
44
45
  readonly manualProgress = new Map<string, ManualAdvisorProgressState>();
45
46
  readonly manualProgressTimers = new Map<
@@ -59,6 +60,7 @@ class CommandRuntime implements CommandRuntimeContract {
59
60
  this.pi = pi;
60
61
  this.advisorSessionState =
61
62
  dependencies.sessionState ?? defaultAdvisorSessionState;
63
+ this.herdrActivity = dependencies.herdrActivity ?? herdrAdvisorActivity;
62
64
  this.scoutStatus =
63
65
  dependencies.statusManager ?? new ScoutStatusManager(false);
64
66
  this.requestAdvisor =
@@ -4,6 +4,7 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  import type { GitContextLevel } from "../git.ts";
7
+ import type { HerdrAdvisorActivityScope } from "../herdr.ts";
7
8
  import type { ScoutLifecycleEvent } from "../scout.ts";
8
9
  import type { AdvisorSessionState } from "../session-state.ts";
9
10
  import type { ScoutStatusManager } from "../tools/scout-status.ts";
@@ -43,12 +44,14 @@ export interface ManualAdvisorProgressState {
43
44
 
44
45
  export interface CommandDependencies {
45
46
  consult?: ManualConsult;
47
+ herdrActivity?: HerdrAdvisorActivityScope;
46
48
  sessionState?: AdvisorSessionState;
47
49
  statusManager?: ScoutStatusManager;
48
50
  }
49
51
 
50
52
  export interface CommandRuntime {
51
53
  readonly advisorSessionState: AdvisorSessionState;
54
+ readonly herdrActivity: HerdrAdvisorActivityScope;
52
55
  flowEnabled: () => boolean;
53
56
  readonly manualConsultations: Map<AbortController, symbol>;
54
57
  readonly manualProgress: Map<string, ManualAdvisorProgressState>;
@@ -10,13 +10,16 @@ import {
10
10
 
11
11
  export class HerdrAdvisorBlock {
12
12
  #blocked = false;
13
+ private readonly emitBlocked: (active: boolean, label: string) => void;
13
14
  private readonly report: Report;
14
15
  private readonly enabled: () => boolean;
15
16
 
16
17
  constructor(
17
18
  report: Report = sendToHerdr,
18
- enabled: () => boolean = () => true
19
+ enabled: () => boolean = () => true,
20
+ emitBlocked: (active: boolean, label: string) => void = safeEmitBlocked
19
21
  ) {
22
+ this.emitBlocked = emitBlocked;
20
23
  this.report = report;
21
24
  this.enabled = enabled;
22
25
  }
@@ -29,7 +32,7 @@ export class HerdrAdvisorBlock {
29
32
  const wasBlocked: boolean = this.#blocked;
30
33
  if (!wasBlocked) {
31
34
  // The listener refcounts, so only the false → true edge may emit.
32
- safeEmitBlocked(true, label);
35
+ this.safeEmitBlocked(true, label);
33
36
  }
34
37
  this.#blocked = true;
35
38
  this.safeReport({ blocked: label });
@@ -43,7 +46,7 @@ export class HerdrAdvisorBlock {
43
46
  }
44
47
  // Clearing previously reported state is a de-escalation and must still be
45
48
  // delivered if integration was disabled after the block was reported.
46
- safeEmitBlocked(false);
49
+ this.safeEmitBlocked(false, "Advisor blocked");
47
50
  try {
48
51
  this.report({
49
52
  id: `${BLOCK_SOURCE}:${nextSequence()}`,
@@ -62,6 +65,15 @@ export class HerdrAdvisorBlock {
62
65
  }
63
66
  }
64
67
 
68
+ private safeEmitBlocked(active: boolean, label: string): boolean {
69
+ try {
70
+ this.emitBlocked(active, label);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
65
77
  private safeReport(labels: { blocked: string }) {
66
78
  try {
67
79
  this.report({
package/src/herdr.ts CHANGED
@@ -32,8 +32,15 @@ const metadataRequest = (clear: boolean): HerdrMetadataRequest => ({
32
32
  },
33
33
  });
34
34
 
35
- export class HerdrAdvisorActivity {
35
+ export interface HerdrAdvisorActivityScope {
36
+ clear: () => void;
37
+ start: () => () => void;
38
+ }
39
+
40
+ export class HerdrAdvisorActivity implements HerdrAdvisorActivityScope {
36
41
  #activeConsultations = 0;
42
+ #activeByOwner = new Map<symbol, Set<symbol>>();
43
+ #defaultOwner = Symbol("default Herdr Advisor activity");
37
44
  private readonly report: Report;
38
45
  private readonly enabled: () => boolean;
39
46
 
@@ -46,31 +53,74 @@ export class HerdrAdvisorActivity {
46
53
  }
47
54
 
48
55
  start() {
56
+ return this.startFor(this.#defaultOwner);
57
+ }
58
+
59
+ finish() {
60
+ this.finishFor(this.#defaultOwner);
61
+ }
62
+
63
+ clear() {
64
+ this.clearFor(this.#defaultOwner);
65
+ }
66
+
67
+ createScope(): HerdrAdvisorActivityScope {
68
+ const owner = Symbol("Herdr Advisor runtime");
69
+ return {
70
+ clear: () => this.clearFor(owner),
71
+ start: () => this.startFor(owner),
72
+ };
73
+ }
74
+
75
+ private startFor(owner: symbol) {
76
+ const lease = Symbol("Herdr activity lease");
49
77
  if (!this.enabled()) {
50
- return;
78
+ return () => this.finishLease(owner, lease);
51
79
  }
80
+ let leases = this.#activeByOwner.get(owner);
81
+ if (!leases) {
82
+ leases = new Set();
83
+ this.#activeByOwner.set(owner, leases);
84
+ }
85
+ leases.add(lease);
52
86
  this.#activeConsultations += 1;
53
87
  if (this.#activeConsultations === 1) {
54
88
  this.safeReport(false);
55
89
  }
90
+ return () => this.finishLease(owner, lease);
56
91
  }
57
92
 
58
- finish() {
59
- if (this.#activeConsultations === 0) {
93
+ private finishFor(owner: symbol) {
94
+ const lease = this.#activeByOwner.get(owner)?.values().next().value;
95
+ if (lease) {
96
+ this.finishLease(owner, lease);
97
+ }
98
+ }
99
+
100
+ private finishLease(owner: symbol, lease: symbol) {
101
+ const leases = this.#activeByOwner.get(owner);
102
+ if (!leases?.delete(lease)) {
60
103
  return;
61
104
  }
105
+ if (leases.size === 0) {
106
+ this.#activeByOwner.delete(owner);
107
+ }
62
108
  this.#activeConsultations -= 1;
63
109
  if (this.#activeConsultations === 0) {
64
110
  this.safeReport(true);
65
111
  }
66
112
  }
67
113
 
68
- clear() {
69
- if (this.#activeConsultations === 0) {
114
+ private clearFor(owner: symbol) {
115
+ const leases = this.#activeByOwner.get(owner);
116
+ if (!leases?.size) {
70
117
  return;
71
118
  }
72
- this.#activeConsultations = 0;
73
- this.safeReport(true);
119
+ this.#activeByOwner.delete(owner);
120
+ this.#activeConsultations -= leases.size;
121
+ if (this.#activeConsultations === 0) {
122
+ this.safeReport(true);
123
+ }
74
124
  }
75
125
 
76
126
  private safeReport(clear: boolean) {
package/src/jev/client.ts CHANGED
@@ -97,9 +97,12 @@ const connectionFailure = <E>(error: E) => {
97
97
  };
98
98
  };
99
99
 
100
- const sleepWithAbort = (signal: AbortSignal, ms: number): Promise<void> =>
100
+ const sleepWithAbort = (signal: AbortSignal, ms: number): Promise<void> => {
101
+ if (signal.aborted) {
102
+ return Promise.resolve();
103
+ }
101
104
  // oxlint-disable-next-line promise/avoid-new -- unref-ed abortable retry timer cannot be expressed with async/await.
102
- new Promise((resolve) => {
105
+ return new Promise((resolve) => {
103
106
  const timer = setTimeout(resolve, ms);
104
107
  timer.unref?.();
105
108
  signal.addEventListener(
@@ -111,6 +114,7 @@ const sleepWithAbort = (signal: AbortSignal, ms: number): Promise<void> =>
111
114
  { once: true }
112
115
  );
113
116
  });
117
+ };
114
118
 
115
119
  /** One systemone client for both transports with a total wall deadline so
116
120
  * retries can never stall a tool call; errors are classified and redacted on
@@ -167,7 +171,11 @@ export class JevClient {
167
171
  for (let attempt = 1; ; attempt += 1) {
168
172
  // The retry loop is bounded to one backoff by the deadline controller.
169
173
  outcome = await this.#attempt(body, deadline.signal);
170
- if (!outcome.retryable || attempt >= MAX_ATTEMPTS) {
174
+ if (
175
+ !outcome.retryable ||
176
+ attempt >= MAX_ATTEMPTS ||
177
+ deadline.signal.aborted
178
+ ) {
171
179
  break;
172
180
  }
173
181
  await sleepWithAbort(deadline.signal, RETRY_BACKOFF_MS);
@@ -212,12 +220,12 @@ export class JevClient {
212
220
  `Jev call exceeded its ${this.#timeoutMs} ms wall-time budget.`
213
221
  );
214
222
  }
215
- if (outcome.failure) {
216
- throw outcome.failure;
217
- }
218
223
  if (signal?.aborted) {
219
224
  throw new Error("Jev call aborted by the caller.");
220
225
  }
226
+ if (outcome.failure) {
227
+ throw outcome.failure;
228
+ }
221
229
  throw new JevFailureError("error", "Jev call failed.");
222
230
  }
223
231
 
package/src/redaction.ts CHANGED
@@ -3,10 +3,15 @@ import { capUtf8Bytes, isString } from "./content-utils.ts";
3
3
  const REDACTION_MARKER = "[REDACTED SECRET]";
4
4
  const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/giu;
5
5
  const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/iu;
6
+ const CREDENTIAL_NAME = String.raw`(?:api[_-]?key|token|secret|password|passwd)`;
7
+ const SECRET_ASSIGNMENT_PATTERN = new RegExp(
8
+ String.raw`(?:(?:"(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}"|'(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}')|(?:\b|_)${CREDENTIAL_NAME})\s*[:=]\s*(?:"(?:\\.|[^"])*(?:"|$)|'(?:\\.|[^'])*(?:'|$)|[^\s"'&,;)}\]]+)`,
9
+ "giu"
10
+ );
6
11
  const SECRET_PATTERNS = [
7
12
  /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/giu,
8
13
  /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/giu,
9
- /\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/giu,
14
+ SECRET_ASSIGNMENT_PATTERN,
10
15
  /(?<scheme>[a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/giu,
11
16
  /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
12
17
  /\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/giu,
@@ -177,6 +177,7 @@ export class AdvisorSessionState {
177
177
  #ledger = freshAdviceLedger();
178
178
  #usage = freshUsage();
179
179
  #consumedCalls = 0;
180
+ #callReservations = new Set<string>();
180
181
  readonly #jev = new AdvisorJevLedgerState();
181
182
  #sessionTurnOrdinal = 0;
182
183
  #turnsSinceConsultation = 0;
@@ -187,6 +188,7 @@ export class AdvisorSessionState {
187
188
  this.#ledger = freshAdviceLedger();
188
189
  this.#usage = freshUsage();
189
190
  this.#consumedCalls = 0;
191
+ this.#callReservations.clear();
190
192
  this.#jev.reset();
191
193
  this.#sessionTurnOrdinal = 0;
192
194
  this.#turnsSinceConsultation = 0;
@@ -227,16 +229,40 @@ export class AdvisorSessionState {
227
229
  return true;
228
230
  }
229
231
 
230
- canConsult(limit: number | undefined) {
231
- return limit === undefined || this.#consumedCalls < limit;
232
+ canConsult(limit: number | undefined, reservationId?: string) {
233
+ const reservations =
234
+ this.#callReservations.size -
235
+ Number(
236
+ reservationId !== undefined && this.#callReservations.has(reservationId)
237
+ );
238
+ return limit === undefined || this.#consumedCalls + reservations < limit;
232
239
  }
233
- consumeCall() {
240
+ reserveCall(id: string, limit: number | undefined) {
241
+ if (this.#callReservations.has(id)) {
242
+ return true;
243
+ }
244
+ if (!this.canConsult(limit)) {
245
+ return false;
246
+ }
247
+ this.#callReservations.add(id);
248
+ return true;
249
+ }
250
+ releaseCall(id: string) {
251
+ this.#callReservations.delete(id);
252
+ }
253
+ clearCallReservations() {
254
+ this.#callReservations.clear();
255
+ }
256
+ consumeCall(reservationId?: string) {
257
+ if (reservationId !== undefined) {
258
+ this.#callReservations.delete(reservationId);
259
+ }
234
260
  this.#consumedCalls += 1;
235
261
  }
236
262
  remainingCalls(limit: number | undefined) {
237
263
  return limit === undefined
238
264
  ? undefined
239
- : Math.max(0, limit - this.#consumedCalls);
265
+ : Math.max(0, limit - this.#consumedCalls - this.#callReservations.size);
240
266
  }
241
267
  get consumedCalls() {
242
268
  return this.#consumedCalls;
@@ -11,6 +11,7 @@ import {
11
11
  isSimpleMode,
12
12
  } from "../config/state.ts";
13
13
  import type { GateFailureMode } from "../config/types.ts";
14
+ import type { HerdrAdvisorBlock } from "../herdr-block.ts";
14
15
  import { herdrAdvisorBlock, notifyHerdrAdvisorFailure } from "../herdr.ts";
15
16
  import type { AdvisorSessionState } from "../session-state.ts";
16
17
  import type { AdvisorGateResult, GateFailureCategory } from "./types.ts";
@@ -66,7 +67,8 @@ export const failureEffect = (
66
67
  message: string,
67
68
  ctx: ExtensionContext,
68
69
  session: AdvisorSessionState,
69
- failureMode: GateFailureMode
70
+ failureMode: GateFailureMode,
71
+ herdrBlock: HerdrAdvisorBlock = herdrAdvisorBlock
70
72
  ) => {
71
73
  const reason = `Advisor gate ${category}: ${message}`;
72
74
  notifyLocalFailure(ctx, message, failureMode === "block-session");
@@ -78,7 +80,7 @@ export const failureEffect = (
78
80
  return { block: true, effect: "tool-blocked" as const, reason };
79
81
  }
80
82
  session.block(reason);
81
- herdrAdvisorBlock.set(reason);
83
+ herdrBlock.set(reason);
82
84
  if (advisorBlockOnBlockedRef) {
83
85
  ctx.abort();
84
86
  }
@@ -89,7 +91,8 @@ export const blockedDecisionEffect = (
89
91
  reason: string,
90
92
  ctx: ExtensionContext,
91
93
  session: AdvisorSessionState,
92
- failureMode: GateFailureMode
94
+ failureMode: GateFailureMode,
95
+ herdrBlock: HerdrAdvisorBlock = herdrAdvisorBlock
93
96
  ) => {
94
97
  if (failureMode === "warn-and-continue") {
95
98
  if (ctx.hasUI) {
@@ -104,7 +107,7 @@ export const blockedDecisionEffect = (
104
107
  return { block: true, effect: "tool-blocked" as const, reason };
105
108
  }
106
109
  session.block(reason);
107
- herdrAdvisorBlock.set(reason);
110
+ herdrBlock.set(reason);
108
111
  if (advisorBlockOnBlockedRef) {
109
112
  ctx.abort();
110
113
  }
@@ -120,7 +123,7 @@ export const reserveAdvisorCall = (
120
123
  if (event.toolName !== "ask_advisor" || isSimpleMode()) {
121
124
  return;
122
125
  }
123
- if (!session.canConsult(getAdvisorMaxCallsPerSession())) {
126
+ if (!session.reserveCall(event.toolCallId, getAdvisorMaxCallsPerSession())) {
124
127
  const message = "Advisor call budget exhausted for this session.";
125
128
  if (ctx.hasUI) {
126
129
  ctx.ui.notify(message, "warning");
@@ -140,11 +140,12 @@ const screenWithJev = async (
140
140
  session.recordJevFilterAllowed();
141
141
  return allow();
142
142
  } catch (error) {
143
+ if (options.signal?.aborted) {
144
+ throw error;
145
+ }
143
146
  session.recordJevFilterFailure();
144
147
  if (error instanceof JevFailure) {
145
148
  notifyOutageOnce(ctx, error.category, error.message);
146
- } else if (options.signal?.aborted) {
147
- throw error;
148
149
  } else {
149
150
  notifyOutageOnce(
150
151
  ctx,
@@ -11,6 +11,7 @@ import {
11
11
  isSimpleMode,
12
12
  } from "../config/state.ts";
13
13
  import { herdrAdvisorActivity } from "../herdr.ts";
14
+ import type { HerdrAdvisorActivityScope } from "../herdr.ts";
14
15
  import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
15
16
  import { composeTurnGateVerdict } from "../jev/questions.ts";
16
17
  import { buildJevState } from "../jev/state.ts";
@@ -55,6 +56,7 @@ export interface JevTurnGateRegistration {
55
56
  activeTools: () => string[];
56
57
  consult: typeof consultAdvisor;
57
58
  deps?: JevTurnGateDeps;
59
+ herdrActivity?: HerdrAdvisorActivityScope;
58
60
  send: (message: {
59
61
  content: string;
60
62
  customType: string;
@@ -71,6 +73,35 @@ const outageNotifier = createOutageNotifier(
71
73
 
72
74
  const notifyFailureOnce = outageNotifier.notify;
73
75
 
76
+ const shouldRunTurnGate = (
77
+ registration: JevTurnGateRegistration,
78
+ ctx: ExtensionContext,
79
+ session: AdvisorSessionState
80
+ ) => {
81
+ const interval = advisorJevTurnGateEveryTurnsRef;
82
+ return (
83
+ interval > 0 &&
84
+ session.turnsSinceConsultation > 0 &&
85
+ session.turnsSinceConsultation % interval === 0 &&
86
+ !isSimpleMode() &&
87
+ !session.blocked &&
88
+ registration.activeTools().includes("ask_advisor") &&
89
+ advisorModelIsAllowed(ctx) &&
90
+ session.canConsult(getAdvisorMaxCallsPerSession())
91
+ );
92
+ };
93
+
94
+ const consumeTurnGateBudget = (
95
+ session: AdvisorSessionState,
96
+ shouldConsult: boolean
97
+ ) => {
98
+ if (!shouldConsult || !session.canConsult(getAdvisorMaxCallsPerSession())) {
99
+ return false;
100
+ }
101
+ session.consumeCall();
102
+ return true;
103
+ };
104
+
74
105
  /** Test-only: re-arms the once-per-outage notification. */
75
106
  export const resetJevTurnGateNotification = outageNotifier.reset;
76
107
 
@@ -86,21 +117,7 @@ export const handleJevTurnEnd = async (
86
117
  ): Promise<void> => {
87
118
  const { session } = registration;
88
119
  session.recordCompletedTurn();
89
- const interval = advisorJevTurnGateEveryTurnsRef;
90
- if (
91
- interval <= 0 ||
92
- session.turnsSinceConsultation <= 0 ||
93
- session.turnsSinceConsultation % interval !== 0
94
- ) {
95
- return;
96
- }
97
- if (
98
- isSimpleMode() ||
99
- session.blocked ||
100
- !registration.activeTools().includes("ask_advisor") ||
101
- !advisorModelIsAllowed(ctx) ||
102
- !session.canConsult(getAdvisorMaxCallsPerSession())
103
- ) {
120
+ if (!shouldRunTurnGate(registration, ctx, session)) {
104
121
  return;
105
122
  }
106
123
 
@@ -128,22 +145,21 @@ export const handleJevTurnEnd = async (
128
145
  result.answers,
129
146
  advisorJevTurnGateNoulThresholdRef
130
147
  );
131
- if (!shouldConsult) {
148
+ if (!consumeTurnGateBudget(session, shouldConsult)) {
132
149
  return;
133
150
  }
134
-
135
- session.consumeCall();
136
- herdrAdvisorActivity.start();
137
- registration.send({
138
- content: "Proactive Advisor turn review",
139
- customType: "advisor-turn-gate-call",
140
- details: {
141
- question: `Turn gate: ${session.turnsSinceConsultation} turns without a consultation`,
142
- turn: session.sessionTurnOrdinal,
143
- },
144
- display: true,
145
- });
151
+ const herdrActivity = registration.herdrActivity ?? herdrAdvisorActivity;
152
+ const finishHerdrActivity = herdrActivity.start();
146
153
  try {
154
+ registration.send({
155
+ content: "Proactive Advisor turn review",
156
+ customType: "advisor-turn-gate-call",
157
+ details: {
158
+ question: `Turn gate: ${session.turnsSinceConsultation} turns without a consultation`,
159
+ turn: session.sessionTurnOrdinal,
160
+ },
161
+ display: true,
162
+ });
147
163
  const consulted = await consult(
148
164
  ctx,
149
165
  undefined,
@@ -173,7 +189,7 @@ export const handleJevTurnEnd = async (
173
189
  display: true,
174
190
  });
175
191
  } finally {
176
- herdrAdvisorActivity.finish();
192
+ finishHerdrActivity();
177
193
  }
178
194
  } catch (error) {
179
195
  session.recordJevGateFailure();