@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 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",
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
- ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
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
- ctx.ui.notify(
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
- ctx.ui.notify(
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
- ctx.ui.notify(
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
- ctx.ui.notify(
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
+ });
@@ -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
+ }