@andares/pi-toolkits 0.9.0 → 0.9.2

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,6 +106,8 @@ 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.
110
+
109
111
  **Defaults**: in-project read/write allowed, outside read allowed, outside write asks.
110
112
 
111
113
  | Scope | reads | writes / exec |
@@ -307,15 +309,20 @@ No file = feature disabled, zero overhead, silently skipped.
307
309
 
308
310
  The global list runs first, so a project can override an earlier setting (for example a later `/ask-perm` with different arguments). Use the global file only when you want the same commands in every project; per-project files remain the recommended default.
309
311
 
312
+ An **identical command line** declared by both sources runs once: a duplicate is skipped for that replay, so a global `/ask-perm ...` copied into a project does not run (or announce itself) twice. Different commands still both run, which is what keeps a project override working.
313
+
310
314
  **When it runs**:
311
315
 
312
316
  | session_start reason | Replayed | Why |
313
317
  | --- | --- | --- |
314
- | `startup` / `new` | ✅ | a new session, replay as declared |
315
- | `fork` | ✅ | the branch point may predate the config's appearance |
316
- | `resume` | ❌ | the restored branch already carries the mount state from its time, replaying would conflict |
318
+ | `startup` / `new`, empty session | ✅ | a new session, replay as declared |
319
+ | `startup` on a session that already has history | ❌ | that is a process started on an existing session (`pi -c` / `-r` / `--session` / `--fork`), and its branch already carries the state a replay would rebuild |
320
+ | `fork` (in-process) | ✅ | the branch point may predate the config's appearance |
321
+ | `resume` (in-process `/resume`) | ❌ | the restored branch already carries the mount state from its time, replaying would conflict |
317
322
  | `reload` | ❌ | extensions are rebuilt, replaying would duplicate side effects |
318
323
 
324
+ > The `startup` split is not cosmetic: pi reports `startup` for **every** process start, including one that opens an existing session. `resume` is emitted only for in-process session switches, never for `pi -c`. Lines 1–2 of the table are therefore decided by the session's content (does it already hold a message?), not by the reason alone.
325
+
319
326
  **Safety gates** (in order):
320
327
 
321
328
  1. **project trust** — no command runs automatically in an untrusted directory (a warning is shown); a config parse failure likewise prevents execution (an error is shown)
package/README.zh-CN.md CHANGED
@@ -106,6 +106,8 @@ pi -e ./src/index.ts
106
106
  - `/ask-perm allow allow allow ask` —— 位置参数形式(`in-read in-write out-read out-write`),不弹窗直接开启;没给全的参数回落到默认值。这也是 autostart 使用的形式
107
107
  - 再输入一次 `/ask-perm` —— 关闭
108
108
 
109
+ 参数形式重复执行、且四项模式与当前一致时是**空操作**:不会再出一条「ON」提示,也不会清掉已经攒下的会话授权。只有模式真的变了才会提示并清空授权——那些授权是按旧策略给的。
110
+
109
111
  **默认值**:项目内读/写全放行,项目外读放行,项目外写询问。
110
112
 
111
113
  | 作用域 | 读 | 写 / 执行 |
@@ -307,15 +309,20 @@ run = [
307
309
 
308
310
  全局列表先执行,项目列表后执行,因此项目可以覆盖此前的设置(例如后面再执行一条不同参数的 `/ask-perm`)。只有希望所有项目都用同一套命令时才用全局文件;仍然建议以项目级配置为主。
309
311
 
312
+ **完全相同的命令行**若同时出现在两个来源,一次重放里只执行一次:重复项跳过,所以把全局的 `/ask-perm ...` 复制到项目里不会再跑一遍、也不会再提示一遍。不同的命令仍然各跑一次——这正是「项目覆盖全局」得以成立的原因。
313
+
310
314
  **执行时机**:
311
315
 
312
316
  | session_start reason | 是否重放 | 原因 |
313
317
  | --- | --- | --- |
314
- | `startup` / `new` | ✅ | 新会话,按声明重放 |
315
- | `fork` | ✅ | 分支点可能早于配置出现 |
316
- | `resume` | ❌ | 恢复的分支已携带当时的挂载状态,重放反而会冲突 |
318
+ | `startup` / `new`,空会话 | ✅ | 新会话,按声明重放 |
319
+ | `startup`,但会话已有历史 | ❌ | 这是「进程启动即带着旧会话」(`pi -c` / `-r` / `--session` / `--fork`),其分支已携带重放本该重建的状态 |
320
+ | `fork`(进程内) | ✅ | 分支点可能早于配置出现 |
321
+ | `resume`(进程内 `/resume`) | ❌ | 恢复的分支已携带当时的挂载状态,重放反而会冲突 |
317
322
  | `reload` | ❌ | 扩展重建,重放会重复副作用 |
318
323
 
324
+ > `startup` 这一行为什么要拆开:pi 对**每一次进程启动**都报 `startup`,包括「启动即打开旧会话」的情形;`resume` 只在进程内切换会话时发出,`pi -c` 永远不会报它。所以前两行的判定依据是**会话内容**(是否已有消息),不能只看 reason。
325
+
319
326
  **安全门**(按序):
320
327
 
321
328
  1. **project trust** —— 未信任目录不自动执行任何命令(warning 提示);配置解析失败同样不执行(error 提示)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",
@@ -19,6 +19,7 @@ import {
19
19
  GLOBAL_AUTOSTART_FILENAME,
20
20
  parseAutostartConfig,
21
21
  RUNNABLE_REASONS,
22
+ shouldReplay,
22
23
  } from "./autostart.js";
23
24
  import { registerAutostart } from "./index.js";
24
25
 
@@ -112,6 +113,28 @@ describe("RUNNABLE_REASONS", () => {
112
113
  });
113
114
  });
114
115
 
116
+ describe("shouldReplay", () => {
117
+ it("replays a genuinely new session", () => {
118
+ expect(shouldReplay("startup", false)).toBe(true);
119
+ expect(shouldReplay("new", false)).toBe(true);
120
+ });
121
+
122
+ it("skips a startup that already has history (a resumed process)", () => {
123
+ // `pi -c`, `-r`, `--session` and `--fork` all report `startup` for a
124
+ // session that already carries history.
125
+ expect(shouldReplay("startup", true)).toBe(false);
126
+ });
127
+
128
+ it("replays a fork even though the branch has history", () => {
129
+ expect(shouldReplay("fork", true)).toBe(true);
130
+ });
131
+
132
+ it("skips in-process resume and reload", () => {
133
+ expect(shouldReplay("resume", true)).toBe(false);
134
+ expect(shouldReplay("reload", true)).toBe(false);
135
+ });
136
+ });
137
+
115
138
  /* ------------------------------------------------------------------ */
116
139
  /* registerAutostart integration (mocked pi surface) */
117
140
  /* ------------------------------------------------------------------ */
@@ -122,6 +145,7 @@ type SessionStartHandler = (
122
145
  cwd: string;
123
146
  isProjectTrusted: () => boolean;
124
147
  ui: { notify: (message: string, type?: string) => void };
148
+ sessionManager: { buildContextEntries: () => Array<{ type: string }> };
125
149
  },
126
150
  ) => Promise<void>;
127
151
 
@@ -133,6 +157,8 @@ interface Harness {
133
157
  commands: Array<{ name: string; source: string }>;
134
158
  failCommands: Set<string>;
135
159
  setTrusted(trusted: boolean): void;
160
+ /** Simulate a session that already had a conversation (resumed process). */
161
+ setHistory(entries: Array<{ type: string }>): void;
136
162
  start(reason: string): Promise<void>;
137
163
  }
138
164
 
@@ -158,6 +184,12 @@ async function makeHarness(
158
184
  await writeFile(join(cwd, AUTOSTART_CONFIG_PATH), config, "utf8");
159
185
  }
160
186
  let trusted = true;
187
+ // Boot-level entries every session has; a resumed session adds messages.
188
+ let history: Array<{ type: string }> = [
189
+ { type: "session" },
190
+ { type: "model_change" },
191
+ { type: "thinking_level_change" },
192
+ ];
161
193
  let handler: SessionStartHandler = async () => {};
162
194
  const sent: Harness["sent"] = [];
163
195
  const notifications: Harness["notifications"] = [];
@@ -191,6 +223,9 @@ async function makeHarness(
191
223
  setTrusted(value: boolean) {
192
224
  trusted = value;
193
225
  },
226
+ setHistory(entries) {
227
+ history = entries;
228
+ },
194
229
  async start(reason: string) {
195
230
  await handler({ reason }, {
196
231
  cwd,
@@ -200,6 +235,7 @@ async function makeHarness(
200
235
  notifications.push({ message, type });
201
236
  },
202
237
  },
238
+ sessionManager: { buildContextEntries: () => history },
203
239
  });
204
240
  },
205
241
  cwd,
@@ -266,6 +302,38 @@ describe("registerAutostart", () => {
266
302
  expect(h.notifications).toEqual([]);
267
303
  });
268
304
 
305
+ it("skips a process started on an existing session (pi -c reports startup)", async () => {
306
+ const h = await harness(fullConfig);
307
+ h.setHistory([
308
+ { type: "session" },
309
+ { type: "model_change" },
310
+ { type: "message" },
311
+ ]);
312
+ await h.start("startup");
313
+ expect(h.sent).toEqual([]);
314
+ expect(h.notifications).toEqual([]);
315
+ });
316
+
317
+ it("still replays when the session holds only boot-level entries", async () => {
318
+ const h = await harness(fullConfig);
319
+ h.setHistory([
320
+ { type: "session" },
321
+ { type: "model_change" },
322
+ { type: "thinking_level_change" },
323
+ { type: "session_info" },
324
+ { type: "custom" },
325
+ ]);
326
+ await h.start("startup");
327
+ expect(h.sent).toHaveLength(2);
328
+ });
329
+
330
+ it("replays a fork of a session that has history", async () => {
331
+ const h = await harness(fullConfig);
332
+ h.setHistory([{ type: "message" }]);
333
+ await h.start("fork");
334
+ expect(h.sent).toHaveLength(2);
335
+ });
336
+
269
337
  it("silently skips when no config file exists", async () => {
270
338
  const h = await harness(null);
271
339
  await h.start("startup");
@@ -350,6 +418,29 @@ describe("registerAutostart", () => {
350
418
  expect(h.notifications[0]?.type).toBe("warning");
351
419
  });
352
420
 
421
+ it("runs an identical command only once across sources", async () => {
422
+ const h = await harness(
423
+ "[command]\nrun = [\"/ask on\"]",
424
+ false,
425
+ "[command]\nrun = [\"/ask on\"]",
426
+ );
427
+ await h.start("startup");
428
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
429
+ });
430
+
431
+ it("still runs different commands from both sources", async () => {
432
+ const h = await harness(
433
+ "[command]\nrun = [\"/ask on\", \"/add-dir ../p\"]",
434
+ false,
435
+ "[command]\nrun = [\"/ask on\"]",
436
+ );
437
+ await h.start("startup");
438
+ expect(h.sent.map((entry) => entry.command)).toEqual([
439
+ "/ask on",
440
+ "/add-dir ../p",
441
+ ]);
442
+ });
443
+
353
444
  it("keeps running the project config when the global one is absent", async () => {
354
445
  const h = await harness('[command]\nrun = ["/ask on"]', false, null);
355
446
  await h.start("startup");
@@ -41,6 +41,30 @@ export const RUNNABLE_REASONS: ReadonlySet<string> = new Set([
41
41
  "fork",
42
42
  ]);
43
43
 
44
+ /**
45
+ * Whether this session start should replay the command lists.
46
+ *
47
+ * The reason alone is not enough at process start. `pi -c` (`--continue`),
48
+ * `-r` (`--resume`), `--session <path>` and `--fork <path>` all open an
49
+ * existing session *before* the first runtime exists, and that runtime is
50
+ * created without an explicit session_start event, so extensions see pi's
51
+ * default reason — `startup` — not `resume` (verified on pi 1.0.0–1.3.0:
52
+ * `main.js` builds the initial session manager from those flags and calls
53
+ * `createAgentSessionRuntime` with no `sessionStartEvent`;
54
+ * `agent-session.js` defaults it to `{ reason: "startup" }`). `resume` is only
55
+ * emitted for in-process session switches, never for a resumed process.
56
+ *
57
+ * So the session content decides: a `startup` on a session that already has
58
+ * conversation history is a resumed (or forked) process, and its branch already
59
+ * carries the state a replay would rebuild. `hasHistory` is the same signal pi
60
+ * itself uses to recognise an existing session
61
+ * (`buildSessionContext().messages.length > 0`).
62
+ */
63
+ export function shouldReplay(reason: string, hasHistory: boolean): boolean {
64
+ if (reason === "startup") return !hasHistory;
65
+ return RUNNABLE_REASONS.has(reason);
66
+ }
67
+
44
68
  /** Parse/validation failure with a user-facing message. */
45
69
  export class AutostartParseError extends Error {}
46
70
 
@@ -12,7 +12,9 @@
12
12
  *
13
13
  * Execution gates, in order:
14
14
  *
15
- * - reason filter — only startup/new/fork replay (see RUNNABLE_REASONS)
15
+ * - reason filter — see `shouldReplay`: startup/new/fork replay, but a
16
+ * `startup` that already carries conversation history is a process started
17
+ * on an existing session (`pi -c`/`-r`/`--session`/`--fork`) and does not
16
18
  * - config file exists — a missing file is a silent opt-in skip
17
19
  * - project trust (project source only)
18
20
  * - per-command pre-validation against `pi.getCommands()`: only commands
@@ -44,7 +46,7 @@ import {
44
46
  commandNameOf,
45
47
  GLOBAL_AUTOSTART_FILENAME,
46
48
  parseAutostartConfig,
47
- RUNNABLE_REASONS,
49
+ shouldReplay,
48
50
  } from "./autostart.js";
49
51
 
50
52
  export interface AutostartOptions {
@@ -63,6 +65,38 @@ function messageOf(error: unknown): string {
63
65
  return error instanceof Error ? error.message : String(error);
64
66
  }
65
67
 
68
+ /**
69
+ * Entry types that prove the session already held a conversation.
70
+ *
71
+ * `buildContextEntries()` returns the active branch's entries including the
72
+ * boot-level ones (`session`, `model_change`, `thinking_level_change`), so
73
+ * presence alone is not enough — a brand-new session has those too.
74
+ */
75
+ const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
76
+ "message",
77
+ "custom_message",
78
+ "compaction",
79
+ "branch_summary",
80
+ ]);
81
+
82
+ /**
83
+ * Whether the session already carries conversation history.
84
+ *
85
+ * This is how a `startup` on an existing session (a resumed process) is told
86
+ * apart from a genuinely new one — see {@link shouldReplay}. An unreadable
87
+ * session manager counts as new, which keeps the feature working in hosts where
88
+ * the session surface is unavailable.
89
+ */
90
+ function hasConversationHistory(ctx: ExtensionContext): boolean {
91
+ try {
92
+ return ctx.sessionManager
93
+ .buildContextEntries()
94
+ .some((entry) => HISTORY_ENTRY_TYPES.has(entry.type));
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
66
100
  export function registerAutostart(
67
101
  pi: ExtensionAPI,
68
102
  options: AutostartOptions = {},
@@ -101,7 +135,10 @@ export function registerAutostart(
101
135
  }
102
136
 
103
137
  pi.on("session_start", async (event, ctx) => {
104
- if (!RUNNABLE_REASONS.has(event.reason)) return;
138
+ // `pi -c` / `-r` / `--session` / `--fork` report `startup` for a session
139
+ // that already has history; the content is what tells them apart. See
140
+ // shouldReplay in autostart.ts for the host-side evidence.
141
+ if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
105
142
 
106
143
  const sources: Source[] = [
107
144
  {
@@ -126,6 +163,13 @@ export function registerAutostart(
126
163
  ),
127
164
  );
128
165
 
166
+ // The same line may be declared by both sources (global defaults plus a
167
+ // project copy). Running an identical command twice in one replay is
168
+ // never meaningful — a repeated `/ask-perm` would re-notify, and a
169
+ // repeated `/add-dir` is pure noise — so identical text runs once.
170
+ // Different commands still both run, which keeps the project's override.
171
+ const dispatched = new Set<string>();
172
+
129
173
  for (const source of sources) {
130
174
  const commands = await readCommands(ctx, source);
131
175
  if (commands === undefined || commands.length === 0) continue;
@@ -147,6 +191,8 @@ export function registerAutostart(
147
191
  );
148
192
  continue;
149
193
  }
194
+ if (dispatched.has(command)) continue;
195
+ dispatched.add(command);
150
196
  // One failing command must not cancel the rest of the list.
151
197
  try {
152
198
  await pi.sendUserMessage(command, { expandPromptTemplates: true });
@@ -269,15 +269,30 @@ describe("/ask-perm", () => {
269
269
  expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
270
270
  });
271
271
 
272
- it("clears session grants when the mode is reconfigured", async () => {
272
+ it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
273
273
  const h = makeHarness();
274
274
  await h.command("allow allow allow ask");
275
+ expect(h.notes).toHaveLength(1);
276
+
275
277
  asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
276
278
  "allow-session",
277
279
  );
280
+ await h.command("allow allow allow ask");
281
+ expect(h.notes).toHaveLength(1);
282
+ // The grant collected before the repeat is still in force.
278
283
  expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
284
+ });
279
285
 
286
+ it("notifies and clears session grants when the modes actually change", async () => {
287
+ const h = makeHarness();
280
288
  await h.command("allow allow allow ask");
289
+ asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
290
+ "allow-session",
291
+ );
292
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
293
+
294
+ await h.command("ask allow allow ask");
295
+ expect(h.notes).toHaveLength(2);
281
296
  expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).kind).toBe("ask");
282
297
  });
283
298
  });
@@ -31,6 +31,7 @@ import {
31
31
  import {
32
32
  formatModes,
33
33
  parsePermissionArgs,
34
+ sameModes,
34
35
  shortMode,
35
36
  } from "./modes.js";
36
37
  import { openPermissionPanel } from "./panel.js";
@@ -253,9 +254,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
253
254
  ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
254
255
  return;
255
256
  }
257
+ // Idempotent: the same command may arrive twice in one session start
258
+ // (both autostart sources declaring it, a duplicated config line, …).
259
+ // Re-applying an unchanged policy is a no-op — no second "ON" notice,
260
+ // and no loss of the session grants collected so far.
261
+ if (enabled && sameModes(modes, parsed.modes)) return;
262
+
263
+ const changed = !sameModes(modes, parsed.modes);
256
264
  modes = parsed.modes;
257
- // A new policy invalidates grants made under the old one.
258
- rules.clear();
265
+ // A changed policy invalidates grants made under the old one.
266
+ if (changed) rules.clear();
259
267
  enabled = true;
260
268
  syncStatus(ctx);
261
269
  ctx.ui.notify(
@@ -284,7 +292,9 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
284
292
  );
285
293
  return;
286
294
  }
295
+ // The panel only opens while off, so this always transitions on.
287
296
  modes = chosen;
297
+ rules.clear();
288
298
  enabled = true;
289
299
  syncStatus(ctx);
290
300
  ctx.ui.notify(
@@ -80,3 +80,13 @@ export function shortMode(mode: TriMode | "allow"): string {
80
80
  return "D";
81
81
  }
82
82
  }
83
+
84
+ /** Structural equality for the four modes. */
85
+ export function sameModes(a: PermissionModes, b: PermissionModes): boolean {
86
+ return (
87
+ a.inRead === b.inRead &&
88
+ a.inWrite === b.inWrite &&
89
+ a.outRead === b.outRead &&
90
+ a.outWrite === b.outWrite
91
+ );
92
+ }