@andares/pi-toolkits 0.7.3 → 0.9.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.
Files changed (35) hide show
  1. package/README.md +78 -6
  2. package/README.zh-CN.md +77 -6
  3. package/package.json +13 -7
  4. package/src/features/ask/index.test.ts +118 -0
  5. package/src/features/ask/index.ts +31 -13
  6. package/src/features/ask/reasons.ts +2 -2
  7. package/src/features/autostart/autostart.test.ts +45 -2
  8. package/src/features/autostart/autostart.ts +4 -2
  9. package/src/features/autostart/index.ts +100 -48
  10. package/src/features/compact/index.ts +3 -3
  11. package/src/features/compact/prompt.ts +2 -2
  12. package/src/features/favorites/constants.ts +3 -3
  13. package/src/features/favorites/patch-cycle.ts +6 -6
  14. package/src/features/favorites/patch-selector.ts +4 -4
  15. package/src/features/permission/constants.test.ts +60 -0
  16. package/src/features/permission/constants.ts +158 -0
  17. package/src/features/permission/index.test.ts +340 -0
  18. package/src/features/permission/index.ts +333 -0
  19. package/src/features/permission/modes.test.ts +66 -0
  20. package/src/features/permission/modes.ts +82 -0
  21. package/src/features/permission/panel.ts +118 -0
  22. package/src/features/permission/paths.test.ts +111 -0
  23. package/src/features/permission/paths.ts +126 -0
  24. package/src/features/permission/policy.test.ts +237 -0
  25. package/src/features/permission/policy.ts +338 -0
  26. package/src/features/permission/prompt.ts +146 -0
  27. package/src/features/permission/session-rules.ts +99 -0
  28. package/src/features/permission/units.test.ts +205 -0
  29. package/src/features/permission/units.ts +995 -0
  30. package/src/features/stash/stash.ts +1 -1
  31. package/src/index.ts +15 -2
  32. package/src/lib/gates.test.ts +171 -0
  33. package/src/lib/gates.ts +153 -0
  34. package/src/lib/model-config.ts +2 -2
  35. package/src/lib/model-picker.ts +2 -2
@@ -21,7 +21,7 @@ import type { KeyId } from "@earendil-works/pi-tui";
21
21
  /**
22
22
  * Stash hotkey.
23
23
  *
24
- * Compatibility rationale (verified against pi 0.99.0–1.2.0 keybindings, live
24
+ * Compatibility rationale (verified against pi 1.0.0–1.3.0 keybindings, live
25
25
  * matchesKey probes and terminal/OS behavior):
26
26
  * - pi: no default binding for ctrl+alt+<letter> (only ctrl+alt+] is taken);
27
27
  * extension shortcuts dispatch first while the editor is focused
package/src/index.ts CHANGED
@@ -11,21 +11,34 @@
11
11
  * - review — third-party code review with a configured review model (third-review)
12
12
  * - compact — dedicated compact model for /compact + auto-compact summaries
13
13
  * - autostart — replay project-declared slash commands at session start (/add-dir persistence)
14
+ * - permission — session-scoped boundary permission checks (/ask-perm)
15
+ *
16
+ * `ask` and `permission` are independent gates on one bus: each answers its own
17
+ * question and the strictest answer wins, so they compose rather than replace
18
+ * each other. The bus is installed once, after every gate is registered.
14
19
  */
15
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import { createGateHub } from "./lib/gates.js";
16
22
  import { registerAsk } from "./features/ask/index.js";
17
23
  import { registerAutostart } from "./features/autostart/index.js";
18
24
  import { registerCompact } from "./features/compact/index.js";
19
25
  import { registerFavorites } from "./features/favorites/index.js";
26
+ import { registerPermission } from "./features/permission/index.js";
20
27
  import { registerReview } from "./features/review/index.js";
21
28
  import { registerStash } from "./features/stash/index.js";
22
29
 
23
30
  export default function piToolkits(pi: ExtensionAPI): void {
24
- registerAsk(pi);
31
+ const gates = createGateHub();
32
+
33
+ registerAsk(pi, gates);
34
+ // Permission registers its session_start reset before autostart, so a
35
+ // project-declared `/ask-perm ...` runs against a freshly reset mode.
36
+ registerPermission(pi, gates);
25
37
  registerAutostart(pi);
26
38
  registerCompact(pi);
27
39
  registerFavorites(pi);
28
40
  registerReview(pi);
29
41
  registerStash(pi);
30
- // registerXxx(pi); // future features
42
+
43
+ gates.install(pi);
31
44
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Gate bus tests.
3
+ *
4
+ * The bus is the composition contract between features: a block short-circuits,
5
+ * an allow never overrides, dialogs are serialized, and a failing gate fails
6
+ * closed.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import type {
10
+ ExtensionContext,
11
+ ToolCallEvent,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ createGateHub,
15
+ type AskDecision,
16
+ type AskRequest,
17
+ type ToolGate,
18
+ } from "./gates.js";
19
+
20
+ function bashEvent(command = "ls"): ToolCallEvent {
21
+ return {
22
+ type: "tool_call",
23
+ toolCallId: "call-1",
24
+ toolName: "bash",
25
+ input: { command },
26
+ } as unknown as ToolCallEvent;
27
+ }
28
+
29
+ const ctx = {} as ExtensionContext;
30
+
31
+ function askRequest(
32
+ decide: AskDecision | undefined,
33
+ applied: AskDecision[] = [],
34
+ ): AskRequest {
35
+ return {
36
+ title: "Permission required",
37
+ details: ["Command: rm x"],
38
+ root: "/proj",
39
+ present: async () => decide,
40
+ apply: (choice) => applied.push(choice),
41
+ denyReason: (choice) => `denied:${choice}`,
42
+ };
43
+ }
44
+
45
+ function gate(
46
+ name: string,
47
+ order: number,
48
+ verdict: ReturnType<ToolGate["evaluate"]>,
49
+ ): ToolGate {
50
+ return { name, order, evaluate: () => verdict };
51
+ }
52
+
53
+ describe("gate hub", () => {
54
+ it("allows when no gate has an opinion", async () => {
55
+ const hub = createGateHub();
56
+ hub.register(gate("noop", 10, undefined));
57
+ await expect(hub.handle(bashEvent(), ctx)).resolves.toBeUndefined();
58
+ });
59
+
60
+ it("an explicit allow does not override another gate's block", async () => {
61
+ const hub = createGateHub();
62
+ hub.register(gate("allow", 10, { kind: "allow" }));
63
+ hub.register(gate("block", 20, { kind: "block", reason: "no" }));
64
+ await expect(hub.handle(bashEvent(), ctx)).resolves.toEqual({ reason: "no" });
65
+ });
66
+
67
+ it("blocks immediately and skips later gates", async () => {
68
+ const hub = createGateHub();
69
+ let laterRan = false;
70
+ hub.register(gate("first", 10, { kind: "block", reason: "stop" }));
71
+ hub.register({
72
+ name: "later",
73
+ order: 20,
74
+ evaluate: () => {
75
+ laterRan = true;
76
+ return undefined;
77
+ },
78
+ });
79
+ await expect(hub.handle(bashEvent(), ctx)).resolves.toEqual({ reason: "stop" });
80
+ expect(laterRan).toBe(false);
81
+ });
82
+
83
+ it("runs gates in ascending order", async () => {
84
+ const hub = createGateHub();
85
+ const seen: string[] = [];
86
+ hub.register({
87
+ name: "second",
88
+ order: 20,
89
+ evaluate: () => {
90
+ seen.push("second");
91
+ return undefined;
92
+ },
93
+ });
94
+ hub.register({
95
+ name: "first",
96
+ order: 10,
97
+ evaluate: () => {
98
+ seen.push("first");
99
+ return undefined;
100
+ },
101
+ });
102
+ await hub.handle(bashEvent(), ctx);
103
+ expect(seen).toEqual(["first", "second"]);
104
+ });
105
+
106
+ it("applies an allow answer and resolves", async () => {
107
+ const hub = createGateHub();
108
+ const applied: AskDecision[] = [];
109
+ hub.register(gate("ask", 10, { kind: "ask", request: askRequest("allow", applied) }));
110
+ await expect(hub.handle(bashEvent(), ctx)).resolves.toBeUndefined();
111
+ expect(applied).toEqual(["allow"]);
112
+ });
113
+
114
+ it("blocks with the request's deny reason", async () => {
115
+ const hub = createGateHub();
116
+ const applied: AskDecision[] = [];
117
+ hub.register(
118
+ gate("ask", 10, { kind: "ask", request: askRequest("deny-session", applied) }),
119
+ );
120
+ await expect(hub.handle(bashEvent(), ctx)).resolves.toEqual({
121
+ reason: "denied:deny-session",
122
+ });
123
+ expect(applied).toEqual(["deny-session"]);
124
+ });
125
+
126
+ it("treats a cancelled or unanswerable dialog as a denial", async () => {
127
+ const hub = createGateHub();
128
+ hub.register(gate("ask", 10, { kind: "ask", request: askRequest(undefined) }));
129
+ const result = await hub.handle(bashEvent(), ctx);
130
+ expect(result?.reason).toContain("was not answered");
131
+ });
132
+
133
+ it("fails closed when a gate throws", async () => {
134
+ const hub = createGateHub();
135
+ hub.register({
136
+ name: "broken",
137
+ order: 10,
138
+ evaluate: () => {
139
+ throw new Error("boom");
140
+ },
141
+ });
142
+ const result = await hub.handle(bashEvent(), ctx);
143
+ expect(result?.reason).toContain("broken");
144
+ expect(result?.reason).toContain("blocked");
145
+ });
146
+
147
+ it("serializes dialogs across concurrent tool calls", async () => {
148
+ const hub = createGateHub();
149
+ let active = 0;
150
+ let maxActive = 0;
151
+ const present = async (): Promise<AskDecision> => {
152
+ active++;
153
+ maxActive = Math.max(maxActive, active);
154
+ await new Promise((resolve) => setTimeout(resolve, 5));
155
+ active--;
156
+ return "allow";
157
+ };
158
+ const request: AskRequest = {
159
+ title: "Permission required",
160
+ details: [],
161
+ root: "/proj",
162
+ present,
163
+ apply: () => {},
164
+ denyReason: () => "denied",
165
+ };
166
+ hub.register(gate("ask", 10, { kind: "ask", request }));
167
+
168
+ await Promise.all([hub.handle(bashEvent("a"), ctx), hub.handle(bashEvent("b"), ctx)]);
169
+ expect(maxActive).toBe(1);
170
+ });
171
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Tool-call gate bus.
3
+ *
4
+ * Features that must veto or confirm tool calls (`ask`, `permission`) register
5
+ * a {@link ToolGate} instead of installing their own `tool_call` handler. One
6
+ * handler composes every gate, which keeps the features independent: each gate
7
+ * speaks only about its own concern, and the call is allowed only when no gate
8
+ * objects.
9
+ *
10
+ * Composition rules:
11
+ * - A `block` is final and short-circuits the remaining gates, so a call one
12
+ * feature already denies never raises another feature's dialog.
13
+ * - An `allow` never overrides another gate's `ask`/`block`; "no opinion" and
14
+ * "explicitly allowed" are both non-blocking.
15
+ * - `ask`s are presented through a single queue. Pi runs tool calls from one
16
+ * assistant message in parallel while `ctx.ui.custom` owns a single inline
17
+ * slot; a second presentation would mount over the first and strand its
18
+ * promise. The queue serializes our own presentations; the host slot is
19
+ * still shared with other extensions (pi does not arbitrate globally).
20
+ * - A gate that throws blocks the call (fail closed), matching pi's own
21
+ * `tool_call` handler contract.
22
+ */
23
+ import type {
24
+ ExtensionAPI,
25
+ ExtensionContext,
26
+ ToolCallEvent,
27
+ } from "@earendil-works/pi-coding-agent";
28
+
29
+ /** What the operator chose in an interactive ask. */
30
+ export type AskDecision =
31
+ | "allow"
32
+ | "allow-session"
33
+ | "deny"
34
+ | "deny-session";
35
+
36
+ /**
37
+ * One interactive confirmation a gate wants before the call may run.
38
+ *
39
+ * `present` renders the dialog (or falls back to a host selector) and resolves
40
+ * `undefined` when there is no way to ask or the operator cancelled — both are
41
+ * treated as a denial. `apply` records the decision (session grants) and runs
42
+ * only for a real answer.
43
+ */
44
+ export interface AskRequest {
45
+ /** Short title, e.g. "Permission required". */
46
+ readonly title: string;
47
+ /** Context lines: command, target, scope, reason. */
48
+ readonly details: readonly string[];
49
+ /** The directory a session grant would cover. */
50
+ readonly root: string;
51
+ present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
52
+ apply(decision: AskDecision): void;
53
+ /** Model-facing reason when the operator answers deny/deny-session. */
54
+ denyReason(decision: AskDecision): string;
55
+ }
56
+
57
+ /** A gate's opinion about one tool call. */
58
+ export type GateVerdict =
59
+ | undefined
60
+ | { readonly kind: "allow" }
61
+ | { readonly kind: "block"; readonly reason: string }
62
+ | { readonly kind: "ask"; readonly request: AskRequest };
63
+
64
+ /** One feature's tool-call policy. Lower `order` runs first. */
65
+ export interface ToolGate {
66
+ readonly name: string;
67
+ readonly order: number;
68
+ evaluate(
69
+ event: ToolCallEvent,
70
+ ctx: ExtensionContext,
71
+ ): GateVerdict | Promise<GateVerdict>;
72
+ }
73
+
74
+ /** Result of composing the gates: a block reason, or nothing (allow). */
75
+ export type GateResult = { readonly reason: string } | undefined;
76
+
77
+ export interface GateHub {
78
+ register(gate: ToolGate): void;
79
+ /** Compose every gate for one call (also the unit-test entry point). */
80
+ handle(event: ToolCallEvent, ctx: ExtensionContext): Promise<GateResult>;
81
+ /** Install the single `tool_call` handler that owns the gates. */
82
+ install(pi: ExtensionAPI): void;
83
+ }
84
+
85
+ function messageOf(error: unknown): string {
86
+ return error instanceof Error ? error.message : String(error);
87
+ }
88
+
89
+ export function createGateHub(): GateHub {
90
+ const gates: ToolGate[] = [];
91
+ let queue: Promise<void> = Promise.resolve();
92
+
93
+ /**
94
+ * Serialize dialogs without letting one rejection poison the queue:
95
+ * the tail always settles, the caller still sees its own outcome.
96
+ */
97
+ function runExclusive<T>(present: () => Promise<T>): Promise<T> {
98
+ const next = queue.then(present, present);
99
+ queue = next.then(
100
+ () => undefined,
101
+ () => undefined,
102
+ );
103
+ return next;
104
+ }
105
+
106
+ async function handle(
107
+ event: ToolCallEvent,
108
+ ctx: ExtensionContext,
109
+ ): Promise<GateResult> {
110
+ const pending: AskRequest[] = [];
111
+ for (const gate of [...gates].sort((a, b) => a.order - b.order)) {
112
+ let verdict: GateVerdict;
113
+ try {
114
+ verdict = await gate.evaluate(event, ctx);
115
+ } catch (error) {
116
+ return {
117
+ reason: `Permission check failed in "${gate.name}": ${messageOf(error)}. The call was blocked.`,
118
+ };
119
+ }
120
+ if (!verdict || verdict.kind === "allow") continue;
121
+ if (verdict.kind === "block") return { reason: verdict.reason };
122
+ pending.push(verdict.request);
123
+ }
124
+
125
+ for (const request of pending) {
126
+ const decision = await runExclusive(() => request.present(ctx));
127
+ if (decision === undefined) {
128
+ return {
129
+ reason: `Permission check "${request.title}" was not answered; the call was blocked.`,
130
+ };
131
+ }
132
+ request.apply(decision);
133
+ if (decision === "deny" || decision === "deny-session") {
134
+ return { reason: request.denyReason(decision) };
135
+ }
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ return {
141
+ register(gate) {
142
+ gates.push(gate);
143
+ },
144
+ handle,
145
+ install(pi) {
146
+ pi.on("tool_call", async (event, ctx) => {
147
+ const result = await handle(event, ctx);
148
+ if (!result) return;
149
+ return { block: true, reason: result.reason };
150
+ });
151
+ },
152
+ };
153
+ }
@@ -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 (0.99
8
- * through 1.2.0 alike) re-exports `Model`/`Api` via extensioned
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
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 0.99.0–1.2.0 dist (modes/interactive/components/model-selector.js +
12
+ * against pi 1.0.0–1.3.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
@@ -42,7 +42,7 @@ function modelRuntimeFacade(ctx: ExtensionContext): ModelRuntime {
42
42
  refresh: (options?: { signal?: AbortSignal }) =>
43
43
  ctx.modelRegistry.refresh(options),
44
44
  };
45
- // SAFETY: the selector only touches the four members above (pi 0.99–1.2
45
+ // SAFETY: the selector only touches the four members above (pi 1.0–1.3
46
46
  // model-selector.js); everything else on ModelRuntime is never reached
47
47
  // from this component.
48
48
  return facade as unknown as ModelRuntime;