@danypops/pi-jittor 0.5.4 → 0.7.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.
@@ -6,7 +6,10 @@ import {
6
6
  CONTEXT_HUB_CONTRIBUTION_CHANNEL,
7
7
  CompactionTelemetry,
8
8
  type ContextAssessment,
9
+ classifyEffort,
9
10
  classifyTaskFromTools,
11
+ declaredEffortFromTaskFocusEvent,
12
+ EFFORT_CLASSIFICATION_MAX_TOOL_NAMES,
10
13
  FOOTER_COMPACTION_RENDER_INTERVAL_MS,
11
14
  HmacContextFingerprinter,
12
15
  loadOpenAiTextTokenCounter,
@@ -15,6 +18,7 @@ import {
15
18
  MILLISECONDS_PER_DAY,
16
19
  type ModelCandidate,
17
20
  type ModelTaskDomain,
21
+ type ModelTaskEffort,
18
22
  type ModelTaskType,
19
23
  PAPYRUS_CONTEXT_INJECTION_CHANNEL,
20
24
  PAPYRUS_TASK_FOCUS_CHANNEL,
@@ -48,16 +52,30 @@ import {
48
52
  import { ContextGrowthCapability } from "./observability/context-growth.ts";
49
53
  import { ContextHubCapability } from "./observability/context-hub.ts";
50
54
  import { showContextView } from "./observability/context-view.ts";
51
- import { type CompactionProgress, type IntegratedFooterState, installIntegratedFooter } from "./observability/footer.ts";
55
+ import {
56
+ type CompactionProgress,
57
+ type IntegratedFooterState,
58
+ installIntegratedFooter,
59
+ type RouterFooterInfo,
60
+ } from "./observability/footer.ts";
52
61
  import { LocalRunTelemetry } from "./observability/model-run.ts";
53
62
  import { captureProviderContextSnapshot } from "./observability/provider-context-snapshot.ts";
54
63
  import { ProviderResponseTelemetry } from "./observability/provider-response.ts";
55
64
  import { buildFooterBudget, providerBudgetMetricQuery } from "./observability/status.ts";
56
65
  import { showUsagePanel } from "./observability/usage.ts";
66
+ import { candidateIdentityOf, decideAutoMode, newAutoModeSessionState, recordAutoModeDismissal } from "./optimization/auto-mode.ts";
67
+ import { showAutoModeSuggestion } from "./optimization/auto-mode-dialog.ts";
68
+ import { fetchBenchmarkRanking } from "./optimization/model-selection-panel.ts";
57
69
  import { CodexRecoveryCapability, type CodexRecoveryRuntime, SYSTEM_RECOVERY_RUNTIME } from "./optimization/recovery/codex.ts";
58
70
  import { callJittor } from "./service-client.ts";
59
71
  import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
60
- import { type CodexRecoveryControl, type EnforcementControl, persistentEnforcementControl, type UsageBudgetControl } from "./settings.ts";
72
+ import {
73
+ type AutoModeControl,
74
+ type CodexRecoveryControl,
75
+ type EnforcementControl,
76
+ persistentEnforcementControl,
77
+ type UsageBudgetControl,
78
+ } from "./settings.ts";
61
79
 
62
80
  export { formatFooterStatus } from "./observability/status.ts";
63
81
  export type { CodexRecoveryRuntime } from "./optimization/recovery/codex.ts";
@@ -108,6 +126,28 @@ function recoveryControl(enforcement: EnforcementControl): CodexRecoveryControl
108
126
  : { isCodexRecoveryEnabled: () => false, setCodexRecoveryEnabled() {} };
109
127
  }
110
128
 
129
+ /**
130
+ * Falls back to "off" (never "suggest", the real persisted default in persistentEnforcementControl
131
+ * itself) when the passed-in control doesn't implement AutoModeControl -- e.g. a test harness's
132
+ * minimal enforcement-only fake. Matches usageBudgetControl/recoveryControl's own conservative
133
+ * fallback stubs: assume the feature is off/unconfigured rather than defaulting it on somewhere
134
+ * a caller never actually wired real persisted state for it.
135
+ */
136
+ function autoModeControl(enforcement: EnforcementControl): AutoModeControl {
137
+ const candidate = enforcement as EnforcementControl & Partial<AutoModeControl>;
138
+ return typeof candidate.getAutoMode === "function" &&
139
+ typeof candidate.setAutoMode === "function" &&
140
+ typeof candidate.isAutoModeVerbose === "function" &&
141
+ typeof candidate.setAutoModeVerbose === "function"
142
+ ? {
143
+ getAutoMode: () => candidate.getAutoMode!(),
144
+ setAutoMode: (mode) => candidate.setAutoMode!(mode),
145
+ isAutoModeVerbose: () => candidate.isAutoModeVerbose!(),
146
+ setAutoModeVerbose: (verbose) => candidate.setAutoModeVerbose!(verbose),
147
+ }
148
+ : { getAutoMode: () => "off", setAutoMode() {}, isAutoModeVerbose: () => false, setAutoModeVerbose() {} };
149
+ }
150
+
111
151
  async function recordMetrics(client: JittorExtensionClient, metrics: MetricObservation[]): Promise<void> {
112
152
  if (metrics.length === 0) return;
113
153
  // One atomic transaction rather than a per-metric RPC loop: a later observation in the same
@@ -404,12 +444,57 @@ export function registerJittorExtension(
404
444
  codexRecovery: CodexRecoveryControl = recoveryControl(enforcement),
405
445
  recoveryRuntime: CodexRecoveryRuntime = SYSTEM_RECOVERY_RUNTIME,
406
446
  contextGrowth: ContextGrowthCapability = new ContextGrowthCapability(),
447
+ autoMode: AutoModeControl = autoModeControl(enforcement),
407
448
  ): void {
408
449
  const footerState: IntegratedFooterState = { providerBudget: null };
409
450
  const usageBudgets = usageBudgetControl(enforcement);
410
451
  let compactionTelemetry = new CompactionTelemetry();
411
452
  let contextGrowthTurn = 0;
412
453
  const localRunTelemetry = new LocalRunTelemetry();
454
+ // Auto mode's own turn-boundary state: the just-received user text and the completed prior
455
+ // turn's tool-call mix feed classifyEffort; autoModeState remembers the last-seen effort and
456
+ // any dismissed suggestion (see optimization/auto-mode.ts's own anti-nag doc comment).
457
+ let pendingUserText: string | null = null;
458
+ let currentTurnToolNames: string[] = [];
459
+ let priorTurnToolNames: string[] = [];
460
+ const autoModeState = newAutoModeSessionState();
461
+ let autoModeSwitchNotifiedThisSession = false;
462
+ // Advisory/best-effort by construction: any failure here must never block or halt the turn the
463
+ // way budget enforcement's own fail-closed halt does -- callers wrap this in try/catch and swallow.
464
+ // Bind-beforehand: a Papyrus task focused with a declared `extra.effort` pins routing to that
465
+ // effort for as long as it stays focused -- no live per-turn detection needed for that task.
466
+ // Live classification remains the fallback whenever no task is focused, or the focused task
467
+ // declares no effort. Reset semantics mirror focusedTaskId's own handling below.
468
+ const runAutoModeTurn = async (ctx: ExtensionContext): Promise<void> => {
469
+ const mode = autoMode.getAutoMode();
470
+ if (mode === "off" || !ctx.model) return;
471
+ const candidates = benchmarkCandidatesFromPi(scopedOrAvailableModels(ctx), pi.getThinkingLevel());
472
+ if (candidates.length === 0) return;
473
+ const currentCandidate: ModelCandidate = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
474
+ const effort = declaredEffort ?? classifyEffort({ userText: pendingUserText ?? "", priorTurnToolNames }).effort;
475
+ const ranking = await fetchBenchmarkRanking(ctx, client, candidates, "general", "general", effort, currentCandidate);
476
+ const decision = decideAutoMode({ mode, effort, ranking, state: autoModeState });
477
+ if (decision.kind === "switch") {
478
+ const applied = await applyRoute(pi, ctx, decision.candidate);
479
+ if (applied && !autoModeSwitchNotifiedThisSession) {
480
+ ctx.ui.notify(`Jittor auto-switched to ${candidateIdentityOf(decision.candidate)} (effort: ${effort}).`, "info");
481
+ autoModeSwitchNotifiedThisSession = true;
482
+ }
483
+ } else if (decision.kind === "suggest") {
484
+ const choice = await showAutoModeSuggestion(
485
+ ctx,
486
+ decision.candidate,
487
+ effort,
488
+ decision.utilityDelta,
489
+ decision.confidence,
490
+ ranking,
491
+ `${currentCandidate.provider}/${currentCandidate.model}`,
492
+ autoMode.isAutoModeVerbose(),
493
+ );
494
+ if (choice === "switch") await applyRoute(pi, ctx, decision.candidate);
495
+ else recordAutoModeDismissal(autoModeState, decision.candidate);
496
+ }
497
+ };
413
498
  const providerResponseTelemetry = new ProviderResponseTelemetry();
414
499
  const codexRecoveryCapability = new CodexRecoveryCapability(pi, codexRecovery, recoveryRuntime);
415
500
  const contextHub = new ContextHubCapability();
@@ -494,11 +579,14 @@ export function registerJittorExtension(
494
579
  // affect this one's attribution.
495
580
  let currentSessionId: string | undefined;
496
581
  let focusedTaskId: string | null = null;
582
+ // Read by runAutoModeTurn (declared above, only ever invoked later) as the bind-beforehand pin.
583
+ let declaredEffort: ModelTaskEffort | null = null;
497
584
  const stopPapyrusTaskFocus = pi.events?.on?.(PAPYRUS_TASK_FOCUS_CHANNEL, (payload) => {
498
585
  try {
499
586
  const event = validateTaskFocusEvent(payload);
500
587
  if (event.sessionId !== undefined && event.sessionId !== currentSessionId) return;
501
588
  focusedTaskId = applyTaskFocusEvent(event);
589
+ declaredEffort = declaredEffortFromTaskFocusEvent(event);
502
590
  } catch {
503
591
  // Reject malformed or stale cross-extension events without retaining payloads or crashing the extension.
504
592
  }
@@ -540,7 +628,13 @@ export function registerJittorExtension(
540
628
  else footerState.requestRender?.();
541
629
  };
542
630
  const showFooter = (ctx: ExtensionContext): void => {
543
- if (enforcement.isFooterEnabled()) installIntegratedFooter(ctx, footerState, () => pi.getThinkingLevel());
631
+ if (enforcement.isFooterEnabled())
632
+ installIntegratedFooter(
633
+ ctx,
634
+ footerState,
635
+ () => pi.getThinkingLevel(),
636
+ (): RouterFooterInfo => ({ autoMode: autoMode.getAutoMode() }),
637
+ );
544
638
  else ctx.ui.setFooter(undefined);
545
639
  };
546
640
  const disable = async (ctx: ExtensionContext): Promise<void> => {
@@ -595,6 +689,7 @@ export function registerJittorExtension(
595
689
  enforcement,
596
690
  recovery: codexRecovery,
597
691
  budgets: usageBudgets,
692
+ autoMode,
598
693
  effects: {
599
694
  setEnforcement: async (enabled) => (enabled ? enable(ctx) : disable(ctx)),
600
695
  setFooter: async (enabled) => {
@@ -606,6 +701,8 @@ export function registerJittorExtension(
606
701
  if (!enabled) cancelRecovery(true);
607
702
  await codexRecovery.setCodexRecoveryEnabled(enabled);
608
703
  },
704
+ setAutoMode: (mode) => autoMode.setAutoMode(mode),
705
+ setAutoModeVerbose: (verbose) => autoMode.setAutoModeVerbose(verbose),
609
706
  },
610
707
  },
611
708
  status: { client },
@@ -615,6 +712,11 @@ export function registerJittorExtension(
615
712
  currentIdentity: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
616
713
  domain: benchmarksTask?.domain ?? "general",
617
714
  type: benchmarksTask?.type ?? "general",
715
+ // This on-demand panel is not tied to a live turn, so it always uses the neutral
716
+ // "medium" effort (matching the pre-effort-axis cost weighting exactly) -- live
717
+ // per-turn effort detection feeds the turn_start Auto-mode decision path instead.
718
+ effort: "medium",
719
+ currentCandidate: ctx.model ? { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() } : null,
618
720
  },
619
721
  cache: { client, windowMs: 7 * MILLISECONDS_PER_DAY },
620
722
  });
@@ -821,6 +923,7 @@ export function registerJittorExtension(
821
923
  pi.on("session_start", async (_event, ctx) => {
822
924
  currentSessionId = ctx.sessionManager.getSessionId();
823
925
  focusedTaskId = null;
926
+ declaredEffort = null;
824
927
  finishCompactionUi();
825
928
  compactionTelemetry = new CompactionTelemetry();
826
929
  contextGrowthTurn = 0;
@@ -918,7 +1021,13 @@ export function registerJittorExtension(
918
1021
  });
919
1022
 
920
1023
  pi.on("input", async (event, ctx) => {
921
- if (event.source !== "extension") cancelRecovery(true);
1024
+ if (event.source !== "extension") {
1025
+ cancelRecovery(true);
1026
+ // Auto mode's own effort classifier reads this at the next turn_start -- captured
1027
+ // unconditionally (independent of enforcement.isEnabled()) since Auto mode is a
1028
+ // separate, independently-toggled feature from budget enforcement.
1029
+ pendingUserText = event.text;
1030
+ }
922
1031
  if (event.source === "extension" || !enforcement.isEnabled()) return { action: "continue" as const };
923
1032
  try {
924
1033
  const next = (await client.call("router.decide", { session_id: ctx.sessionManager.getSessionId() })) as PolicyDecision;
@@ -950,20 +1059,26 @@ export function registerJittorExtension(
950
1059
  codexRecoveryCapability.resetTurn();
951
1060
  providerResponseTelemetry.resetTurn();
952
1061
  localRunTelemetry.beginTurn(event.timestamp);
953
- if (!enforcement.isEnabled()) return;
954
- try {
955
- await syncCurrentRoute(pi, client, ctx);
956
- await syncAvailableRoutes(pi, client, ctx);
957
- await applyDecision(
958
- pi,
959
- client,
960
- ctx,
961
- (await client.call("router.decide", { session_id: ctx.sessionManager.getSessionId() })) as PolicyDecision,
962
- );
963
- await refreshFooter(client, footerState, ctx.sessionManager.getSessionId());
964
- } catch {
965
- halt(ctx, "Jittor could not verify or apply a safe route");
1062
+ // Budget/enforcement routing is safety-critical and always wins on conflict: Auto mode is
1063
+ // only evaluated when budget pressure required no action this turn (action === "continue"),
1064
+ // so the two decision sources never fight over the model mid-turn.
1065
+ let budgetActedThisTurn = false;
1066
+ if (enforcement.isEnabled()) {
1067
+ try {
1068
+ await syncCurrentRoute(pi, client, ctx);
1069
+ await syncAvailableRoutes(pi, client, ctx);
1070
+ const budgetDecision = (await client.call("router.decide", {
1071
+ session_id: ctx.sessionManager.getSessionId(),
1072
+ })) as PolicyDecision;
1073
+ budgetActedThisTurn = budgetDecision.action !== "continue";
1074
+ await applyDecision(pi, client, ctx, budgetDecision);
1075
+ await refreshFooter(client, footerState, ctx.sessionManager.getSessionId());
1076
+ } catch {
1077
+ halt(ctx, "Jittor could not verify or apply a safe route");
1078
+ return;
1079
+ }
966
1080
  }
1081
+ if (!budgetActedThisTurn) await runAutoModeTurn(ctx).catch(() => undefined);
967
1082
  });
968
1083
 
969
1084
  pi.on("message_update", async (event) => {
@@ -974,6 +1089,10 @@ export function registerJittorExtension(
974
1089
  localRunTelemetry.onToolExecutionEnd(event.toolName, event.isError);
975
1090
  const classification = classifyTaskFromTools([event.toolName]);
976
1091
  compactionTelemetry.observeToolClass(`${classification.domain}-${classification.type}`, event.isError);
1092
+ // Bounded accumulation for the effort classifier's tool-call-mix signal -- classifyEffort
1093
+ // itself would slice defensively too, but there's no reason to grow this array unboundedly
1094
+ // across a very long turn in the meantime.
1095
+ if (currentTurnToolNames.length < EFFORT_CLASSIFICATION_MAX_TOOL_NAMES) currentTurnToolNames.push(event.toolName);
977
1096
  });
978
1097
 
979
1098
  pi.on("after_provider_response", async (event, ctx) => {
@@ -991,6 +1110,8 @@ export function registerJittorExtension(
991
1110
  if (typeof tokens === "number" && Number.isFinite(tokens)) contextGrowth.observe(++contextGrowthTurn, tokens);
992
1111
  const metrics = localRunTelemetry.completeTurn(event.message, pi.getThinkingLevel());
993
1112
  await recordMetrics(client, metrics).catch(() => undefined);
1113
+ priorTurnToolNames = currentTurnToolNames;
1114
+ currentTurnToolNames = [];
994
1115
  });
995
1116
 
996
1117
  pi.on("message_end", async (event, ctx) => {
@@ -19,7 +19,7 @@
19
19
  * shell first opens. Switching to a tab that has never been visited fetches it once, on first
20
20
  * visit, not before.
21
21
  */
22
- import type { ModelCandidate, ModelRankingResult, ModelTaskDomain, ModelTaskType, RouterStatus } from "@danypops/jittor";
22
+ import type { ModelCandidate, ModelRankingResult, ModelTaskDomain, ModelTaskEffort, ModelTaskType, RouterStatus } from "@danypops/jittor";
23
23
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
24
24
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
25
25
  import { BorderedSelectPanel, type MnemonicContext, type TabBarTheme, TabbedContainer, type TextMeasure } from "malevich-tui-components";
@@ -46,7 +46,7 @@ import {
46
46
  fetchBenchmarkRanking,
47
47
  showBenchmarkPanel,
48
48
  } from "./optimization/model-selection-panel.ts";
49
- import type { CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
49
+ import type { AutoModeControl, CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
50
50
  import {
51
51
  createSettingsPanel,
52
52
  runSettingsAction,
@@ -64,6 +64,7 @@ export interface JittorShellDeps {
64
64
  enforcement: EnforcementControl;
65
65
  recovery: CodexRecoveryControl;
66
66
  budgets: UsageBudgetControl;
67
+ autoMode: AutoModeControl;
67
68
  effects: SettingsEffects;
68
69
  };
69
70
  status: { client: JittorPanelClient };
@@ -73,6 +74,8 @@ export interface JittorShellDeps {
73
74
  currentIdentity: string;
74
75
  domain: ModelTaskDomain;
75
76
  type: ModelTaskType;
77
+ effort: ModelTaskEffort;
78
+ currentCandidate: ModelCandidate | null;
76
79
  };
77
80
  cache: { client: CacheEconomicsPanelClient; windowMs: number; now?: () => number };
78
81
  }
@@ -158,7 +161,7 @@ async function ensureLoaded(
158
161
  ): Promise<void> {
159
162
  // Settings reads already-in-memory persisted state -- no daemon round trip, so eagerly keeping
160
163
  // it fresh costs nothing and never violates "no network call for a tab never visited".
161
- state.settings = settingsSnapshot(deps.settings.enforcement, deps.settings.recovery, deps.settings.budgets);
164
+ state.settings = settingsSnapshot(deps.settings.enforcement, deps.settings.recovery, deps.settings.budgets, deps.settings.autoMode);
162
165
  if (activeKey === "status" && state.status === undefined) {
163
166
  state.status = await fetchStatusSnapshot(deps.status.client, ctx.sessionManager.getSessionId());
164
167
  }
@@ -169,6 +172,8 @@ async function ensureLoaded(
169
172
  deps.benchmarks.candidates,
170
173
  deps.benchmarks.domain,
171
174
  deps.benchmarks.type,
175
+ deps.benchmarks.effort,
176
+ deps.benchmarks.currentCandidate,
172
177
  );
173
178
  }
174
179
  if (activeKey === "cache" && state.cache === undefined) {
@@ -205,7 +210,14 @@ function tabBarTheme(theme: ShellTheme): TabBarTheme {
205
210
  */
206
211
  async function showNonTuiFallback(ctx: ExtensionCommandContext, deps: JittorShellDeps, initialTab: JittorTabKey): Promise<void> {
207
212
  if (initialTab === "settings")
208
- return showSettingsPanel(ctx, deps.settings.enforcement, deps.settings.recovery, deps.settings.budgets, deps.settings.effects);
213
+ return showSettingsPanel(
214
+ ctx,
215
+ deps.settings.enforcement,
216
+ deps.settings.recovery,
217
+ deps.settings.budgets,
218
+ deps.settings.autoMode,
219
+ deps.settings.effects,
220
+ );
209
221
  if (initialTab === "status") return showJittorPanel(ctx, deps.status.client);
210
222
  if (initialTab === "benchmarks")
211
223
  return showBenchmarkPanel(
@@ -215,6 +227,8 @@ async function showNonTuiFallback(ctx: ExtensionCommandContext, deps: JittorShel
215
227
  deps.benchmarks.currentIdentity,
216
228
  deps.benchmarks.domain,
217
229
  deps.benchmarks.type,
230
+ deps.benchmarks.effort,
231
+ deps.benchmarks.currentCandidate,
218
232
  );
219
233
  return showCacheEconomicsPanel(ctx, deps.cache.client, deps.cache.windowMs, deps.cache.now);
220
234
  }
@@ -314,6 +328,7 @@ export async function showJittorShell(
314
328
  deps.settings.recovery,
315
329
  deps.settings.budgets,
316
330
  deps.settings.effects,
331
+ deps.settings.autoMode,
317
332
  );
318
333
  continue;
319
334
  }
@@ -69,6 +69,11 @@ export type ProviderBudget =
69
69
  valueText: string;
70
70
  };
71
71
 
72
+ /** Compact, always-visible state of Jittor's effort-based Auto mode -- the newer, effort-driven router, distinct from the existing budget-pressure route already reflected by the model/budget segments. */
73
+ export interface RouterFooterInfo {
74
+ autoMode: "off" | "suggest" | "auto-switch";
75
+ }
76
+
72
77
  export interface CompactionProgress {
73
78
  startedAt: number;
74
79
  initialFraction: number;
@@ -238,6 +243,19 @@ function budgetSegment(
238
243
  return `${budget.label} ${bar} ${value}${reset ? ` · ${reset}` : ""}${staleText}`;
239
244
  }
240
245
 
246
+ /**
247
+ * "off" is dim (nothing happening); "suggest" is plain/unstyled -- it evaluates and may prompt,
248
+ * but only ever acts on explicit confirmation; "auto-switch" alone gets the eye-catching accent
249
+ * color, since it is the only state that can change the active model without asking first --
250
+ * the same distinction that already gates entering it behind an explicit settings confirmation.
251
+ */
252
+ function routerSegment(info: RouterFooterInfo | undefined, theme: FooterTheme): string | undefined {
253
+ if (!info) return undefined;
254
+ if (info.autoMode === "off") return `auto ${theme.fg("dim", info.autoMode)}`;
255
+ if (info.autoMode === "auto-switch") return `auto ${theme.fg("accent", info.autoMode)}`;
256
+ return `auto ${info.autoMode}`;
257
+ }
258
+
241
259
  function usageSegment(context: FooterContext): string {
242
260
  const totals = usageTotals(context);
243
261
  const parts: string[] = [];
@@ -295,6 +313,7 @@ export function renderFooterLines(
295
313
  width: number,
296
314
  now = Date.now(),
297
315
  compaction?: CompactionProgress,
316
+ router?: RouterFooterInfo,
298
317
  ): string[] {
299
318
  const safeWidth = Math.max(1, width);
300
319
  const repository = repositorySegment(context, footerData, theme);
@@ -306,6 +325,7 @@ export function renderFooterLines(
306
325
  const minimalContext = minimalContextSegment(context, theme, safeWidth, now, compaction);
307
326
  const fullBudget = budgetSegment(providerBudget, theme, safeWidth, false, now);
308
327
  const compactBudget = budgetSegment(providerBudget, theme, safeWidth, true, now);
328
+ const routerInfo = routerSegment(router, theme);
309
329
  const statuses = [...footerData.getExtensionStatuses().entries()]
310
330
  .filter(([key]) => key !== "jittor")
311
331
  .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
@@ -313,8 +333,11 @@ export function renderFooterLines(
313
333
  .join(" ");
314
334
 
315
335
  const candidates = [
336
+ joinSegments([repository, model.full, usage, fullContext, fullBudget, routerInfo, statuses]),
337
+ joinSegments([repository, model.full, usage, fullContext, fullBudget, routerInfo]),
316
338
  joinSegments([repository, model.full, usage, fullContext, fullBudget, statuses]),
317
339
  joinSegments([repository, model.full, usage, fullContext, fullBudget]),
340
+ joinSegments([model.full, usage, compactContext, compactBudget, routerInfo, statuses]),
318
341
  joinSegments([model.full, usage, compactContext, compactBudget, statuses]),
319
342
  joinSegments([model.full, usage, compactContext, compactBudget]),
320
343
  joinSegments([model.full, compactUsage, compactContext, compactBudget]),
@@ -332,7 +355,14 @@ export interface IntegratedFooterState {
332
355
  requestRender?: () => void;
333
356
  }
334
357
 
335
- export function installIntegratedFooter(ctx: ExtensionContext, state: IntegratedFooterState, getThinkingLevel: () => string): void {
358
+ export function installIntegratedFooter(
359
+ ctx: ExtensionContext,
360
+ state: IntegratedFooterState,
361
+ getThinkingLevel: () => string,
362
+ // Called fresh every render, the same as getThinkingLevel -- Auto mode's setting can change at
363
+ // any time via /jittor settings, so the footer must never cache a stale snapshot of it.
364
+ getRouterInfo: () => RouterFooterInfo | undefined = () => undefined,
365
+ ): void {
336
366
  ctx.ui.setStatus("jittor", undefined);
337
367
  ctx.ui.setFooter((tui, theme, footerData) => {
338
368
  state.requestRender = () => tui.requestRender();
@@ -349,6 +379,7 @@ export function installIntegratedFooter(ctx: ExtensionContext, state: Integrated
349
379
  width,
350
380
  Date.now(),
351
381
  state.compaction,
382
+ getRouterInfo(),
352
383
  );
353
384
  },
354
385
  dispose() {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Suggest-mode's real interactive surface: a compact one-line Dialog (Switch / Details / Not
3
+ * now) with Details expanding into the existing benchmark panel's full component/confidence/
4
+ * provenance breakdown before the user decides -- both verbosity levels are supported, never
5
+ * just one. Reuses `createBenchmarkPanel` rather than building a second detail renderer.
6
+ */
7
+
8
+ import type { ModelCandidate, ModelRankingResult, ModelTaskEffort } from "@danypops/jittor";
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import { Dialog, type TextMeasure } from "malevich-tui-components";
12
+ import { candidateIdentityOf } from "./auto-mode.ts";
13
+ import { createBenchmarkPanel } from "./model-selection-panel.ts";
14
+
15
+ export type AutoModeSuggestionChoice = "switch" | "dismiss";
16
+
17
+ interface SuggestionTheme {
18
+ fg(color: string, text: string): string;
19
+ bold(text: string): string;
20
+ }
21
+
22
+ const hostTextMeasure: TextMeasure = { visibleWidth, truncateToWidth };
23
+
24
+ function summaryLine(candidate: ModelCandidate, effort: ModelTaskEffort, utilityDelta: number, confidence: number): string {
25
+ return `Jittor suggests ${candidateIdentityOf(candidate)} for this turn -- effort: ${effort}, +${utilityDelta.toFixed(2)} utility, ${(confidence * 100).toFixed(0)}% confidence.`;
26
+ }
27
+
28
+ /** Shows the existing benchmark breakdown as a read-only detail view; any dismissal (Esc, "r", Ctrl+C) returns control to the caller, which re-shows the compact decision dialog. */
29
+ async function showSuggestionDetails(ctx: ExtensionContext, ranking: ModelRankingResult, currentIdentity: string): Promise<void> {
30
+ await ctx.ui.custom<"close">((_tui, theme: SuggestionTheme, _keybindings, done) => {
31
+ const panel = createBenchmarkPanel(ranking, currentIdentity, theme, () => done("close"), true);
32
+ return {
33
+ invalidate: () => panel.invalidate(),
34
+ render: (width: number) => panel.render(width),
35
+ handleInput(data: string): void {
36
+ if (matchesKey(data, "ctrl+c")) {
37
+ done("close");
38
+ return;
39
+ }
40
+ panel.handleInput(data);
41
+ },
42
+ };
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Non-TUI (`ctx.mode !== "tui"`) mode has no interactive dialog to show, matching every other
48
+ * jittor confirmation's own non-TUI fallback -- it stays a plain notify and the suggestion is
49
+ * treated as dismissed (advisory-only; the user reads it, no automatic action follows).
50
+ */
51
+ export async function showAutoModeSuggestion(
52
+ ctx: ExtensionContext,
53
+ candidate: ModelCandidate,
54
+ effort: ModelTaskEffort,
55
+ utilityDelta: number,
56
+ confidence: number,
57
+ ranking: ModelRankingResult,
58
+ currentIdentity: string,
59
+ verboseByDefault: boolean,
60
+ ): Promise<AutoModeSuggestionChoice> {
61
+ const summary = summaryLine(candidate, effort, utilityDelta, confidence);
62
+ if (ctx.mode !== "tui") {
63
+ ctx.ui.notify(`${summary} Run /jittor benchmarks for details.`, "info");
64
+ return "dismiss";
65
+ }
66
+ let showDetails = verboseByDefault;
67
+ for (;;) {
68
+ if (showDetails) {
69
+ await showSuggestionDetails(ctx, ranking, currentIdentity);
70
+ showDetails = false;
71
+ continue;
72
+ }
73
+ const choice = await ctx.ui.custom<AutoModeSuggestionChoice | "details">((_tui, theme: SuggestionTheme, _keybindings, done) => {
74
+ const dialog = new Dialog({
75
+ title: "Jittor suggestion",
76
+ body: summary,
77
+ actions: [
78
+ { label: "Switch", key: "s", action: () => done("switch") },
79
+ { label: "Details", key: "d", action: () => done("details") },
80
+ { label: "Not now", key: "n", action: () => done("dismiss") },
81
+ ],
82
+ theme: {
83
+ border: (text) => theme.fg("borderMuted", text),
84
+ title: theme.bold,
85
+ body: (text) => text,
86
+ dim: (text) => theme.fg("dim", text),
87
+ },
88
+ measure: hostTextMeasure,
89
+ });
90
+ return {
91
+ invalidate: () => dialog.invalidate(),
92
+ render: (width: number) => dialog.render(width),
93
+ handleInput(data: string): void {
94
+ if (matchesKey(data, "ctrl+c")) {
95
+ done("dismiss");
96
+ return;
97
+ }
98
+ dialog.handleInput(data);
99
+ },
100
+ };
101
+ });
102
+ if (choice === "details") {
103
+ showDetails = true;
104
+ continue;
105
+ }
106
+ return choice ?? "dismiss";
107
+ }
108
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure effort-based Auto mode decision logic -- deliberately separate from the existing
3
+ * budget-pressure `JittorRouter`/`applyDecision` path (optimization/routing), which reacts to
4
+ * provider quota pressure, not task complexity. The two compose at the call site (index.ts's
5
+ * `turn_start` handler): budget/enforcement always takes precedence on conflict, matching the
6
+ * design recorded in Doc "Auto mode: effort-aware model routing -- prior art and design".
7
+ *
8
+ * This module owns only the decision (what should happen this turn, given a fresh ranking
9
+ * result and the anti-nag session state) -- never the side effect itself (showing a dialog,
10
+ * calling `pi.setModel`, persisting a setting). That stays in index.ts, so this logic is fully
11
+ * unit-testable without mocking any Pi/TUI surface.
12
+ */
13
+ import type { ModelCandidate, ModelRankingResult, ModelTaskEffort } from "@danypops/jittor";
14
+
15
+ export const AUTO_MODE_SETTINGS = ["off", "suggest", "auto-switch"] as const;
16
+ export type AutoModeSetting = (typeof AUTO_MODE_SETTINGS)[number];
17
+
18
+ export interface AutoModeSessionState {
19
+ lastEffort: ModelTaskEffort | null;
20
+ dismissedIdentity: string | null;
21
+ }
22
+
23
+ export function newAutoModeSessionState(): AutoModeSessionState {
24
+ return { lastEffort: null, dismissedIdentity: null };
25
+ }
26
+
27
+ export function candidateIdentityOf(candidate: ModelCandidate): string {
28
+ return `${candidate.provider}/${candidate.model}:${candidate.thinking}`;
29
+ }
30
+
31
+ export interface AutoModeDecisionInput {
32
+ mode: AutoModeSetting;
33
+ /** The effort level this turn was classified/declared as -- only used to decide whether to re-evaluate a previously dismissed suggestion, never re-derived here. */
34
+ effort: ModelTaskEffort;
35
+ /** A ranking result already computed with this turn's effort and current model as its own uplift-gate baseline (see rankModelCandidates). */
36
+ ranking: ModelRankingResult;
37
+ state: AutoModeSessionState;
38
+ }
39
+
40
+ export type AutoModeDecision =
41
+ | { kind: "none" }
42
+ | { kind: "suggest"; candidate: ModelCandidate; utilityDelta: number; confidence: number }
43
+ | { kind: "switch"; candidate: ModelCandidate };
44
+
45
+ /**
46
+ * Anti-nag bound: a Suggest-mode dismissal for one specific candidate identity is remembered and
47
+ * not re-offered again until either the detected/declared effort changes, or the recommended
48
+ * candidate itself changes (a stronger, different upgrade is always worth surfacing). The uplift
49
+ * gate inside `rankModelCandidates` itself is the primary anti-spam mechanism -- this only
50
+ * prevents re-nagging about the exact same already-declined suggestion every single turn.
51
+ */
52
+ export function decideAutoMode(input: AutoModeDecisionInput): AutoModeDecision {
53
+ const { mode, effort, ranking, state } = input;
54
+ const effortChanged = state.lastEffort !== effort;
55
+ state.lastEffort = effort;
56
+ if (mode === "off") return { kind: "none" };
57
+ const recommendation = ranking.recommendation;
58
+ if (!recommendation) {
59
+ state.dismissedIdentity = null;
60
+ return { kind: "none" };
61
+ }
62
+ const identity = candidateIdentityOf(recommendation.candidate);
63
+ if (!effortChanged && state.dismissedIdentity === identity) return { kind: "none" };
64
+ if (mode === "auto-switch") return { kind: "switch", candidate: recommendation.candidate };
65
+ return {
66
+ kind: "suggest",
67
+ candidate: recommendation.candidate,
68
+ utilityDelta: recommendation.utilityDelta,
69
+ confidence: recommendation.confidence,
70
+ };
71
+ }
72
+
73
+ export function recordAutoModeDismissal(state: AutoModeSessionState, candidate: ModelCandidate): void {
74
+ state.dismissedIdentity = candidateIdentityOf(candidate);
75
+ }
@@ -9,11 +9,12 @@ import {
9
9
  type ModelCandidate,
10
10
  type ModelRankingResult,
11
11
  type ModelTaskDomain,
12
+ type ModelTaskEffort,
12
13
  type ModelTaskType,
13
14
  type RankedModel,
14
15
  type UtilityComponentName,
15
16
  } from "@danypops/jittor";
16
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
18
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
19
  import { BorderedSelectPanel, Table, type TextMeasure } from "malevich-tui-components";
19
20
  import { sessionSecretField } from "../session-identity.ts";
@@ -130,22 +131,39 @@ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity:
130
131
  return createBenchmarkPanel(result, currentIdentity, theme, () => undefined).render(Math.max(1, width));
131
132
  }
132
133
 
134
+ /**
135
+ * Pi's own `--models`/`enabledModels` scoping (`ctx.scopedModels`, the same set the
136
+ * `/scoped-models` command shows) is the exact-session authority once it is configured -- an
137
+ * empty list means "no scoping configured" (matching Pi's own semantics), not "scoped to
138
+ * nothing", and stays advisory. Mirrors the identical predicate `scopedOrAvailableModels` in
139
+ * `index.ts` already uses to build the candidate set itself; this call site independently
140
+ * hardcoded `"available-models"` after that fix landed, which kept automatic selection
141
+ * disabled for a reason (Pi lacking `ctx.scopedModels`) that no longer exists.
142
+ */
143
+ function scopeAuthorityFor(ctx: ExtensionContext): ModelRankingResult["scopeAuthority"] {
144
+ return ctx.scopedModels.length > 0 ? "exact-session" : "available-models";
145
+ }
146
+
133
147
  /** Shared by the standalone benchmark panel below and the unified /jittor shell, so both fetch identically. */
134
148
  export async function fetchBenchmarkRanking(
135
- ctx: ExtensionCommandContext,
149
+ ctx: ExtensionContext,
136
150
  client: BenchmarkPanelClient,
137
151
  candidates: ModelCandidate[],
138
152
  domain: ModelTaskDomain,
139
153
  type: ModelTaskType,
154
+ effort: ModelTaskEffort,
155
+ currentCandidate: ModelCandidate | null,
140
156
  ): Promise<ModelRankingResult> {
141
157
  const session_id = ctx.sessionManager.getSessionId();
142
158
  return (await client.call("models.rank", {
143
159
  candidates,
144
160
  session_id,
145
161
  ...sessionSecretField(session_id),
146
- scopeAuthority: "available-models",
162
+ scopeAuthority: scopeAuthorityFor(ctx),
147
163
  domain,
148
164
  type,
165
+ effort,
166
+ currentCandidate,
149
167
  budgetPressure: 0,
150
168
  weights: {
151
169
  quality: MODEL_RANKING_DEFAULT_QUALITY_WEIGHT,
@@ -165,9 +183,11 @@ export async function showBenchmarkPanel(
165
183
  currentIdentity: string,
166
184
  domain: ModelTaskDomain,
167
185
  type: ModelTaskType,
186
+ effort: ModelTaskEffort,
187
+ currentCandidate: ModelCandidate | null,
168
188
  ): Promise<void> {
169
189
  for (;;) {
170
- const result = await fetchBenchmarkRanking(ctx, client, candidates, domain, type);
190
+ const result = await fetchBenchmarkRanking(ctx, client, candidates, domain, type, effort, currentCandidate);
171
191
  if (ctx.mode !== "tui") {
172
192
  ctx.ui.notify(
173
193
  renderBenchmarkView(result, currentIdentity, 100, { fg: (_color, text) => text, bold: (text) => text }).join("\n"),
@@ -2,7 +2,8 @@ import { USAGE_PERIODS, type UsagePeriod } from "@danypops/jittor";
2
2
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
3
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  import { BorderedSelectPanel, Menu, type MenuTheme, type TextMeasure } from "malevich-tui-components";
5
- import type { CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
5
+ import { AUTO_MODE_SETTINGS, type AutoModeSetting } from "./optimization/auto-mode.ts";
6
+ import type { AutoModeControl, CodexRecoveryControl, EnforcementControl, UsageBudgetControl } from "./settings.ts";
6
7
  import { showConfirmDialog } from "./tui-prompts.ts";
7
8
 
8
9
  export interface SettingsSnapshot {
@@ -10,6 +11,8 @@ export interface SettingsSnapshot {
10
11
  footerEnabled: boolean;
11
12
  codexRecoveryEnabled: boolean;
12
13
  usageTokenBudgets: Partial<Record<UsagePeriod, number>>;
14
+ autoMode: AutoModeSetting;
15
+ autoModeVerbose: boolean;
13
16
  }
14
17
 
15
18
  interface SettingsTheme {
@@ -17,26 +20,37 @@ interface SettingsTheme {
17
20
  bold(text: string): string;
18
21
  }
19
22
 
20
- export type SettingsKey = "enforcement" | "footer" | "recovery" | `budget-${UsagePeriod}`;
23
+ export type SettingsKey = "enforcement" | "auto-mode" | "auto-mode-verbose" | "footer" | "recovery" | `budget-${UsagePeriod}`;
21
24
  export type SettingsAction = { kind: "activate"; key: SettingsKey } | { kind: "close" };
22
25
 
23
26
  export interface SettingsEffects {
24
27
  setEnforcement(enabled: boolean): void | Promise<void>;
25
28
  setFooter(enabled: boolean): void | Promise<void>;
26
29
  setRecovery(enabled: boolean): void | Promise<void>;
30
+ setAutoMode(mode: AutoModeSetting): void | Promise<void>;
31
+ setAutoModeVerbose(verbose: boolean): void | Promise<void>;
27
32
  }
28
33
 
29
- // Enforcement -> Budget -> Providers -> UI: safety posture (the global kill-switch everything
30
- // else is downstream of) leads, followed by spend limits, then provider-specific quirks (today,
31
- // only Codex recovery -- grouped under its own header instead of sitting flat next to global
32
- // switches, which read as "too Codex-oriented" with nothing to signal its narrower scope), then
33
- // display preferences last.
34
- const SETTINGS_KEYS: SettingsKey[] = ["enforcement", ...USAGE_PERIODS.map(({ id }) => `budget-${id}` as const), "recovery", "footer"];
34
+ // Enforcement -> Routing -> Budget -> Providers -> UI: safety posture (the global kill-switch
35
+ // everything else is downstream of) leads, followed by effort-based Auto mode (a routing
36
+ // behavior, not a safety switch, but consequential enough to sit right after Enforcement), then
37
+ // spend limits, then provider-specific quirks (today, only Codex recovery -- grouped under its
38
+ // own header instead of sitting flat next to global switches, which read as "too Codex-oriented"
39
+ // with nothing to signal its narrower scope), then display preferences last.
40
+ const SETTINGS_KEYS: SettingsKey[] = [
41
+ "enforcement",
42
+ "auto-mode",
43
+ "auto-mode-verbose",
44
+ ...USAGE_PERIODS.map(({ id }) => `budget-${id}` as const),
45
+ "recovery",
46
+ "footer",
47
+ ];
35
48
 
36
- type SettingsCategory = "Enforcement" | "Budget" | "Providers" | "UI";
49
+ type SettingsCategory = "Enforcement" | "Routing" | "Budget" | "Providers" | "UI";
37
50
 
38
51
  function categoryOf(key: SettingsKey): SettingsCategory {
39
52
  if (key === "enforcement") return "Enforcement";
53
+ if (key === "auto-mode" || key === "auto-mode-verbose") return "Routing";
40
54
  if (key === "recovery") return "Providers";
41
55
  if (key === "footer") return "UI";
42
56
  return "Budget";
@@ -51,8 +65,16 @@ function budgetLabel(period: UsagePeriod, snapshot: SettingsSnapshot): string {
51
65
  return value === undefined ? "not configured" : `${value.toLocaleString()} tokens`;
52
66
  }
53
67
 
68
+ function autoModeLabel(mode: AutoModeSetting, theme: SettingsTheme): string {
69
+ if (mode === "off") return theme.fg("muted", "OFF");
70
+ if (mode === "auto-switch") return theme.fg("success", "AUTO-SWITCH");
71
+ return theme.fg("accent", "SUGGEST");
72
+ }
73
+
54
74
  function rowText(key: SettingsKey, snapshot: SettingsSnapshot, theme: SettingsTheme): string {
55
75
  if (key === "enforcement") return `Routing enforcement ${state(snapshot.enforcementEnabled, theme)}`;
76
+ if (key === "auto-mode") return `Auto mode ${autoModeLabel(snapshot.autoMode, theme)}`;
77
+ if (key === "auto-mode-verbose") return `Suggestion details ${state(snapshot.autoModeVerbose, theme)}`;
56
78
  if (key === "footer") return `Informational footer ${state(snapshot.footerEnabled, theme)}`;
57
79
  if (key === "recovery") return `Codex recovery ${state(snapshot.codexRecoveryEnabled, theme)}`;
58
80
  const period = key.slice("budget-".length) as UsagePeriod;
@@ -63,12 +85,15 @@ export function settingsSnapshot(
63
85
  enforcement: EnforcementControl,
64
86
  recovery: CodexRecoveryControl,
65
87
  budgets: UsageBudgetControl,
88
+ autoMode: AutoModeControl,
66
89
  ): SettingsSnapshot {
67
90
  return {
68
91
  enforcementEnabled: enforcement.isEnabled(),
69
92
  footerEnabled: enforcement.isFooterEnabled(),
70
93
  codexRecoveryEnabled: recovery.isCodexRecoveryEnabled(),
71
94
  usageTokenBudgets: Object.fromEntries(USAGE_PERIODS.map(({ id }) => [id, budgets.getUsageTokenBudget(id)])),
95
+ autoMode: autoMode.getAutoMode(),
96
+ autoModeVerbose: autoMode.isAutoModeVerbose(),
72
97
  };
73
98
  }
74
99
 
@@ -201,8 +226,31 @@ export async function runSettingsAction(
201
226
  recovery: CodexRecoveryControl,
202
227
  budgets: UsageBudgetControl,
203
228
  effects: SettingsEffects,
229
+ autoMode: AutoModeControl,
204
230
  ): Promise<void> {
205
231
  if (action.kind === "close") return;
232
+ if (action.key === "auto-mode") {
233
+ const current = autoMode.getAutoMode();
234
+ const next = AUTO_MODE_SETTINGS[(AUTO_MODE_SETTINGS.indexOf(current) + 1) % AUTO_MODE_SETTINGS.length]!;
235
+ // Entering the highest-autonomy state gets the same explicit confirmation Codex recovery's
236
+ // own opt-in already gets; leaving it (like disabling enforcement) needs none -- becoming
237
+ // more conservative is never something to gate behind a confirmation.
238
+ if (next === "auto-switch") {
239
+ if (
240
+ await showConfirmDialog(
241
+ ctx,
242
+ "Enable Auto-switch?",
243
+ "Jittor may switch your active model on its own when a turn's effort clearly calls for a different one, with no confirmation prompt.",
244
+ )
245
+ )
246
+ await effects.setAutoMode(next);
247
+ } else await effects.setAutoMode(next);
248
+ return;
249
+ }
250
+ if (action.key === "auto-mode-verbose") {
251
+ await effects.setAutoModeVerbose(!autoMode.isAutoModeVerbose());
252
+ return;
253
+ }
206
254
  if (action.key === "enforcement") {
207
255
  if (enforcement.isEnabled()) {
208
256
  if (
@@ -241,19 +289,22 @@ export async function showSettingsPanel(
241
289
  enforcement: EnforcementControl,
242
290
  recovery: CodexRecoveryControl,
243
291
  budgets: UsageBudgetControl,
292
+ autoMode: AutoModeControl,
244
293
  effects: SettingsEffects = {
245
294
  setEnforcement: (enabled) => enforcement.setEnabled(enabled),
246
295
  setFooter: (enabled) => enforcement.setFooterEnabled(enabled),
247
296
  setRecovery: (enabled) => recovery.setCodexRecoveryEnabled(enabled),
297
+ setAutoMode: (mode) => autoMode.setAutoMode(mode),
298
+ setAutoModeVerbose: (verbose) => autoMode.setAutoModeVerbose(verbose),
248
299
  },
249
300
  ): Promise<void> {
250
301
  if (ctx.mode !== "tui") {
251
- const snapshot = settingsSnapshot(enforcement, recovery, budgets);
302
+ const snapshot = settingsSnapshot(enforcement, recovery, budgets, autoMode);
252
303
  ctx.ui.notify(["Jittor Settings", ...SETTINGS_KEYS.map((key) => rowText(key, snapshot, plainTheme()))].join("\n"), "info");
253
304
  return;
254
305
  }
255
306
  for (;;) {
256
- const snapshot = settingsSnapshot(enforcement, recovery, budgets);
307
+ const snapshot = settingsSnapshot(enforcement, recovery, budgets, autoMode);
257
308
  const action = await ctx.ui.custom<SettingsAction>((tui, theme, _keybindings, done) => {
258
309
  const panel = createSettingsPanel(snapshot, theme, done);
259
310
  return {
@@ -266,6 +317,6 @@ export async function showSettingsPanel(
266
317
  };
267
318
  });
268
319
  if (!action || action.kind === "close") return;
269
- await runSettingsAction(ctx, action, enforcement, recovery, budgets, effects);
320
+ await runSettingsAction(ctx, action, enforcement, recovery, budgets, effects, autoMode);
270
321
  }
271
322
  }
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { JITTOR_EXTENSION_SETTINGS_FILENAME, JITTOR_STATE_DIRECTORY, USAGE_PERIODS, type UsagePeriod } from "@danypops/jittor";
5
5
  import { createAtomicJsonWriter } from "@danypops/vehicle-core";
6
6
  import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
7
+ import { AUTO_MODE_SETTINGS, type AutoModeSetting } from "./optimization/auto-mode.ts";
7
8
 
8
9
  const atomicJson = createAtomicJsonWriter({ fs: createNodeAtomicJsonFsAdapter() });
9
10
 
@@ -30,17 +31,35 @@ export interface UsageBudgetControl {
30
31
  setUsageTokenBudget(period: UsagePeriod, tokens: number | undefined): void | Promise<void>;
31
32
  }
32
33
 
33
- export interface PersistentExtensionControl extends EnforcementControl, CodexRecoveryControl, UsageBudgetControl {}
34
+ export interface AutoModeControl {
35
+ getAutoMode(): AutoModeSetting;
36
+ setAutoMode(mode: AutoModeSetting): void | Promise<void>;
37
+ /** Whether a Suggest-mode dialog opens straight to the full benchmark breakdown instead of requiring the "Details" keypress. Purely a display preference -- never changes what action is taken. */
38
+ isAutoModeVerbose(): boolean;
39
+ setAutoModeVerbose(verbose: boolean): void | Promise<void>;
40
+ }
41
+
42
+ export interface PersistentExtensionControl extends EnforcementControl, CodexRecoveryControl, UsageBudgetControl, AutoModeControl {}
34
43
 
35
44
  interface ExtensionSettings {
36
45
  enforcementEnabled: boolean;
37
46
  footerEnabled: boolean;
38
47
  codexRecoveryEnabled: boolean;
39
48
  usageTokenBudgets: Partial<Record<UsagePeriod, number>>;
49
+ /** Suggest, not Auto-switch, is the recorded default -- Auto-switch (an active mutation) is fully available from day one but never silently pre-selected, per this project's shadow-mode-first governance for optimization interventions. */
50
+ autoMode: AutoModeSetting;
51
+ autoModeVerbose: boolean;
40
52
  }
41
53
 
42
54
  function defaultSettings(): ExtensionSettings {
43
- return { enforcementEnabled: true, footerEnabled: true, codexRecoveryEnabled: false, usageTokenBudgets: {} };
55
+ return {
56
+ enforcementEnabled: true,
57
+ footerEnabled: true,
58
+ codexRecoveryEnabled: false,
59
+ usageTokenBudgets: {},
60
+ autoMode: "suggest",
61
+ autoModeVerbose: false,
62
+ };
44
63
  }
45
64
 
46
65
  function parseUsageTokenBudgets(value: unknown): Partial<Record<UsagePeriod, number>> {
@@ -69,6 +88,8 @@ function loadSettings(path: string): ExtensionSettings {
69
88
  footerEnabled: record.footerEnabled !== false,
70
89
  codexRecoveryEnabled: record.codexRecoveryEnabled === true,
71
90
  usageTokenBudgets: parseUsageTokenBudgets(record.usageTokenBudgets),
91
+ autoMode: AUTO_MODE_SETTINGS.includes(record.autoMode as AutoModeSetting) ? (record.autoMode as AutoModeSetting) : "suggest",
92
+ autoModeVerbose: record.autoModeVerbose === true,
72
93
  };
73
94
  } catch {
74
95
  return defaultSettings();
@@ -109,5 +130,16 @@ export function persistentEnforcementControl(env: Record<string, string | undefi
109
130
  else settings.usageTokenBudgets[period] = tokens;
110
131
  await persistSettings(path, settings);
111
132
  },
133
+ getAutoMode: () => settings.autoMode,
134
+ async setAutoMode(mode): Promise<void> {
135
+ if (!AUTO_MODE_SETTINGS.includes(mode)) throw new Error("auto mode must be one of off, suggest, auto-switch");
136
+ settings.autoMode = mode;
137
+ await persistSettings(path, settings);
138
+ },
139
+ isAutoModeVerbose: () => settings.autoModeVerbose,
140
+ async setAutoModeVerbose(verbose): Promise<void> {
141
+ settings.autoModeVerbose = verbose;
142
+ await persistSettings(path, settings);
143
+ },
112
144
  };
113
145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-jittor",
3
- "version": "0.5.4",
3
+ "version": "0.7.0",
4
4
  "description": "Pi extension for Jittor token and context observability with optimization controls backed by the @danypops/jittor daemon",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  "@danypops/vehicle-client": "^0.10.1",
17
17
  "@danypops/vehicle-core": "^0.17.1",
18
18
  "@danypops/vehicle-server": "^0.25.1",
19
- "@danypops/jittor": "^0.18.0",
19
+ "@danypops/jittor": "^0.20.0",
20
20
  "malevich-tui-components": "^0.28.0"
21
21
  },
22
22
  "peerDependencies": {