pi-jev-guard 0.2.0 → 0.2.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.
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,49 @@ 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.
78
+
79
+ Se filtri i modelli con `enabledModels`, usa i glob invece delle voci esplicite,
80
+ così i twin futuri non restano nascosti:
81
+
82
+ ```json
83
+ "enabledModels": ["openai-codex/*__jev", "deepseek/*__jev", "...altri pattern..."]
84
+ ```
67
85
 
68
- Se filtri i modelli con `enabledModels`, tieni le voci `__jev` dei twin che usi
69
- o restano nascosti.
86
+ Dopo aver aggiunto un twin serve `/reload` (lo scope è risolto all'avvio della sessione).
87
+ `/jev upstream` avvisa subito quando il twin appena creato è fuori dal picker.
70
88
  L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
71
89
 
72
- Headless/CI: `JEV_AUTO_UPSTREAM=provider/model` crea il guarded all'avvio
73
- (con `pi --print` solo `deepseek/deepseek-flash`).
90
+ Headless/CI: `JEV_MODE=automatic JEV_AUTO_UPSTREAM=provider/model` crea e attiva
91
+ il guarded nelle sessioni senza uno stato Jev salvato.
92
+
93
+ Un hold `[JEV — OUTPUT NON PUBBLICATO]` interrompe intenzionalmente il turno:
94
+ controlla `/jev last`. Non viene aggirato con un proseguimento automatico.
95
+
96
+ Verdetto **incerto** (`review`): il gate tenta prima una riga riparazione privata
97
+ (`automatic.repairOnReview`, default `true`), poi applica la policy configurata:
98
+ `policy.onUncertain` e `policy.onUnavailable` (`"hold"` default, oppure `"pass"`).
99
+ Con `"pass"` il testo viene pubblicato con una nota di trasparenza sul verdetto.
100
+ Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
101
+ la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
74
102
 
75
103
  ## Come decide
76
104
 
@@ -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.2",
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": [
@@ -130,6 +130,26 @@ export async function consumePrivate(
130
130
  return final;
131
131
  }
132
132
 
133
+ /** Aggiunge una riga di trasparenza sul verdetto non conclusivo. */
134
+ function uncertainNotice(verdict: ReviewResult): string {
135
+ const top = [...verdict.checks].sort((a, b) => b.pFlaw - a.pFlaw)[0];
136
+ const detail = top ? ` (${top.ruleId} p=${top.pFlaw.toFixed(3)})` : "";
137
+ const reason = verdict.errorCode ? ` errore=${verdict.errorCode}` : "";
138
+ return `\n\n> [jev] verdetto non conclusivo: ${verdict.status}${detail}${reason} — pubblicato per policy onUncertain/onUnavailable=pass.`;
139
+ }
140
+
141
+ /** Copia del candidato con la nota accodata all'ultimo blocco di testo. */
142
+ function withNotice(message: AssistantMessage, notice: string): AssistantMessage {
143
+ const content = message.content.map((block) => ({ ...block }));
144
+ const last = [...content].reverse().find((block) => block.type === "text");
145
+ if (last && last.type === "text") {
146
+ last.text = `${last.text}${notice}`;
147
+ } else {
148
+ content.push({ type: "text", text: notice.trimStart() });
149
+ }
150
+ return { ...message, content };
151
+ }
152
+
133
153
  function assertCompleteCandidate(message: AssistantMessage): void {
134
154
  // Rifiuta terminali non supportati per pubblicazione.
135
155
  if (
@@ -177,6 +197,10 @@ export function runGuardedStream(
177
197
  const usages: Usage[] = [];
178
198
  let attemptContext = originalContext;
179
199
  const maxRegenerations = deps.config.automatic.maxRegenerations;
200
+ // Un verdetto incerto non è un difetto accertato: un solo tentativo di
201
+ // riparazione mirata evita l'hold immediato su prosa borderline.
202
+ const maxReviewRepairs = deps.config.automatic.repairOnReview === false ? 0 : 1;
203
+ let reviewRepairs = 0;
180
204
 
181
205
  try {
182
206
  for (let attempt = 0; attempt <= maxRegenerations; attempt++) {
@@ -224,10 +248,11 @@ export function runGuardedStream(
224
248
  }
225
249
  signal?.throwIfAborted();
226
250
 
227
- if (verdict.status === "pass") {
251
+ const publishCandidate = async (notice?: string): Promise<void> => {
228
252
  deps.onVerdict?.({ verdict, attempts: attempt + 1, outcome: "published" });
253
+ const approved = notice ? withNotice(candidate, notice) : candidate;
229
254
  const aggregated = aggregateUsage(usages);
230
- const replayed = replayApproved(candidate, {
255
+ const replayed = replayApproved(approved, {
231
256
  provider: deps.publishedProvider,
232
257
  model: deps.publishedModel,
233
258
  usage: aggregated,
@@ -239,10 +264,19 @@ export function runGuardedStream(
239
264
  }
240
265
  const final = await replayed.result();
241
266
  out.end(final);
267
+ };
268
+
269
+ if (verdict.status === "pass") {
270
+ await publishCandidate();
242
271
  return;
243
272
  }
244
273
 
245
- if (verdict.status === "block" && attempt < maxRegenerations) {
274
+ const canRepair =
275
+ verdict.status === "block"
276
+ ? attempt < maxRegenerations
277
+ : verdict.status === "review" && reviewRepairs < maxReviewRepairs;
278
+ if (canRepair) {
279
+ if (verdict.status === "review") reviewRepairs++;
246
280
  attemptContext = buildRepairContext({
247
281
  originalContext,
248
282
  rejectedText: rawCandidate,
@@ -252,6 +286,15 @@ export function runGuardedStream(
252
286
  continue;
253
287
  }
254
288
 
289
+ // Policy esplicita per i verdetti non conclusivi (config, non silenziosa).
290
+ const uncertainPass = verdict.status === "review" && deps.config.policy.onUncertain === "pass";
291
+ const unavailablePass =
292
+ verdict.status === "unavailable" && deps.config.policy.onUnavailable === "pass";
293
+ if (uncertainPass || unavailablePass) {
294
+ await publishCandidate(uncertainNotice(verdict));
295
+ return;
296
+ }
297
+
255
298
  // Hold: pubblica failure sicura, mai il candidato bocciato.
256
299
  const attempts = attempt + 1;
257
300
  deps.onVerdict?.({ verdict, attempts, outcome: "held" });
@@ -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,32 @@ 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
+ // Terza domanda del picker: registrato, visibile, selezionato. Il twin
137
+ // appena creato non è mai nello scope risolto a inizio sessione.
138
+ function twinInScope(provider: string, modelId: string): boolean {
139
+ try {
140
+ const scoped = ctx.scopedModels ?? [];
141
+ if (scoped.length === 0) return true;
142
+ return scoped.some(
143
+ (s) => s.model.provider === provider && s.model.id === `${modelId}__jev`,
144
+ );
145
+ } catch {
146
+ return true;
147
+ }
148
+ }
149
+
150
+ // Never unregister the route while a synthetic model is still selected.
151
+ // The caller must disable automatic enforcement before switching away.
152
+ async function leaveSelectedTwin(): Promise<boolean> {
153
+ if (!ctx.model || !isTwinId(ctx.model.id)) return true;
154
+ const original = ctx.modelRegistry.find(ctx.model.provider, untwinId(ctx.model.id));
155
+ try {
156
+ if (original && await pi.setModel(original)) return true;
157
+ } catch { /* Keep the overlay on failure. */ }
158
+ ctx.ui.notify("Impossibile selezionare il modello originale; twin mantenuto per evitare richieste __jev al provider.", "error");
159
+ return false;
160
+ }
161
+
133
162
  if (sub === "help" || sub === "--help" || sub === "-h") {
134
163
  ctx.ui.notify(JEV_HELP_TEXT, "info");
135
164
  return;
@@ -179,20 +208,21 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
179
208
  if (m?.provider && m?.id) {
180
209
  const ref = `${m.provider}/${m.id}`;
181
210
  currentModel =
182
- gate.active && ref === gate.twin ? `${ref} (twin: enforcement attivo)` : ref;
211
+ cfg.mode === "automatic" && twinView.active.some((r) => r.provider === m.provider && `${r.model}__jev` === m.id)
212
+ ? `${ref} (twin: enforcement attivo)` : ref;
183
213
  }
184
214
  } catch {
185
215
  currentModel = "sconosciuto";
186
216
  }
187
217
  ctx.ui.notify(
188
218
  [
189
- `Modalità: ${cfg.mode}`,
219
+ `Modalità: ${cfg.mode} (salvata nella sessione)`,
190
220
  `Backend: ${info.backend} (setting=${cfg.jev.backend}, ${resolved.reason})`,
191
221
  `Modello: ${info.model}`,
192
222
  `Verificatore timeout: ${resolved.timeoutMs}ms`,
193
223
  `Policy: ${cfg.policy.revision} pass<=${cfg.policy.passMaxFlawProbability} block>=${cfg.policy.blockMinFlawProbability}`,
194
224
  `Chiave presente: ${resolved.apiKeyPresent ? "sì" : "no"} (${resolved.keySource})`,
195
- `Gate: ${gate.active ? `attivo (twin ${gate.twin})` : `non attivo (${gate.reason})`}`,
225
+ `Gate: ${cfg.mode === "automatic" && gate.active ? `disponibile (twin primario ${gate.twin})` : `non attivo (${cfg.mode === "on-demand" ? "on-demand" : gate.reason})`}`,
196
226
  `Twin attivi: ${twinView.active.length > 0 ? twinView.active.map((r) => `${r.provider}/${r.model}__jev`).join(", ") : "nessuno"}`,
197
227
  `Twin nel picker: ${twinScope}`,
198
228
  `Modello corrente: ${currentModel}`,
@@ -220,14 +250,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
220
250
  if (value === "automatic") {
221
251
  const sw = await deps.switchToGuarded(ctx.modelRegistry);
222
252
  ctx.ui.notify(
223
- `Modalità automatic attivata. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
253
+ `Modalità automatic attivata per questa sessione. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
224
254
  (sw.ok
225
255
  ? `Modello selezionato: ${sw.model} (enforcement attivo).`
226
256
  : `ATTENZIONE: ${sw.error} (solo diagnostica, nessun enforcement).`),
227
257
  sw.ok ? "info" : "warning",
228
258
  );
229
259
  } else {
230
- ctx.ui.notify("Modalità on-demand attivata. Nessuna verifica automatica.", "info");
260
+ await leaveSelectedTwin();
261
+ ctx.ui.notify("Modalità on-demand salvata per questa sessione. Nessuna verifica automatica.", "info");
231
262
  }
232
263
  return;
233
264
  }
@@ -293,10 +324,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
293
324
  return;
294
325
  }
295
326
  const total = deps.twinStatus().active.length;
327
+ const visible = twinInScope(parsed.provider, parsed.model);
296
328
  ctx.ui.notify(
297
329
  `Twin aggiunto: ${res.twin} (totale attivi: ${total}, stessa auth del provider). ` +
298
- `Selezionalo con /model per enforcement in automatic.`,
299
- "info",
330
+ (visible
331
+ ? "Selezionalo con /model per enforcement in automatic."
332
+ : `ATTENZIONE: non compare in /model — enabledModels non lo copre. ` +
333
+ `Aggiungi "${parsed.provider}/*__jev" a enabledModels e fai /reload, ` +
334
+ `oppure attivalo direttamente con /jev mode automatic.`),
335
+ visible ? "info" : "warning",
300
336
  );
301
337
  return;
302
338
  }
@@ -319,7 +355,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
319
355
  const key = `${ref.provider}/${ref.model}`;
320
356
  const mark = activeKeys.has(key) ? "● " : "○ ";
321
357
  const star = key === primaryKey ? "★" : " ";
322
- lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}`);
358
+ const hidden = twinInScope(ref.provider, ref.model) ? "" : " (fuori dal picker)";
359
+ lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}${hidden}`);
323
360
  }
324
361
  ctx.ui.notify(
325
362
  [
@@ -342,8 +379,21 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
342
379
  );
343
380
  return;
344
381
  }
382
+ const previousMode = deps.config().mode;
383
+ const selected = ctx.model?.provider === parsed.provider && ctx.model.id === `${parsed.model}__jev`;
384
+ if (selected) {
385
+ deps.setMode("on-demand");
386
+ if (!await leaveSelectedTwin()) {
387
+ deps.setMode(previousMode);
388
+ return;
389
+ }
390
+ }
345
391
  const res = deps.removeTwin(ctx.modelRegistry, parsed.provider, parsed.model);
346
392
  const total = deps.twinStatus().active.length;
393
+ if (selected && previousMode === "automatic" && total > 0) {
394
+ deps.setMode(previousMode);
395
+ await deps.switchToGuarded(ctx.modelRegistry);
396
+ }
347
397
  ctx.ui.notify(
348
398
  res.ok
349
399
  ? `Twin rimosso: ${parsed.provider}/${parsed.model} (attivi: ${total}).`
@@ -354,11 +404,13 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
354
404
  }
355
405
 
356
406
  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");
407
+ const previousMode = deps.config().mode;
408
+ deps.setMode("on-demand");
409
+ if (!await leaveSelectedTwin()) {
410
+ deps.setMode(previousMode);
411
+ return;
361
412
  }
413
+ const res = deps.removeGuarded();
362
414
  ctx.ui.notify(
363
415
  res.ok
364
416
  ? `Twin rimossi: ${res.removed ?? 0} (provider originali ripristinati), modalità on-demand.`
@@ -465,7 +517,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
465
517
  // Punta la config al file e ricarica gli handle col nuovo stato.
466
518
  cfg.jev.apiKeyFile = target;
467
519
  try {
468
- saveConfig(cfg);
520
+ saveConfig({ ...cfg, mode: deps.sharedConfig?.().mode ?? cfg.mode });
469
521
  } catch {
470
522
  // Resta in memoria anche se il salvataggio fallisce.
471
523
  }
package/src/config.ts CHANGED
@@ -31,6 +31,8 @@ export interface JevConfig {
31
31
  automatic: {
32
32
  transport: "provider-gate";
33
33
  maxRegenerations: number;
34
+ /** Tentativo privato extra quando il verdetto è incerto (review). */
35
+ repairOnReview: boolean;
34
36
  requireGuardedModel: boolean;
35
37
  /** Twin salvati: più modelli guarded insieme (uno o più per provider). */
36
38
  twins: TwinRef[];
@@ -121,6 +123,7 @@ export const DEFAULT_CONFIG: JevConfig = {
121
123
  automatic: {
122
124
  transport: "provider-gate",
123
125
  maxRegenerations: 2,
126
+ repairOnReview: true,
124
127
  requireGuardedModel: true,
125
128
  twins: [],
126
129
  // Vuoti di default: nessun twin "salvato" finché l'utente non ne aggiunge
@@ -408,7 +411,8 @@ export function withTwinRefs(config: JevConfig, refs: TwinRef[]): JevConfig {
408
411
  automatic: {
409
412
  ...config.automatic,
410
413
  twins: refs.map((r) => ({ ...r })),
411
- ...(primary ? { upstreamProvider: primary.provider, upstreamModel: primary.model } : {}),
414
+ upstreamProvider: primary?.provider ?? "",
415
+ upstreamModel: primary?.model ?? "",
412
416
  },
413
417
  };
414
418
  }