@andares/pi-toolkits 0.9.7 → 0.10.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/lib/gates.ts CHANGED
@@ -17,14 +17,28 @@
17
17
  * slot; a second presentation would mount over the first and strand its
18
18
  * promise. The queue serializes our own presentations; the host slot is
19
19
  * still shared with other extensions (pi does not arbitrate globally).
20
+ * pi 1.4.0 stacks extension dialogs so a second one no longer strands the
21
+ * first (#11166), but the 1.1–1.3 hosts in the supported window still
22
+ * replace it — the queue stays, and it also keeps one confirmation visible
23
+ * at a time instead of a pile of stacked dialogs on 1.4.0.
20
24
  * - A gate that throws blocks the call (fail closed), matching pi's own
21
25
  * `tool_call` handler contract.
26
+ *
27
+ * The hub is also the single audit point (`lib/approval-log.ts`): it is the
28
+ * only place that sees the gate verdict, the prompt, the operator's answer,
29
+ * and the final outcome in one frame. Features attach a structured `audit`
30
+ * payload; the hub writes blocks and asks, while a gate that auto-allows an
31
+ * operation it considers worth recording writes its own `allow` record through
32
+ * the same log (permission's session-grant allows). Logging never changes a
33
+ * verdict: the writer fails open and the hub swallows payload errors.
22
34
  */
23
35
  import type {
24
36
  ExtensionAPI,
25
37
  ExtensionContext,
26
38
  ToolCallEvent,
27
39
  } from "@earendil-works/pi-coding-agent";
40
+ import type { ApprovalLog, ApprovalRecord } from "./approval-log.js";
41
+ import { auditBase, auditTool, clipText, sessionIdOf } from "./approval-log.js";
28
42
 
29
43
  /** What the operator chose in an interactive ask. */
30
44
  export type AskDecision =
@@ -48,17 +62,37 @@ export interface AskRequest {
48
62
  readonly details: readonly string[];
49
63
  /** The directory a session grant would cover. */
50
64
  readonly root: string;
65
+ /**
66
+ * Structured context written with the audit record when the log is on.
67
+ * Built by the feature; the hub adds the tool call, answer and outcome.
68
+ */
69
+ readonly audit?: ApprovalRecord;
51
70
  present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
52
71
  apply(decision: AskDecision): void;
53
72
  /** Model-facing reason when the operator answers deny/deny-session. */
54
73
  denyReason(decision: AskDecision): string;
74
+ /**
75
+ * Called exactly once when the ask settles — answer, cancel, or "never
76
+ * shown because an earlier gate blocked". Returned fields are merged into
77
+ * the audit record (for example the session grant an answer created).
78
+ * A throwing settle is ignored: auditing must not affect the verdict.
79
+ */
80
+ settle?(
81
+ decision: AskDecision | undefined,
82
+ meta: { presented: boolean; ms: number },
83
+ ): ApprovalRecord | undefined;
55
84
  }
56
85
 
57
86
  /** A gate's opinion about one tool call. */
58
87
  export type GateVerdict =
59
88
  | undefined
60
89
  | { readonly kind: "allow" }
61
- | { readonly kind: "block"; readonly reason: string }
90
+ | {
91
+ readonly kind: "block";
92
+ readonly reason: string;
93
+ /** Structured context for the audit log. */
94
+ readonly audit?: ApprovalRecord;
95
+ }
62
96
  | { readonly kind: "ask"; readonly request: AskRequest };
63
97
 
64
98
  /** One feature's tool-call policy. Lower `order` runs first. */
@@ -74,6 +108,11 @@ export interface ToolGate {
74
108
  /** Result of composing the gates: a block reason, or nothing (allow). */
75
109
  export type GateResult = { readonly reason: string } | undefined;
76
110
 
111
+ export interface GateHubOptions {
112
+ /** Write-only audit sink; absent in tests and when logging is off. */
113
+ readonly audit?: ApprovalLog;
114
+ }
115
+
77
116
  export interface GateHub {
78
117
  register(gate: ToolGate): void;
79
118
  /** Compose every gate for one call (also the unit-test entry point). */
@@ -82,14 +121,74 @@ export interface GateHub {
82
121
  install(pi: ExtensionAPI): void;
83
122
  }
84
123
 
124
+ interface PendingAsk {
125
+ readonly gate: string;
126
+ readonly request: AskRequest;
127
+ }
128
+
129
+ interface SessionCounters {
130
+ session?: string;
131
+ startedAt: number;
132
+ calls: number;
133
+ asks: number;
134
+ blocks: number;
135
+ answers: Record<string, number>;
136
+ base?: ApprovalRecord;
137
+ }
138
+
85
139
  function messageOf(error: unknown): string {
86
140
  return error instanceof Error ? error.message : String(error);
87
141
  }
88
142
 
89
- export function createGateHub(): GateHub {
143
+ function emptyCounters(session?: string, base?: ApprovalRecord): SessionCounters {
144
+ return {
145
+ session,
146
+ startedAt: Date.now(),
147
+ calls: 0,
148
+ asks: 0,
149
+ blocks: 0,
150
+ answers: {},
151
+ base,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Outcome label plus the counter key for a settled ask. A plain nested ternary
157
+ * chain here was unreadable and easy to get wrong, so the mapping lives in one
158
+ * named place.
159
+ */
160
+ function answerLabel(
161
+ decision: AskDecision | undefined,
162
+ presented: boolean,
163
+ error: unknown,
164
+ ): { outcome: string; key: string } {
165
+ if (error !== undefined) return { outcome: "error", key: "error" };
166
+ if (!presented) return { outcome: "not-presented", key: "not-presented" };
167
+ if (decision === undefined) return { outcome: "cancelled", key: "cancelled" };
168
+ if (decision === "allow" || decision === "allow-session") {
169
+ return { outcome: "allowed", key: decision };
170
+ }
171
+ return { outcome: "blocked", key: decision };
172
+ }
173
+
174
+ export function createGateHub(options: GateHubOptions = {}): GateHub {
90
175
  const gates: ToolGate[] = [];
91
176
  let queue: Promise<void> = Promise.resolve();
92
177
 
178
+ const audit = options.audit;
179
+ const logging = audit !== undefined && audit.mode !== "off";
180
+ /**
181
+ * Counters per session id. A single slot would be wrong as soon as two
182
+ * sessions run in one process (a child/subagent session or a parent and a
183
+ * replaced runtime): each call from the other session would flush the first
184
+ * one's partial counters and start over, producing a burst of tiny summaries
185
+ * with misattributed numbers. Keyed storage keeps each session's counters
186
+ * intact regardless of interleaving.
187
+ */
188
+ const countersBySession = new Map<string, SessionCounters>();
189
+ /** Sessions kept in memory at once; each entry is a handful of numbers. */
190
+ const MAX_TRACKED_SESSIONS = 8;
191
+
93
192
  /**
94
193
  * Serialize dialogs without letting one rejection poison the queue:
95
194
  * the tail always settles, the caller still sees its own outcome.
@@ -103,27 +202,203 @@ export function createGateHub(): GateHub {
103
202
  return next;
104
203
  }
105
204
 
205
+ /**
206
+ * One summary record per session gives the analysis a denominator without
207
+ * filling the ring buffer with auto-allowed read/grep calls. It is written on
208
+ * shutdown (quit / reload / session replacement) and when a stale session is
209
+ * evicted; a crash loses at most the live sessions' counters.
210
+ */
211
+ function flushSession(
212
+ key: string,
213
+ reason: string,
214
+ fallback?: ApprovalRecord,
215
+ ): void {
216
+ const counters = countersBySession.get(key);
217
+ if (!counters) return;
218
+ countersBySession.delete(key);
219
+ if (!logging) return;
220
+ const base = counters.base ?? fallback;
221
+ const hadWork =
222
+ counters.calls > 0 || counters.asks > 0 || counters.blocks > 0;
223
+ if (base && hadWork) {
224
+ audit?.append({
225
+ ...base,
226
+ ts: new Date().toISOString(),
227
+ event: "session",
228
+ reason,
229
+ durationMs: Date.now() - counters.startedAt,
230
+ counts: {
231
+ calls: counters.calls,
232
+ asks: counters.asks,
233
+ blocks: counters.blocks,
234
+ answers: counters.answers,
235
+ },
236
+ });
237
+ }
238
+ }
239
+
240
+ /** Counters for this call's session, creating the slot on first use. */
241
+ function countersFor(
242
+ session: string | undefined,
243
+ base: ApprovalRecord,
244
+ ): SessionCounters {
245
+ const key = session ?? "";
246
+ let counters = countersBySession.get(key);
247
+ if (!counters) {
248
+ counters = emptyCounters(session, base);
249
+ countersBySession.set(key, counters);
250
+ if (countersBySession.size > MAX_TRACKED_SESSIONS) {
251
+ const oldest = countersBySession.keys().next().value;
252
+ if (oldest !== undefined && oldest !== key) {
253
+ flushSession(oldest, "evicted");
254
+ }
255
+ }
256
+ } else {
257
+ // Keep the newest envelope (cwd / model) for the summary.
258
+ counters.base = base;
259
+ }
260
+ return counters;
261
+ }
262
+
263
+ function logBlock(
264
+ gateName: string,
265
+ reason: string,
266
+ base: ApprovalRecord,
267
+ tool: ApprovalRecord,
268
+ payload: ApprovalRecord | undefined,
269
+ counters: SessionCounters | undefined,
270
+ ): void {
271
+ if (!logging) return;
272
+ if (counters) counters.blocks++;
273
+ audit?.append({
274
+ ...base,
275
+ ...tool,
276
+ ...(payload ?? {}),
277
+ event: "block",
278
+ gate: gateName,
279
+ reason: clipText(reason, 2048),
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Settle one ask and write its record. `presented: false` means an earlier
285
+ * gate blocked before this dialog could run — recorded, not silently lost.
286
+ */
287
+ function logAsk(
288
+ pending: PendingAsk,
289
+ base: ApprovalRecord,
290
+ tool: ApprovalRecord,
291
+ decision: AskDecision | undefined,
292
+ presented: boolean,
293
+ ms: number,
294
+ counters: SessionCounters | undefined,
295
+ queueMs?: number,
296
+ error?: unknown,
297
+ ): void {
298
+ if (!logging) return;
299
+ let patch: ApprovalRecord | undefined;
300
+ try {
301
+ patch = pending.request.settle?.(error ? undefined : decision, {
302
+ presented,
303
+ ms,
304
+ });
305
+ } catch {
306
+ // Auditing must never change the verdict.
307
+ }
308
+ const { outcome, key } = answerLabel(decision, presented, error);
309
+ if (counters) counters.answers[key] = (counters.answers[key] ?? 0) + 1;
310
+
311
+ const request = pending.request;
312
+ const prompt = Array.isArray(request.audit?.prompt)
313
+ ? request.audit.prompt
314
+ : [request.title, ...request.details];
315
+ audit?.append({
316
+ ...base,
317
+ ...tool,
318
+ ...(request.audit ?? {}),
319
+ ...(patch ?? {}),
320
+ event: "ask",
321
+ gate: pending.gate,
322
+ title: request.title,
323
+ prompt,
324
+ answer: decision
325
+ ? { choice: decision, ms, ...(queueMs === undefined ? {} : { queueMs }) }
326
+ : null,
327
+ outcome,
328
+ ...(error === undefined
329
+ ? {}
330
+ : { error: clipText(messageOf(error), 1024) }),
331
+ });
332
+ }
333
+
106
334
  async function handle(
107
335
  event: ToolCallEvent,
108
336
  ctx: ExtensionContext,
109
337
  ): Promise<GateResult> {
110
- const pending: AskRequest[] = [];
338
+ const base = logging ? auditBase(ctx) : {};
339
+ const counters = logging ? countersFor(sessionIdOf(ctx), base) : undefined;
340
+ const tool = logging ? auditTool(event) : {};
341
+ if (counters) counters.calls++;
342
+
343
+ const pending: PendingAsk[] = [];
111
344
  for (const gate of [...gates].sort((a, b) => a.order - b.order)) {
112
345
  let verdict: GateVerdict;
113
346
  try {
114
347
  verdict = await gate.evaluate(event, ctx);
115
348
  } catch (error) {
116
- return {
117
- reason: `Permission check failed in "${gate.name}": ${messageOf(error)}. The call was blocked.`,
118
- };
349
+ const reason = `Permission check failed in "${gate.name}": ${messageOf(error)}. The call was blocked.`;
350
+ logBlock(gate.name, reason, base, tool, {
351
+ verdict: { action: "block", layer: "gate-error" },
352
+ }, counters);
353
+ for (const abandoned of pending) {
354
+ logAsk(abandoned, base, tool, undefined, false, 0, counters);
355
+ }
356
+ return { reason };
119
357
  }
120
358
  if (!verdict || verdict.kind === "allow") continue;
121
- if (verdict.kind === "block") return { reason: verdict.reason };
122
- pending.push(verdict.request);
359
+ if (verdict.kind === "block") {
360
+ logBlock(
361
+ gate.name,
362
+ verdict.reason,
363
+ base,
364
+ tool,
365
+ verdict.audit,
366
+ counters,
367
+ );
368
+ for (const abandoned of pending) {
369
+ logAsk(abandoned, base, tool, undefined, false, 0, counters);
370
+ }
371
+ return { reason: verdict.reason };
372
+ }
373
+ if (counters) counters.asks++;
374
+ pending.push({ gate: gate.name, request: verdict.request });
123
375
  }
124
376
 
125
- for (const request of pending) {
126
- const decision = await runExclusive(() => request.present(ctx));
377
+ for (const current of pending) {
378
+ const request = current.request;
379
+ const started = Date.now();
380
+ let presentedAt = 0;
381
+ let decision: AskDecision | undefined;
382
+ let error: unknown;
383
+ try {
384
+ decision = await runExclusive(() => {
385
+ presentedAt = Date.now();
386
+ return request.present(ctx);
387
+ });
388
+ } catch (caught) {
389
+ error = caught;
390
+ }
391
+ const ms = Date.now() - started;
392
+ // `ms` is how long the call waited in total; `queueMs` is the part spent
393
+ // behind another dialog in the same assistant message. Analysis needs
394
+ // the split, otherwise a batch looks like a slow operator.
395
+ const queueMs = presentedAt === 0 ? ms : presentedAt - started;
396
+ logAsk(current, base, tool, decision, true, ms, counters, queueMs, error);
397
+ if (error !== undefined) {
398
+ return {
399
+ reason: `Permission dialog "${request.title}" failed: ${messageOf(error)}. The call was blocked.`,
400
+ };
401
+ }
127
402
  if (decision === undefined) {
128
403
  return {
129
404
  reason: `Permission check "${request.title}" was not answered; the call was blocked.`,
@@ -148,6 +423,16 @@ export function createGateHub(): GateHub {
148
423
  if (!result) return;
149
424
  return { block: true, reason: result.reason };
150
425
  });
426
+ // `session_shutdown` fires for quit / reload / session replacement;
427
+ // the summary closes the current segment before the runtime goes away.
428
+ pi.on("session_shutdown", (event, ctx) => {
429
+ if (!logging) return;
430
+ // `ctx` is the session being torn down, so this flushes exactly that
431
+ // session's counters. Other tracked sessions stay untouched: an
432
+ // in-process child session ending must not truncate its parent's
433
+ // summary into two halves. Stale slots are reclaimed by eviction.
434
+ flushSession(sessionIdOf(ctx) ?? "", event.reason, auditBase(ctx));
435
+ });
151
436
  },
152
437
  };
153
438
  }
@@ -4,8 +4,8 @@
4
4
  * (review, compact-model — favorites keeps its own store-local key logic).
5
5
  *
6
6
  * Types are derived from pi-coding-agent's own exports (ModelRegistry)
7
- * rather than imported from @earendil-works/pi-ai: pi-ai's index.d.ts (1.0.0
8
- * through 1.3.0 alike) re-exports `Model`/`Api` via extensioned
7
+ * rather than imported from @earendil-works/pi-ai: pi-ai's index.d.ts (1.1.0
8
+ * through 1.4.0 alike) re-exports `Model`/`Api` via extensioned
9
9
  * `./types.ts` specifiers, which tsc 5.9 bundler resolution substitutes to
10
10
  * `.d.ts` but some tsserver builds do not. Deriving from ModelRegistry keeps
11
11
  * every resolver in agreement with the exact type
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The component is driven through a small ModelRuntime facade backed by
11
11
  * ctx.modelRegistry (pi's extension-facing wrapper). SAFETY: verified
12
- * against pi 1.0.0–1.3.0 dist (modes/interactive/components/model-selector.js +
12
+ * against pi 1.1.0–1.4.0 dist (modes/interactive/components/model-selector.js +
13
13
  * model-catalog-refresh.js — byte-identical across that span) — the
14
14
  * component only calls getAvailableSnapshot, getModel, getError and refresh
15
15
  * on modelRuntime, and ModelRegistry provides exactly those