@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/README.md +41 -12
- package/README.zh-CN.md +42 -12
- package/package.json +6 -5
- package/src/features/ask/reasons.ts +1 -1
- package/src/features/autostart/autostart.ts +1 -1
- package/src/features/autostart/index.ts +1 -1
- package/src/features/compact/compact.test.ts +33 -10
- package/src/features/compact/index.ts +27 -34
- package/src/features/compact/prompt.ts +23 -12
- package/src/features/favorites/constants.ts +6 -5
- package/src/features/favorites/patch-cycle.ts +4 -3
- package/src/features/favorites/patch-selector.ts +2 -2
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/features/stash/stash.ts +1 -1
- package/src/index.ts +6 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +295 -10
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +1 -1
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
|
-
| {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
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")
|
|
122
|
-
|
|
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
|
|
126
|
-
const
|
|
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
|
}
|
package/src/lib/model-config.ts
CHANGED
|
@@ -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.
|
|
8
|
-
* through 1.
|
|
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
|
package/src/lib/model-picker.ts
CHANGED
|
@@ -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.
|
|
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
|