@andares/pi-toolkits 0.9.0 → 0.9.1

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
@@ -311,11 +311,14 @@ The global list runs first, so a project can override an earlier setting (for ex
311
311
 
312
312
  | session_start reason | Replayed | Why |
313
313
  | --- | --- | --- |
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 |
314
+ | `startup` / `new`, empty session | ✅ | a new session, replay as declared |
315
+ | `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 |
316
+ | `fork` (in-process) | ✅ | the branch point may predate the config's appearance |
317
+ | `resume` (in-process `/resume`) | ❌ | the restored branch already carries the mount state from its time, replaying would conflict |
317
318
  | `reload` | ❌ | extensions are rebuilt, replaying would duplicate side effects |
318
319
 
320
+ > 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.
321
+
319
322
  **Safety gates** (in order):
320
323
 
321
324
  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
@@ -311,11 +311,14 @@ run = [
311
311
 
312
312
  | session_start reason | 是否重放 | 原因 |
313
313
  | --- | --- | --- |
314
- | `startup` / `new` | ✅ | 新会话,按声明重放 |
315
- | `fork` | ✅ | 分支点可能早于配置出现 |
316
- | `resume` | ❌ | 恢复的分支已携带当时的挂载状态,重放反而会冲突 |
314
+ | `startup` / `new`,空会话 | ✅ | 新会话,按声明重放 |
315
+ | `startup`,但会话已有历史 | ❌ | 这是「进程启动即带着旧会话」(`pi -c` / `-r` / `--session` / `--fork`),其分支已携带重放本该重建的状态 |
316
+ | `fork`(进程内) | ✅ | 分支点可能早于配置出现 |
317
+ | `resume`(进程内 `/resume`) | ❌ | 恢复的分支已携带当时的挂载状态,重放反而会冲突 |
317
318
  | `reload` | ❌ | 扩展重建,重放会重复副作用 |
318
319
 
320
+ > `startup` 这一行为什么要拆开:pi 对**每一次进程启动**都报 `startup`,包括「启动即打开旧会话」的情形;`resume` 只在进程内切换会话时发出,`pi -c` 永远不会报它。所以前两行的判定依据是**会话内容**(是否已有消息),不能只看 reason。
321
+
319
322
  **安全门**(按序):
320
323
 
321
324
  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.1",
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");
@@ -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
  {