pi-jev-guard 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,15 @@ export TYPESAFE_API_KEY="..." # backend typesafe diretto
26
26
 
27
27
  ## Uso normale (invariato)
28
28
 
29
- Con l'estensione installata non cambia nulla finché non la usi: i modelli normali restano selezionabili e nessuna verifica parte da sola (`mode: on-demand`).
29
+ Le nuove sessioni partono in `on-demand`: i modelli normali restano selezionabili e nessuna verifica parte da sola.
30
+ `/jev mode automatic|on-demand` salva la scelta **nella sessione**, non nella config globale.
31
+ La scelta sopravvive a `/reload`, riavvio e `/resume`; fork e `/tree` seguono lo stato del ramo.
32
+ Credenziali, policy e catalogo dei twin restano condivisi.
33
+
34
+ Migrazione: il vecchio `mode` in `jev-config.json` non controlla più l'estensione
35
+ (resta compatibile con i consumer standalone). Nelle sessioni senza uno stato salvato,
36
+ riattiva `automatic` una volta se desiderato. `JEV_MODE=automatic` può impostare il valore
37
+ iniziale in headless; una scelta già salvata nella sessione ha precedenza.
30
38
 
31
39
  ```text
32
40
  /jev check ./src/example.ts # verifica singola di un file
@@ -48,29 +56,37 @@ In chat, l'LLM può chiamare `jev_validate` per controlli mirati.
48
56
 
49
57
  **Più twin insieme**: ogni `/jev upstream` *aggiunge* un modello guarded, senza
50
58
  rimuovere i precedenti. I twin vengono registrati anche all'avvio (i modelli
51
- dinamici come Codex sono letti dal models-store), quindi la sessione riapre
52
- direttamente sul `__jev` senza warning. Il **primario** (ultimo aggiunto, o
53
- quello salvato come tale) è il target di auto-selezione e revert.
59
+ dinamici come Codex sono letti dal models-store), quando gli originali sono disponibili.
60
+ Il **primario** salvato è il target di auto-selezione e revert; un twin già selezionato
61
+ nella sessione viene mantenuto al reload, anche se non è il primario.
54
62
 
55
63
  Nessun login extra: i twin vivono nello stesso provider e riusano la credenziale già configurata (env, stored, OAuth/SSO come Codex business plan). I modelli originali restano intatti e selezionabili.
56
64
 
57
- In `automatic` il twin viene registrato già all'avvio (prima del ripristino del
58
- modello di sessione), così la sessione può riaprire direttamente su `__jev`.
65
+ I twin salvati vengono registrati prima del ripristino del modello anche in
66
+ `on-demand`: registrazione e attivazione del gate sono indipendenti. Il suffisso
67
+ `__jev` è locale e non viene inviato al provider; anche la cronologia viene
68
+ normalizzata sull'identità originale senza perdere firme o metadati di continuazione.
59
69
 
60
- Torna normale con `/model` (modello normale) + `/jev mode on-demand`.
70
+ Torna normale con `/jev mode on-demand`, che seleziona anche il modello originale.
61
71
  In `automatic`, selezionare un modello non protetto lo fa ritornare
62
72
  subito al twin (enforcement a livello selezione).
63
73
  `/jev upstream` senza argomenti ripristina tutti i twin salvati, `/jev untwin P M`
64
74
  ne rimuove uno solo, `/jev off` li rimuove tutti
65
- (e torna a on-demand, provider originali ripristinati). In `automatic` i twin
66
- si ripristinano da soli al riavvio.
75
+ (e salva on-demand nella sessione, selezionando l'originale prima di rimuovere il routing).
76
+ `/jev off` conserva il catalogo salvato: al reload gli alias tornano disponibili,
77
+ ma il gate rimane disattivato.
67
78
 
68
79
  Se filtri i modelli con `enabledModels`, tieni le voci `__jev` dei twin che usi
69
80
  o restano nascosti.
70
81
  L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
71
82
 
72
- Headless/CI: `JEV_AUTO_UPSTREAM=provider/model` crea il guarded all'avvio
73
- (con `pi --print` solo `deepseek/deepseek-flash`).
83
+ Headless/CI: `JEV_MODE=automatic JEV_AUTO_UPSTREAM=provider/model` crea e attiva
84
+ il guarded nelle sessioni senza uno stato Jev salvato.
85
+
86
+ Un hold `[JEV — OUTPUT NON PUBBLICATO]` interrompe intenzionalmente il turno:
87
+ controlla `/jev last`. Non viene aggirato con un proseguimento automatico.
88
+ Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
89
+ la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
74
90
 
75
91
  ## Come decide
76
92
 
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
2
  import type { Model, Provider } from "@earendil-works/pi-ai";
3
3
  import type { Api } from "@earendil-works/pi-ai";
4
- import { builtinProviders } from "@earendil-works/pi-ai/providers/all";import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
4
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
5
+ import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
5
6
  import { registerJevAskTool } from "../src/ask.ts";
6
7
  import {
7
8
  configSnapshot,
@@ -23,6 +24,7 @@ import {
23
24
  isTwinId,
24
25
  mergeModelsById,
25
26
  twinIdFor,
27
+ untwinId,
26
28
  } from "../src/automatic/overlay.ts";
27
29
  import type { GateVerdictReport } from "../src/automatic/guardian.ts";
28
30
  import { readStoreModels } from "../src/store-models.ts";
@@ -61,6 +63,20 @@ export interface GateStatus {
61
63
 
62
64
  export default function (pi: ExtensionAPI) {
63
65
  let config: JevConfig = loadConfig();
66
+ // Shared config holds credentials/policy/twins, not the session toggle.
67
+ const initialMode = process.env.JEV_MODE === "automatic" ? "automatic" : "on-demand";
68
+ let sessionMode: JevConfig["mode"] = initialMode;
69
+ function getConfig(): JevConfig {
70
+ return { ...config, mode: sessionMode };
71
+ }
72
+ function restoreMode(ctx: ExtensionContext): void {
73
+ sessionMode = initialMode;
74
+ for (const entry of ctx.sessionManager.getBranch()) {
75
+ if (entry.type !== "custom" || entry.customType !== "jev-mode") continue;
76
+ const mode = (entry.data as { mode?: unknown } | undefined)?.mode;
77
+ if (mode === "automatic" || mode === "on-demand") sessionMode = mode;
78
+ }
79
+ }
64
80
  // Full (3 regole) per tool/comandi/diagnostica; gate (difetti concreti) per provider.
65
81
  let handle = createReviewer(configSnapshot(config));
66
82
  let gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
@@ -85,7 +101,6 @@ export default function (pi: ExtensionAPI) {
85
101
  }
86
102
  const overlays = new Map<string, OverlayState>();
87
103
 
88
- const getConfig = () => config;
89
104
  const getReview = () => handle.review;
90
105
  const getGateReview = () => gateHandle.review;
91
106
  const getInfo = () => ({ backend: handle.backend, model: handle.model });
@@ -150,7 +165,7 @@ export default function (pi: ExtensionAPI) {
150
165
  typedHandle = createTypedAsk(configSnapshot(config));
151
166
  outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
152
167
  exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
153
- return config;
168
+ return getConfig();
154
169
  }
155
170
 
156
171
  /** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
@@ -324,6 +339,7 @@ export default function (pi: ExtensionAPI) {
324
339
  async function switchToGuarded(
325
340
  registry: ModelRegistry,
326
341
  ): Promise<{ ok: boolean; error?: string; model?: string }> {
342
+ if (overlays.size === 0) refreshUpstream(registry);
327
343
  const primary = primaryTwin();
328
344
  if (!primary) {
329
345
  return { ok: false, error: "nessun guarded attivo — prima /jev upstream <provider> <model>" };
@@ -359,17 +375,11 @@ export default function (pi: ExtensionAPI) {
359
375
  registerJevCommand(pi, {
360
376
  config: getConfig,
361
377
  setMode: (mode) => {
362
- config = { ...config, mode };
363
- handle = createReviewer(configSnapshot(config));
364
- gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
365
- typedHandle = createTypedAsk(configSnapshot(config));
366
- try {
367
- saveConfig(config);
368
- } catch {
369
- // Resta in memoria anche se il salvataggio fallisce.
370
- }
371
- return config;
378
+ pi.appendEntry("jev-mode", { mode });
379
+ sessionMode = mode;
380
+ return getConfig();
372
381
  },
382
+ sharedConfig: () => config,
373
383
  reload: refresh,
374
384
  review: getReview,
375
385
  reviewerInfo: getInfo,
@@ -392,16 +402,15 @@ export default function (pi: ExtensionAPI) {
392
402
  // Seed all'avvio: pi ripristina il modello di sessione PRIMA di
393
403
  // session_start, quindi un twin persistito non esisterebbe ancora e
394
404
  // cadrebbe su "Could not restore model ...__jev". Pre-registriamo un
395
- // overlay statico dai provider builtin quando la config persiste
396
- // automatic (o JEV_AUTO_UPSTREAM è impostato); session_start lo
397
- // sostituisce col clone live dal registry.
405
+ // overlay per tutti i twin salvati, anche in on-demand: il mode della
406
+ // sessione non è disponibile prima di session_start. Un alias non attiva il gate.
398
407
  const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
399
408
  const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
400
409
  let seedRefs: TwinRef[] = [];
401
410
  if (bootAuto && !autoOff) {
402
411
  const parsed = parseUpstreamRef([bootAuto]);
403
412
  if (!("error" in parsed)) seedRefs = [parsed];
404
- } else if (!bootAuto && config.mode === "automatic") {
413
+ } else if (!bootAuto) {
405
414
  seedRefs = twinRefs(config);
406
415
  }
407
416
  if (seedRefs.length > 0) {
@@ -439,7 +448,8 @@ export default function (pi: ExtensionAPI) {
439
448
  }
440
449
  }
441
450
 
442
- pi.on("session_start", async (_event, ctx) => {
451
+ async function startSession(ctx: ExtensionContext): Promise<void> {
452
+ restoreMode(ctx);
443
453
  const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
444
454
  if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
445
455
  try {
@@ -458,9 +468,17 @@ export default function (pi: ExtensionAPI) {
458
468
  // Resta opt-in manuale.
459
469
  }
460
470
  }
461
- // Config automatic senza guarded = contraddittorio: ripristina + seleziona.
471
+ // Automatic richiede il routing guarded della sessione.
462
472
  try {
463
- if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
473
+ if (sessionMode !== "automatic") {
474
+ // Reload can retain a stale alias after its registration was removed.
475
+ if (ctx.model && isTwinId(ctx.model.id) && !isGuardedModel(ctx.model.provider, ctx.model.id)) {
476
+ const original = ctx.modelRegistry.find(ctx.model.provider, untwinId(ctx.model.id));
477
+ if (original) await pi.setModel(original);
478
+ }
479
+ return;
480
+ }
481
+ if (!config.automatic.requireGuardedModel) return;
464
482
  if (overlays.size === 0) {
465
483
  const restored = refreshUpstream(ctx.modelRegistry);
466
484
  if (!restored.ok) {
@@ -473,7 +491,7 @@ export default function (pi: ExtensionAPI) {
473
491
  const primary = primaryTwin();
474
492
  if (!primary) return;
475
493
  const twinId = twinIdFor(primary.model);
476
- if (ctx.model?.provider === primary.provider && ctx.model?.id === twinId) return;
494
+ if (isGuardedModel(ctx.model?.provider, ctx.model?.id)) return;
477
495
  const twin = ctx.modelRegistry.find(primary.provider, twinId);
478
496
  if (!twin) return;
479
497
  const switched = await pi.setModel(twin);
@@ -488,13 +506,27 @@ export default function (pi: ExtensionAPI) {
488
506
  } catch {
489
507
  // Non bloccare l'avvio sessione.
490
508
  }
509
+ }
510
+ pi.on("session_start", async (_event, ctx) => {
511
+ await startSession(ctx);
512
+ pi.appendEntry("jev-mode", { mode: sessionMode });
491
513
  });
514
+ pi.on("session_tree", async (_event, ctx) => { await startSession(ctx); });
515
+
516
+ // Normalize persisted aliases even after /jev off removed their overlays.
517
+ // The event is a context copy: session history remains unchanged.
518
+ pi.on("context", async (event) => ({
519
+ messages: event.messages.map((message) =>
520
+ message.role === "assistant" && isTwinId(message.model)
521
+ ? { ...message, model: untwinId(message.model) }
522
+ : message),
523
+ }));
492
524
 
493
525
  // Enforcement a livello selezione: in automatic non si può restare
494
526
  // su un modello non guarded (revert immediato al twin).
495
527
  pi.on("model_select", async (event, ctx) => {
496
528
  try {
497
- if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
529
+ if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
498
530
  if (overlays.size === 0) return;
499
531
  if (isGuardedModel(event.model.provider, event.model.id)) return;
500
532
 
@@ -527,7 +559,7 @@ export default function (pi: ExtensionAPI) {
527
559
  // Jev non approva; mostra stato invece di silenzio ambiguo.
528
560
  pi.on("message_start", async (event, ctx) => {
529
561
  try {
530
- if (config.mode !== "automatic" || !ctx.hasUI) return;
562
+ if (sessionMode !== "automatic" || !ctx.hasUI) return;
531
563
  const msg: any = event.message;
532
564
  if (msg?.role !== "assistant") return;
533
565
  const provider: string | undefined =
@@ -545,7 +577,7 @@ export default function (pi: ExtensionAPI) {
545
577
  // Policy pre-esecuzione tool (solo automatic).
546
578
  pi.on("tool_call", async (event, ctx) => {
547
579
  try {
548
- if (config.mode !== "automatic") return;
580
+ if (sessionMode !== "automatic") return;
549
581
  if (!config.toolsPolicy.enabled) return;
550
582
 
551
583
  if (event.toolName === "bash" || event.toolName === "powershell") {
@@ -660,6 +692,7 @@ export default function (pi: ExtensionAPI) {
660
692
  const allow = await ctx.ui.confirm(
661
693
  "Jev: comando di rete sospetto",
662
694
  `${toolName}\n${summary}\n\nEseguire comunque?`,
695
+ { signal: ctx.signal },
663
696
  );
664
697
  return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
665
698
  }
@@ -669,7 +702,7 @@ export default function (pi: ExtensionAPI) {
669
702
  // una riga al risultato che il modello legge.
670
703
  pi.on("tool_result", async (event, ctx) => {
671
704
  try {
672
- if (config.mode !== "automatic") return;
705
+ if (sessionMode !== "automatic") return;
673
706
  if (!config.outputJudge.enabled) return;
674
707
  if (!config.outputJudge.tools.includes(event.toolName)) return;
675
708
  if (!resolveBackend(config).apiKeyPresent) return;
@@ -735,7 +768,7 @@ export default function (pi: ExtensionAPI) {
735
768
  pi.on("message_end", async (event, ctx) => {
736
769
  try {
737
770
  if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
738
- if (config.mode !== "automatic") return;
771
+ if (sessionMode !== "automatic") return;
739
772
  if (event.message.role !== "assistant") return;
740
773
 
741
774
  const msg = event.message as { provider?: string; model?: string };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Jev validation guard for pi: on-demand tool + automatic provider gate. Dual backend: TypeSafe direct + OpenRouter.",
6
6
  "keywords": [
@@ -20,6 +20,8 @@ import type {
20
20
  SimpleStreamOptions,
21
21
  } from "@earendil-works/pi-ai";
22
22
  import type { Api } from "@earendil-works/pi-ai";
23
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
24
+ import { emptyUsage } from "./serialize.ts";
23
25
  import type { JevConfig } from "../config.ts";
24
26
  import { configSnapshot } from "../config.ts";
25
27
  import type { ReviewFn } from "../reviewer.ts";
@@ -134,7 +136,7 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
134
136
  const chosen = new Set(deps.twinModelIds);
135
137
  // Dedup per id: sorgenti miste (builtin + store + statica) possono
136
138
  // ripetere lo stesso modello e produrrebbero twin duplicati.
137
- const originals = mergeModelsById(rawOriginals, []);
139
+ const originals = mergeModelsById(stripTwins(rawOriginals), []);
138
140
  const twins: Model<Api>[] = [];
139
141
  for (const original of originals) {
140
142
  if (chosen.has(original.id)) {
@@ -155,10 +157,19 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
155
157
  context: Context,
156
158
  options?: SimpleStreamOptions,
157
159
  ): AssistantMessageEventStream {
160
+ // Public aliases are not model changes. Preserve reasoning signatures,
161
+ // response phases and tool ids when replaying the transcript upstream.
162
+ const originalContext: Context = {
163
+ ...context,
164
+ messages: context.messages.map((message) =>
165
+ message.role === "assistant" && message.provider === upstream.id && isTwinId(message.model)
166
+ ? { ...message, model: untwinId(message.model) }
167
+ : message),
168
+ };
158
169
  if (deps.upstreamStreams) {
159
- return deps.upstreamStreams.streamSimple(model, context, options);
170
+ return deps.upstreamStreams.streamSimple(model, originalContext, options);
160
171
  }
161
- return upstream.streamSimple(model, context, options);
172
+ return upstream.streamSimple(model, originalContext, options);
162
173
  }
163
174
 
164
175
  function guardedStreamSimple(
@@ -167,9 +178,20 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
167
178
  options?: SimpleStreamOptions,
168
179
  ): AssistantMessageEventStream {
169
180
  const twinOf = twinToOriginal.get(model.id);
170
- // Non gemello: passthrough esatto all'upstream.
181
+ // A stale/unknown alias must never leak to the upstream API, even if a
182
+ // dynamic refresh removed its original from the catalogue.
171
183
  if (!twinOf) {
172
- return upstreamCall(model, context, options);
184
+ if (!isTwinId(model.id)) return upstreamCall(model, context, options);
185
+ const stream = createAssistantMessageEventStream();
186
+ const error = {
187
+ role: "assistant" as const, content: [], api: model.api,
188
+ provider: model.provider, model: model.id, usage: emptyUsage(),
189
+ stopReason: "error" as const, timestamp: Date.now(),
190
+ errorMessage: `Jev: twin non disponibile ${model.provider}/${model.id}. Ripristina con /jev upstream o seleziona il modello originale.`,
191
+ };
192
+ stream.push({ type: "error", reason: "error", error });
193
+ stream.end(error);
194
+ return stream;
173
195
  }
174
196
  const snapshot = configSnapshot(deps.getConfig());
175
197
  // Gemello in on-demand: passthrough (nessuna verifica implicita).
package/src/commands.ts CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, existsSync, statSync, realpathSync, writeFileSync, mkdirS
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
5
+ import { isTwinId, untwinId } from "./automatic/overlay.ts";
5
6
  import type { JevConfig, TwinRef } from "./config.ts";
6
7
  import {
7
8
  defaultConfigPath,
@@ -42,6 +43,8 @@ export interface UpstreamResult {
42
43
  export interface JevCommandDeps {
43
44
  config: () => JevConfig;
44
45
  setMode: (mode: "on-demand" | "automatic") => JevConfig;
46
+ /** Shared settings without the session-only mode override. */
47
+ sharedConfig?: () => JevConfig;
45
48
  reload: () => JevConfig;
46
49
  review: () => ReviewFn;
47
50
  reviewerInfo: () => { backend: string; model: string };
@@ -91,8 +94,8 @@ export const JEV_HELP_TEXT = [
91
94
  "Jev guard — verifica risposte con TypeSafe Jev (via OpenRouter o diretto).",
92
95
  "",
93
96
  " /jev status Stato: modalità, backend, policy, gate, auth",
94
- " /jev mode on-demand Solo verifiche esplicite (default, zero costi impliciti)",
95
- " /jev mode automatic Enforcement: seleziona il twin guarded + verifica",
97
+ " /jev mode on-demand Disattiva il gate nella sessione (scelta persistente)",
98
+ " /jev mode automatic Attiva il gate nella sessione (scelta persistente)",
96
99
  " /jev models [filtro] Modelli pi (● twin guarded attivo, ★ ultimo salvato)",
97
100
  " /jev upstream P M Aggiunge il twin M__jev nel provider P (salvato)",
98
101
  " /jev upstream P/M Stessa cosa in forma compatta",
@@ -111,7 +114,7 @@ export const JEV_HELP_TEXT = [
111
114
  "",
112
115
  "Flusso tipico: /jev upstream deepseek deepseek-flash → /model (scegli __jev)",
113
116
  "→ /jev mode automatic. Il twin usa la STESSA auth del provider: nessun login extra.",
114
- "Torna normale con: /model + /jev mode on-demand.",
117
+ "Torna normale con: /jev mode on-demand. La scelta sopravvive a /reload e /resume.",
115
118
  "Env JEV_AUTO_UPSTREAM=P/M: crea il twin all'avvio (default: nessuno;",
116
119
  "con pi --print solo deepseek/deepseek-flash).",
117
120
  ].join("\n");
@@ -130,6 +133,18 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
130
133
  const parts = args.trim().split(/\s+/).filter(Boolean);
131
134
  const sub = parts[0] ?? "status";
132
135
 
136
+ // Never unregister the route while a synthetic model is still selected.
137
+ // The caller must disable automatic enforcement before switching away.
138
+ async function leaveSelectedTwin(): Promise<boolean> {
139
+ if (!ctx.model || !isTwinId(ctx.model.id)) return true;
140
+ const original = ctx.modelRegistry.find(ctx.model.provider, untwinId(ctx.model.id));
141
+ try {
142
+ if (original && await pi.setModel(original)) return true;
143
+ } catch { /* Keep the overlay on failure. */ }
144
+ ctx.ui.notify("Impossibile selezionare il modello originale; twin mantenuto per evitare richieste __jev al provider.", "error");
145
+ return false;
146
+ }
147
+
133
148
  if (sub === "help" || sub === "--help" || sub === "-h") {
134
149
  ctx.ui.notify(JEV_HELP_TEXT, "info");
135
150
  return;
@@ -179,20 +194,21 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
179
194
  if (m?.provider && m?.id) {
180
195
  const ref = `${m.provider}/${m.id}`;
181
196
  currentModel =
182
- gate.active && ref === gate.twin ? `${ref} (twin: enforcement attivo)` : ref;
197
+ cfg.mode === "automatic" && twinView.active.some((r) => r.provider === m.provider && `${r.model}__jev` === m.id)
198
+ ? `${ref} (twin: enforcement attivo)` : ref;
183
199
  }
184
200
  } catch {
185
201
  currentModel = "sconosciuto";
186
202
  }
187
203
  ctx.ui.notify(
188
204
  [
189
- `Modalità: ${cfg.mode}`,
205
+ `Modalità: ${cfg.mode} (salvata nella sessione)`,
190
206
  `Backend: ${info.backend} (setting=${cfg.jev.backend}, ${resolved.reason})`,
191
207
  `Modello: ${info.model}`,
192
208
  `Verificatore timeout: ${resolved.timeoutMs}ms`,
193
209
  `Policy: ${cfg.policy.revision} pass<=${cfg.policy.passMaxFlawProbability} block>=${cfg.policy.blockMinFlawProbability}`,
194
210
  `Chiave presente: ${resolved.apiKeyPresent ? "sì" : "no"} (${resolved.keySource})`,
195
- `Gate: ${gate.active ? `attivo (twin ${gate.twin})` : `non attivo (${gate.reason})`}`,
211
+ `Gate: ${cfg.mode === "automatic" && gate.active ? `disponibile (twin primario ${gate.twin})` : `non attivo (${cfg.mode === "on-demand" ? "on-demand" : gate.reason})`}`,
196
212
  `Twin attivi: ${twinView.active.length > 0 ? twinView.active.map((r) => `${r.provider}/${r.model}__jev`).join(", ") : "nessuno"}`,
197
213
  `Twin nel picker: ${twinScope}`,
198
214
  `Modello corrente: ${currentModel}`,
@@ -220,14 +236,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
220
236
  if (value === "automatic") {
221
237
  const sw = await deps.switchToGuarded(ctx.modelRegistry);
222
238
  ctx.ui.notify(
223
- `Modalità automatic attivata. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
239
+ `Modalità automatic attivata per questa sessione. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
224
240
  (sw.ok
225
241
  ? `Modello selezionato: ${sw.model} (enforcement attivo).`
226
242
  : `ATTENZIONE: ${sw.error} (solo diagnostica, nessun enforcement).`),
227
243
  sw.ok ? "info" : "warning",
228
244
  );
229
245
  } else {
230
- ctx.ui.notify("Modalità on-demand attivata. Nessuna verifica automatica.", "info");
246
+ await leaveSelectedTwin();
247
+ ctx.ui.notify("Modalità on-demand salvata per questa sessione. Nessuna verifica automatica.", "info");
231
248
  }
232
249
  return;
233
250
  }
@@ -342,8 +359,21 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
342
359
  );
343
360
  return;
344
361
  }
362
+ const previousMode = deps.config().mode;
363
+ const selected = ctx.model?.provider === parsed.provider && ctx.model.id === `${parsed.model}__jev`;
364
+ if (selected) {
365
+ deps.setMode("on-demand");
366
+ if (!await leaveSelectedTwin()) {
367
+ deps.setMode(previousMode);
368
+ return;
369
+ }
370
+ }
345
371
  const res = deps.removeTwin(ctx.modelRegistry, parsed.provider, parsed.model);
346
372
  const total = deps.twinStatus().active.length;
373
+ if (selected && previousMode === "automatic" && total > 0) {
374
+ deps.setMode(previousMode);
375
+ await deps.switchToGuarded(ctx.modelRegistry);
376
+ }
347
377
  ctx.ui.notify(
348
378
  res.ok
349
379
  ? `Twin rimosso: ${parsed.provider}/${parsed.model} (attivi: ${total}).`
@@ -354,11 +384,13 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
354
384
  }
355
385
 
356
386
  if (sub === "off") {
357
- const res = deps.removeGuarded();
358
- if (res.ok) {
359
- // Senza twin, automatic è contraddittorio: torna on-demand.
360
- deps.setMode("on-demand");
387
+ const previousMode = deps.config().mode;
388
+ deps.setMode("on-demand");
389
+ if (!await leaveSelectedTwin()) {
390
+ deps.setMode(previousMode);
391
+ return;
361
392
  }
393
+ const res = deps.removeGuarded();
362
394
  ctx.ui.notify(
363
395
  res.ok
364
396
  ? `Twin rimossi: ${res.removed ?? 0} (provider originali ripristinati), modalità on-demand.`
@@ -465,7 +497,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
465
497
  // Punta la config al file e ricarica gli handle col nuovo stato.
466
498
  cfg.jev.apiKeyFile = target;
467
499
  try {
468
- saveConfig(cfg);
500
+ saveConfig({ ...cfg, mode: deps.sharedConfig?.().mode ?? cfg.mode });
469
501
  } catch {
470
502
  // Resta in memoria anche se il salvataggio fallisce.
471
503
  }
package/src/config.ts CHANGED
@@ -408,7 +408,8 @@ export function withTwinRefs(config: JevConfig, refs: TwinRef[]): JevConfig {
408
408
  automatic: {
409
409
  ...config.automatic,
410
410
  twins: refs.map((r) => ({ ...r })),
411
- ...(primary ? { upstreamProvider: primary.provider, upstreamModel: primary.model } : {}),
411
+ upstreamProvider: primary?.provider ?? "",
412
+ upstreamModel: primary?.model ?? "",
412
413
  },
413
414
  };
414
415
  }