pi-agent-supervisor 1.0.0 → 1.1.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/src/config.ts ADDED
@@ -0,0 +1,69 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface SupervisorConfig {
5
+ blockedPatterns: string[];
6
+ protectedFiles: string[];
7
+ protectedPatterns: string[];
8
+ rateLimitPerMinute: number;
9
+ rateLimitHardBlock: number;
10
+ maxConsecutiveErrors: number;
11
+ enableAuditLog: boolean;
12
+ auditLogPath: string;
13
+ contextWarnThreshold: number;
14
+ contextCriticalThreshold: number;
15
+ blockAtCriticalContext: boolean;
16
+ }
17
+
18
+ export const DEFAULT_CONFIG: SupervisorConfig = {
19
+ blockedPatterns: [],
20
+ protectedFiles: [".env", ".env.local", ".env.production", "credentials.json", "serviceAccountKey.json", ".claude/settings.local.json", ".git/config"],
21
+ protectedPatterns: ["*.pem", "*.key", "id_rsa*", "*secret*", "*credential*"],
22
+ rateLimitPerMinute: 50, rateLimitHardBlock: 80, maxConsecutiveErrors: 3,
23
+ enableAuditLog: true, auditLogPath: ".supervisor/audit.log",
24
+ contextWarnThreshold: 70, contextCriticalThreshold: 90, blockAtCriticalContext: false,
25
+ };
26
+
27
+ export function loadBlockedPatterns(extensionDir: string): string[] {
28
+ const patternsDir = path.join(extensionDir, "patterns");
29
+ if (!fs.existsSync(patternsDir)) return [];
30
+ const patterns: string[] = [];
31
+ try {
32
+ for (const file of fs.readdirSync(patternsDir).filter(f => f.endsWith(".txt"))) {
33
+ const content = fs.readFileSync(path.join(patternsDir, file), "utf-8");
34
+ for (const line of content.split("\n")) {
35
+ const t = line.trim();
36
+ if (t && !t.startsWith("#")) patterns.push(t);
37
+ }
38
+ }
39
+ } catch { /* ignore */ }
40
+ return patterns;
41
+ }
42
+
43
+ export function loadConfig(cwd: string, extensionDir?: string): SupervisorConfig {
44
+ const configPath = path.join(cwd, ".supervisorrc.yml");
45
+ const config = { ...DEFAULT_CONFIG };
46
+ if (extensionDir) config.blockedPatterns = loadBlockedPatterns(extensionDir);
47
+ if (!fs.existsSync(configPath)) return config;
48
+ try {
49
+ const content = fs.readFileSync(configPath, "utf-8");
50
+ const result: Record<string, unknown> = {};
51
+ for (const line of content.split("\n")) {
52
+ const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
53
+ if (m) { let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); result[m[1]] = v; }
54
+ }
55
+ return {
56
+ blockedPatterns: result["blockedPatterns"] ? (result["blockedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean) : config.blockedPatterns,
57
+ protectedFiles: result["protectedFiles"] ? (result["protectedFiles"] as string).split(",").map(s => s.trim()).filter(Boolean) : DEFAULT_CONFIG.protectedFiles,
58
+ protectedPatterns: result["protectedPatterns"] ? (result["protectedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean) : DEFAULT_CONFIG.protectedPatterns,
59
+ rateLimitPerMinute: parseInt(result["rateLimitPerMinute"] as string) || DEFAULT_CONFIG.rateLimitPerMinute,
60
+ rateLimitHardBlock: parseInt(result["rateLimitHardBlock"] as string) || DEFAULT_CONFIG.rateLimitHardBlock,
61
+ maxConsecutiveErrors: parseInt(result["maxConsecutiveErrors"] as string) || DEFAULT_CONFIG.maxConsecutiveErrors,
62
+ enableAuditLog: result["enableAuditLog"] !== "false",
63
+ auditLogPath: (result["auditLogPath"] as string) || DEFAULT_CONFIG.auditLogPath,
64
+ contextWarnThreshold: parseInt(result["contextWarnThreshold"] as string) || DEFAULT_CONFIG.contextWarnThreshold,
65
+ contextCriticalThreshold: parseInt(result["contextCriticalThreshold"] as string) || DEFAULT_CONFIG.contextCriticalThreshold,
66
+ blockAtCriticalContext: result["blockAtCriticalContext"] === "true",
67
+ };
68
+ } catch { return config; }
69
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,44 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { SupervisorConfig } from "./config";
4
+ import { state } from "./state";
5
+
6
+ export function getAuditLogPath(baseDir: string, config: SupervisorConfig): string {
7
+ return path.join(baseDir, config.auditLogPath);
8
+ }
9
+
10
+ export function appendToAuditLog(baseDir: string, config: SupervisorConfig, entry: string): void {
11
+ if (!config.enableAuditLog) return;
12
+ const logPath = getAuditLogPath(baseDir, config);
13
+ const dir = path.dirname(logPath);
14
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
15
+ fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${entry}\n`, { encoding: "utf-8" });
16
+ }
17
+
18
+ export function getCurrentRate(config: SupervisorConfig): number {
19
+ const now = Date.now();
20
+ state.toolCalls = state.toolCalls.filter(c => c.timestamp > now - 60000);
21
+ return state.toolCalls.length;
22
+ }
23
+
24
+ export function matchBlockedCommand(cmd: string, config: SupervisorConfig): string | null {
25
+ for (const pattern of config.blockedPatterns) {
26
+ try { if (new RegExp(pattern, "i").test(cmd)) return pattern; } catch { /* skip */ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export function isProtectedFile(filePath: string, config: SupervisorConfig): boolean {
32
+ const basename = path.basename(filePath);
33
+ if (config.protectedFiles.some(f => basename === f || filePath.endsWith(f))) return true;
34
+ for (const pattern of config.protectedPatterns) {
35
+ const regexStr = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*");
36
+ try { if (new RegExp(`^${regexStr}$`, "i").test(basename)) return true; } catch { /* skip */ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ export function detectFileWrite(cmd: string): string | null {
42
+ const m = cmd.match(/>>?\s*(\S+)/);
43
+ return m ? m[1] : null;
44
+ }
package/src/index.ts CHANGED
@@ -1,477 +1,39 @@
1
1
  /**
2
2
  * pi-agent-supervisor — Runtime Safety Net
3
3
  *
4
- * Watches agents while they work. Blocks dangerous commands, protects
5
- * sensitive files, enforces rate limits, tracks context budget, records
6
- * sessions to an append-only log, and escalates on consecutive errors.
7
- *
8
- * Tools:
9
- * supervisor_status() → show session stats (rate, errors, context)
10
- * supervisor_log(tail) → read session log (read-only)
11
- * supervisor_override(reason) → request human override for blocked operation
12
- *
4
+ * Tools: supervisor_status, supervisor_log, supervisor_override
13
5
  * Config: .supervisorrc.yml
6
+ * Patterns: patterns/*.txt
14
7
  */
15
-
16
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
- import { Type } from "typebox";
18
- import * as fs from "node:fs";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
9
  import * as path from "node:path";
20
10
  import * as os from "node:os";
21
-
22
- // ── Types ──
23
-
24
- interface SupervisorConfig {
25
- /** Blocked command patterns */
26
- blockedPatterns: string[];
27
- /** Protected file paths (write blocked) */
28
- protectedFiles: string[];
29
- /** Protected file patterns (glob-like, write blocked) */
30
- protectedPatterns: string[];
31
- /** Max tool calls per minute before warning */
32
- rateLimitPerMinute: number;
33
- /** Max tool calls per minute before blocking */
34
- rateLimitHardBlock: number;
35
- /** Max consecutive errors before escalation */
36
- maxConsecutiveErrors: number;
37
- /** Whether to record session to audit log */
38
- enableAuditLog: boolean;
39
- /** Audit log path */
40
- auditLogPath: string;
41
- /** Context budget warning threshold (percentage) */
42
- contextWarnThreshold: number;
43
- /** Context budget critical threshold (percentage) */
44
- contextCriticalThreshold: number;
45
- /** Whether to block at critical context */
46
- blockAtCriticalContext: boolean;
47
- }
48
-
49
- const DEFAULT_CONFIG: SupervisorConfig = {
50
- blockedPatterns: [
51
- "rm\\s+-rf\\s+/",
52
- "rm\\s+-rf\\s+~",
53
- "rm\\s+-rf\\s+\\*",
54
- "git\\s+push\\s+.*--force",
55
- "git\\s+push\\s+.*-f\\b",
56
- "sudo\\s+",
57
- "chmod\\s+777",
58
- ">\\s*/dev/sd[a-z]",
59
- "dd\\s+if=",
60
- "mkfs\\.",
61
- ":(){ :|:& };:", // fork bomb
62
- ">\\s*\\.env",
63
- ">\\s*\\.git",
64
- ],
65
- protectedFiles: [
66
- ".env",
67
- ".env.local",
68
- ".env.production",
69
- "credentials.json",
70
- "serviceAccountKey.json",
71
- ".claude/settings.local.json",
72
- ".git/config",
73
- ],
74
- protectedPatterns: [
75
- "*.pem",
76
- "*.key",
77
- "id_rsa*",
78
- "*secret*",
79
- "*credential*",
80
- ],
81
- rateLimitPerMinute: 50,
82
- rateLimitHardBlock: 80,
83
- maxConsecutiveErrors: 3,
84
- enableAuditLog: true,
85
- auditLogPath: ".supervisor/audit.log",
86
- contextWarnThreshold: 70,
87
- contextCriticalThreshold: 90,
88
- blockAtCriticalContext: false,
89
- };
90
-
91
- // ── Config ──
92
-
93
- function loadConfig(cwd: string): SupervisorConfig {
94
- const configPath = path.join(cwd, ".supervisorrc.yml");
95
- if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
96
- try {
97
- const content = fs.readFileSync(configPath, "utf-8");
98
- const result: Record<string, unknown> = {};
99
- for (const line of content.split("\n")) {
100
- const m = line.match(/^\s*([\w][\w.]*):\s*(.+)$/);
101
- if (m) result[m[1]] = m[2].trim();
102
- }
103
- return {
104
- blockedPatterns: result["blockedPatterns"]
105
- ? (result["blockedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean)
106
- : DEFAULT_CONFIG.blockedPatterns,
107
- protectedFiles: result["protectedFiles"]
108
- ? (result["protectedFiles"] as string).split(",").map(s => s.trim()).filter(Boolean)
109
- : DEFAULT_CONFIG.protectedFiles,
110
- protectedPatterns: result["protectedPatterns"]
111
- ? (result["protectedPatterns"] as string).split(",").map(s => s.trim()).filter(Boolean)
112
- : DEFAULT_CONFIG.protectedPatterns,
113
- rateLimitPerMinute: parseInt(result["rateLimitPerMinute"] as string) || DEFAULT_CONFIG.rateLimitPerMinute,
114
- rateLimitHardBlock: parseInt(result["rateLimitHardBlock"] as string) || DEFAULT_CONFIG.rateLimitHardBlock,
115
- maxConsecutiveErrors: parseInt(result["maxConsecutiveErrors"] as string) || DEFAULT_CONFIG.maxConsecutiveErrors,
116
- enableAuditLog: result["enableAuditLog"] !== "false",
117
- auditLogPath: (result["auditLogPath"] as string) || DEFAULT_CONFIG.auditLogPath,
118
- contextWarnThreshold: parseInt(result["contextWarnThreshold"] as string) || DEFAULT_CONFIG.contextWarnThreshold,
119
- contextCriticalThreshold: parseInt(result["contextCriticalThreshold"] as string) || DEFAULT_CONFIG.contextCriticalThreshold,
120
- blockAtCriticalContext: result["blockAtCriticalContext"] === "true",
121
- };
122
- } catch {
123
- return { ...DEFAULT_CONFIG };
124
- }
125
- }
126
-
127
- // ── Session state ──
128
-
129
- interface SessionState {
130
- toolCalls: Array<{ tool: string; timestamp: number }>;
131
- errorCount: number;
132
- consecutiveErrors: number;
133
- blockedCount: number;
134
- lastEscalation: number;
135
- contextBudget: { used: number; total: number } | null;
136
- }
137
-
138
- let state: SessionState = {
139
- toolCalls: [],
140
- errorCount: 0,
141
- consecutiveErrors: 0,
142
- blockedCount: 0,
143
- lastEscalation: 0,
144
- contextBudget: null,
145
- };
146
-
147
- // ── Audit log ──
148
-
149
- function getAuditLogPath(baseDir: string, config: SupervisorConfig): string {
150
- return path.join(baseDir, config.auditLogPath);
151
- }
152
-
153
- function appendToAuditLog(baseDir: string, config: SupervisorConfig, entry: string): void {
154
- if (!config.enableAuditLog) return;
155
- const logPath = getAuditLogPath(baseDir, config);
156
- const dir = path.dirname(logPath);
157
- if (!fs.existsSync(dir)) {
158
- fs.mkdirSync(dir, { recursive: true });
159
- }
160
- // Append-only — use O_APPEND flag
161
- const timestamp = new Date().toISOString();
162
- const line = `[${timestamp}] ${entry}\n`;
163
- fs.appendFileSync(logPath, line, { encoding: "utf-8" });
164
- }
165
-
166
- // ── Rate limiting ──
167
-
168
- function getCurrentRate(config: SupervisorConfig): number {
169
- const now = Date.now();
170
- const oneMinuteAgo = now - 60000;
171
- // Keep only calls from the last minute
172
- state.toolCalls = state.toolCalls.filter(c => c.timestamp > oneMinuteAgo);
173
- return state.toolCalls.length;
174
- }
175
-
176
- // ── Command pattern matching ──
177
-
178
- function matchBlockedCommand(cmd: string, config: SupervisorConfig): string | null {
179
- for (const pattern of config.blockedPatterns) {
180
- try {
181
- if (new RegExp(pattern, "i").test(cmd)) {
182
- return pattern;
183
- }
184
- } catch {
185
- // Invalid regex, skip
186
- }
187
- }
188
- return null;
189
- }
190
-
191
- // ── File protection ──
192
-
193
- function isProtectedFile(filePath: string, config: SupervisorConfig): boolean {
194
- const basename = path.basename(filePath);
195
-
196
- // Exact file match
197
- if (config.protectedFiles.some(f => filePath.includes(f) || basename === f)) {
198
- return true;
199
- }
200
-
201
- // Glob pattern match
202
- for (const pattern of config.protectedPatterns) {
203
- // Simple glob: convert * to regex
204
- const regexStr = pattern
205
- .replace(/\./g, "\\.")
206
- .replace(/\*/g, ".*");
207
- try {
208
- if (new RegExp(`^${regexStr}$`, "i").test(basename)) {
209
- return true;
210
- }
211
- } catch {
212
- // Invalid regex, skip
213
- }
214
- }
215
-
216
- return false;
217
- }
218
-
219
- function detectFileWrite(cmd: string): string | null {
220
- // Detect patterns like: > file, write to file, edit file, cat > file
221
- const redirectMatch = cmd.match(/>\s*(\S+)/);
222
- if (redirectMatch) return redirectMatch[1];
223
-
224
- const writeMatch = cmd.match(/(?:write|edit)\s+["']?([^\s"']+)["']?/i);
225
- if (writeMatch) return writeMatch[1];
226
-
227
- return null;
228
- }
229
-
230
- // ── Extension ──
11
+ import { loadConfig } from "./config";
12
+ import { appendToAuditLog } from "./helpers";
13
+ import { resetState } from "./state";
14
+ import { interceptToolCall, trackToolError, trackToolSuccess } from "./intercepts";
15
+ import { statusTool, logTool, overrideTool } from "./tools/supervisor";
231
16
 
232
17
  export default function (pi: ExtensionAPI) {
233
- // ═══════════════════════════════════════
234
- // Runtime monitoring — intercept ALL tool calls
235
- // ═══════════════════════════════════════
236
- pi.on("tool_call", async (event, ctx) => {
237
- const config = loadConfig(ctx.cwd);
238
- const now = Date.now();
239
-
240
- // Track call for rate limiting
241
- state.toolCalls.push({ tool: event.toolName, timestamp: now });
242
- const rate = getCurrentRate(config);
243
-
244
- // Log the call
245
- appendToAuditLog(ctx.cwd, config, `CALL ${event.toolName} (rate: ${rate}/min)`);
246
-
247
- // ── Rate limiting ──
248
- if (rate > config.rateLimitHardBlock) {
249
- const msg = `⛔ Rate limit exceeded (${rate}/${config.rateLimitHardBlock} calls/min). Paused.`;
250
- ctx.ui.notify(msg, "error");
251
- appendToAuditLog(ctx.cwd, config, `BLOCK rate-limit: ${rate}/min`);
252
- state.blockedCount++;
253
- return { block: true, reason: msg };
254
- }
255
- if (rate > config.rateLimitPerMinute) {
256
- ctx.ui.notify(
257
- `⚠️ High tool call rate (${rate}/${config.rateLimitPerMinute} calls/min). Slow down.`,
258
- "warning",
259
- );
260
- }
261
-
262
- // ── Dangerous command blocking (bash only) ──
263
- if (event.toolName === "bash" && typeof event.input.command === "string") {
264
- const cmd = event.input.command;
265
-
266
- // Check blocked patterns
267
- const blockedPattern = matchBlockedCommand(cmd, config);
268
- if (blockedPattern) {
269
- const msg = `⛔ Dangerous command blocked (pattern: "${blockedPattern}")`;
270
- ctx.ui.notify(msg, "error");
271
- appendToAuditLog(ctx.cwd, config, `BLOCK dangerous-cmd: ${cmd.substring(0, 100)}`);
272
- state.blockedCount++;
273
- return { block: true, reason: msg };
274
- }
275
-
276
- // Check file protection
277
- const writtenFile = detectFileWrite(cmd);
278
- if (writtenFile && isProtectedFile(writtenFile, config)) {
279
- const msg = `⛔ Write to protected file blocked: ${writtenFile}`;
280
- ctx.ui.notify(msg, "error");
281
- appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${writtenFile}`);
282
- state.blockedCount++;
283
- return { block: true, reason: msg };
284
- }
285
- }
286
-
287
- // ── File operation protection (write/edit tools) ──
288
- if ((event.toolName === "write" || event.toolName === "edit") && event.input.path) {
289
- const filePath = event.input.path as string;
290
- if (isProtectedFile(filePath, config)) {
291
- const msg = `⛔ Write to protected file blocked: ${filePath}`;
292
- ctx.ui.notify(msg, "error");
293
- appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${filePath}`);
294
- state.blockedCount++;
295
- return { block: true, reason: msg };
296
- }
297
- }
298
- });
299
-
300
- // ── Error tracking ──
301
- pi.on("tool_error", async (event, ctx) => {
302
- const config = loadConfig(ctx.cwd);
303
- state.errorCount++;
304
- state.consecutiveErrors++;
305
-
306
- appendToAuditLog(ctx.cwd, config,
307
- `ERROR ${event.toolName}: ${String(event.error).substring(0, 200)} (consecutive: ${state.consecutiveErrors})`,
308
- );
309
-
310
- if (state.consecutiveErrors >= config.maxConsecutiveErrors) {
311
- const msg = `🚨 ${state.consecutiveErrors} consecutive errors — escalation triggered. Pausing for human review.`;
312
- ctx.ui.notify(msg, "error");
313
- appendToAuditLog(ctx.cwd, config, `ESCALATE consecutive-errors: ${state.consecutiveErrors}`);
314
- state.lastEscalation = Date.now();
315
- }
316
- });
317
-
318
- // ── Track successes to reset error counter ──
319
- pi.on("tool_result", async (event, ctx) => {
320
- state.consecutiveErrors = 0; // Reset on success
321
- });
322
-
323
- // ═══════════════════════════════════════
324
- // Tool: supervisor_status
325
- // ═══════════════════════════════════════
326
- pi.registerTool({
327
- name: "supervisor_status",
328
- label: "Supervisor Status",
329
- description: "Show supervisor session stats — rate, errors, blocked calls, context budget.",
330
- parameters: Type.Object({}),
331
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
332
- const config = loadConfig(ctx.cwd);
333
- const rate = getCurrentRate(config);
334
-
335
- const lines: string[] = [];
336
- lines.push("🛡 Supervisor Status");
337
- lines.push("");
338
- lines.push(`📊 Tool calls: ${state.toolCalls.length} total (${rate}/min current)`);
339
- lines.push(` Rate limit: ${config.rateLimitPerMinute}/min warn, ${config.rateLimitHardBlock}/min block`);
340
- lines.push(`❌ Errors: ${state.errorCount} total, ${state.consecutiveErrors} consecutive`);
341
- lines.push(` Escalation at: ${config.maxConsecutiveErrors} consecutive errors`);
342
- lines.push(`🚫 Blocked: ${state.blockedCount} calls blocked`);
343
- lines.push(`📝 Audit log: ${config.enableAuditLog ? config.auditLogPath : "disabled"}`);
344
- lines.push(`🔒 Protected files: ${config.protectedFiles.length} exact, ${config.protectedPatterns.length} patterns`);
345
-
346
- if (state.lastEscalation > 0) {
347
- const escTime = new Date(state.lastEscalation).toISOString();
348
- lines.push(`🚨 Last escalation: ${escTime}`);
349
- }
350
-
351
- return {
352
- content: [{ type: "text", text: lines.join("\n") }],
353
- details: {
354
- rate,
355
- errors: state.errorCount,
356
- consecutiveErrors: state.consecutiveErrors,
357
- blocked: state.blockedCount,
358
- lastEscalation: state.lastEscalation,
359
- },
360
- };
361
- },
362
- });
363
-
364
- // ═══════════════════════════════════════
365
- // Tool: supervisor_log
366
- // ═══════════════════════════════════════
367
- pi.registerTool({
368
- name: "supervisor_log",
369
- label: "View Audit Log",
370
- description: "Read the supervisor audit log (read-only, last N lines).",
371
- parameters: Type.Object({
372
- tail: Type.Optional(Type.Number({ description: "Number of recent lines to show (default: 50)" })),
373
- }),
374
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
375
- const config = loadConfig(ctx.cwd);
376
- const logPath = getAuditLogPath(ctx.cwd, config);
377
- const tail = params.tail || 50;
378
-
379
- if (!fs.existsSync(logPath)) {
380
- return {
381
- content: [{ type: "text", text: "No audit log found. Session hasn't been recorded yet." }],
382
- details: {},
383
- };
384
- }
18
+ const extensionDir = path.dirname(__filename || path.join(__dirname, ".."));
385
19
 
386
- const content = fs.readFileSync(logPath, "utf-8");
387
- const lines = content.split("\n").filter(Boolean);
388
- const recent = lines.slice(-tail);
20
+ pi.on("tool_call", (event, ctx) => interceptToolCall(event, ctx, extensionDir));
21
+ pi.on("tool_error", (event, ctx) => trackToolError(event, ctx, extensionDir));
22
+ pi.on("tool_result", () => trackToolSuccess());
389
23
 
390
- // Highlight blocked/escalation entries
391
- const formatted = recent.map(line => {
392
- if (line.includes("BLOCK")) return `🚫 ${line}`;
393
- if (line.includes("ESCALATE")) return `🚨 ${line}`;
394
- if (line.includes("ERROR")) return `❌ ${line}`;
395
- return ` ${line}`;
396
- });
24
+ pi.registerTool(statusTool);
25
+ pi.registerTool(logTool);
26
+ pi.registerTool(overrideTool);
397
27
 
398
- return {
399
- content: [{
400
- type: "text",
401
- text: [
402
- `📝 Audit Log (last ${recent.length} of ${lines.length} entries)`,
403
- ` Path: ${logPath}`,
404
- "",
405
- ...formatted,
406
- ].join("\n"),
407
- }],
408
- details: { totalLines: lines.length, shown: recent.length, path: logPath },
409
- };
410
- },
411
- });
412
-
413
- // ═══════════════════════════════════════
414
- // Tool: supervisor_override
415
- // ═══════════════════════════════════════
416
- pi.registerTool({
417
- name: "supervisor_override",
418
- label: "Request Override",
419
- description: "Request human override for a blocked operation. Requires explicit confirmation.",
420
- parameters: Type.Object({
421
- reason: Type.String({ description: "Why the blocked operation should be allowed" }),
422
- command: Type.Optional(Type.String({ description: "The specific command that was blocked (if applicable)" })),
423
- }),
424
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
425
- const config = loadConfig(ctx.cwd);
426
-
427
- const cmdInfo = params.command ? `\n\nBlocked command: ${params.command}` : "";
428
- const msg = `Override requested${cmdInfo}\n\nReason: ${params.reason}\n\nAllow this operation?`;
429
-
430
- const allowed = await ctx.ui.confirm("Supervisor Override", msg);
431
-
432
- if (allowed) {
433
- appendToAuditLog(ctx.cwd, config,
434
- `OVERRIDE allowed: ${params.reason}${params.command ? ` (cmd: ${params.command.substring(0, 80)})` : ""}`,
435
- );
436
- return {
437
- content: [{ type: "text", text: "✅ Override granted. Proceed with the operation." }],
438
- details: { override: true, reason: params.reason },
439
- };
440
- } else {
441
- appendToAuditLog(ctx.cwd, config,
442
- `OVERRIDE denied: ${params.reason}`,
443
- );
444
- return {
445
- content: [{ type: "text", text: "❌ Override denied. Operation remains blocked." }],
446
- isError: true,
447
- details: { override: false, reason: params.reason },
448
- };
449
- }
450
- },
451
- });
452
-
453
- // ═══════════════════════════════════════
454
- // Session lifecycle
455
- // ═══════════════════════════════════════
456
- pi.on("session_start", async (_event, ctx) => {
457
- const config = loadConfig(ctx.cwd);
28
+ pi.on("session_start", (_event, ctx) => {
29
+ const config = loadConfig(ctx.cwd, extensionDir);
30
+ resetState();
458
31
  appendToAuditLog(ctx.cwd, config, `SESSION_START host=${os.hostname()} cwd=${ctx.cwd}`);
459
-
460
- // Reset state for new session
461
- state = {
462
- toolCalls: [],
463
- errorCount: 0,
464
- consecutiveErrors: 0,
465
- blockedCount: 0,
466
- lastEscalation: 0,
467
- contextBudget: null,
468
- };
469
32
  });
470
33
 
471
- pi.on("session_shutdown", async (_event, ctx) => {
472
- const config = loadConfig(ctx.cwd);
473
- appendToAuditLog(ctx.cwd, config,
474
- `SESSION_END calls=${state.toolCalls.length} errors=${state.errorCount} blocked=${state.blockedCount}`,
475
- );
34
+ pi.on("session_shutdown", (_event, ctx) => {
35
+ const config = loadConfig(ctx.cwd, extensionDir);
36
+ const { state } = require("./state");
37
+ appendToAuditLog(ctx.cwd, config, `SESSION_END calls=${state.toolCalls.length} errors=${state.errorCount} blocked=${state.blockedCount}`);
476
38
  });
477
39
  }
@@ -0,0 +1,70 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfig } from "./config";
3
+ import { getCurrentRate, matchBlockedCommand, isProtectedFile, detectFileWrite, appendToAuditLog } from "./helpers";
4
+ import { state } from "./state";
5
+
6
+ export async function interceptToolCall(event: any, ctx: ExtensionContext, extensionDir: string) {
7
+ const config = loadConfig(ctx.cwd, extensionDir);
8
+ const now = Date.now();
9
+ state.toolCalls.push({ tool: event.toolName, timestamp: now });
10
+ const rate = getCurrentRate(config);
11
+ appendToAuditLog(ctx.cwd, config, `CALL ${event.toolName} (rate: ${rate}/min)`);
12
+
13
+ if (rate > config.rateLimitHardBlock) {
14
+ const msg = `⛔ Rate limit exceeded (${rate}/${config.rateLimitHardBlock}/min).`;
15
+ ctx.ui.notify(msg, "error");
16
+ appendToAuditLog(ctx.cwd, config, `BLOCK rate-limit: ${rate}/min`);
17
+ state.blockedCount++;
18
+ return { block: true, reason: msg };
19
+ }
20
+ if (rate > config.rateLimitPerMinute) {
21
+ ctx.ui.notify(`⚠️ High rate (${rate}/${config.rateLimitPerMinute}/min).`, "warning");
22
+ }
23
+
24
+ if (event.toolName === "bash" && typeof event.input.command === "string") {
25
+ const cmd = event.input.command;
26
+ const blocked = matchBlockedCommand(cmd, config);
27
+ if (blocked) {
28
+ const msg = `⛔ Dangerous command blocked (pattern: "${blocked}")`;
29
+ ctx.ui.notify(msg, "error");
30
+ appendToAuditLog(ctx.cwd, config, `BLOCK dangerous-cmd: ${cmd.substring(0, 100)}`);
31
+ state.blockedCount++;
32
+ return { block: true, reason: msg };
33
+ }
34
+ const written = detectFileWrite(cmd);
35
+ if (written && isProtectedFile(written, config)) {
36
+ const msg = `⛔ Write to protected file blocked: ${written}`;
37
+ ctx.ui.notify(msg, "error");
38
+ appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${written}`);
39
+ state.blockedCount++;
40
+ return { block: true, reason: msg };
41
+ }
42
+ }
43
+
44
+ if ((event.toolName === "write" || event.toolName === "edit") && event.input.path) {
45
+ if (isProtectedFile(event.input.path as string, config)) {
46
+ const msg = `⛔ Write to protected file blocked: ${event.input.path}`;
47
+ ctx.ui.notify(msg, "error");
48
+ appendToAuditLog(ctx.cwd, config, `BLOCK protected-file: ${event.input.path}`);
49
+ state.blockedCount++;
50
+ return { block: true, reason: msg };
51
+ }
52
+ }
53
+ }
54
+
55
+ export function trackToolError(event: any, ctx: ExtensionContext, extensionDir: string) {
56
+ const config = loadConfig(ctx.cwd, extensionDir);
57
+ state.errorCount++;
58
+ state.consecutiveErrors++;
59
+ appendToAuditLog(ctx.cwd, config, `ERROR ${event.toolName}: ${String(event.error).substring(0, 200)} (consecutive: ${state.consecutiveErrors})`);
60
+ if (state.consecutiveErrors >= config.maxConsecutiveErrors) {
61
+ const msg = `🚨 ${state.consecutiveErrors} consecutive errors — escalation triggered.`;
62
+ ctx.ui.notify(msg, "error");
63
+ appendToAuditLog(ctx.cwd, config, `ESCALATE consecutive-errors: ${state.consecutiveErrors}`);
64
+ state.lastEscalation = Date.now();
65
+ }
66
+ }
67
+
68
+ export function trackToolSuccess() {
69
+ state.consecutiveErrors = 0;
70
+ }