@andares/pi-toolkits 0.9.3 → 0.9.5

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
@@ -106,7 +106,7 @@ Enter with `/ask` (press it again to exit). Meant for the case where you only wa
106
106
  - `/ask-perm allow allow allow ask` — positional form (`in-read in-write out-read out-write`), enables immediately without a dialog. Missing arguments fall back to the defaults. This is the form autostart uses.
107
107
  - `/ask-perm` again — turns it off.
108
108
 
109
- Repeating the argument form with the modes already in force is a **no-op**: no second "ON" notice and no loss of the session grants collected so far. Changing any mode notifies and clears those grants, because they were made under the old policy.
109
+ Repeating the argument form with the modes already in force is a **no-op**: no second "ON" notice and no loss of the session grants collected so far. Changing any mode records the change and clears those grants, because they were made under the old policy.
110
110
 
111
111
  **Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
112
112
 
@@ -141,7 +141,7 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
141
141
  run = ["/ask-perm allow allow allow ask"]
142
142
  ```
143
143
 
144
- > **Implementation notes**: state is reset on `session_start` and cleared on `session_shutdown`; the footer shows `perm i:A/A o:A/ask` while on (`A` allow, `a` ask, `D` deny). Subagent sessions start with their own state and do not inherit the parent's mode — when child sessions must be gated too, enable it through the project's autostart file. POSIX hosts (Linux / macOS / WSL) are the tested target; native Windows Git-Bash path spellings are approximated, not verified. bash decomposition uses `shell-quote`; unknown variables expand to a sentinel that makes the unit ask instead of silently becoming an empty path. Added directories are read from pi-add-dir's session entry (`add-dir:state`). Heuristic, not a sandbox: project-internal destructive commands are covered by the fixed table, not by a sandbox. One known blind spot: a *read* command's bare filename operand (`cat notes.txt`) is not collected as a target, so with `out read = ask` only absolute-path-shaped read operands are questioned — reads never write, so the four modes' write side is unaffected.
144
+ > **Implementation notes**: a new session resets the state on `session_start` (a repeated emission for the same session keeps it); the footer shows `perm i:A/A o:A/ask` while on (`A` allow, `a` ask, `D` deny). Subagent sessions start with their own state and do not inherit the parent's mode — when child sessions must be gated too, enable it through the project's autostart file. POSIX hosts (Linux / macOS / WSL) are the tested target; native Windows Git-Bash path spellings are approximated, not verified. bash decomposition uses `shell-quote`; unknown variables expand to a sentinel that makes the unit ask instead of silently becoming an empty path. Added directories are read from pi-add-dir's session entry (`add-dir:state`). Heuristic, not a sandbox: project-internal destructive commands are covered by the fixed table, not by a sandbox. One known blind spot: a *read* command's bare filename operand (`cat notes.txt`) is not collected as a target, so with `out read = ask` only absolute-path-shaped read operands are questioned — reads never write, so the four modes' write side is unaffected.
145
145
 
146
146
  ---
147
147
 
package/README.zh-CN.md CHANGED
@@ -141,7 +141,7 @@ pi -e ./src/index.ts
141
141
  run = ["/ask-perm allow allow allow ask"]
142
142
  ```
143
143
 
144
- > **实现说明**:状态在 `session_start` 重置、`session_shutdown` 清空;开启时 footer 显示 `perm i:A/A o:A/ask`(`A` allow、`a` ask、`D` deny)。子代理会话有自己的状态、不继承父会话的模式;需要子会话也受控时,通过项目 autostart 再开启一次。实测目标是 POSIX 宿主(Linux/macOS/WSL);原生 Windows Git-Bash 的路径拼写只做了近似,未验证。bash 拆解使用 `shell-quote`;未知变量会展开成哨兵而不是空字符串,使该命令转为询问而不是被悄悄当成空路径。add-dir 目录从 pi-add-dir 的会话条目 `add-dir:state` 读取。启发式,不是沙箱:项目内的破坏性操作由固定表兜底,而不是靠沙箱。已知盲区一处:**读命令的裸文件名参数**(`cat notes.txt`)不被收集为目标,所以把 `out read` 调成 `ask` 时只有绝对路径形状的读目标会被问——读本身不会写,四格模式的写侧不受影响。
144
+ > **实现说明**:新会话在 `session_start` 重置状态(同一会话的重复 emission 保持状态);开启时 footer 显示 `perm i:A/A o:A/ask`(`A` allow、`a` ask、`D` deny)。子代理会话有自己的状态、不继承父会话的模式;需要子会话也受控时,通过项目 autostart 再开启一次。实测目标是 POSIX 宿主(Linux/macOS/WSL);原生 Windows Git-Bash 的路径拼写只做了近似,未验证。bash 拆解使用 `shell-quote`;未知变量会展开成哨兵而不是空字符串,使该命令转为询问而不是被悄悄当成空路径。add-dir 目录从 pi-add-dir 的会话条目 `add-dir:state` 读取。启发式,不是沙箱:项目内的破坏性操作由固定表兜底,而不是靠沙箱。已知盲区一处:**读命令的裸文件名参数**(`cat notes.txt`)不被收集为目标,所以把 `out read` 调成 `ask` 时只有绝对路径形状的读目标会被问——读本身不会写,四格模式的写侧不受影响。
145
145
 
146
146
  ---
147
147
 
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.5",
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 { consumeStartupCommand } from "../../lib/startup.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
+ startupAtDispatch: Array<{ command: string; startup: 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 startupAtDispatch: Harness["startupAtDispatch"] = [];
205
209
  const failCommands = new Set<string>();
206
210
  const pi = {
207
211
  on: (event: string, h: SessionStartHandler) => {
@@ -213,6 +217,10 @@ async function makeHarness(
213
217
  options?: { expandPromptTemplates?: boolean },
214
218
  ) => {
215
219
  sent.push({ command, options }); // records attempts, including failures
220
+ startupAtDispatch.push({
221
+ command,
222
+ startup: consumeStartupCommand("ask-perm"),
223
+ });
216
224
  if (failCommands.has(command)) {
217
225
  return Promise.reject(new Error("boom"));
218
226
  }
@@ -222,6 +230,7 @@ async function makeHarness(
222
230
  return {
223
231
  handler,
224
232
  sent,
233
+ startupAtDispatch,
225
234
  notifications,
226
235
  commands,
227
236
  failCommands,
@@ -315,6 +324,23 @@ describe("registerAutostart", () => {
315
324
  expect(h.notifications).toEqual([]);
316
325
  });
317
326
 
327
+ it("marks our own command as a startup notice and clears the mark", async () => {
328
+ const h = await harness('[command]\nrun = ["/ask-perm allow"]');
329
+ await h.start("startup");
330
+ expect(h.startupAtDispatch).toEqual([
331
+ { command: "/ask-perm allow", startup: true },
332
+ ]);
333
+ expect(consumeStartupCommand("ask-perm")).toBe(false);
334
+ });
335
+
336
+ it("leaves other extensions' commands unmarked", async () => {
337
+ const h = await harness(fullConfig);
338
+ await h.start("startup");
339
+ expect(h.startupAtDispatch.every((entry) => entry.startup === false)).toBe(
340
+ true,
341
+ );
342
+ });
343
+
318
344
  it("replays once per session even if session_start is emitted twice", async () => {
319
345
  const h = await harness(fullConfig);
320
346
  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 { clearStartupCommand, markStartupCommand } from "../../lib/startup.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 command answers a startup notice with a session entry
218
+ // instead of a status line: at startup several commands emit status
219
+ // lines and a later UI invalidation would paint our text on all of
220
+ // them (see lib/startup.ts).
221
+ const startup = name === "ask-perm";
222
+ if (startup) markStartupCommand(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 (startup) clearStartupCommand(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 { markStartupCommand } from "../../lib/startup.js";
18
19
  import { registerPermission } from "./index.js";
19
20
 
20
21
  function bashEvent(command: string): ToolCallEvent {
@@ -50,6 +51,7 @@ interface Harness {
50
51
  readonly gate: ToolGate;
51
52
  readonly statuses: Array<[string, string | undefined]>;
52
53
  readonly notes: Array<{ message: string; kind?: string }>;
54
+ readonly entries: Array<{ type: string; data: unknown }>;
53
55
  readonly branch: Array<Record<string, unknown>>;
54
56
  setSessionId(id: string): void;
55
57
  evaluate(event: ToolCallEvent): Promise<unknown>;
@@ -62,6 +64,7 @@ function makeHarness(): Harness {
62
64
  const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
63
65
  const statuses: Harness["statuses"] = [];
64
66
  const notes: Harness["notes"] = [];
67
+ const entries: Harness["entries"] = [];
65
68
  const branch: Harness["branch"] = [];
66
69
  let sessionId = "session-1";
67
70
  const ctx = {
@@ -102,6 +105,10 @@ function makeHarness(): Harness {
102
105
  ) {
103
106
  if (name === "ask-perm") commandHandler = options.handler;
104
107
  },
108
+ appendEntry(type: string, data: unknown) {
109
+ entries.push({ type, data });
110
+ },
111
+ registerEntryRenderer() {},
105
112
  } as unknown as ExtensionAPI;
106
113
 
107
114
  registerPermission(pi, hub);
@@ -114,6 +121,7 @@ function makeHarness(): Harness {
114
121
  gate,
115
122
  statuses,
116
123
  notes,
124
+ entries,
117
125
  branch,
118
126
  setSessionId(id: string) {
119
127
  sessionId = id;
@@ -208,6 +216,29 @@ describe("/ask-perm", () => {
208
216
  expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
209
217
  });
210
218
 
219
+ it("writes a startup notice as an entry instead of a status line", async () => {
220
+ const h = makeHarness();
221
+ markStartupCommand("ask-perm");
222
+ await h.command("allow allow allow ask");
223
+ expect(h.notes).toHaveLength(0);
224
+ expect(h.entries).toHaveLength(1);
225
+ expect((h.entries[0]?.data as { text: string }).text).toContain(
226
+ "Permission ON",
227
+ );
228
+ expect(h.statuses.at(-1)?.[1]).toContain("perm");
229
+ expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
230
+ "ask",
231
+ );
232
+ });
233
+
234
+ it("still reports an invalid argument from autostart", async () => {
235
+ const h = makeHarness();
236
+ markStartupCommand("ask-perm");
237
+ await h.command("deny");
238
+ expect(h.notes.at(-1)?.kind).toBe("error");
239
+ expect(h.entries).toHaveLength(0);
240
+ });
241
+
211
242
  it("keeps the mode off when the panel is cancelled", async () => {
212
243
  const h = makeHarness();
213
244
  await h.command("");
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Design notes:
5
5
  * - Off by default and never persisted. The mode, the modes, and every session
6
- * grant live in this closure; `session_start` resets them and
7
- * `session_shutdown` clears them. A forgotten toggle cannot follow the
8
- * operator into the next session.
6
+ * grant live in this closure; `session_start` resets them for a new session
7
+ * and keeps them across a rebind of the same session. A forgotten toggle
8
+ * cannot follow the operator into the next session.
9
9
  * - Toggling: no arguments flips on→off, off→panel. Arguments configure and
10
10
  * enable in one step (`/ask-perm allow allow allow ask`), which is also the
11
11
  * autostart path and never opens a dialog.
@@ -21,7 +21,9 @@ import type {
21
21
  ExtensionContext,
22
22
  ToolCallEvent,
23
23
  } from "@earendil-works/pi-coding-agent";
24
+ import { Text } from "@earendil-works/pi-tui";
24
25
  import type { AskRequest, GateHub } from "../../lib/gates.js";
26
+ import { consumeStartupCommand } from "../../lib/startup.js";
25
27
  import {
26
28
  DEFAULT_MODES,
27
29
  type PermissionModes,
@@ -96,7 +98,37 @@ function toolUnit(
96
98
  };
97
99
  }
98
100
 
101
+ /**
102
+ * Chat notices are custom session entries, not `ctx.ui.notify("info")`.
103
+ *
104
+ * The host renders every info notice as a `ThemedText` whose builder reads one
105
+ * shared `lastStatusMessage` field, and `ui.invalidate()` (theme switch, theme
106
+ * file edit, syntax-grammar load at startup) rebuilds *every* status line from
107
+ * that field. Any earlier status line — for example `/add-dir`'s — then shows
108
+ * our text too, so a single command looks like it ran twice. The invalidate is
109
+ * also why the duplicate depends on startup timing and terminal.
110
+ *
111
+ * A custom entry carries its own text and is rendered from that data, so it is
112
+ * immune to invalidation. Entries stay out of the model context (same pattern
113
+ * as pi-chrome) and survive the startup chat rebuild instead of being wiped.
114
+ *
115
+ * Only startup notices (dispatched by autostart, `lib/startup.ts`) use an
116
+ * entry. Interactive toggles keep `ctx.ui.notify`: consecutive info notices
117
+ * update the previous line in place, so ON/OFF/ON does not scroll the screen.
118
+ */
119
+ const NOTICE_ENTRY = "ask-perm:notice";
120
+
121
+ interface NoticeData {
122
+ text: string;
123
+ }
124
+
99
125
  export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
126
+ pi.registerEntryRenderer?.(NOTICE_ENTRY, (entry, _options, theme) => {
127
+ const text = (entry.data as NoticeData | undefined)?.text;
128
+ return text === undefined
129
+ ? undefined
130
+ : new Text(theme.fg("dim", text), 1, 0);
131
+ });
100
132
  let enabled = false;
101
133
  let modes: PermissionModes = { ...DEFAULT_MODES };
102
134
  // Session this state belongs to. A runtime rebind can emit `session_start`
@@ -263,6 +295,12 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
263
295
  description:
264
296
  "Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
265
297
  handler: async (args, ctx) => {
298
+ // Startup notices are entries; interactive ones stay in-place toasts.
299
+ const startup = consumeStartupCommand("ask-perm");
300
+ const notice = (text: string): void => {
301
+ if (startup) pi.appendEntry<NoticeData>(NOTICE_ENTRY, { text });
302
+ else ctx.ui.notify(text, "info");
303
+ };
266
304
  const raw = args.trim();
267
305
 
268
306
  if (raw !== "") {
@@ -283,10 +321,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
283
321
  if (changed) rules.clear();
284
322
  enabled = true;
285
323
  syncStatus(ctx);
286
- ctx.ui.notify(
287
- `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
288
- "info",
289
- );
324
+ notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
290
325
  return;
291
326
  }
292
327
 
@@ -294,18 +329,14 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
294
329
  enabled = false;
295
330
  rules.clear();
296
331
  syncStatus(ctx);
297
- ctx.ui.notify(
298
- "Permission OFF — no boundary checks in this session.",
299
- "info",
300
- );
332
+ notice("Permission OFF — no boundary checks in this session.");
301
333
  return;
302
334
  }
303
335
 
304
336
  const chosen = await openPermissionPanel(ctx, modes);
305
337
  if (!chosen) {
306
- ctx.ui.notify(
338
+ notice(
307
339
  "ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
308
- "info",
309
340
  );
310
341
  return;
311
342
  }
@@ -314,10 +345,7 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
314
345
  rules.clear();
315
346
  enabled = true;
316
347
  syncStatus(ctx);
317
- ctx.ui.notify(
318
- `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
319
- "info",
320
- );
348
+ notice(`Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`);
321
349
  },
322
350
  });
323
351
 
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ clearStartupCommand,
4
+ consumeStartupCommand,
5
+ markStartupCommand,
6
+ } from "./startup.js";
7
+
8
+ describe("startup handoff", () => {
9
+ it("consumes a mark exactly once", () => {
10
+ markStartupCommand("ask-perm");
11
+ expect(consumeStartupCommand("ask-perm")).toBe(true);
12
+ expect(consumeStartupCommand("ask-perm")).toBe(false);
13
+ });
14
+
15
+ it("ignores other commands", () => {
16
+ markStartupCommand("ask-perm");
17
+ expect(consumeStartupCommand("third-review")).toBe(false);
18
+ expect(consumeStartupCommand("ask-perm")).toBe(true);
19
+ });
20
+
21
+ it("can be cleared without a consumer", () => {
22
+ markStartupCommand("ask-perm");
23
+ clearStartupCommand("ask-perm");
24
+ expect(consumeStartupCommand("ask-perm")).toBe(false);
25
+ });
26
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Startup handoff for commands dispatched by autostart.
3
+ *
4
+ * Why the distinction: the host renders an info notice (`ctx.ui.notify`) as a
5
+ * `ThemedText` whose builder reads one shared `lastStatusMessage` field, and a
6
+ * UI invalidation (theme switch, theme-file edit, syntax-grammar load at
7
+ * startup) rebuilds *every* status line from that field. At startup several
8
+ * commands emit status lines (/add-dir, …), so our notice makes the earlier
9
+ * lines display our text too — one command looks like it ran twice.
10
+ *
11
+ * Interactive toggles have the opposite preference: consecutive info notices
12
+ * update the previous line in place, so ON/OFF/ON does not scroll the screen.
13
+ *
14
+ * Autostart therefore marks the commands it starts, and our command answers
15
+ * differently: a startup notice is a custom session entry (own text, immune to
16
+ * invalidation), while an interactive one stays a transient status line.
17
+ *
18
+ * The mark is keyed by command name and is always cleared by the dispatcher
19
+ * (`finally`), so it cannot leak into a later manual invocation.
20
+ */
21
+ const startupCommands = new Set<string>();
22
+
23
+ /** Mark `commandName` as dispatched by autostart. */
24
+ export function markStartupCommand(commandName: string): void {
25
+ startupCommands.add(commandName);
26
+ }
27
+
28
+ /** Drop a mark whether or not the command consumed it. */
29
+ export function clearStartupCommand(commandName: string): void {
30
+ startupCommands.delete(commandName);
31
+ }
32
+
33
+ /** `true` once, when the command was started by autostart; consumes the mark. */
34
+ export function consumeStartupCommand(commandName: string): boolean {
35
+ return startupCommands.delete(commandName);
36
+ }