specpi 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +2 -2
- package/SECURITY_MODEL.md +15 -9
- package/THIRD_PARTY.md +1 -2
- package/extensions/jev-advisor/broker.mjs +1 -0
- package/extensions/jev-advisor/client.mjs +17 -27
- package/extensions/jev-advisor/config.mjs +45 -29
- package/extensions/jev-advisor/gate.mjs +14 -0
- package/extensions/jev-advisor/index.ts +255 -105
- package/extensions/jev-advisor/key-source.mjs +252 -0
- package/extensions/jev-advisor/layer.mjs +169 -0
- package/extensions/jev-advisor/questions/guard.mjs +168 -0
- package/extensions/jev-advisor/risk.mjs +442 -0
- package/extensions/jev-advisor/usage.mjs +1 -1
- package/package.json +1 -1
- package/scripts/packages.mjs +56 -0
- package/scripts/specpi.mjs +53 -16
- package/templates/settings.json +1 -2
- package/extensions/jev-advisor/guard.mjs +0 -140
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { SYSTEM_NAMES,
|
|
3
|
+
import { SYSTEM_NAMES, loadSettings, saveSettings, settingsPath } from "./config.mjs";
|
|
4
|
+
import { keySources } from "./key-source.mjs";
|
|
5
|
+
import { applyLayer, guardWarning, layerScopeLine, layerToPersist, startupToPersist } from "./layer.mjs";
|
|
4
6
|
import { consentPath, granted, revokeConsent } from "./consent.mjs";
|
|
5
7
|
import { createBroker } from "./broker.mjs";
|
|
6
8
|
import { ledgerPath, read as readLedger } from "./ledger.mjs";
|
|
7
9
|
import { usagePath } from "./usage.mjs";
|
|
8
|
-
import {
|
|
10
|
+
import { GATED_TOOLS, SHELL_TOOLS, callTargets, classifyCall, commandText } from "./risk.mjs";
|
|
9
11
|
import * as retention from "./questions/retention.mjs";
|
|
10
12
|
import * as compaction from "./questions/compaction.mjs";
|
|
11
13
|
import * as gap from "./questions/gap.mjs";
|
|
@@ -13,13 +15,42 @@ import * as sources from "./questions/sources.mjs";
|
|
|
13
15
|
import * as progress from "./questions/progress.mjs";
|
|
14
16
|
import * as untrusted from "./questions/untrusted.mjs";
|
|
15
17
|
import * as capabilities from "./questions/capabilities.mjs";
|
|
18
|
+
import * as guard from "./questions/guard.mjs";
|
|
16
19
|
|
|
17
20
|
const MAX_RECENT = 8;
|
|
18
21
|
|
|
22
|
+
/** One line of a call, for a notification or a block reason. Never a digest; never sent anywhere. */
|
|
23
|
+
function short(value: string, limit: number) {
|
|
24
|
+
const text = String(value ?? "")
|
|
25
|
+
.replace(/\s+/gu, " ")
|
|
26
|
+
.trim();
|
|
27
|
+
|
|
28
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
function safeMessage(error: unknown) {
|
|
20
32
|
return String((error as any)?.message ?? error ?? "unknown error").slice(0, 200);
|
|
21
33
|
}
|
|
22
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Tell the person something, and never let the telling change what happens.
|
|
37
|
+
*
|
|
38
|
+
* `ctx.ui.notify` reaches the host over RPC and can throw -- a disconnected client, a torn-down UI,
|
|
39
|
+
* a host without the method. Called inline inside the guard's fail-open catch, one such throw
|
|
40
|
+
* unwound a decided refusal into an allow, so the announcement is isolated from the decision here.
|
|
41
|
+
*/
|
|
42
|
+
function announce(ctx: ExtensionContext, message: string) {
|
|
43
|
+
if (!ctx.hasUI) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
ctx.ui.notify(message, "error");
|
|
49
|
+
} catch {
|
|
50
|
+
// A failed notification is not a reason to run a command, or not to.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
export default function jevAdvisor(pi: ExtensionAPI) {
|
|
24
55
|
// Session switches live in memory. A session toggle must never write the startup preference,
|
|
25
56
|
// so the saved file is read once per session and only /jev startup ever writes it.
|
|
@@ -70,14 +101,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
70
101
|
let capabilityAsked = false;
|
|
71
102
|
const capabilityDeclined = new Set<string>();
|
|
72
103
|
|
|
73
|
-
|
|
74
|
-
const
|
|
104
|
+
/** Persist the session's switches, or report that it could not be done. */
|
|
105
|
+
const persistLayer = (result: { settings: any }) => {
|
|
75
106
|
try {
|
|
76
|
-
return
|
|
107
|
+
return saveSettings(layerToPersist(result, loadSettings()));
|
|
77
108
|
} catch {
|
|
78
|
-
|
|
79
|
-
// /jev status reports rather than hides.
|
|
80
|
-
return { applied: false, reason: "unwritable" };
|
|
109
|
+
return undefined;
|
|
81
110
|
}
|
|
82
111
|
};
|
|
83
112
|
|
|
@@ -92,12 +121,6 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
92
121
|
resetHistory();
|
|
93
122
|
capabilityAsked = false;
|
|
94
123
|
capabilityDeclined.clear();
|
|
95
|
-
guardEnabled = settings.guard.startup === true;
|
|
96
|
-
// Deliberately outside the master switch. The guard is a separate package with its own
|
|
97
|
-
// gate, and whether it is inert is a property of the install rather than a feature of the
|
|
98
|
-
// advisor, so its configuration is rewritten every session either way. Off is the default
|
|
99
|
-
// and is a real written configuration, not an absence of one.
|
|
100
|
-
syncGuard();
|
|
101
124
|
});
|
|
102
125
|
|
|
103
126
|
pi.on("session_shutdown", () => {
|
|
@@ -231,6 +254,111 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
231
254
|
}
|
|
232
255
|
});
|
|
233
256
|
|
|
257
|
+
// System 8: the command guard, before a shell or file call runs.
|
|
258
|
+
//
|
|
259
|
+
// Fail open at every step. Local triage settles most calls for nothing; anything else is asked
|
|
260
|
+
// about, and a call is blocked only on a confident verdict that the request does not account
|
|
261
|
+
// for. Every other outcome -- no key, no budget, a timeout, an unconfident answer, no human to
|
|
262
|
+
// ask -- returns the call to @gotgenes/pi-permission-system, which decides it exactly as it did
|
|
263
|
+
// before this layer existed. The package this replaced was fail-closed, so an outage or a
|
|
264
|
+
// missing key stopped work; that is the single behaviour most worth not reproducing.
|
|
265
|
+
pi.on("tool_call", async (event: any, ctx: ExtensionContext) => {
|
|
266
|
+
if (!enabled("guard") || !GATED_TOOLS.includes(event?.toolName)) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const shell = SHELL_TOOLS.includes(event.toolName);
|
|
271
|
+
// Not `input.command`: `write_stdin` types into a live shell under another name, so reading
|
|
272
|
+
// one key classified every such call as the empty string -- spending a guard call on nothing
|
|
273
|
+
// while the text actually being run went unexamined.
|
|
274
|
+
const command = shell ? commandText(event?.input) : "";
|
|
275
|
+
// Every file the call names, because `multi_edit` and `apply_patch` do not carry one `path`
|
|
276
|
+
// and a target the guard cannot see is a target it never asks the credential question about.
|
|
277
|
+
const targets = callTargets(event?.input);
|
|
278
|
+
const local = classifyCall({ tool: event.toolName, command, targets, cwd: ctx.cwd });
|
|
279
|
+
const subject = shell
|
|
280
|
+
? command || "(command unknown)"
|
|
281
|
+
: `${event.toolName} ${targets.join(", ") || "(target unknown)"}`;
|
|
282
|
+
|
|
283
|
+
// Built once, and nothing inside it may throw. A refusal that has already been decided must
|
|
284
|
+
// reach the harness: an exception raised while announcing it would unwind into the fail-open
|
|
285
|
+
// catch below and turn the layer's only blocking action into an allow.
|
|
286
|
+
const refuse = (reason: string) => {
|
|
287
|
+
announce(ctx, `Jev guard blocked ${event.toolName}: ${reason}.`);
|
|
288
|
+
|
|
289
|
+
return { block: true, reason: `Jev guard: ${reason}. Call: ${short(subject, 160)}` };
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (local.decision === "safe") {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (local.decision === "dangerous") {
|
|
297
|
+
// Catastrophic and unambiguous, so it needs neither a network call nor a human. This is
|
|
298
|
+
// the one path that blocks without asking Jev, which is why its rule list is tiny.
|
|
299
|
+
return refuse(local.reason);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let verdict;
|
|
303
|
+
try {
|
|
304
|
+
const result = await broker.request({
|
|
305
|
+
system: "guard",
|
|
306
|
+
state: guard.buildInput({
|
|
307
|
+
tool: event.toolName,
|
|
308
|
+
subject,
|
|
309
|
+
protectedTarget: local.reason === "writes to a protected path",
|
|
310
|
+
objective,
|
|
311
|
+
recent,
|
|
312
|
+
cwd: ctx.cwd,
|
|
313
|
+
}),
|
|
314
|
+
questions: guard.questions({ protected: local.reason === "writes to a protected path" }),
|
|
315
|
+
ctx,
|
|
316
|
+
root: ctx.cwd,
|
|
317
|
+
decide: (answers: any) => {
|
|
318
|
+
const verdict = guard.decide(answers, { hasUI: ctx.hasUI });
|
|
319
|
+
|
|
320
|
+
return { applied: verdict.action !== "defer", decision: verdict };
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
if (!result.ok) {
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
verdict = result.decision;
|
|
328
|
+
} catch {
|
|
329
|
+
// An advisor must never be the reason a tool call fails. Anything unexpected while
|
|
330
|
+
// asking hands the call back to the permission system unchanged. The catch ends here, so
|
|
331
|
+
// that everything the verdict then decides is outside it.
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (verdict.action === "block") {
|
|
336
|
+
return refuse(verdict.reason);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (verdict.action === "ask" && ctx.hasUI) {
|
|
340
|
+
let choice;
|
|
341
|
+
try {
|
|
342
|
+
choice = await ctx.ui.select({
|
|
343
|
+
title: "Jev guard",
|
|
344
|
+
message: `This looks ${verdict.reason}: ${short(subject, 300)}`,
|
|
345
|
+
options: [guard.CHOICES.run, guard.CHOICES.block],
|
|
346
|
+
});
|
|
347
|
+
} catch {
|
|
348
|
+
// The one failure in this file that does not fail open, and deliberately. Reaching
|
|
349
|
+
// here means the verdict already said this call needs a person's approval; a host
|
|
350
|
+
// that cannot ask has not obtained it, and an unanswerable question resolved as yes
|
|
351
|
+
// is the failure mode a confirmation dialog exists to rule out.
|
|
352
|
+
return refuse("this needs your approval and you could not be asked");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// `guard.approved` owns the rule; see it for why every non-answer is a refusal.
|
|
356
|
+
return guard.approved(choice) ? undefined : refuse("not approved by you");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return undefined;
|
|
360
|
+
});
|
|
361
|
+
|
|
234
362
|
// System 1: condense a spent tool result before it is appended. Doing this after the fact would
|
|
235
363
|
// rewrite a cached prefix; on arrival it never touches one.
|
|
236
364
|
pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
|
|
@@ -251,6 +379,16 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
251
379
|
}
|
|
252
380
|
}
|
|
253
381
|
|
|
382
|
+
// Every result, not only the ones retention asked about. This history is what lets the
|
|
383
|
+
// command guard tell a cleanup step from a first move, and it was written in one place --
|
|
384
|
+
// inside retention's success path -- so a session running the guard with retention off, or
|
|
385
|
+
// with retention's budget spent, evaluated the block rule against an empty history for its
|
|
386
|
+
// whole length while the question set said history was what the intent answer weighed.
|
|
387
|
+
recent.push({ tool: String(event?.toolName ?? ""), outcome: event?.isError === true ? "error" : "ok" });
|
|
388
|
+
if (recent.length > MAX_RECENT) {
|
|
389
|
+
recent.shift();
|
|
390
|
+
}
|
|
391
|
+
|
|
254
392
|
// Two systems share this hook. Retention wants large read-only results; system 7 wants
|
|
255
393
|
// externally fetched ones whatever their size, because an injected instruction can be two
|
|
256
394
|
// hundred bytes. When both want the same result they are one call: questions are evaluated
|
|
@@ -310,11 +448,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
310
448
|
}
|
|
311
449
|
|
|
312
450
|
const { verdict, replacement } = result.decision;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
451
|
+
// Retention knows something the bookkeeping above does not -- whether the result was
|
|
452
|
+
// spent -- so it refines its own entry rather than appending a second one for the same
|
|
453
|
+
// call. If anything has been recorded since, the entry is gone and so is the chance.
|
|
454
|
+
const latest = recent[recent.length - 1];
|
|
455
|
+
if (wantRetention && latest?.tool === event.toolName) {
|
|
456
|
+
latest.outcome = verdict.elide ? "spent" : "kept";
|
|
318
457
|
}
|
|
319
458
|
|
|
320
459
|
if (replacement === undefined) {
|
|
@@ -653,7 +792,7 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
653
792
|
pi.registerCommand("jev", {
|
|
654
793
|
description: "Show or change the Jev advisor: master switch, per-system switches and the transmission ledger",
|
|
655
794
|
getArgumentCompletions: (prefix: string) =>
|
|
656
|
-
["status", "on", "off", "startup", "enable", "disable", "
|
|
795
|
+
["status", "on", "off", "startup", "enable", "disable", "ledger", "forget"]
|
|
657
796
|
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
658
797
|
.map((value) => ({ value, label: value })),
|
|
659
798
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
@@ -661,14 +800,33 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
661
800
|
const action = actionRaw.toLowerCase();
|
|
662
801
|
try {
|
|
663
802
|
if (action === "on" || action === "off") {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
803
|
+
const on = action === "on";
|
|
804
|
+
// `--session` is the old behaviour, kept for the case it was the right one: a
|
|
805
|
+
// one-off try that must not change what the next session does.
|
|
806
|
+
const sessionOnly = rest.some((value) => /^--?(session|once)$/u.test(value.toLowerCase()));
|
|
807
|
+
const unknown = rest.filter((value) => !/^--?(session|once)$/u.test(value.toLowerCase()));
|
|
808
|
+
if (unknown.length > 0) {
|
|
809
|
+
throw new Error(`Usage: /jev ${action} [--session]`);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const result = applyLayer({ on }, { settings }, { keySources: () => keySources() });
|
|
813
|
+
settings = result.settings;
|
|
814
|
+
// Persisting is the default because a switch that forgets is not a switch. The
|
|
815
|
+
// old rule -- that only /jev startup may write -- protected against a session
|
|
816
|
+
// toggle silently changing tomorrow's sessions, but the cost of that protection
|
|
817
|
+
// was a layer people turned on repeatedly and never actually ran.
|
|
818
|
+
const persisted = !sessionOnly && ctx.hasUI ? persistLayer(result) : undefined;
|
|
819
|
+
const lines = [
|
|
820
|
+
...result.lines,
|
|
821
|
+
layerScopeLine({
|
|
822
|
+
sessionOnly,
|
|
823
|
+
interactive: ctx.hasUI,
|
|
824
|
+
persisted,
|
|
825
|
+
stored: loadSettings(),
|
|
826
|
+
settingsFile: settingsPath(),
|
|
827
|
+
}),
|
|
828
|
+
];
|
|
829
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
672
830
|
|
|
673
831
|
return;
|
|
674
832
|
}
|
|
@@ -680,14 +838,46 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
680
838
|
throw new Error(`Usage: /jev ${action} <${SYSTEM_NAMES.join("|")}>`);
|
|
681
839
|
}
|
|
682
840
|
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
841
|
+
const changes = Object.fromEntries(names.map((name) => [name, action === "enable"]));
|
|
842
|
+
const systems = { ...settings.systems, ...changes };
|
|
843
|
+
// Disabling the last system while the layer is on leaves it running and doing
|
|
844
|
+
// nothing -- the state `enableSystems`, `startupToPersist`, `couple` and the
|
|
845
|
+
// Chat panel's save check all exist to prevent, reachable through the one path
|
|
846
|
+
// that did not check it. Switching the layer off is the honest reading of
|
|
847
|
+
// "disable everything", and it is announced rather than inferred.
|
|
848
|
+
const emptied = settings.master && SYSTEM_NAMES.every((name) => !systems[name]);
|
|
849
|
+
settings = { ...settings, systems, master: emptied ? false : settings.master };
|
|
850
|
+
// Persisted like every other switch here, and merged into the stored map rather
|
|
851
|
+
// than overwriting it: this session's copy may predate systems enabled on disk
|
|
852
|
+
// since it started, and writing it whole turned those back off silently.
|
|
853
|
+
const kept = ctx.hasUI
|
|
854
|
+
? (() => {
|
|
855
|
+
try {
|
|
856
|
+
const current = loadSettings();
|
|
857
|
+
const merged = { ...current.systems, ...changes };
|
|
858
|
+
const dead = current.master && SYSTEM_NAMES.every((name) => !merged[name]);
|
|
859
|
+
|
|
860
|
+
return saveSettings({
|
|
861
|
+
...current,
|
|
862
|
+
systems: merged,
|
|
863
|
+
master: dead ? false : current.master,
|
|
864
|
+
startup: dead ? false : current.startup,
|
|
865
|
+
});
|
|
866
|
+
} catch {
|
|
867
|
+
return undefined;
|
|
868
|
+
}
|
|
869
|
+
})()
|
|
870
|
+
: undefined;
|
|
871
|
+
// The same disclosure `/jev on` makes, on the path that arms the guard by name.
|
|
872
|
+
// Learning from a blocked call that calls can be blocked is the outcome that
|
|
873
|
+
// rule exists to prevent, and which command did the arming does not change it.
|
|
874
|
+
const armedGuard = action === "enable" && names.includes("guard") && settings.master;
|
|
689
875
|
ctx.ui.notify(
|
|
690
|
-
`${action === "enable" ? "Enabled" : "Disabled"}
|
|
876
|
+
`${action === "enable" ? "Enabled" : "Disabled"}: ${names.join(", ")}.` +
|
|
877
|
+
`${kept ? " Remembered for new sessions." : " This session only."}` +
|
|
878
|
+
`${emptied ? " That was the last system, so the layer was switched off; it would otherwise run and do nothing." : ""}` +
|
|
879
|
+
`${!emptied && !settings.master ? " The layer is still off; run /jev on." : ""}` +
|
|
880
|
+
`${armedGuard ? `\n${guardWarning()}` : ""}`,
|
|
691
881
|
"info",
|
|
692
882
|
);
|
|
693
883
|
|
|
@@ -697,8 +887,9 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
697
887
|
if (action === "startup") {
|
|
698
888
|
const [choice] = rest;
|
|
699
889
|
if (!choice) {
|
|
890
|
+
const current = loadSettings();
|
|
700
891
|
ctx.ui.notify(
|
|
701
|
-
`Jev starts ${
|
|
892
|
+
`Jev starts ${current.startup && current.master ? "on" : "off"} in new sessions. Preference: ${settingsPath()}`,
|
|
702
893
|
"info",
|
|
703
894
|
);
|
|
704
895
|
|
|
@@ -713,70 +904,21 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
713
904
|
throw new Error("Usage: /jev startup [on|off]");
|
|
714
905
|
}
|
|
715
906
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
if (action === "guard") {
|
|
728
|
-
const [verb, choice] = rest.map((value) => value.toLowerCase());
|
|
729
|
-
if (!verb) {
|
|
730
|
-
ctx.ui.notify(guardStatusLine(), "info");
|
|
731
|
-
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
if (verb === "on" || verb === "off") {
|
|
736
|
-
guardEnabled = verb === "on";
|
|
737
|
-
const result = syncGuard();
|
|
738
|
-
ctx.ui.notify(
|
|
739
|
-
result.reason === "not-installed"
|
|
740
|
-
? "specpi-jev-guard is not installed, so there is nothing to switch. Command policy stays with the permission system."
|
|
741
|
-
: guardEnabled
|
|
742
|
-
? "Jev guard on for this session. It scores shell and file calls and defers to the permission system whenever Jev is unavailable or unconfident."
|
|
743
|
-
: "Jev guard off for this session. Every tool call goes straight to the permission system.",
|
|
744
|
-
"info",
|
|
745
|
-
);
|
|
746
|
-
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (verb !== "startup") {
|
|
751
|
-
throw new Error("Usage: /jev guard [on|off|startup [on|off]]");
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
if (!choice) {
|
|
755
|
-
ctx.ui.notify(
|
|
756
|
-
`The Jev guard starts ${loadSettings().guard.startup ? "on" : "off"} in new sessions.`,
|
|
757
|
-
"info",
|
|
758
|
-
);
|
|
759
|
-
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
if (!ctx.hasUI) {
|
|
764
|
-
throw new Error("Startup changes require a human interactive command");
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
if (!["on", "off"].includes(choice)) {
|
|
768
|
-
throw new Error("Usage: /jev guard startup [on|off]");
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const stored = loadSettings();
|
|
772
|
-
const saved = saveSettings({
|
|
773
|
-
...stored,
|
|
774
|
-
guard: { ...stored.guard, startup: choice === "on" },
|
|
775
|
-
});
|
|
907
|
+
// Both keys, and the systems with them. Writing `startup` alone was the whole
|
|
908
|
+
// two-keys-for-one-intention trap, left in the command named after it: the
|
|
909
|
+
// advisor's session_start keeps a stored `master` only when `startup` is true,
|
|
910
|
+
// so `startup: true, master: false` starts every future session with the layer
|
|
911
|
+
// off while this command cheerfully reported it would start on. And a layer
|
|
912
|
+
// that starts on with no system enabled runs and does nothing, so the same rule
|
|
913
|
+
// `/jev on` uses applies here: fill them in only when none are chosen.
|
|
914
|
+
const wanted = choice.toLowerCase() === "on";
|
|
915
|
+
const saved = saveSettings(startupToPersist(wanted, loadSettings()));
|
|
916
|
+
const enabled = SYSTEM_NAMES.filter((name) => saved.systems[name]);
|
|
776
917
|
ctx.ui.notify(
|
|
777
|
-
saved.
|
|
778
|
-
?
|
|
779
|
-
|
|
918
|
+
saved.startup && saved.master
|
|
919
|
+
? `New Pi sessions will start with the Jev layer on, with ${enabled.length} of ${SYSTEM_NAMES.length} systems: ${enabled.join(", ")}. This session is unchanged; run /jev on to switch it on now.` +
|
|
920
|
+
`${saved.systems.guard ? `\n${guardWarning()}` : ""}`
|
|
921
|
+
: "New Pi sessions will start with the Jev layer off. This session is unchanged.",
|
|
780
922
|
"info",
|
|
781
923
|
);
|
|
782
924
|
|
|
@@ -817,21 +959,29 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
817
959
|
|
|
818
960
|
if (action !== "status") {
|
|
819
961
|
throw new Error(
|
|
820
|
-
"Usage: /jev [status|on|off|startup [on|off]|enable <system>|disable <system>|
|
|
962
|
+
"Usage: /jev [status|on [--session]|off [--session]|startup [on|off]|enable <system>|disable <system>|ledger [n]|forget]",
|
|
821
963
|
);
|
|
822
964
|
}
|
|
823
965
|
|
|
824
966
|
const state = broker.status();
|
|
967
|
+
const sources = keySources();
|
|
968
|
+
const activeSource = sources.find((source: { present: boolean }) => source.present)?.name;
|
|
969
|
+
const stored = loadSettings();
|
|
825
970
|
const lines = [
|
|
826
|
-
`master: ${settings.master ? "on" : "off"} (new sessions start ${
|
|
971
|
+
`master: ${settings.master ? "on" : "off"} (new sessions start ${stored.startup && stored.master ? "on" : "off"})`,
|
|
827
972
|
...SYSTEM_NAMES.map((name) => ` ${name}: ${settings.systems[name] ? "on" : "off"}`),
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
|
|
973
|
+
// Every place a key could come from, in the order they are consulted, with the
|
|
974
|
+
// one in force marked. A bare "missing" was actively misleading here: it is
|
|
975
|
+
// what someone saw who had a perfectly good OpenRouter key stored by /login,
|
|
976
|
+
// and it gave them nothing to act on. Names only -- no key is ever printed.
|
|
977
|
+
`key: ${activeSource ? `in use from ${activeSource}` : "none found"}`,
|
|
978
|
+
...sources.map(
|
|
979
|
+
(source: { name: string; label: string; detail: string; present: boolean }) =>
|
|
980
|
+
` ${source.present ? "found" : " - "} ${source.label} (${source.detail})`,
|
|
981
|
+
),
|
|
831
982
|
`consent: ${granted() ? "granted" : "not granted"}`,
|
|
832
983
|
`calls this session: ${state.callsUsed}/${state.budgets.total} total`,
|
|
833
984
|
...SYSTEM_NAMES.map((name) => ` ${name}: ${state.usedBySystem[name] ?? 0}/${state.budgets[name]}`),
|
|
834
|
-
guardStatusLine(),
|
|
835
985
|
`settings: ${settingsPath()}`,
|
|
836
986
|
`consent file: ${consentPath()}`,
|
|
837
987
|
`ledger: ${ledgerPath()}`,
|