@andares/pi-toolkits 0.9.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.9.2",
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([
@@ -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),
@@ -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");
@@ -99,6 +99,10 @@ function toolUnit(
99
99
  export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
100
100
  let enabled = false;
101
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;
102
106
  const rules = new SessionRules();
103
107
  let rootsCache:
104
108
  | { cwd: string; branchLength: number; roots: string[] }
@@ -111,6 +115,15 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
111
115
  rootsCache = undefined;
112
116
  }
113
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
+
114
127
  function syncStatus(ctx: ExtensionContext): void {
115
128
  if (!enabled) {
116
129
  ctx.ui.setStatus(PERMISSION_STATUS_KEY, undefined);
@@ -233,12 +246,16 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
233
246
  // ── Lifecycle ──────────────────────────────────────────────────────────
234
247
 
235
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;
236
256
  reset();
237
257
  syncStatus(ctx);
238
258
  });
239
- pi.on("session_shutdown", () => {
240
- reset();
241
- });
242
259
 
243
260
  // ── Command ────────────────────────────────────────────────────────────
244
261