@ian-pascoe/pi-guardian 0.0.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.
@@ -0,0 +1,398 @@
1
+ import * as piSdk from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AgentSession,
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ ExtensionUIContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { discoverPiAgentSession } from "@ian-pascoe/pi-utils/pi-agent-session-discovery";
9
+ import { reviewEntryType, reviewTotals } from "./guardian-audit.js";
10
+ import {
11
+ parseGuardianCommand,
12
+ completeGuardianCommandArguments,
13
+ updatedToolEntries,
14
+ } from "./guardian-command.js";
15
+ import { installReviewGate } from "./guardian-gate.js";
16
+ import {
17
+ GuardianSettingsMenu,
18
+ type GuardianMenuHost,
19
+ type GuardianScopedOptions,
20
+ } from "./guardian-menu.js";
21
+ import {
22
+ guardianFooterText,
23
+ guardianStatusHeadline,
24
+ renderReviewEntry,
25
+ renderStatusEntry,
26
+ statusEntryType,
27
+ type GuardianRenderTheme,
28
+ type GuardianStatusEntry,
29
+ } from "./guardian-rendering.js";
30
+ import {
31
+ guardedSessionRole,
32
+ publishRootSession,
33
+ rootSettingsReader,
34
+ type GuardedSessionRole,
35
+ } from "./guardian-root-registry.js";
36
+ import {
37
+ guardianDefaults,
38
+ parseGuardianOptions,
39
+ readGuardianLayers,
40
+ readGuardianOverrides,
41
+ readGuardianSettings,
42
+ writeGuardianSettings,
43
+ type GuardianAppliedChange,
44
+ type GuardianChange,
45
+ type GuardianLayers,
46
+ type GuardianSettingScope,
47
+ type ResolvedGuardianSettings,
48
+ } from "./guardian-settings.js";
49
+
50
+ /** Effective settings for this session, or why they cannot be read. */
51
+ type Effective =
52
+ | { ok: true; resolved: ResolvedGuardianSettings; followsRoot: string | null }
53
+ | { ok: false; error: string };
54
+
55
+ function message(cause: unknown): string {
56
+ return cause instanceof Error ? cause.message : String(cause);
57
+ }
58
+
59
+ /** Gate the Guarded Agent's tool calls with Guardian Reviews; see the package README. */
60
+ export default function guardian(pi: ExtensionAPI): void {
61
+ let session: AgentSession | undefined;
62
+ let discoveryError: string | undefined;
63
+ let layers: GuardianLayers = { global: {}, project: {} };
64
+ let role: GuardedSessionRole = { kind: "main" };
65
+ let unpublish: (() => void) | undefined;
66
+ let footer: ExtensionUIContext | undefined;
67
+ let activeMenu: { refresh: () => void; close: () => void } | undefined;
68
+ let generation = 0;
69
+ /** The notice recommending a dedicated Guardian model is shown once per process. */
70
+ let modelNoticeShown = false;
71
+
72
+ // Allowed reviews stay out of the transcript unless `verbose` is on; their entries still count.
73
+ pi.registerEntryRenderer(reviewEntryType, (entry, { expanded }, theme) => {
74
+ const current = effective();
75
+ return renderReviewEntry(
76
+ entry.data,
77
+ expanded,
78
+ theme,
79
+ current.ok && current.resolved.settings.verbose,
80
+ );
81
+ });
82
+ pi.registerEntryRenderer(statusEntryType, (entry, { expanded }, theme) =>
83
+ renderStatusEntry(entry.data, expanded, theme),
84
+ );
85
+
86
+ /** Effective settings: the root session's for Child Agents and Advisors, else this session's. */
87
+ function effective(): Effective {
88
+ try {
89
+ if (role.kind !== "main") {
90
+ const read = rootSettingsReader(role.rootSessionId);
91
+ if (read) return { ok: true, resolved: read(), followsRoot: role.rootSessionId };
92
+ }
93
+ if (!session)
94
+ return { ok: false, error: discoveryError ?? "Guardian session is unavailable" };
95
+ return { ok: true, resolved: readGuardianSettings(session, layers), followsRoot: null };
96
+ } catch (cause) {
97
+ return { ok: false, error: message(cause) };
98
+ }
99
+ }
100
+
101
+ const gate = installReviewGate(pi, {
102
+ session: () => session,
103
+ unavailable: () => discoveryError ?? "Guardian session is unavailable",
104
+ role: () => role,
105
+ settings() {
106
+ const current = effective();
107
+ // On a settings error the gate fails closed; the defaults only shape its dialogs.
108
+ return current.ok
109
+ ? { config: current.resolved.settings, error: undefined }
110
+ : { config: guardianDefaults, error: current.error };
111
+ },
112
+ reviewingChanged: () => publishFooter(),
113
+ });
114
+
115
+ function publishFooter(): void {
116
+ activeMenu?.refresh();
117
+ if (!footer) return;
118
+ const current = effective();
119
+ try {
120
+ footer.setStatus(
121
+ "guardian",
122
+ current.ok
123
+ ? guardianFooterText(current.resolved.settings.enabled, gate.reviewing(), footer.theme)
124
+ : footer.theme.fg("error", "guardian: settings error"),
125
+ );
126
+ } catch {
127
+ // A replaced session's UI is stale; status entries remain authoritative.
128
+ }
129
+ }
130
+
131
+ function notify(ctx: ExtensionContext, text: string, level: "info" | "warning" | "error"): void {
132
+ if (!ctx.hasUI) return;
133
+ try {
134
+ ctx.ui.notify(text, level);
135
+ } catch {
136
+ // A replaced session's UI is stale; the review entry remains the durable record.
137
+ }
138
+ }
139
+
140
+ pi.on("session_start", (_event, ctx) => {
141
+ activeMenu?.close();
142
+ generation++;
143
+ // The previous session's reviews were recorded at its shutdown; drop what remains.
144
+ gate.reset();
145
+ unpublish?.();
146
+ unpublish = undefined;
147
+ role = guardedSessionRole(ctx.sessionManager.getBranch());
148
+ footer = ctx.hasUI ? ctx.ui : undefined;
149
+ const found = discoverPiAgentSession(pi, piSdk.AgentSession);
150
+ if (found.ok) {
151
+ session = found.session;
152
+ discoveryError = undefined;
153
+ try {
154
+ layers = readGuardianLayers(session.settingsManager);
155
+ } catch (cause) {
156
+ layers = { global: new Error(message(cause)), project: {} };
157
+ }
158
+ } else {
159
+ session = undefined;
160
+ discoveryError = `Guardian cannot read this session's settings: ${found.warning}`;
161
+ }
162
+ if (role.kind === "main" && session) {
163
+ const subject = session;
164
+ unpublish = publishRootSession(ctx.sessionManager.getSessionId(), {
165
+ settings: () => readGuardianSettings(subject, layers),
166
+ userMessages: () => gate.typedUserMessages(),
167
+ });
168
+ }
169
+ const current = effective();
170
+ if (!current.ok)
171
+ notify(ctx, `Guardian: ${current.error}. Run /skill:pi-guardian to diagnose.`, "error");
172
+ else if (
173
+ !modelNoticeShown &&
174
+ role.kind === "main" &&
175
+ current.resolved.settings.enabled &&
176
+ current.resolved.settings.model === undefined &&
177
+ ctx.hasUI
178
+ ) {
179
+ modelNoticeShown = true;
180
+ notify(
181
+ ctx,
182
+ "Guardian reviews with the session's model, which can be slow and costly for every review. Pick a small, fast model with thinking off in /guardian (Model), such as anthropic/claude-haiku-4-5.",
183
+ "info",
184
+ );
185
+ }
186
+ publishFooter();
187
+ });
188
+ pi.on("session_tree", () => {
189
+ activeMenu?.close();
190
+ generation++;
191
+ publishFooter();
192
+ });
193
+ pi.on("session_shutdown", () => {
194
+ activeMenu?.close();
195
+ generation++;
196
+ gate.flush();
197
+ unpublish?.();
198
+ unpublish = undefined;
199
+ session = undefined;
200
+ footer = undefined;
201
+ });
202
+
203
+ /** Effective settings, totals, and any error, as recorded in status entries. */
204
+ function statusData(ctx: ExtensionContext): GuardianStatusEntry {
205
+ const totals = reviewTotals(ctx.sessionManager.getBranch());
206
+ const current = effective();
207
+ if (!current.ok) return { state: "error", totals, error: current.error };
208
+ const { settings, sources } = current.resolved;
209
+ return {
210
+ state: settings.enabled ? "enabled" : "disabled",
211
+ settings,
212
+ sources,
213
+ followsRoot: current.followsRoot,
214
+ totals,
215
+ error: null,
216
+ };
217
+ }
218
+
219
+ function status(
220
+ ctx: ExtensionContext,
221
+ changes: readonly GuardianAppliedChange[] = [],
222
+ error?: string,
223
+ ): void {
224
+ const entry = statusData(ctx);
225
+ if (changes.length) entry.changes = [...changes];
226
+ if (error) entry.error = error;
227
+ pi.appendEntry(statusEntryType, entry);
228
+ if (entry.error) notify(ctx, `Guardian: ${entry.error}`, "error");
229
+ }
230
+
231
+ /** The authored `tools` option at one scope. */
232
+ function authoredTools(subject: AgentSession, scope: GuardianSettingScope) {
233
+ if (scope === "session") return readGuardianOverrides(subject.sessionManager).tools;
234
+ const layer = layers[scope];
235
+ if (layer instanceof Error) throw layer;
236
+ return layer.tools;
237
+ }
238
+
239
+ /** Persist one validated change at its scope; undefined if superseded. */
240
+ async function applyChange(
241
+ subject: AgentSession,
242
+ scope: GuardianSettingScope,
243
+ change: GuardianChange,
244
+ isCurrent: () => boolean,
245
+ ): Promise<GuardianAppliedChange | undefined> {
246
+ if (role.kind !== "main" && rootSettingsReader(role.rootSessionId))
247
+ throw new Error(
248
+ "This session follows its root session's Guardian settings; change them there",
249
+ );
250
+ if (scope === "session") {
251
+ const overrides = readGuardianOverrides(subject.sessionManager);
252
+ if (change.action === "inherit") delete overrides[change.key];
253
+ else Object.assign(overrides, change.patch);
254
+ pi.appendEntry("pi-guardian-settings", { version: 1, overrides });
255
+ } else {
256
+ const updated = await writeGuardianSettings(
257
+ subject.settingsManager,
258
+ scope,
259
+ change,
260
+ isCurrent,
261
+ );
262
+ if (!isCurrent() || !updated) return undefined;
263
+ layers[scope] = updated;
264
+ }
265
+ publishFooter();
266
+ return { scope, key: change.key, options: change.action === "inherit" ? {} : change.patch };
267
+ }
268
+
269
+ function menuHost(
270
+ ctx: ExtensionContext,
271
+ subject: AgentSession,
272
+ theme: GuardianRenderTheme,
273
+ applied: GuardianAppliedChange[],
274
+ ): GuardianMenuHost {
275
+ return {
276
+ view() {
277
+ const data = statusData(ctx);
278
+ const authored: Partial<GuardianScopedOptions> = {
279
+ session: readGuardianOverrides(subject.sessionManager),
280
+ };
281
+ if (!(layers.project instanceof Error)) authored.project = layers.project;
282
+ if (!(layers.global instanceof Error)) authored.global = layers.global;
283
+ return {
284
+ headline: guardianStatusHeadline(data, theme),
285
+ scopes: subject.settingsManager.isProjectTrusted()
286
+ ? ["session", "project", "global"]
287
+ : ["session", "global"],
288
+ settings: data.settings ?? {},
289
+ sources: data.sources ?? {},
290
+ authored,
291
+ models: ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`),
292
+ tools: pi.getAllTools().map((tool) => tool.name),
293
+ };
294
+ },
295
+ async apply(scope, change) {
296
+ const stamp = ++generation;
297
+ const result = await applyChange(
298
+ subject,
299
+ scope,
300
+ change,
301
+ () => generation === stamp && session === subject,
302
+ );
303
+ if (result) applied.push(result);
304
+ },
305
+ };
306
+ }
307
+
308
+ async function openMenu(ctx: ExtensionContext, subject: AgentSession): Promise<void> {
309
+ const applied: GuardianAppliedChange[] = [];
310
+ let discarded = false;
311
+ let menu: GuardianSettingsMenu | undefined;
312
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
313
+ const opened = new GuardianSettingsMenu(
314
+ menuHost(ctx, subject, theme, applied),
315
+ {
316
+ tui,
317
+ keybindings,
318
+ theme,
319
+ externalEditorCommand: subject.settingsManager.getExternalEditorCommand(),
320
+ },
321
+ () => done(),
322
+ );
323
+ menu = opened;
324
+ activeMenu = {
325
+ refresh: () => {
326
+ opened.refresh();
327
+ tui.requestRender();
328
+ },
329
+ close: () => {
330
+ discarded = true;
331
+ activeMenu = undefined;
332
+ done();
333
+ },
334
+ };
335
+ return opened;
336
+ });
337
+ activeMenu = undefined;
338
+ await menu?.settled();
339
+ if (!discarded && session === subject && applied.length) status(ctx, applied);
340
+ }
341
+
342
+ pi.registerCommand("guardian", {
343
+ description: "Guardian settings menu, or on, off, status, policy, tool, inherit, and set",
344
+ getArgumentCompletions: completeGuardianCommandArguments,
345
+ async handler(args, ctx) {
346
+ const subject = session;
347
+ let stamp = generation;
348
+ const isCurrent = () => generation === stamp && session === subject;
349
+ let applied: GuardianAppliedChange | undefined;
350
+ let error: string | undefined;
351
+ try {
352
+ const command = parseGuardianCommand(args);
353
+ if (command.action === "status" || (command.action === "menu" && ctx.mode !== "tui")) {
354
+ status(ctx);
355
+ return;
356
+ }
357
+ if (!subject) throw new Error(discoveryError ?? "Guardian session is unavailable");
358
+ if (command.action === "menu") {
359
+ await openMenu(ctx, subject);
360
+ return;
361
+ }
362
+ stamp = ++generation;
363
+ let change: GuardianChange;
364
+ if (command.action === "policy") {
365
+ if (!ctx.hasUI)
366
+ throw new Error(
367
+ "Editing the Security Policy requires UI; use /guardian set policy <JSON>",
368
+ );
369
+ const current = effective();
370
+ const edited = await ctx.ui.editor(
371
+ "Guardian Security Policy",
372
+ current.ok ? current.resolved.settings.policy : "",
373
+ );
374
+ if (!isCurrent() || edited === undefined) return;
375
+ change = {
376
+ action: "set",
377
+ key: "policy",
378
+ patch: parseGuardianOptions({ policy: edited }, command.scope),
379
+ };
380
+ } else if (command.action === "tool") {
381
+ const tools = updatedToolEntries(
382
+ authoredTools(subject, command.scope),
383
+ command.name,
384
+ command.value,
385
+ );
386
+ change = tools
387
+ ? { action: "set", key: "tools", patch: parseGuardianOptions({ tools }, command.scope) }
388
+ : { action: "inherit", key: "tools" };
389
+ } else change = command;
390
+ applied = await applyChange(subject, command.scope, change, isCurrent);
391
+ } catch (cause) {
392
+ if (!isCurrent()) return;
393
+ error = message(cause);
394
+ }
395
+ if (isCurrent()) status(ctx, applied ? [applied] : [], error);
396
+ },
397
+ });
398
+ }