@andares/pi-toolkits 0.9.3 → 0.9.4
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 +2 -0
- package/README.zh-CN.md +2 -0
- package/package.json +1 -1
- package/src/features/autostart/autostart.test.ts +21 -0
- package/src/features/autostart/index.ts +9 -0
- package/src/features/permission/index.test.ts +20 -0
- package/src/features/permission/index.ts +15 -5
- package/src/lib/quiet.test.ts +22 -0
- package/src/lib/quiet.ts +35 -0
package/README.md
CHANGED
|
@@ -133,6 +133,8 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
|
|
|
133
133
|
|
|
134
134
|
**Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists.
|
|
135
135
|
|
|
136
|
+
**Startup is quiet**: when the command comes from autostart, the chat notice is suppressed and only the footer status (`perm i:… o:…`) announces the mode. This is deliberate — the host keeps every info notice in the chat but renders all of them from one shared "last status" field, so an info notice emitted after another extension's warning (for example `/lens-toggle`) makes the earlier line display the new text too, i.e. the same message twice. A manual `/ask-perm` still prints the notice.
|
|
137
|
+
|
|
136
138
|
**Enable it for every session** (the state itself is never persisted — this is the opt-in):
|
|
137
139
|
|
|
138
140
|
```toml
|
package/README.zh-CN.md
CHANGED
|
@@ -133,6 +133,8 @@ pi -e ./src/index.ts
|
|
|
133
133
|
|
|
134
134
|
**与 `/ask` 的组合**:两者是同一总线上的独立闸门,各自只回答自己的问题 —— ask 回答「会不会产生变更」,permission 回答「有没有越过边界」,任一拦截都生效。所以 ask 模式下凭据仍然读不到,permission 在只读沙箱下也照常工作;两个功能互不需要知道对方存在。
|
|
135
135
|
|
|
136
|
+
**启动时保持安静**:命令由 autostart 触发时不再往聊天区发提示,只由 footer 状态(`perm i:… o:…`)体现。这是有意的——宿主会把每条 info 提示留在聊天记录里,但所有这种行都渲染同一个「最新状态」字段,于是紧跟在别的扩展 warning(例如 `/lens-toggle`)之后的 info 提示会让前一行也显示新文案,也就是同一条消息出现两次。手动执行 `/ask-perm` 仍然会提示。
|
|
137
|
+
|
|
136
138
|
**每个会话自动开启**(状态本身不落盘,这就是持久化的替身):
|
|
137
139
|
|
|
138
140
|
```toml
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, and a dedicated compaction model",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
RUNNABLE_REASONS,
|
|
22
22
|
shouldReplay,
|
|
23
23
|
} from "./autostart.js";
|
|
24
|
+
import { consumeQuiet } from "../../lib/quiet.js";
|
|
24
25
|
import { registerAutostart } from "./index.js";
|
|
25
26
|
|
|
26
27
|
describe("parseAutostartConfig", () => {
|
|
@@ -159,6 +160,7 @@ interface Harness {
|
|
|
159
160
|
notifications: Array<{ message: string; type?: string }>;
|
|
160
161
|
commands: Array<{ name: string; source: string }>;
|
|
161
162
|
failCommands: Set<string>;
|
|
163
|
+
quietAtDispatch: Array<{ command: string; quiet: boolean }>;
|
|
162
164
|
setTrusted(trusted: boolean): void;
|
|
163
165
|
/** Simulate a session that already had a conversation (resumed process). */
|
|
164
166
|
setHistory(entries: Array<{ type: string }>): void;
|
|
@@ -201,7 +203,9 @@ async function makeHarness(
|
|
|
201
203
|
const commands: Harness["commands"] = [
|
|
202
204
|
{ name: "add-dir", source: "extension" },
|
|
203
205
|
{ name: "ask", source: "extension" },
|
|
206
|
+
{ name: "ask-perm", source: "extension" },
|
|
204
207
|
];
|
|
208
|
+
const quietAtDispatch: Harness["quietAtDispatch"] = [];
|
|
205
209
|
const failCommands = new Set<string>();
|
|
206
210
|
const pi = {
|
|
207
211
|
on: (event: string, h: SessionStartHandler) => {
|
|
@@ -213,6 +217,7 @@ async function makeHarness(
|
|
|
213
217
|
options?: { expandPromptTemplates?: boolean },
|
|
214
218
|
) => {
|
|
215
219
|
sent.push({ command, options }); // records attempts, including failures
|
|
220
|
+
quietAtDispatch.push({ command, quiet: consumeQuiet("ask-perm") });
|
|
216
221
|
if (failCommands.has(command)) {
|
|
217
222
|
return Promise.reject(new Error("boom"));
|
|
218
223
|
}
|
|
@@ -222,6 +227,7 @@ async function makeHarness(
|
|
|
222
227
|
return {
|
|
223
228
|
handler,
|
|
224
229
|
sent,
|
|
230
|
+
quietAtDispatch,
|
|
225
231
|
notifications,
|
|
226
232
|
commands,
|
|
227
233
|
failCommands,
|
|
@@ -315,6 +321,21 @@ describe("registerAutostart", () => {
|
|
|
315
321
|
expect(h.notifications).toEqual([]);
|
|
316
322
|
});
|
|
317
323
|
|
|
324
|
+
it("marks our own command quiet for the dispatch and clears the mark", async () => {
|
|
325
|
+
const h = await harness('[command]\nrun = ["/ask-perm allow"]');
|
|
326
|
+
await h.start("startup");
|
|
327
|
+
expect(h.quietAtDispatch).toEqual([
|
|
328
|
+
{ command: "/ask-perm allow", quiet: true },
|
|
329
|
+
]);
|
|
330
|
+
expect(consumeQuiet("ask-perm")).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("leaves other extensions' commands unmarked", async () => {
|
|
334
|
+
const h = await harness(fullConfig);
|
|
335
|
+
await h.start("startup");
|
|
336
|
+
expect(h.quietAtDispatch.every((entry) => entry.quiet === false)).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
318
339
|
it("replays once per session even if session_start is emitted twice", async () => {
|
|
319
340
|
const h = await harness(fullConfig);
|
|
320
341
|
await h.start("startup");
|
|
@@ -40,6 +40,7 @@ import { readFile } from "node:fs/promises";
|
|
|
40
40
|
import { join } from "node:path";
|
|
41
41
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
42
42
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
43
|
+
import { clearQuiet, markQuiet } from "../../lib/quiet.js";
|
|
43
44
|
import {
|
|
44
45
|
AUTOSTART_CONFIG_PATH,
|
|
45
46
|
AutostartParseError,
|
|
@@ -213,6 +214,12 @@ export function registerAutostart(
|
|
|
213
214
|
}
|
|
214
215
|
if (dispatched.has(command)) continue;
|
|
215
216
|
dispatched.add(command);
|
|
217
|
+
// Our own commands stay silent on startup: the host keeps every
|
|
218
|
+
// info notice and re-renders old lines with the newest text, so a
|
|
219
|
+
// second info notice after another extension's warning would show
|
|
220
|
+
// the same message twice (see lib/quiet.ts).
|
|
221
|
+
const quiet = name === "ask-perm";
|
|
222
|
+
if (quiet) markQuiet(name);
|
|
216
223
|
// One failing command must not cancel the rest of the list.
|
|
217
224
|
try {
|
|
218
225
|
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
@@ -221,6 +228,8 @@ export function registerAutostart(
|
|
|
221
228
|
`autostart: "${command}" failed — ${messageOf(err)}`,
|
|
222
229
|
"error",
|
|
223
230
|
);
|
|
231
|
+
} finally {
|
|
232
|
+
if (quiet) clearQuiet(name);
|
|
224
233
|
}
|
|
225
234
|
}
|
|
226
235
|
}
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ToolCallEvent,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
|
|
18
|
+
import { markQuiet } from "../../lib/quiet.js";
|
|
18
19
|
import { registerPermission } from "./index.js";
|
|
19
20
|
|
|
20
21
|
function bashEvent(command: string): ToolCallEvent {
|
|
@@ -296,6 +297,25 @@ describe("/ask-perm", () => {
|
|
|
296
297
|
expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
|
|
297
298
|
});
|
|
298
299
|
|
|
300
|
+
it("suppresses the chat notice when autostart started the command", async () => {
|
|
301
|
+
const h = makeHarness();
|
|
302
|
+
markQuiet("ask-perm");
|
|
303
|
+
await h.command("allow allow allow ask");
|
|
304
|
+
// The footer carries the state; the chat stays quiet at startup.
|
|
305
|
+
expect(h.notes).toHaveLength(0);
|
|
306
|
+
expect(h.statuses.at(-1)?.[1]).toContain("perm");
|
|
307
|
+
expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
|
|
308
|
+
"ask",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("still reports an invalid argument from autostart", async () => {
|
|
313
|
+
const h = makeHarness();
|
|
314
|
+
markQuiet("ask-perm");
|
|
315
|
+
await h.command("deny");
|
|
316
|
+
expect(h.notes.at(-1)?.kind).toBe("error");
|
|
317
|
+
});
|
|
318
|
+
|
|
299
319
|
it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
|
|
300
320
|
const h = makeHarness();
|
|
301
321
|
await h.command("allow allow allow ask");
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ToolCallEvent,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import type { AskRequest, GateHub } from "../../lib/gates.js";
|
|
25
|
+
import { consumeQuiet } from "../../lib/quiet.js";
|
|
25
26
|
import {
|
|
26
27
|
DEFAULT_MODES,
|
|
27
28
|
type PermissionModes,
|
|
@@ -263,12 +264,21 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
263
264
|
description:
|
|
264
265
|
"Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
|
|
265
266
|
handler: async (args, ctx) => {
|
|
267
|
+
// Started by autostart: the footer status is the notice (lib/quiet.ts).
|
|
268
|
+
const quietNotice = consumeQuiet("ask-perm");
|
|
269
|
+
const notify = (
|
|
270
|
+
message: string,
|
|
271
|
+
type: "info" | "warning" | "error" = "info",
|
|
272
|
+
): void => {
|
|
273
|
+
if (type === "info" && quietNotice) return;
|
|
274
|
+
ctx.ui.notify(message, type);
|
|
275
|
+
};
|
|
266
276
|
const raw = args.trim();
|
|
267
277
|
|
|
268
278
|
if (raw !== "") {
|
|
269
279
|
const parsed = parsePermissionArgs(raw);
|
|
270
280
|
if ("error" in parsed) {
|
|
271
|
-
|
|
281
|
+
notify(`ask-perm: ${parsed.error}`, "error");
|
|
272
282
|
return;
|
|
273
283
|
}
|
|
274
284
|
// Idempotent: the same command may arrive twice in one session start
|
|
@@ -283,7 +293,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
283
293
|
if (changed) rules.clear();
|
|
284
294
|
enabled = true;
|
|
285
295
|
syncStatus(ctx);
|
|
286
|
-
|
|
296
|
+
notify(
|
|
287
297
|
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
288
298
|
"info",
|
|
289
299
|
);
|
|
@@ -294,7 +304,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
294
304
|
enabled = false;
|
|
295
305
|
rules.clear();
|
|
296
306
|
syncStatus(ctx);
|
|
297
|
-
|
|
307
|
+
notify(
|
|
298
308
|
"Permission OFF — no boundary checks in this session.",
|
|
299
309
|
"info",
|
|
300
310
|
);
|
|
@@ -303,7 +313,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
303
313
|
|
|
304
314
|
const chosen = await openPermissionPanel(ctx, modes);
|
|
305
315
|
if (!chosen) {
|
|
306
|
-
|
|
316
|
+
notify(
|
|
307
317
|
"ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
|
|
308
318
|
"info",
|
|
309
319
|
);
|
|
@@ -314,7 +324,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
314
324
|
rules.clear();
|
|
315
325
|
enabled = true;
|
|
316
326
|
syncStatus(ctx);
|
|
317
|
-
|
|
327
|
+
notify(
|
|
318
328
|
`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
|
|
319
329
|
"info",
|
|
320
330
|
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clearQuiet, consumeQuiet, markQuiet } from "./quiet.js";
|
|
3
|
+
|
|
4
|
+
describe("quiet handoff", () => {
|
|
5
|
+
it("consumes a mark exactly once", () => {
|
|
6
|
+
markQuiet("ask-perm");
|
|
7
|
+
expect(consumeQuiet("ask-perm")).toBe(true);
|
|
8
|
+
expect(consumeQuiet("ask-perm")).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("ignores other commands", () => {
|
|
12
|
+
markQuiet("ask-perm");
|
|
13
|
+
expect(consumeQuiet("third-review")).toBe(false);
|
|
14
|
+
expect(consumeQuiet("ask-perm")).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("can be cleared without a consumer", () => {
|
|
18
|
+
markQuiet("ask-perm");
|
|
19
|
+
clearQuiet("ask-perm");
|
|
20
|
+
expect(consumeQuiet("ask-perm")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/lib/quiet.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet handoff for commands dispatched by autostart.
|
|
3
|
+
*
|
|
4
|
+
* The host's interactive status line keeps every info notice in the chat, but
|
|
5
|
+
* each line renders one shared "last status message" field
|
|
6
|
+
* (`interactive-mode.js#showStatus`: `new ThemedText(() => ... this.lastStatusMessage)`).
|
|
7
|
+
* A warning emitted between two info notices breaks the coalescing, so a second
|
|
8
|
+
* info notice creates a new line — and *both* lines then render the newest
|
|
9
|
+
* text. The visible result is the same message twice, separated by the warning.
|
|
10
|
+
*
|
|
11
|
+
* That is a host display quirk, not a second execution. Startup replay is the
|
|
12
|
+
* worst case: `/add-dir` (info) → `/lens-toggle` (warning) → `/ask-perm` (info)
|
|
13
|
+
* shows "Permission ON" twice. Autostart therefore marks the commands it
|
|
14
|
+
* dispatches as quiet, and our own commands skip the chat notice when they were
|
|
15
|
+
* started that way. The footer status still reflects the new state.
|
|
16
|
+
*
|
|
17
|
+
* The mark is keyed by command name and is always cleared by the dispatcher
|
|
18
|
+
* (`finally`), so it cannot leak into a later manual invocation.
|
|
19
|
+
*/
|
|
20
|
+
const quietCommands = new Set<string>();
|
|
21
|
+
|
|
22
|
+
/** Mark `commandName` as dispatched by autostart (no chat notice). */
|
|
23
|
+
export function markQuiet(commandName: string): void {
|
|
24
|
+
quietCommands.add(commandName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Drop a mark whether or not the command consumed it. */
|
|
28
|
+
export function clearQuiet(commandName: string): void {
|
|
29
|
+
quietCommands.delete(commandName);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `true` once, when the command was marked quiet; consumes the mark. */
|
|
33
|
+
export function consumeQuiet(commandName: string): boolean {
|
|
34
|
+
return quietCommands.delete(commandName);
|
|
35
|
+
}
|