@andares/pi-toolkits 0.9.2 → 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.2",
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", () => {
@@ -145,7 +146,10 @@ type SessionStartHandler = (
145
146
  cwd: string;
146
147
  isProjectTrusted: () => boolean;
147
148
  ui: { notify: (message: string, type?: string) => void };
148
- sessionManager: { buildContextEntries: () => Array<{ type: string }> };
149
+ sessionManager: {
150
+ buildContextEntries: () => Array<{ type: string }>;
151
+ getSessionId: () => string;
152
+ };
149
153
  },
150
154
  ) => Promise<void>;
151
155
 
@@ -156,9 +160,11 @@ interface Harness {
156
160
  notifications: Array<{ message: string; type?: string }>;
157
161
  commands: Array<{ name: string; source: string }>;
158
162
  failCommands: Set<string>;
163
+ quietAtDispatch: Array<{ command: string; quiet: boolean }>;
159
164
  setTrusted(trusted: boolean): void;
160
165
  /** Simulate a session that already had a conversation (resumed process). */
161
166
  setHistory(entries: Array<{ type: string }>): void;
167
+ setSessionId(id: string): void;
162
168
  start(reason: string): Promise<void>;
163
169
  }
164
170
 
@@ -190,13 +196,16 @@ async function makeHarness(
190
196
  { type: "model_change" },
191
197
  { type: "thinking_level_change" },
192
198
  ];
199
+ let sessionId = "session-1";
193
200
  let handler: SessionStartHandler = async () => {};
194
201
  const sent: Harness["sent"] = [];
195
202
  const notifications: Harness["notifications"] = [];
196
203
  const commands: Harness["commands"] = [
197
204
  { name: "add-dir", source: "extension" },
198
205
  { name: "ask", source: "extension" },
206
+ { name: "ask-perm", source: "extension" },
199
207
  ];
208
+ const quietAtDispatch: Harness["quietAtDispatch"] = [];
200
209
  const failCommands = new Set<string>();
201
210
  const pi = {
202
211
  on: (event: string, h: SessionStartHandler) => {
@@ -208,6 +217,7 @@ async function makeHarness(
208
217
  options?: { expandPromptTemplates?: boolean },
209
218
  ) => {
210
219
  sent.push({ command, options }); // records attempts, including failures
220
+ quietAtDispatch.push({ command, quiet: consumeQuiet("ask-perm") });
211
221
  if (failCommands.has(command)) {
212
222
  return Promise.reject(new Error("boom"));
213
223
  }
@@ -217,6 +227,7 @@ async function makeHarness(
217
227
  return {
218
228
  handler,
219
229
  sent,
230
+ quietAtDispatch,
220
231
  notifications,
221
232
  commands,
222
233
  failCommands,
@@ -226,6 +237,9 @@ async function makeHarness(
226
237
  setHistory(entries) {
227
238
  history = entries;
228
239
  },
240
+ setSessionId(id) {
241
+ sessionId = id;
242
+ },
229
243
  async start(reason: string) {
230
244
  await handler({ reason }, {
231
245
  cwd,
@@ -235,7 +249,10 @@ async function makeHarness(
235
249
  notifications.push({ message, type });
236
250
  },
237
251
  },
238
- sessionManager: { buildContextEntries: () => history },
252
+ sessionManager: {
253
+ buildContextEntries: () => history,
254
+ getSessionId: () => sessionId,
255
+ },
239
256
  });
240
257
  },
241
258
  cwd,
@@ -286,6 +303,8 @@ describe("registerAutostart", () => {
286
303
  it("replays on new and fork", async () => {
287
304
  const h = await harness('[command]\nrun = ["/ask on"]');
288
305
  await h.start("new");
306
+ // A fork is a different session, so it replays again.
307
+ h.setSessionId("forked-session");
289
308
  await h.start("fork");
290
309
  expect(h.sent).toEqual([
291
310
  { command: "/ask on", options: { expandPromptTemplates: true } },
@@ -302,6 +321,36 @@ describe("registerAutostart", () => {
302
321
  expect(h.notifications).toEqual([]);
303
322
  });
304
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
+
339
+ it("replays once per session even if session_start is emitted twice", async () => {
340
+ const h = await harness(fullConfig);
341
+ await h.start("startup");
342
+ await h.start("startup");
343
+ expect(h.sent).toHaveLength(2);
344
+ });
345
+
346
+ it("replays again for a new session id", async () => {
347
+ const h = await harness(fullConfig);
348
+ await h.start("startup");
349
+ h.setSessionId("session-2");
350
+ await h.start("startup");
351
+ expect(h.sent).toHaveLength(4);
352
+ });
353
+
305
354
  it("skips a process started on an existing session (pi -c reports startup)", async () => {
306
355
  const h = await harness(fullConfig);
307
356
  h.setHistory([
@@ -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,
@@ -87,6 +88,15 @@ const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
87
88
  * session manager counts as new, which keeps the feature working in hosts where
88
89
  * the session surface is unavailable.
89
90
  */
91
+ /** Session id, or `undefined` when the host exposes none. */
92
+ function sessionIdOf(ctx: ExtensionContext): string | undefined {
93
+ try {
94
+ return ctx.sessionManager.getSessionId();
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ }
99
+
90
100
  function hasConversationHistory(ctx: ExtensionContext): boolean {
91
101
  try {
92
102
  return ctx.sessionManager
@@ -102,6 +112,11 @@ export function registerAutostart(
102
112
  options: AutostartOptions = {},
103
113
  ): void {
104
114
  const agentDir = options.agentDir ?? getAgentDir();
115
+ // One replay per session. The host can emit `session_start` more than once
116
+ // for the same session (a runtime rebind during startup reports `startup`
117
+ // again), and replaying on the second emission would announce everything
118
+ // twice.
119
+ const replayedSessions = new Set<string>();
105
120
 
106
121
  async function readCommands(
107
122
  ctx: ExtensionContext,
@@ -140,6 +155,12 @@ export function registerAutostart(
140
155
  // shouldReplay in autostart.ts for the host-side evidence.
141
156
  if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
142
157
 
158
+ const sessionId = sessionIdOf(ctx);
159
+ if (sessionId !== undefined) {
160
+ if (replayedSessions.has(sessionId)) return;
161
+ replayedSessions.add(sessionId);
162
+ }
163
+
143
164
  const sources: Source[] = [
144
165
  {
145
166
  path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
@@ -193,6 +214,12 @@ export function registerAutostart(
193
214
  }
194
215
  if (dispatched.has(command)) continue;
195
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);
196
223
  // One failing command must not cancel the rest of the list.
197
224
  try {
198
225
  await pi.sendUserMessage(command, { expandPromptTemplates: true });
@@ -201,6 +228,8 @@ export function registerAutostart(
201
228
  `autostart: "${command}" failed — ${messageOf(err)}`,
202
229
  "error",
203
230
  );
231
+ } finally {
232
+ if (quiet) clearQuiet(name);
204
233
  }
205
234
  }
206
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 {
@@ -51,6 +52,7 @@ interface Harness {
51
52
  readonly statuses: Array<[string, string | undefined]>;
52
53
  readonly notes: Array<{ message: string; kind?: string }>;
53
54
  readonly branch: Array<Record<string, unknown>>;
55
+ setSessionId(id: string): void;
54
56
  evaluate(event: ToolCallEvent): Promise<unknown>;
55
57
  command(args: string): Promise<void>;
56
58
  sessionStart(): Promise<void>;
@@ -62,6 +64,7 @@ function makeHarness(): Harness {
62
64
  const statuses: Harness["statuses"] = [];
63
65
  const notes: Harness["notes"] = [];
64
66
  const branch: Harness["branch"] = [];
67
+ let sessionId = "session-1";
65
68
  const ctx = {
66
69
  cwd,
67
70
  hasUI: true,
@@ -73,7 +76,10 @@ function makeHarness(): Harness {
73
76
  custom: async () => undefined,
74
77
  select: async () => undefined,
75
78
  },
76
- sessionManager: { getBranch: () => branch },
79
+ sessionManager: {
80
+ getBranch: () => branch,
81
+ getSessionId: () => sessionId,
82
+ },
77
83
  } as unknown as ExtensionContext;
78
84
 
79
85
  const captured: ToolGate[] = [];
@@ -110,6 +116,9 @@ function makeHarness(): Harness {
110
116
  statuses,
111
117
  notes,
112
118
  branch,
119
+ setSessionId(id: string) {
120
+ sessionId = id;
121
+ },
113
122
  async evaluate(event) {
114
123
  return gate.evaluate(event, ctx);
115
124
  },
@@ -214,6 +223,25 @@ describe("/ask-perm", () => {
214
223
  expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
215
224
  });
216
225
 
226
+ it("keeps the state when the same session emits session_start again", async () => {
227
+ const h = makeHarness();
228
+ await h.sessionStart(); // the real first emission
229
+ await h.command("allow allow allow ask");
230
+ // A startup rebind reports the same session again; state must survive.
231
+ await h.sessionStart();
232
+ expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
233
+ "ask",
234
+ );
235
+ });
236
+
237
+ it("resets on session_start for a different session", async () => {
238
+ const h = makeHarness();
239
+ await h.command("allow allow allow ask");
240
+ h.setSessionId("session-2");
241
+ await h.sessionStart();
242
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
243
+ });
244
+
217
245
  it("resets on session_start", async () => {
218
246
  const h = makeHarness();
219
247
  await h.command("allow allow allow ask");
@@ -269,6 +297,25 @@ describe("/ask-perm", () => {
269
297
  expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
270
298
  });
271
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
+
272
319
  it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
273
320
  const h = makeHarness();
274
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,
@@ -99,6 +100,10 @@ function toolUnit(
99
100
  export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
100
101
  let enabled = false;
101
102
  let modes: PermissionModes = { ...DEFAULT_MODES };
103
+ // Session this state belongs to. A runtime rebind can emit `session_start`
104
+ // again for the same session; resetting then would wipe a mode that the
105
+ // autostart replay had just enabled, so only a different session resets.
106
+ let currentSessionId: string | undefined;
102
107
  const rules = new SessionRules();
103
108
  let rootsCache:
104
109
  | { cwd: string; branchLength: number; roots: string[] }
@@ -111,6 +116,15 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
111
116
  rootsCache = undefined;
112
117
  }
113
118
 
119
+ /** Session id, or `undefined` when the host exposes none. */
120
+ function sessionIdOf(ctx: ExtensionContext): string | undefined {
121
+ try {
122
+ return ctx.sessionManager.getSessionId();
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ }
127
+
114
128
  function syncStatus(ctx: ExtensionContext): void {
115
129
  if (!enabled) {
116
130
  ctx.ui.setStatus(PERMISSION_STATUS_KEY, undefined);
@@ -233,12 +247,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
233
247
  // ── Lifecycle ──────────────────────────────────────────────────────────
234
248
 
235
249
  pi.on("session_start", (_event, ctx) => {
250
+ const sessionId = sessionIdOf(ctx);
251
+ // Same session, repeated emission (a startup rebind): keep the state.
252
+ if (sessionId !== undefined && sessionId === currentSessionId) {
253
+ syncStatus(ctx);
254
+ return;
255
+ }
256
+ currentSessionId = sessionId;
236
257
  reset();
237
258
  syncStatus(ctx);
238
259
  });
239
- pi.on("session_shutdown", () => {
240
- reset();
241
- });
242
260
 
243
261
  // ── Command ────────────────────────────────────────────────────────────
244
262
 
@@ -246,12 +264,21 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
246
264
  description:
247
265
  "Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
248
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
+ };
249
276
  const raw = args.trim();
250
277
 
251
278
  if (raw !== "") {
252
279
  const parsed = parsePermissionArgs(raw);
253
280
  if ("error" in parsed) {
254
- ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
281
+ notify(`ask-perm: ${parsed.error}`, "error");
255
282
  return;
256
283
  }
257
284
  // Idempotent: the same command may arrive twice in one session start
@@ -266,7 +293,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
266
293
  if (changed) rules.clear();
267
294
  enabled = true;
268
295
  syncStatus(ctx);
269
- ctx.ui.notify(
296
+ notify(
270
297
  `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
271
298
  "info",
272
299
  );
@@ -277,7 +304,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
277
304
  enabled = false;
278
305
  rules.clear();
279
306
  syncStatus(ctx);
280
- ctx.ui.notify(
307
+ notify(
281
308
  "Permission OFF — no boundary checks in this session.",
282
309
  "info",
283
310
  );
@@ -286,7 +313,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
286
313
 
287
314
  const chosen = await openPermissionPanel(ctx, modes);
288
315
  if (!chosen) {
289
- ctx.ui.notify(
316
+ notify(
290
317
  "ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
291
318
  "info",
292
319
  );
@@ -297,7 +324,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
297
324
  rules.clear();
298
325
  enabled = true;
299
326
  syncStatus(ctx);
300
- ctx.ui.notify(
327
+ notify(
301
328
  `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
302
329
  "info",
303
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
+ }