pi-jev-guard 0.1.6 → 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
@@ -39,28 +47,46 @@ In chat, l'LLM può chiamare `jev_validate` per controlli mirati.
39
47
  ## Uso protetto
40
48
 
41
49
  ```text
42
- /jev upstream deepseek deepseek-flash # crea deepseek/deepseek-flash__jev
43
- /jev mode automatic # seleziona il twin + attiva enforcement
50
+ /jev upstream deepseek deepseek-flash # crea deepseek/deepseek-flash__jev
51
+ /jev upstream openai-codex gpt-5.6-terra # aggiunge openai-codex/gpt-5.6-terra__jev
52
+ /jev twins # elenca attivi e salvati
53
+ /jev untwin openai-codex gpt-5.6-terra # rimuove uno solo
54
+ /jev mode automatic # seleziona il primario + attiva enforcement
44
55
  ```
45
56
 
46
- Nessun login extra: il twin vive nello stesso provider e riusa la credenziale già configurata (env, stored, OAuth/SSO come Codex business plan). I modelli originali restano intatti e selezionabili.
57
+ **Più twin insieme**: ogni `/jev upstream` *aggiunge* un modello guarded, senza
58
+ rimuovere i precedenti. I twin vengono registrati anche all'avvio (i modelli
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.
62
+
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.
47
64
 
48
- In `automatic` il twin viene registrato già all'avvio (prima del ripristino del
49
- 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.
50
69
 
51
- Torna normale con `/model` (modello normale) + `/jev mode on-demand`.
70
+ Torna normale con `/jev mode on-demand`, che seleziona anche il modello originale.
52
71
  In `automatic`, selezionare un modello non protetto lo fa ritornare
53
72
  subito al twin (enforcement a livello selezione).
54
- `/jev upstream` senza argomenti ripristina l'ultimo salvato, `/jev off` rimuove il twin
55
- (e torna a on-demand, provider originale ripristinato). In `automatic` il twin
56
- si ripristina da solo al riavvio.
57
-
58
- Se filtri i modelli con `enabledModels`, tieni la voce del twin (es.
59
- `deepseek/deepseek-flash__jev`) o resta nascosto.
73
+ `/jev upstream` senza argomenti ripristina tutti i twin salvati, `/jev untwin P M`
74
+ ne rimuove uno solo, `/jev off` li rimuove tutti
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`, tieni le voci `__jev` dei twin che usi
80
+ o restano nascosti.
60
81
  L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
61
82
 
62
- Headless/CI: `JEV_AUTO_UPSTREAM=provider/model` crea il guarded all'avvio
63
- (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.
64
90
 
65
91
  ## Come decide
66
92
 
@@ -8,7 +8,10 @@
8
8
  "timeoutMs": 4000,
9
9
  "timeoutMsTypesafe": 1500,
10
10
  "timeoutMsOpenRouter": 4000,
11
- "retryTransients": { "enabled": true, "maxRetries": 1 },
11
+ "retryTransients": {
12
+ "enabled": true,
13
+ "maxRetries": 1
14
+ },
12
15
  "apiKeyFile": "~/.pi/agent/jev-api-key.txt"
13
16
  },
14
17
  "policy": {
@@ -22,6 +25,16 @@
22
25
  "transport": "provider-gate",
23
26
  "maxRegenerations": 2,
24
27
  "requireGuardedModel": true,
28
+ "twins": [
29
+ {
30
+ "provider": "deepseek",
31
+ "model": "deepseek-flash"
32
+ },
33
+ {
34
+ "provider": "openai-codex",
35
+ "model": "gpt-5.6-terra"
36
+ }
37
+ ],
25
38
  "upstreamProvider": "deepseek",
26
39
  "upstreamModel": "deepseek-flash"
27
40
  },
@@ -44,7 +57,9 @@
44
57
  },
45
58
  "outputJudge": {
46
59
  "enabled": true,
47
- "tools": ["bash"],
60
+ "tools": [
61
+ "bash"
62
+ ],
48
63
  "outputChars": 2000,
49
64
  "leakThreshold": 0.9,
50
65
  "minConfidence": 0.6,
@@ -52,8 +67,16 @@
52
67
  },
53
68
  "exfilCheck": {
54
69
  "enabled": true,
55
- "tools": ["bash", "powershell"],
56
- "blockOn": { "destructive": 0.9, "exfiltration": 0.7, "beyondScope": 0.85, "impact": 2.5 },
70
+ "tools": [
71
+ "bash",
72
+ "powershell"
73
+ ],
74
+ "blockOn": {
75
+ "destructive": 0.9,
76
+ "exfiltration": 0.7,
77
+ "beyondScope": 0.85,
78
+ "impact": 2.5
79
+ },
57
80
  "minConfidence": 0.5,
58
81
  "cacheSeconds": 120,
59
82
  "blockWithoutUI": false
@@ -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,
@@ -9,7 +10,10 @@ import {
9
10
  parseUpstreamRef,
10
11
  resolveBackend,
11
12
  saveConfig,
13
+ twinRefs,
14
+ withTwinRefs,
12
15
  type JevConfig,
16
+ type TwinRef,
13
17
  } from "../src/config.ts";
14
18
  import { createReviewer, createTypedAsk } from "../src/factory.ts";
15
19
  import { createMetrics } from "../src/metrics.ts";
@@ -18,7 +22,9 @@ import {
18
22
  STATIC_DEEPSEEK_FLASH,
19
23
  buildOverlaidProvider,
20
24
  isTwinId,
25
+ mergeModelsById,
21
26
  twinIdFor,
27
+ untwinId,
22
28
  } from "../src/automatic/overlay.ts";
23
29
  import type { GateVerdictReport } from "../src/automatic/guardian.ts";
24
30
  import { readStoreModels } from "../src/store-models.ts";
@@ -57,6 +63,20 @@ export interface GateStatus {
57
63
 
58
64
  export default function (pi: ExtensionAPI) {
59
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
+ }
60
80
  // Full (3 regole) per tool/comandi/diagnostica; gate (difetti concreti) per provider.
61
81
  let handle = createReviewer(configSnapshot(config));
62
82
  let gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
@@ -73,24 +93,55 @@ export default function (pi: ExtensionAPI) {
73
93
  let lastExfilErrorAt = 0;
74
94
  const metrics = createMetrics();
75
95
 
76
- // Opt-in: nessun overlay all'avvio. Si crea solo con /jev upstream
77
- // (o ripristino automatico quando la config dice automatic).
78
- let overlaidProviderId: string | undefined;
79
- let upstreamRefs = new Map<string, Provider>();
80
- let twinTarget: { provider: string; model: string } | undefined;
96
+ // Twin attivi, uno o più per provider. Nessun overlay finché la config non
97
+ // li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
98
+ interface OverlayState {
99
+ upstream: Provider;
100
+ twinIds: string[];
101
+ }
102
+ const overlays = new Map<string, OverlayState>();
81
103
 
82
- const getConfig = () => config;
83
104
  const getReview = () => handle.review;
84
105
  const getGateReview = () => gateHandle.review;
85
106
  const getInfo = () => ({ backend: handle.backend, model: handle.model });
86
107
 
108
+ /** Twin primario (auto-selezione e revert): ultimo aggiunto che è attivo. */
109
+ function primaryTwin(): TwinRef | undefined {
110
+ const saved = twinRefs(config);
111
+ const wanted =
112
+ saved.find(
113
+ (r) =>
114
+ r.provider === config.automatic.upstreamProvider &&
115
+ r.model === config.automatic.upstreamModel,
116
+ ) ?? saved[saved.length - 1];
117
+ if (wanted && overlays.get(wanted.provider)?.twinIds.includes(wanted.model)) {
118
+ return wanted;
119
+ }
120
+ // Ripiego: primo twin attivo.
121
+ for (const [providerId, state] of overlays) {
122
+ const model = state.twinIds[0];
123
+ if (model) return { provider: providerId, model };
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ function activeTwinRefs(): TwinRef[] {
129
+ const refs: TwinRef[] = [];
130
+ for (const [providerId, state] of overlays) {
131
+ for (const model of state.twinIds) refs.push({ provider: providerId, model });
132
+ }
133
+ return refs;
134
+ }
135
+
87
136
  function getGateStatus(): GateStatus {
88
- if (overlaidProviderId && twinTarget) {
137
+ const primary = primaryTwin();
138
+ if (primary) {
139
+ const count = activeTwinRefs().length;
89
140
  return {
90
141
  active: true,
91
- twin: `${twinTarget.provider}/${twinIdFor(twinTarget.model)}`,
92
- upstream: `${twinTarget.provider}/${twinTarget.model}`,
93
- reason: `overlay attivo su ${overlaidProviderId} (stessa auth del provider)`,
142
+ twin: `${primary.provider}/${twinIdFor(primary.model)}`,
143
+ upstream: `${primary.provider}/${primary.model}`,
144
+ reason: `${count} twin attiv${count === 1 ? "o" : "i"} su ${overlays.size} provider (stessa auth)`,
94
145
  };
95
146
  }
96
147
  return {
@@ -99,13 +150,12 @@ export default function (pi: ExtensionAPI) {
99
150
  };
100
151
  }
101
152
 
153
+ /** True se la coppia provider/modello è una twin da noi registrata. */
102
154
  function isGuardedModel(provider: string | undefined, modelId: string | undefined): boolean {
103
- if (!overlaidProviderId || !twinTarget || !provider || !modelId) return false;
104
- return (
105
- provider === twinTarget.provider &&
106
- modelId === twinIdFor(twinTarget.model) &&
107
- isTwinId(modelId)
108
- );
155
+ if (!provider || !modelId || !isTwinId(modelId)) return false;
156
+ const state = overlays.get(provider);
157
+ if (!state) return false;
158
+ return state.twinIds.some((model) => twinIdFor(model) === modelId);
109
159
  }
110
160
 
111
161
  function refresh() {
@@ -115,18 +165,15 @@ export default function (pi: ExtensionAPI) {
115
165
  typedHandle = createTypedAsk(configSnapshot(config));
116
166
  outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
117
167
  exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
118
- return config;
168
+ return getConfig();
119
169
  }
120
170
 
121
- /** Provider upstream vero, spacchettando il nostro overlay se già attivo. */
171
+ /** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
122
172
  function liveUpstreamProvider(
123
173
  registry: ModelRegistry,
124
174
  providerId: string,
125
175
  ): Provider | undefined {
126
- if (overlaidProviderId === providerId) {
127
- return upstreamRefs.get(providerId);
128
- }
129
- return registry.getProvider(providerId);
176
+ return overlays.get(providerId)?.upstream ?? registry.getProvider(providerId);
130
177
  }
131
178
 
132
179
  function snapshotUpstream(
@@ -147,56 +194,78 @@ export default function (pi: ExtensionAPI) {
147
194
  return { provider, model: model as Model<Api> };
148
195
  }
149
196
 
150
- function applyOverlay(
151
- snapshot: { provider: Provider; model: Model<Api> },
197
+ /** Registra (o aggiorna) l'overlay di un provider con i suoi twin. */
198
+ function registerOverlay(
199
+ providerId: string,
200
+ upstream: Provider,
201
+ twinIds: string[],
202
+ ): void {
203
+ const overlay = buildOverlaidProvider({
204
+ upstream,
205
+ twinModelIds: twinIds,
206
+ getConfig,
207
+ getReview: getGateReview,
208
+ onVerdict: recordGateVerdict,
209
+ });
210
+ pi.registerProvider(overlay);
211
+ overlays.set(providerId, { upstream, twinIds: [...twinIds] });
212
+ }
213
+
214
+ /**
215
+ * Allinea il registry ai twin richiesti: registra i provider coinvolti e
216
+ * ripristina (unregister) quelli che non hanno più twin.
217
+ */
218
+ function applyTwins(
219
+ refs: TwinRef[],
220
+ registry: ModelRegistry,
152
221
  persist: boolean,
153
- ): UpstreamResult {
154
- const providerId = snapshot.provider.id;
222
+ ): { ok: boolean; error?: string; twins?: string[] } {
223
+ const byProvider = new Map<string, string[]>();
224
+ for (const ref of refs) {
225
+ const list = byProvider.get(ref.provider) ?? [];
226
+ if (!list.includes(ref.model)) list.push(ref.model);
227
+ byProvider.set(ref.provider, list);
228
+ }
229
+
155
230
  try {
156
- // Rimuovi overlay precedente su provider diverso (ripristina builtin).
157
- if (overlaidProviderId && overlaidProviderId !== providerId) {
158
- try {
159
- pi.unregisterProvider(overlaidProviderId);
160
- } catch {
161
- // ignora
231
+ // Provider non più gemellati: unregister builtin ripristinato.
232
+ for (const providerId of [...overlays.keys()]) {
233
+ if (!byProvider.has(providerId)) {
234
+ try {
235
+ pi.unregisterProvider(providerId);
236
+ } catch {
237
+ // ignora
238
+ }
239
+ overlays.delete(providerId);
162
240
  }
163
- upstreamRefs.delete(overlaidProviderId);
164
241
  }
165
- // Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
166
- if (!upstreamRefs.has(providerId)) {
167
- upstreamRefs.set(providerId, snapshot.provider);
242
+
243
+ for (const [providerId, twinIds] of byProvider) {
244
+ // Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
245
+ const existing = overlays.get(providerId)?.upstream;
246
+ const live = existing ?? registry.getProvider(providerId);
247
+ if (!live) return { ok: false, error: `Provider non trovato: ${providerId}` };
248
+ for (const model of twinIds) {
249
+ const has = live.getModels().some((m) => m.id === model && !isTwinId(m.id));
250
+ if (!has) return { ok: false, error: `Modello non trovato: ${providerId}/${model}` };
251
+ }
252
+ registerOverlay(providerId, live, twinIds);
168
253
  }
169
- const overlay = buildOverlaidProvider({
170
- upstream: upstreamRefs.get(providerId) as Provider,
171
- twinModelIds: [snapshot.model.id],
172
- getConfig,
173
- getReview: getGateReview,
174
- onVerdict: recordGateVerdict,
175
- });
176
- pi.registerProvider(overlay);
177
- overlaidProviderId = providerId;
178
- twinTarget = { provider: providerId, model: snapshot.model.id };
179
- config = {
180
- ...config,
181
- automatic: {
182
- ...config.automatic,
183
- upstreamProvider: providerId,
184
- upstreamModel: snapshot.model.id,
185
- },
186
- };
254
+
255
+ config = withTwinRefs(config, refs);
187
256
  if (persist) {
188
257
  try {
189
258
  saveConfig(config);
190
259
  } catch (error) {
191
260
  return {
192
261
  ok: false,
193
- error: `Upstream aggiornato in memoria ma salvataggio fallito: ${
262
+ error: `Twin aggiornati in memoria ma salvataggio fallito: ${
194
263
  error instanceof Error ? error.message : String(error)
195
264
  }`,
196
265
  };
197
266
  }
198
267
  }
199
- return { ok: true, twin: `${providerId}/${twinIdFor(snapshot.model.id)}` };
268
+ return { ok: true, twins: activeTwinRefs().map((r) => `${r.provider}/${twinIdFor(r.model)}`) };
200
269
  } catch (error) {
201
270
  return {
202
271
  ok: false,
@@ -205,7 +274,7 @@ export default function (pi: ExtensionAPI) {
205
274
  }
206
275
  }
207
276
 
208
- /** Crea o sposta il twin guarded (persistente). */
277
+ /** Aggiunge un twin (persistente). Gli altri restano attivi. */
209
278
  function setUpstream(
210
279
  registry: ModelRegistry,
211
280
  providerId: string,
@@ -213,55 +282,79 @@ export default function (pi: ExtensionAPI) {
213
282
  ): UpstreamResult {
214
283
  const found = snapshotUpstream(registry, providerId, modelId);
215
284
  if ("error" in found) return { ok: false, error: found.error };
216
- return applyOverlay(found, true);
285
+ const refs = twinRefs(config).filter(
286
+ (r) => !(r.provider === providerId && r.model === modelId),
287
+ );
288
+ refs.push({ provider: providerId, model: modelId });
289
+ const res = applyTwins(refs, registry, true);
290
+ if (!res.ok) return { ok: false, error: res.error };
291
+ return { ok: true, twin: `${providerId}/${twinIdFor(modelId)}` };
292
+ }
293
+
294
+ /** Rimuove un twin; se il provider resta senza twin, ripristina il builtin. */
295
+ function removeTwin(
296
+ registry: ModelRegistry,
297
+ providerId: string,
298
+ modelId: string,
299
+ ): { ok: boolean; error?: string } {
300
+ const refs = twinRefs(config);
301
+ const next = refs.filter((r) => !(r.provider === providerId && r.model === modelId));
302
+ if (next.length === refs.length) {
303
+ return { ok: false, error: `twin non registrato: ${providerId}/${modelId}` };
304
+ }
305
+ const res = applyTwins(next, registry, true);
306
+ return res.ok ? { ok: true } : { ok: false, error: res.error };
217
307
  }
218
308
 
219
- /** Riapplica l'ultimo upstream salvato in config. */
309
+ /** Riapplica tutti i twin salvati in config (es. dopo il riavvio). */
220
310
  function refreshUpstream(registry: ModelRegistry): UpstreamResult {
221
- const found = snapshotUpstream(
222
- registry,
223
- config.automatic.upstreamProvider,
224
- config.automatic.upstreamModel,
225
- );
226
- if ("error" in found) return { ok: false, error: found.error };
227
- return applyOverlay(found, false);
311
+ const res = applyTwins(twinRefs(config), registry, false);
312
+ if (!res.ok) return { ok: false, error: res.error };
313
+ const primary = primaryTwin();
314
+ return {
315
+ ok: true,
316
+ twin: primary ? `${primary.provider}/${twinIdFor(primary.model)}` : undefined,
317
+ };
228
318
  }
229
319
 
230
- /** Rimuove l'overlay (il builtin torna da solo). */
231
- function removeGuarded(): { ok: boolean; error?: string } {
232
- if (!overlaidProviderId) return { ok: false, error: "nessun guarded attivo" };
233
- try {
234
- pi.unregisterProvider(overlaidProviderId);
235
- } catch (error) {
236
- return {
237
- ok: false,
238
- error: error instanceof Error ? error.message : String(error),
239
- };
320
+ /** Rimuove tutti gli overlay (i builtin tornano da soli). */
321
+ function removeGuarded(): { ok: boolean; error?: string; removed?: number } {
322
+ if (overlays.size === 0) return { ok: false, error: "nessun guarded attivo" };
323
+ const removed = overlays.size;
324
+ for (const providerId of [...overlays.keys()]) {
325
+ try {
326
+ pi.unregisterProvider(providerId);
327
+ } catch (error) {
328
+ return {
329
+ ok: false,
330
+ error: error instanceof Error ? error.message : String(error),
331
+ };
332
+ }
333
+ overlays.delete(providerId);
240
334
  }
241
- upstreamRefs.delete(overlaidProviderId);
242
- overlaidProviderId = undefined;
243
- twinTarget = undefined;
244
- return { ok: true };
335
+ return { ok: true, removed };
245
336
  }
246
337
 
247
- /** Seleziona il twin attivo come modello di sessione. */
338
+ /** Seleziona il twin primario come modello di sessione. */
248
339
  async function switchToGuarded(
249
340
  registry: ModelRegistry,
250
341
  ): Promise<{ ok: boolean; error?: string; model?: string }> {
251
- if (!overlaidProviderId || !twinTarget) {
342
+ if (overlays.size === 0) refreshUpstream(registry);
343
+ const primary = primaryTwin();
344
+ if (!primary) {
252
345
  return { ok: false, error: "nessun guarded attivo — prima /jev upstream <provider> <model>" };
253
346
  }
254
- const twinId = twinIdFor(twinTarget.model);
255
- const model = registry.find(overlaidProviderId, twinId);
347
+ const twinId = twinIdFor(primary.model);
348
+ const model = registry.find(primary.provider, twinId);
256
349
  if (!model) {
257
- return { ok: false, error: `twin non in registry: ${overlaidProviderId}/${twinId}` };
350
+ return { ok: false, error: `twin non in registry: ${primary.provider}/${twinId}` };
258
351
  }
259
352
  try {
260
353
  const ok = await pi.setModel(model);
261
354
  if (!ok) {
262
- return { ok: false, error: `auth mancante per ${overlaidProviderId} — /login ${overlaidProviderId}` };
355
+ return { ok: false, error: `auth mancante per ${primary.provider} — /login ${primary.provider}` };
263
356
  }
264
- return { ok: true, model: `${overlaidProviderId}/${model.id}` };
357
+ return { ok: true, model: `${primary.provider}/${model.id}` };
265
358
  } catch (error) {
266
359
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
267
360
  }
@@ -282,25 +375,25 @@ export default function (pi: ExtensionAPI) {
282
375
  registerJevCommand(pi, {
283
376
  config: getConfig,
284
377
  setMode: (mode) => {
285
- config = { ...config, mode };
286
- handle = createReviewer(configSnapshot(config));
287
- gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
288
- typedHandle = createTypedAsk(configSnapshot(config));
289
- try {
290
- saveConfig(config);
291
- } catch {
292
- // Resta in memoria anche se il salvataggio fallisce.
293
- }
294
- return config;
378
+ pi.appendEntry("jev-mode", { mode });
379
+ sessionMode = mode;
380
+ return getConfig();
295
381
  },
382
+ sharedConfig: () => config,
296
383
  reload: refresh,
297
384
  review: getReview,
298
385
  reviewerInfo: getInfo,
299
386
  metrics,
300
387
  gateStatus: getGateStatus,
388
+ twinStatus: () => ({
389
+ active: activeTwinRefs(),
390
+ saved: twinRefs(config),
391
+ primary: primaryTwin(),
392
+ }),
301
393
  setUpstream,
302
394
  refreshUpstream,
303
395
  removeGuarded,
396
+ removeTwin,
304
397
  switchToGuarded,
305
398
  getLastOutput: () => lastOutput,
306
399
  getLastGate: () => lastGate,
@@ -309,68 +402,54 @@ export default function (pi: ExtensionAPI) {
309
402
  // Seed all'avvio: pi ripristina il modello di sessione PRIMA di
310
403
  // session_start, quindi un twin persistito non esisterebbe ancora e
311
404
  // cadrebbe su "Could not restore model ...__jev". Pre-registriamo un
312
- // overlay statico dai provider builtin quando la config persiste
313
- // automatic (o JEV_AUTO_UPSTREAM è impostato); session_start lo
314
- // 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.
315
407
  const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
316
408
  const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
317
- let seedTarget: { provider: string; model: string } | undefined;
409
+ let seedRefs: TwinRef[] = [];
318
410
  if (bootAuto && !autoOff) {
319
411
  const parsed = parseUpstreamRef([bootAuto]);
320
- if (!("error" in parsed)) seedTarget = parsed;
321
- } else if (!bootAuto && config.mode === "automatic") {
322
- seedTarget = {
323
- provider: config.automatic.upstreamProvider,
324
- model: config.automatic.upstreamModel,
325
- };
412
+ if (!("error" in parsed)) seedRefs = [parsed];
413
+ } else if (!bootAuto) {
414
+ seedRefs = twinRefs(config);
326
415
  }
327
- if (seedTarget) {
328
- try {
329
- // providers/all è mappato dagli alias di pi; il subpath del singolo
330
- // provider non lo è e fallirebbe a load (solo installazioni npm).
331
- const base = builtinProviders().find((p) => p.id === seedTarget?.provider);
332
- if (!base) throw new Error(`provider ${seedTarget.provider} not in builtins`);
333
- // Il catalogo builtin può non avere il modello salvato: provider
334
- // dinamici (es. openai-codex) tengono la lista nel models-store,
335
- // altri (deepseek-flash su versioni vecchie) in una definizione statica.
336
- const storeModels = readStoreModels(seedTarget.provider);
337
- const extra = [
338
- ...(seedTarget.provider === "deepseek" && seedTarget.model === "deepseek-flash"
339
- ? [STATIC_DEEPSEEK_FLASH]
340
- : []),
341
- ...storeModels,
342
- ];
343
- const seedUpstream: Provider = {
344
- ...base,
345
- getModels: () => {
346
- const models = base.getModels();
347
- const missing = extra.filter(
348
- (m) => !models.some((existing) => existing.id === m.id),
349
- );
350
- return [...models, ...missing];
351
- },
352
- };
353
- const hasModel = seedUpstream
354
- .getModels()
355
- .some((m) => m.id === seedTarget?.model);
356
- if (!hasModel) throw new Error(`model ${seedTarget.model} not in builtins`);
357
- const overlay = buildOverlaidProvider({
358
- upstream: seedUpstream,
359
- twinModelIds: [seedTarget.model],
360
- getConfig,
361
- getReview: getGateReview,
362
- onVerdict: recordGateVerdict,
363
- });
364
- pi.registerProvider(overlay);
365
- upstreamRefs.set(seedTarget.provider, seedUpstream);
366
- overlaidProviderId = seedTarget.provider;
367
- twinTarget = { provider: seedTarget.provider, model: seedTarget.model };
368
- } catch {
369
- // Nessun seed: il twin arriverà da session_start se possibile.
416
+ if (seedRefs.length > 0) {
417
+ // Raggruppa per provider: un overlay per provider con tutti i suoi twin.
418
+ const byProvider = new Map<string, string[]>();
419
+ for (const ref of seedRefs) {
420
+ const list = byProvider.get(ref.provider) ?? [];
421
+ if (!list.includes(ref.model)) list.push(ref.model);
422
+ byProvider.set(ref.provider, list);
423
+ }
424
+ for (const [providerId, twinIds] of byProvider) {
425
+ try {
426
+ // providers/all è mappato dagli alias di pi; il subpath del singolo
427
+ // provider non lo è e fallirebbe a load (solo installazioni npm).
428
+ const base = builtinProviders().find((p) => p.id === providerId);
429
+ if (!base) throw new Error(`provider ${providerId} not in builtins`);
430
+ // Il catalogo builtin può non avere i modelli salvati: provider
431
+ // dinamici (es. openai-codex) li tengono nel models-store, altri
432
+ // (deepseek-flash su versioni vecchie) in una definizione statica.
433
+ const extra = [
434
+ ...(providerId === "deepseek" ? [STATIC_DEEPSEEK_FLASH] : []),
435
+ ...readStoreModels(providerId),
436
+ ];
437
+ const seedUpstream: Provider = {
438
+ ...base,
439
+ getModels: () => mergeModelsById(base.getModels(), extra),
440
+ };
441
+ const known = new Set(seedUpstream.getModels().map((m) => m.id));
442
+ const usable = twinIds.filter((model) => known.has(model));
443
+ if (usable.length === 0) continue;
444
+ registerOverlay(providerId, seedUpstream, usable);
445
+ } catch {
446
+ // Provider non gemellabile all'avvio: session_start riproverà dal registry.
447
+ }
370
448
  }
371
449
  }
372
450
 
373
- pi.on("session_start", async (_event, ctx) => {
451
+ async function startSession(ctx: ExtensionContext): Promise<void> {
452
+ restoreMode(ctx);
374
453
  const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
375
454
  if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
376
455
  try {
@@ -382,17 +461,25 @@ export default function (pi: ExtensionAPI) {
382
461
  if ("error" in found) {
383
462
  if (ctx.hasUI) ctx.ui.notify(`JEV_AUTO_UPSTREAM fallito: ${found.error}`, "warning");
384
463
  } else {
385
- applyOverlay(found, false);
464
+ applyTwins([parsed], ctx.modelRegistry, false);
386
465
  }
387
466
  }
388
467
  } catch {
389
468
  // Resta opt-in manuale.
390
469
  }
391
470
  }
392
- // Config automatic senza guarded = contraddittorio: ripristina + seleziona.
471
+ // Automatic richiede il routing guarded della sessione.
393
472
  try {
394
- if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
395
- if (!overlaidProviderId || !twinTarget) {
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;
482
+ if (overlays.size === 0) {
396
483
  const restored = refreshUpstream(ctx.modelRegistry);
397
484
  if (!restored.ok) {
398
485
  if (ctx.hasUI) {
@@ -401,37 +488,51 @@ export default function (pi: ExtensionAPI) {
401
488
  return;
402
489
  }
403
490
  }
404
- if (!overlaidProviderId || !twinTarget) return;
405
- const twinId = twinIdFor(twinTarget.model);
406
- if (ctx.model?.provider === overlaidProviderId && ctx.model?.id === twinId) return;
407
- const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
491
+ const primary = primaryTwin();
492
+ if (!primary) return;
493
+ const twinId = twinIdFor(primary.model);
494
+ if (isGuardedModel(ctx.model?.provider, ctx.model?.id)) return;
495
+ const twin = ctx.modelRegistry.find(primary.provider, twinId);
408
496
  if (!twin) return;
409
497
  const switched = await pi.setModel(twin);
410
498
  if (ctx.hasUI) {
411
499
  ctx.ui.notify(
412
500
  switched
413
- ? `Jev automatic: selezionato ${overlaidProviderId}/${twinId}.`
414
- : `Jev automatic: auth mancante per ${overlaidProviderId} — /login ${overlaidProviderId}.`,
501
+ ? `Jev automatic: selezionato ${primary.provider}/${twinId}.`
502
+ : `Jev automatic: auth mancante per ${primary.provider} — /login ${primary.provider}.`,
415
503
  switched ? "info" : "warning",
416
504
  );
417
505
  }
418
506
  } catch {
419
507
  // Non bloccare l'avvio sessione.
420
508
  }
509
+ }
510
+ pi.on("session_start", async (_event, ctx) => {
511
+ await startSession(ctx);
512
+ pi.appendEntry("jev-mode", { mode: sessionMode });
421
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
+ }));
422
524
 
423
525
  // Enforcement a livello selezione: in automatic non si può restare
424
526
  // su un modello non guarded (revert immediato al twin).
425
527
  pi.on("model_select", async (event, ctx) => {
426
528
  try {
427
- if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
428
- if (!overlaidProviderId || !twinTarget) return;
529
+ if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
530
+ if (overlays.size === 0) return;
429
531
  if (isGuardedModel(event.model.provider, event.model.id)) return;
430
532
 
431
- const twinId = twinIdFor(twinTarget.model);
432
- const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
533
+ const primary = primaryTwin();
433
534
  const fallback =
434
- twin ??
535
+ (primary ? ctx.modelRegistry.find(primary.provider, twinIdFor(primary.model)) : undefined) ??
435
536
  (event.previousModel && isGuardedModel(event.previousModel.provider, event.previousModel.id)
436
537
  ? event.previousModel
437
538
  : undefined);
@@ -458,7 +559,7 @@ export default function (pi: ExtensionAPI) {
458
559
  // Jev non approva; mostra stato invece di silenzio ambiguo.
459
560
  pi.on("message_start", async (event, ctx) => {
460
561
  try {
461
- if (config.mode !== "automatic" || !ctx.hasUI) return;
562
+ if (sessionMode !== "automatic" || !ctx.hasUI) return;
462
563
  const msg: any = event.message;
463
564
  if (msg?.role !== "assistant") return;
464
565
  const provider: string | undefined =
@@ -476,7 +577,7 @@ export default function (pi: ExtensionAPI) {
476
577
  // Policy pre-esecuzione tool (solo automatic).
477
578
  pi.on("tool_call", async (event, ctx) => {
478
579
  try {
479
- if (config.mode !== "automatic") return;
580
+ if (sessionMode !== "automatic") return;
480
581
  if (!config.toolsPolicy.enabled) return;
481
582
 
482
583
  if (event.toolName === "bash" || event.toolName === "powershell") {
@@ -591,6 +692,7 @@ export default function (pi: ExtensionAPI) {
591
692
  const allow = await ctx.ui.confirm(
592
693
  "Jev: comando di rete sospetto",
593
694
  `${toolName}\n${summary}\n\nEseguire comunque?`,
695
+ { signal: ctx.signal },
594
696
  );
595
697
  return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
596
698
  }
@@ -600,7 +702,7 @@ export default function (pi: ExtensionAPI) {
600
702
  // una riga al risultato che il modello legge.
601
703
  pi.on("tool_result", async (event, ctx) => {
602
704
  try {
603
- if (config.mode !== "automatic") return;
705
+ if (sessionMode !== "automatic") return;
604
706
  if (!config.outputJudge.enabled) return;
605
707
  if (!config.outputJudge.tools.includes(event.toolName)) return;
606
708
  if (!resolveBackend(config).apiKeyPresent) return;
@@ -666,14 +768,16 @@ export default function (pi: ExtensionAPI) {
666
768
  pi.on("message_end", async (event, ctx) => {
667
769
  try {
668
770
  if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
669
- if (config.mode !== "automatic") return;
771
+ if (sessionMode !== "automatic") return;
670
772
  if (event.message.role !== "assistant") return;
671
773
 
672
774
  const msg = event.message as { provider?: string; model?: string };
673
775
  if (isGuardedModel(msg.provider, msg.model)) {
776
+ const primary = primaryTwin();
674
777
  pi.appendEntry("jev-guarded", {
675
- twin: twinTarget ? `${twinTarget.provider}/${twinIdFor(twinTarget.model)}` : undefined,
676
- upstream: twinTarget ? `${twinTarget.provider}/${twinTarget.model}` : undefined,
778
+ twin: msg.model ? `${msg.provider}/${msg.model}` : undefined,
779
+ upstream: primary ? `${primary.provider}/${primary.model}` : undefined,
780
+ active: activeTwinRefs().map((r) => `${r.provider}/${r.model}`),
677
781
  });
678
782
  return;
679
783
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.1.6",
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";
@@ -100,6 +102,21 @@ export function twinModelFor(
100
102
  };
101
103
  }
102
104
 
105
+ /** Unione di liste modelli con dedup per id (il primo vince). */
106
+ export function mergeModelsById(
107
+ primary: readonly Model<Api>[],
108
+ extra: readonly Model<Api>[],
109
+ ): Model<Api>[] {
110
+ const out: Model<Api>[] = [];
111
+ const seen = new Set<string>();
112
+ for (const model of [...primary, ...extra]) {
113
+ if (!model || typeof model.id !== "string" || seen.has(model.id)) continue;
114
+ seen.add(model.id);
115
+ out.push(model);
116
+ }
117
+ return out;
118
+ }
119
+
103
120
  function stripTwins(models: readonly Model<Api>[]): Model<Api>[] {
104
121
  return models.filter((m) => !isTwinId(m.id)).map((m) => ({ ...m }));
105
122
  }
@@ -114,13 +131,17 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
114
131
  let twinToOriginal = new Map<string, Model<Api>>();
115
132
  let currentModels: Model<Api>[] = [];
116
133
 
117
- function rebuildTwins(originals: readonly Model<Api>[]): Model<Api>[] {
134
+ function rebuildTwins(rawOriginals: readonly Model<Api>[]): Model<Api>[] {
118
135
  twinToOriginal = new Map();
119
136
  const chosen = new Set(deps.twinModelIds);
137
+ // Dedup per id: sorgenti miste (builtin + store + statica) possono
138
+ // ripetere lo stesso modello e produrrebbero twin duplicati.
139
+ const originals = mergeModelsById(stripTwins(rawOriginals), []);
120
140
  const twins: Model<Api>[] = [];
121
141
  for (const original of originals) {
122
142
  if (chosen.has(original.id)) {
123
143
  const twin = twinModelFor(original, upstream.id);
144
+ if (twinToOriginal.has(twin.id)) continue;
124
145
  twinToOriginal.set(twin.id, original);
125
146
  twins.push(twin);
126
147
  }
@@ -136,10 +157,19 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
136
157
  context: Context,
137
158
  options?: SimpleStreamOptions,
138
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
+ };
139
169
  if (deps.upstreamStreams) {
140
- return deps.upstreamStreams.streamSimple(model, context, options);
170
+ return deps.upstreamStreams.streamSimple(model, originalContext, options);
141
171
  }
142
- return upstream.streamSimple(model, context, options);
172
+ return upstream.streamSimple(model, originalContext, options);
143
173
  }
144
174
 
145
175
  function guardedStreamSimple(
@@ -148,9 +178,20 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
148
178
  options?: SimpleStreamOptions,
149
179
  ): AssistantMessageEventStream {
150
180
  const twinOf = twinToOriginal.get(model.id);
151
- // 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.
152
183
  if (!twinOf) {
153
- 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;
154
195
  }
155
196
  const snapshot = configSnapshot(deps.getConfig());
156
197
  // Gemello in on-demand: passthrough (nessuna verifica implicita).
package/src/commands.ts CHANGED
@@ -2,8 +2,16 @@ 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 type { JevConfig } from "./config.ts";
6
- import { defaultConfigPath, expandHome, parseUpstreamRef, resolveBackend, saveConfig } from "./config.ts";
5
+ import { isTwinId, untwinId } from "./automatic/overlay.ts";
6
+ import type { JevConfig, TwinRef } from "./config.ts";
7
+ import {
8
+ defaultConfigPath,
9
+ expandHome,
10
+ parseUpstreamRef,
11
+ resolveBackend,
12
+ saveConfig,
13
+ twinRefs,
14
+ } from "./config.ts";
7
15
  import type { ReviewFn } from "./reviewer.ts";
8
16
  import { reviewWithCoverage } from "./reviewer.ts";
9
17
  import type { Metrics } from "./metrics.ts";
@@ -17,6 +25,15 @@ export interface GateStatusView {
17
25
  reason: string;
18
26
  }
19
27
 
28
+ export interface TwinStatusView {
29
+ /** Twin attivi adesso (overlay registrati). */
30
+ active: TwinRef[];
31
+ /** Twin salvati in config (ripristinati all'avvio/refresh). */
32
+ saved: TwinRef[];
33
+ /** Twin primario: auto-selezione e revert. */
34
+ primary?: TwinRef;
35
+ }
36
+
20
37
  export interface UpstreamResult {
21
38
  ok: boolean;
22
39
  error?: string;
@@ -26,6 +43,8 @@ export interface UpstreamResult {
26
43
  export interface JevCommandDeps {
27
44
  config: () => JevConfig;
28
45
  setMode: (mode: "on-demand" | "automatic") => JevConfig;
46
+ /** Shared settings without the session-only mode override. */
47
+ sharedConfig?: () => JevConfig;
29
48
  reload: () => JevConfig;
30
49
  review: () => ReviewFn;
31
50
  reviewerInfo: () => { backend: string; model: string };
@@ -37,7 +56,13 @@ export interface JevCommandDeps {
37
56
  modelId: string,
38
57
  ) => UpstreamResult;
39
58
  refreshUpstream: (registry: ModelRegistry) => UpstreamResult;
40
- removeGuarded: () => { ok: boolean; error?: string };
59
+ removeGuarded: () => { ok: boolean; error?: string; removed?: number };
60
+ removeTwin: (
61
+ registry: ModelRegistry,
62
+ providerId: string,
63
+ modelId: string,
64
+ ) => { ok: boolean; error?: string };
65
+ twinStatus: () => TwinStatusView;
41
66
  switchToGuarded: (registry: ModelRegistry) => Promise<{
42
67
  ok: boolean;
43
68
  error?: string;
@@ -52,6 +77,8 @@ export const JEV_SUBCOMMANDS = [
52
77
  "mode",
53
78
  "models",
54
79
  "upstream",
80
+ "twins",
81
+ "untwin",
55
82
  "off",
56
83
  "refresh-upstream",
57
84
  "check",
@@ -67,13 +94,15 @@ export const JEV_HELP_TEXT = [
67
94
  "Jev guard — verifica risposte con TypeSafe Jev (via OpenRouter o diretto).",
68
95
  "",
69
96
  " /jev status Stato: modalità, backend, policy, gate, auth",
70
- " /jev mode on-demand Solo verifiche esplicite (default, zero costi impliciti)",
71
- " /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)",
72
99
  " /jev models [filtro] Modelli pi (● twin guarded attivo, ★ ultimo salvato)",
73
- " /jev upstream P M Crea twin guarded M__jev dentro il provider P (salvato)",
100
+ " /jev upstream P M Aggiunge il twin M__jev nel provider P (salvato)",
74
101
  " /jev upstream P/M Stessa cosa in forma compatta",
75
- " /jev upstream Riapplica l'ultimo upstream salvato",
76
- " /jev off Rimuove il twin (torna on-demand)",
102
+ " /jev upstream Ripristina tutti i twin salvati",
103
+ " /jev twins Elenca twin attivi e salvati ( attivo, ★ primario)",
104
+ " /jev untwin P M Rimuove un twin (anche P/M)",
105
+ " /jev off Rimuove tutti i twin (torna on-demand)",
77
106
  " /jev refresh-upstream Ricrea il twin dal registry (dopo refresh cataloghi)",
78
107
  " /jev check <file> [req] Verifica un file subito, senza cambiare modalità",
79
108
  " /jev stats Conteggi pass/block/review/unavailable + latenze",
@@ -85,7 +114,7 @@ export const JEV_HELP_TEXT = [
85
114
  "",
86
115
  "Flusso tipico: /jev upstream deepseek deepseek-flash → /model (scegli __jev)",
87
116
  "→ /jev mode automatic. Il twin usa la STESSA auth del provider: nessun login extra.",
88
- "Torna normale con: /model + /jev mode on-demand.",
117
+ "Torna normale con: /jev mode on-demand. La scelta sopravvive a /reload e /resume.",
89
118
  "Env JEV_AUTO_UPSTREAM=P/M: crea il twin all'avvio (default: nessuno;",
90
119
  "con pi --print solo deepseek/deepseek-flash).",
91
120
  ].join("\n");
@@ -104,6 +133,18 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
104
133
  const parts = args.trim().split(/\s+/).filter(Boolean);
105
134
  const sub = parts[0] ?? "status";
106
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
+
107
148
  if (sub === "help" || sub === "--help" || sub === "-h") {
108
149
  ctx.ui.notify(JEV_HELP_TEXT, "info");
109
150
  return;
@@ -114,7 +155,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
114
155
  const resolved = resolveBackend(cfg);
115
156
  const info = deps.reviewerInfo();
116
157
  const gate = deps.gateStatus();
117
- const authProvider = gate.upstream?.split("/")[0] ?? cfg.automatic.upstreamProvider;
158
+ const twinView = deps.twinStatus();
159
+ const authProvider = twinView.primary?.provider ?? cfg.automatic.upstreamProvider;
118
160
  let guardedAuth = "—";
119
161
  if (gate.active) {
120
162
  try {
@@ -152,20 +194,22 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
152
194
  if (m?.provider && m?.id) {
153
195
  const ref = `${m.provider}/${m.id}`;
154
196
  currentModel =
155
- 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;
156
199
  }
157
200
  } catch {
158
201
  currentModel = "sconosciuto";
159
202
  }
160
203
  ctx.ui.notify(
161
204
  [
162
- `Modalità: ${cfg.mode}`,
205
+ `Modalità: ${cfg.mode} (salvata nella sessione)`,
163
206
  `Backend: ${info.backend} (setting=${cfg.jev.backend}, ${resolved.reason})`,
164
207
  `Modello: ${info.model}`,
165
208
  `Verificatore timeout: ${resolved.timeoutMs}ms`,
166
209
  `Policy: ${cfg.policy.revision} pass<=${cfg.policy.passMaxFlawProbability} block>=${cfg.policy.blockMinFlawProbability}`,
167
210
  `Chiave presente: ${resolved.apiKeyPresent ? "sì" : "no"} (${resolved.keySource})`,
168
- `Gate: ${gate.active ? `attivo (twin ${gate.twin}, stessa auth di ${authProvider})` : `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})`}`,
212
+ `Twin attivi: ${twinView.active.length > 0 ? twinView.active.map((r) => `${r.provider}/${r.model}__jev`).join(", ") : "nessuno"}`,
169
213
  `Twin nel picker: ${twinScope}`,
170
214
  `Modello corrente: ${currentModel}`,
171
215
  `Ultimo upstream salvato: ${cfg.automatic.upstreamProvider}/${cfg.automatic.upstreamModel}`,
@@ -192,14 +236,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
192
236
  if (value === "automatic") {
193
237
  const sw = await deps.switchToGuarded(ctx.modelRegistry);
194
238
  ctx.ui.notify(
195
- `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}. ` +
196
240
  (sw.ok
197
241
  ? `Modello selezionato: ${sw.model} (enforcement attivo).`
198
242
  : `ATTENZIONE: ${sw.error} (solo diagnostica, nessun enforcement).`),
199
243
  sw.ok ? "info" : "warning",
200
244
  );
201
245
  } else {
202
- 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");
203
248
  }
204
249
  return;
205
250
  }
@@ -242,12 +287,13 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
242
287
  }
243
288
 
244
289
  if (sub === "upstream") {
245
- // Senza argomenti: riapplica l'ultimo upstream salvato.
290
+ // Senza argomenti: ripristina tutti i twin salvati.
246
291
  if (parts.length === 1) {
247
292
  const res = deps.refreshUpstream(ctx.modelRegistry);
293
+ const count = deps.twinStatus().active.length;
248
294
  ctx.ui.notify(
249
295
  res.ok
250
- ? `Twin ripristinato: ${res.twin}. Selezionalo con /model.`
296
+ ? `Twin ripristinati: ${count} (primario ${res.twin ?? "—"}). Seleziona con /model.`
251
297
  : `Ripristino fallito: ${res.error}`,
252
298
  res.ok ? "info" : "error",
253
299
  );
@@ -260,26 +306,94 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
260
306
  }
261
307
  const res = deps.setUpstream(ctx.modelRegistry, parsed.provider, parsed.model);
262
308
  if (!res.ok) {
263
- ctx.ui.notify(`Upstream non cambiato: ${res.error}`, "error");
309
+ ctx.ui.notify(`Twin non aggiunto: ${res.error}`, "error");
264
310
  return;
265
311
  }
312
+ const total = deps.twinStatus().active.length;
266
313
  ctx.ui.notify(
267
- `Twin creato: ${res.twin} da ${parsed.provider}/${parsed.model} (salvato, stessa auth del provider). ` +
314
+ `Twin aggiunto: ${res.twin} (totale attivi: ${total}, stessa auth del provider). ` +
268
315
  `Selezionalo con /model per enforcement in automatic.`,
269
316
  "info",
270
317
  );
271
318
  return;
272
319
  }
273
320
 
274
- if (sub === "off") {
275
- const res = deps.removeGuarded();
276
- if (res.ok) {
277
- // Senza twin, automatic è contraddittorio: torna on-demand.
321
+ if (sub === "twins") {
322
+ const view = deps.twinStatus();
323
+ if (view.active.length === 0 && view.saved.length === 0) {
324
+ ctx.ui.notify(
325
+ "Jev: nessun twin — aggiungine con /jev upstream <provider> <model>.",
326
+ "info",
327
+ );
328
+ return;
329
+ }
330
+ const primaryKey = view.primary
331
+ ? `${view.primary.provider}/${view.primary.model}`
332
+ : undefined;
333
+ const activeKeys = new Set(view.active.map((r) => `${r.provider}/${r.model}`));
334
+ const lines: string[] = [];
335
+ for (const ref of view.saved) {
336
+ const key = `${ref.provider}/${ref.model}`;
337
+ const mark = activeKeys.has(key) ? "● " : "○ ";
338
+ const star = key === primaryKey ? "★" : " ";
339
+ lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}`);
340
+ }
341
+ ctx.ui.notify(
342
+ [
343
+ `Twin attivi: ${view.active.length} · salvati: ${view.saved.length}`,
344
+ "● registrato ★ primario (auto-selezione/revert)",
345
+ "",
346
+ ...lines,
347
+ ].join("\n"),
348
+ "info",
349
+ );
350
+ return;
351
+ }
352
+
353
+ if (sub === "untwin") {
354
+ const parsed = parseUpstreamRef(parts.slice(1));
355
+ if ("error" in parsed) {
356
+ ctx.ui.notify(
357
+ `${parsed.error.replace("/jev upstream", "/jev untwin")}`,
358
+ "warning",
359
+ );
360
+ return;
361
+ }
362
+ const previousMode = deps.config().mode;
363
+ const selected = ctx.model?.provider === parsed.provider && ctx.model.id === `${parsed.model}__jev`;
364
+ if (selected) {
278
365
  deps.setMode("on-demand");
366
+ if (!await leaveSelectedTwin()) {
367
+ deps.setMode(previousMode);
368
+ return;
369
+ }
370
+ }
371
+ const res = deps.removeTwin(ctx.modelRegistry, parsed.provider, parsed.model);
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
+ }
377
+ ctx.ui.notify(
378
+ res.ok
379
+ ? `Twin rimosso: ${parsed.provider}/${parsed.model} (attivi: ${total}).`
380
+ : `Niente da rimuovere: ${res.error}`,
381
+ res.ok ? "info" : "warning",
382
+ );
383
+ return;
384
+ }
385
+
386
+ if (sub === "off") {
387
+ const previousMode = deps.config().mode;
388
+ deps.setMode("on-demand");
389
+ if (!await leaveSelectedTwin()) {
390
+ deps.setMode(previousMode);
391
+ return;
279
392
  }
393
+ const res = deps.removeGuarded();
280
394
  ctx.ui.notify(
281
395
  res.ok
282
- ? "Twin rimosso (provider originale ripristinato), modalità on-demand."
396
+ ? `Twin rimossi: ${res.removed ?? 0} (provider originali ripristinati), modalità on-demand.`
283
397
  : `Niente da rimuovere: ${res.error}`,
284
398
  res.ok ? "info" : "warning",
285
399
  );
@@ -383,7 +497,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
383
497
  // Punta la config al file e ricarica gli handle col nuovo stato.
384
498
  cfg.jev.apiKeyFile = target;
385
499
  try {
386
- saveConfig(cfg);
500
+ saveConfig({ ...cfg, mode: deps.sharedConfig?.().mode ?? cfg.mode });
387
501
  } catch {
388
502
  // Resta in memoria anche se il salvataggio fallisce.
389
503
  }
package/src/config.ts CHANGED
@@ -32,6 +32,9 @@ export interface JevConfig {
32
32
  transport: "provider-gate";
33
33
  maxRegenerations: number;
34
34
  requireGuardedModel: boolean;
35
+ /** Twin salvati: più modelli guarded insieme (uno o più per provider). */
36
+ twins: TwinRef[];
37
+ /** Ultimo twin aggiunto: primario per auto-selezione e revert. */
35
38
  upstreamProvider: string;
36
39
  upstreamModel: string;
37
40
  };
@@ -89,6 +92,11 @@ export interface JevConfig {
89
92
  };
90
93
  }
91
94
 
95
+ export interface TwinRef {
96
+ provider: string;
97
+ model: string;
98
+ }
99
+
92
100
  export const DEFAULT_CONFIG: JevConfig = {
93
101
  version: 1,
94
102
  mode: "on-demand",
@@ -114,8 +122,11 @@ export const DEFAULT_CONFIG: JevConfig = {
114
122
  transport: "provider-gate",
115
123
  maxRegenerations: 2,
116
124
  requireGuardedModel: true,
117
- upstreamProvider: "deepseek",
118
- upstreamModel: "deepseek-flash",
125
+ twins: [],
126
+ // Vuoti di default: nessun twin "salvato" finché l'utente non ne aggiunge
127
+ // uno con /jev upstream. Il seed usa questa lista (o l'env).
128
+ upstreamProvider: "",
129
+ upstreamModel: "",
119
130
  },
120
131
  limits: {
121
132
  maxPayloadBytes: 120000,
@@ -184,11 +195,15 @@ export function loadConfig(configPath?: string): JevConfig {
184
195
  }
185
196
  }
186
197
 
187
- const merged = mergeDeep(
198
+ const loaded = mergeDeep(
188
199
  JSON.parse(JSON.stringify(DEFAULT_CONFIG)),
189
200
  fileConfig,
190
201
  ) as JevConfig;
191
202
 
203
+ // Normalizza la lista twin (config scritta a mano o da versioni vecchie):
204
+ // dedup, scarta voci malformate, garantisce che il primario sia incluso.
205
+ let merged = withTwinRefs(loaded, twinRefs(loaded));
206
+
192
207
  const envMode = process.env.JEV_MODE;
193
208
  if (envMode === "on-demand" || envMode === "automatic") {
194
209
  merged.mode = envMode;
@@ -352,6 +367,57 @@ export function configSnapshot(config: JevConfig): JevConfig {
352
367
  return JSON.parse(JSON.stringify(config)) as JevConfig;
353
368
  }
354
369
 
370
+ /**
371
+ * Twin salvati: la lista `automatic.twins` se popolata, altrimenti il singolo
372
+ * upstream primario (migrazione dalle versioni precedenti). Semplifica e
373
+ * scarta voci malformate: la config è modificabile a mano.
374
+ */
375
+ export function twinRefs(config: JevConfig): TwinRef[] {
376
+ const raw = Array.isArray(config.automatic.twins) ? config.automatic.twins : [];
377
+ const cleaned: TwinRef[] = [];
378
+ const seen = new Set<string>();
379
+ for (const entry of raw) {
380
+ if (!entry || typeof entry.provider !== "string" || typeof entry.model !== "string") continue;
381
+ const provider = entry.provider.trim();
382
+ const model = entry.model.trim();
383
+ if (!provider || !model) continue;
384
+ const key = `${provider}\0${model}`;
385
+ if (seen.has(key)) continue;
386
+ seen.add(key);
387
+ cleaned.push({ provider, model });
388
+ }
389
+ if (cleaned.length > 0) return cleaned;
390
+ const provider = config.automatic.upstreamProvider?.trim();
391
+ const model = config.automatic.upstreamModel?.trim();
392
+ return provider && model ? [{ provider, model }] : [];
393
+ }
394
+
395
+ /**
396
+ * Applica una lista di twin alla config: la lista è la fonte di verità.
397
+ * Il campo primario (auto-selezione/revert) resta se già presente nella
398
+ * lista, altrimenti diventa l'ultimo aggiunto.
399
+ */
400
+ export function withTwinRefs(config: JevConfig, refs: TwinRef[]): JevConfig {
401
+ const primary = refs.find(
402
+ (r) =>
403
+ r.provider === config.automatic.upstreamProvider &&
404
+ r.model === config.automatic.upstreamModel,
405
+ ) ?? refs[refs.length - 1];
406
+ return {
407
+ ...config,
408
+ automatic: {
409
+ ...config.automatic,
410
+ twins: refs.map((r) => ({ ...r })),
411
+ upstreamProvider: primary?.provider ?? "",
412
+ upstreamModel: primary?.model ?? "",
413
+ },
414
+ };
415
+ }
416
+
417
+ export function twinKey(ref: TwinRef): string {
418
+ return `${ref.provider}/${ref.model}`;
419
+ }
420
+
355
421
  export function saveConfig(config: JevConfig, configPath?: string): string {
356
422
  const path = configPath ?? defaultConfigPath();
357
423
  mkdirSync(dirname(path), { recursive: true });