@andares/pi-toolkits 0.9.1 → 0.9.3

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,6 +309,8 @@ 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 |
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,6 +309,8 @@ run = [
307
309
 
308
310
  全局列表先执行,项目列表后执行,因此项目可以覆盖此前的设置(例如后面再执行一条不同参数的 `/ask-perm`)。只有希望所有项目都用同一套命令时才用全局文件;仍然建议以项目级配置为主。
309
311
 
312
+ **完全相同的命令行**若同时出现在两个来源,一次重放里只执行一次:重复项跳过,所以把全局的 `/ask-perm ...` 复制到项目里不会再跑一遍、也不会再提示一遍。不同的命令仍然各跑一次——这正是「项目覆盖全局」得以成立的原因。
313
+
310
314
  **执行时机**:
311
315
 
312
316
  | session_start reason | 是否重放 | 原因 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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",
@@ -145,7 +145,10 @@ type SessionStartHandler = (
145
145
  cwd: string;
146
146
  isProjectTrusted: () => boolean;
147
147
  ui: { notify: (message: string, type?: string) => void };
148
- sessionManager: { buildContextEntries: () => Array<{ type: string }> };
148
+ sessionManager: {
149
+ buildContextEntries: () => Array<{ type: string }>;
150
+ getSessionId: () => string;
151
+ };
149
152
  },
150
153
  ) => Promise<void>;
151
154
 
@@ -159,6 +162,7 @@ interface Harness {
159
162
  setTrusted(trusted: boolean): void;
160
163
  /** Simulate a session that already had a conversation (resumed process). */
161
164
  setHistory(entries: Array<{ type: string }>): void;
165
+ setSessionId(id: string): void;
162
166
  start(reason: string): Promise<void>;
163
167
  }
164
168
 
@@ -190,6 +194,7 @@ async function makeHarness(
190
194
  { type: "model_change" },
191
195
  { type: "thinking_level_change" },
192
196
  ];
197
+ let sessionId = "session-1";
193
198
  let handler: SessionStartHandler = async () => {};
194
199
  const sent: Harness["sent"] = [];
195
200
  const notifications: Harness["notifications"] = [];
@@ -226,6 +231,9 @@ async function makeHarness(
226
231
  setHistory(entries) {
227
232
  history = entries;
228
233
  },
234
+ setSessionId(id) {
235
+ sessionId = id;
236
+ },
229
237
  async start(reason: string) {
230
238
  await handler({ reason }, {
231
239
  cwd,
@@ -235,7 +243,10 @@ async function makeHarness(
235
243
  notifications.push({ message, type });
236
244
  },
237
245
  },
238
- sessionManager: { buildContextEntries: () => history },
246
+ sessionManager: {
247
+ buildContextEntries: () => history,
248
+ getSessionId: () => sessionId,
249
+ },
239
250
  });
240
251
  },
241
252
  cwd,
@@ -286,6 +297,8 @@ describe("registerAutostart", () => {
286
297
  it("replays on new and fork", async () => {
287
298
  const h = await harness('[command]\nrun = ["/ask on"]');
288
299
  await h.start("new");
300
+ // A fork is a different session, so it replays again.
301
+ h.setSessionId("forked-session");
289
302
  await h.start("fork");
290
303
  expect(h.sent).toEqual([
291
304
  { command: "/ask on", options: { expandPromptTemplates: true } },
@@ -302,6 +315,21 @@ describe("registerAutostart", () => {
302
315
  expect(h.notifications).toEqual([]);
303
316
  });
304
317
 
318
+ it("replays once per session even if session_start is emitted twice", async () => {
319
+ const h = await harness(fullConfig);
320
+ await h.start("startup");
321
+ await h.start("startup");
322
+ expect(h.sent).toHaveLength(2);
323
+ });
324
+
325
+ it("replays again for a new session id", async () => {
326
+ const h = await harness(fullConfig);
327
+ await h.start("startup");
328
+ h.setSessionId("session-2");
329
+ await h.start("startup");
330
+ expect(h.sent).toHaveLength(4);
331
+ });
332
+
305
333
  it("skips a process started on an existing session (pi -c reports startup)", async () => {
306
334
  const h = await harness(fullConfig);
307
335
  h.setHistory([
@@ -418,6 +446,29 @@ describe("registerAutostart", () => {
418
446
  expect(h.notifications[0]?.type).toBe("warning");
419
447
  });
420
448
 
449
+ it("runs an identical command only once across sources", async () => {
450
+ const h = await harness(
451
+ "[command]\nrun = [\"/ask on\"]",
452
+ false,
453
+ "[command]\nrun = [\"/ask on\"]",
454
+ );
455
+ await h.start("startup");
456
+ expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
457
+ });
458
+
459
+ it("still runs different commands from both sources", async () => {
460
+ const h = await harness(
461
+ "[command]\nrun = [\"/ask on\", \"/add-dir ../p\"]",
462
+ false,
463
+ "[command]\nrun = [\"/ask on\"]",
464
+ );
465
+ await h.start("startup");
466
+ expect(h.sent.map((entry) => entry.command)).toEqual([
467
+ "/ask on",
468
+ "/add-dir ../p",
469
+ ]);
470
+ });
471
+
421
472
  it("keeps running the project config when the global one is absent", async () => {
422
473
  const h = await harness('[command]\nrun = ["/ask on"]', false, null);
423
474
  await h.start("startup");
@@ -87,6 +87,15 @@ const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
87
87
  * session manager counts as new, which keeps the feature working in hosts where
88
88
  * the session surface is unavailable.
89
89
  */
90
+ /** Session id, or `undefined` when the host exposes none. */
91
+ function sessionIdOf(ctx: ExtensionContext): string | undefined {
92
+ try {
93
+ return ctx.sessionManager.getSessionId();
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
90
99
  function hasConversationHistory(ctx: ExtensionContext): boolean {
91
100
  try {
92
101
  return ctx.sessionManager
@@ -102,6 +111,11 @@ export function registerAutostart(
102
111
  options: AutostartOptions = {},
103
112
  ): void {
104
113
  const agentDir = options.agentDir ?? getAgentDir();
114
+ // One replay per session. The host can emit `session_start` more than once
115
+ // for the same session (a runtime rebind during startup reports `startup`
116
+ // again), and replaying on the second emission would announce everything
117
+ // twice.
118
+ const replayedSessions = new Set<string>();
105
119
 
106
120
  async function readCommands(
107
121
  ctx: ExtensionContext,
@@ -140,6 +154,12 @@ export function registerAutostart(
140
154
  // shouldReplay in autostart.ts for the host-side evidence.
141
155
  if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
142
156
 
157
+ const sessionId = sessionIdOf(ctx);
158
+ if (sessionId !== undefined) {
159
+ if (replayedSessions.has(sessionId)) return;
160
+ replayedSessions.add(sessionId);
161
+ }
162
+
143
163
  const sources: Source[] = [
144
164
  {
145
165
  path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
@@ -163,6 +183,13 @@ export function registerAutostart(
163
183
  ),
164
184
  );
165
185
 
186
+ // The same line may be declared by both sources (global defaults plus a
187
+ // project copy). Running an identical command twice in one replay is
188
+ // never meaningful — a repeated `/ask-perm` would re-notify, and a
189
+ // repeated `/add-dir` is pure noise — so identical text runs once.
190
+ // Different commands still both run, which keeps the project's override.
191
+ const dispatched = new Set<string>();
192
+
166
193
  for (const source of sources) {
167
194
  const commands = await readCommands(ctx, source);
168
195
  if (commands === undefined || commands.length === 0) continue;
@@ -184,6 +211,8 @@ export function registerAutostart(
184
211
  );
185
212
  continue;
186
213
  }
214
+ if (dispatched.has(command)) continue;
215
+ dispatched.add(command);
187
216
  // One failing command must not cancel the rest of the list.
188
217
  try {
189
218
  await pi.sendUserMessage(command, { expandPromptTemplates: true });
@@ -51,6 +51,7 @@ interface Harness {
51
51
  readonly statuses: Array<[string, string | undefined]>;
52
52
  readonly notes: Array<{ message: string; kind?: string }>;
53
53
  readonly branch: Array<Record<string, unknown>>;
54
+ setSessionId(id: string): void;
54
55
  evaluate(event: ToolCallEvent): Promise<unknown>;
55
56
  command(args: string): Promise<void>;
56
57
  sessionStart(): Promise<void>;
@@ -62,6 +63,7 @@ function makeHarness(): Harness {
62
63
  const statuses: Harness["statuses"] = [];
63
64
  const notes: Harness["notes"] = [];
64
65
  const branch: Harness["branch"] = [];
66
+ let sessionId = "session-1";
65
67
  const ctx = {
66
68
  cwd,
67
69
  hasUI: true,
@@ -73,7 +75,10 @@ function makeHarness(): Harness {
73
75
  custom: async () => undefined,
74
76
  select: async () => undefined,
75
77
  },
76
- sessionManager: { getBranch: () => branch },
78
+ sessionManager: {
79
+ getBranch: () => branch,
80
+ getSessionId: () => sessionId,
81
+ },
77
82
  } as unknown as ExtensionContext;
78
83
 
79
84
  const captured: ToolGate[] = [];
@@ -110,6 +115,9 @@ function makeHarness(): Harness {
110
115
  statuses,
111
116
  notes,
112
117
  branch,
118
+ setSessionId(id: string) {
119
+ sessionId = id;
120
+ },
113
121
  async evaluate(event) {
114
122
  return gate.evaluate(event, ctx);
115
123
  },
@@ -214,6 +222,25 @@ describe("/ask-perm", () => {
214
222
  expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
215
223
  });
216
224
 
225
+ it("keeps the state when the same session emits session_start again", async () => {
226
+ const h = makeHarness();
227
+ await h.sessionStart(); // the real first emission
228
+ await h.command("allow allow allow ask");
229
+ // A startup rebind reports the same session again; state must survive.
230
+ await h.sessionStart();
231
+ expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).kind).toBe(
232
+ "ask",
233
+ );
234
+ });
235
+
236
+ it("resets on session_start for a different session", async () => {
237
+ const h = makeHarness();
238
+ await h.command("allow allow allow ask");
239
+ h.setSessionId("session-2");
240
+ await h.sessionStart();
241
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
242
+ });
243
+
217
244
  it("resets on session_start", async () => {
218
245
  const h = makeHarness();
219
246
  await h.command("allow allow allow ask");
@@ -269,15 +296,30 @@ describe("/ask-perm", () => {
269
296
  expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
270
297
  });
271
298
 
272
- it("clears session grants when the mode is reconfigured", async () => {
299
+ it("is idempotent: repeating the same command notifies once and keeps grants", async () => {
273
300
  const h = makeHarness();
274
301
  await h.command("allow allow allow ask");
302
+ expect(h.notes).toHaveLength(1);
303
+
275
304
  asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
276
305
  "allow-session",
277
306
  );
307
+ await h.command("allow allow allow ask");
308
+ expect(h.notes).toHaveLength(1);
309
+ // The grant collected before the repeat is still in force.
278
310
  expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
311
+ });
279
312
 
313
+ it("notifies and clears session grants when the modes actually change", async () => {
314
+ const h = makeHarness();
280
315
  await h.command("allow allow allow ask");
316
+ asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
317
+ "allow-session",
318
+ );
319
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
320
+
321
+ await h.command("ask allow allow ask");
322
+ expect(h.notes).toHaveLength(2);
281
323
  expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).kind).toBe("ask");
282
324
  });
283
325
  });
@@ -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";
@@ -98,6 +99,10 @@ function toolUnit(
98
99
  export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
99
100
  let enabled = false;
100
101
  let modes: PermissionModes = { ...DEFAULT_MODES };
102
+ // Session this state belongs to. A runtime rebind can emit `session_start`
103
+ // again for the same session; resetting then would wipe a mode that the
104
+ // autostart replay had just enabled, so only a different session resets.
105
+ let currentSessionId: string | undefined;
101
106
  const rules = new SessionRules();
102
107
  let rootsCache:
103
108
  | { cwd: string; branchLength: number; roots: string[] }
@@ -110,6 +115,15 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
110
115
  rootsCache = undefined;
111
116
  }
112
117
 
118
+ /** Session id, or `undefined` when the host exposes none. */
119
+ function sessionIdOf(ctx: ExtensionContext): string | undefined {
120
+ try {
121
+ return ctx.sessionManager.getSessionId();
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
113
127
  function syncStatus(ctx: ExtensionContext): void {
114
128
  if (!enabled) {
115
129
  ctx.ui.setStatus(PERMISSION_STATUS_KEY, undefined);
@@ -232,12 +246,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
232
246
  // ── Lifecycle ──────────────────────────────────────────────────────────
233
247
 
234
248
  pi.on("session_start", (_event, ctx) => {
249
+ const sessionId = sessionIdOf(ctx);
250
+ // Same session, repeated emission (a startup rebind): keep the state.
251
+ if (sessionId !== undefined && sessionId === currentSessionId) {
252
+ syncStatus(ctx);
253
+ return;
254
+ }
255
+ currentSessionId = sessionId;
235
256
  reset();
236
257
  syncStatus(ctx);
237
258
  });
238
- pi.on("session_shutdown", () => {
239
- reset();
240
- });
241
259
 
242
260
  // ── Command ────────────────────────────────────────────────────────────
243
261
 
@@ -253,9 +271,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
253
271
  ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
254
272
  return;
255
273
  }
274
+ // Idempotent: the same command may arrive twice in one session start
275
+ // (both autostart sources declaring it, a duplicated config line, …).
276
+ // Re-applying an unchanged policy is a no-op — no second "ON" notice,
277
+ // and no loss of the session grants collected so far.
278
+ if (enabled && sameModes(modes, parsed.modes)) return;
279
+
280
+ const changed = !sameModes(modes, parsed.modes);
256
281
  modes = parsed.modes;
257
- // A new policy invalidates grants made under the old one.
258
- rules.clear();
282
+ // A changed policy invalidates grants made under the old one.
283
+ if (changed) rules.clear();
259
284
  enabled = true;
260
285
  syncStatus(ctx);
261
286
  ctx.ui.notify(
@@ -284,7 +309,9 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
284
309
  );
285
310
  return;
286
311
  }
312
+ // The panel only opens while off, so this always transitions on.
287
313
  modes = chosen;
314
+ rules.clear();
288
315
  enabled = true;
289
316
  syncStatus(ctx);
290
317
  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
+ }