@zhushanwen/pi-pending-notifications 0.2.0

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/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@zhushanwen/pi-pending-notifications",
3
+ "version": "0.2.0",
4
+ "description": "Cross-extension async operation registration/query mechanism for Pi — prevents message injection during long-running operations.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "pi": {
8
+ "extensions": [
9
+ "./index.ts"
10
+ ]
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "extension",
15
+ "notifications",
16
+ "async",
17
+ "pending"
18
+ ],
19
+ "license": "MIT",
20
+ "files": [
21
+ "src/",
22
+ "index.ts"
23
+ ],
24
+ "devDependencies": {
25
+ "vitest": "^4.1.8"
26
+ },
27
+ "peerDependencies": {
28
+ "@mariozechner/pi-coding-agent": "*",
29
+ "@sinclair/typebox": "*"
30
+ },
31
+ "scripts": {
32
+ "typecheck": "npx tsc --noEmit",
33
+ "test": "vitest run"
34
+ }
35
+ }
@@ -0,0 +1,415 @@
1
+ // 测试框架:vitest
2
+ // 运行命令:npx vitest run src/__tests__/pending-notifications.test.ts
3
+ //
4
+ // W1 核心实现测试。覆盖 plan.md U1-U11。
5
+ //
6
+ // 测试策略:
7
+ // - 用最小 mock 的 ExtensionAPI(mock events.on/emit、appendEntry、on、registerTool)
8
+ // - 用最小 mock 的 ExtensionContext(mock sessionManager.getEntries/getSessionId)
9
+ // - 调 pendingNotificationsExtension(pi) 触发工厂注册 handler
10
+ // - 手动触发 session_start / events / tool / session_shutdown,断言 state + appendEntry
11
+
12
+ /* eslint-disable taste/no-unsafe-cast */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+
17
+ import pendingNotificationsExtension from "../index";
18
+ import type { PendingEntry } from "../state";
19
+ import { createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
20
+
21
+ // ── Mock 工具 ───────────────────────────────────────
22
+
23
+ interface HandlerRegistry {
24
+ sessionStart: ((event: unknown, ctx: ExtensionContext) => void | Promise<void>) | undefined;
25
+ sessionShutdown: ((event: unknown, ctx: ExtensionContext) => void | Promise<void>) | undefined;
26
+ pendingRegister: ((data: unknown) => void) | undefined;
27
+ pendingUnregister: ((data: unknown) => void) | undefined;
28
+ }
29
+
30
+ interface MockSessionEntry {
31
+ customType: string;
32
+ data: Record<string, unknown>;
33
+ }
34
+
35
+ interface MockSetup {
36
+ pi: ExtensionAPI;
37
+ handlers: HandlerRegistry;
38
+ appendEntryMock: ReturnType<typeof vi.fn>;
39
+ registerToolMock: ReturnType<typeof vi.fn>;
40
+ }
41
+
42
+ function createMockPi(): MockSetup {
43
+ const handlers: HandlerRegistry = {
44
+ sessionStart: undefined,
45
+ sessionShutdown: undefined,
46
+ pendingRegister: undefined,
47
+ pendingUnregister: undefined,
48
+ };
49
+ const appendEntryMock = vi.fn();
50
+ const registerToolMock = vi.fn();
51
+ const sendMessageMock = vi.fn();
52
+
53
+ const pi = {
54
+ appendEntry: appendEntryMock,
55
+ registerTool: registerToolMock,
56
+ sendMessage: sendMessageMock,
57
+ on: vi.fn((event: string, handler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>) => {
58
+ if (event === "session_start") handlers.sessionStart = handler;
59
+ if (event === "session_shutdown") handlers.sessionShutdown = handler;
60
+ }),
61
+ events: {
62
+ emit: vi.fn(),
63
+ on: vi.fn((channel: string, handler: (data: unknown) => void) => {
64
+ if (channel === "pending:register") handlers.pendingRegister = handler;
65
+ if (channel === "pending:unregister") handlers.pendingUnregister = handler;
66
+ }),
67
+ },
68
+ } as unknown as ExtensionAPI;
69
+
70
+ return { pi, handlers, appendEntryMock, registerToolMock };
71
+ }
72
+
73
+ function createMockCtx(entries: MockSessionEntry[], sessionId = "sess-current"): ExtensionContext {
74
+ return {
75
+ sessionManager: {
76
+ getEntries: () => entries as unknown[],
77
+ getSessionId: () => sessionId,
78
+ },
79
+ } as unknown as ExtensionContext;
80
+ }
81
+
82
+ function fireSessionStart(setup: MockSetup, ctx: ExtensionContext): void {
83
+ if (!setup.handlers.sessionStart) throw new Error("session_start handler not registered");
84
+ void setup.handlers.sessionStart({ type: "session_start", reason: "resume" }, ctx);
85
+ }
86
+
87
+ async function runTool(
88
+ setup: MockSetup,
89
+ params: Record<string, unknown>,
90
+ ): Promise<{ content: Array<{ type: string; text: string }>; details: unknown }> {
91
+ const tool = setup.registerToolMock.mock.calls[0][0] as {
92
+ execute: (p: unknown) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
93
+ };
94
+ return tool.execute(params);
95
+ }
96
+
97
+ async function getCount(setup: MockSetup): Promise<number> {
98
+ const res = await runTool(setup, { action: "count" });
99
+ return Number(res.content[0].text.replace(/[^0-9]/g, ""));
100
+ }
101
+
102
+ // ── 共享 fixtures ───────────────────────────────────
103
+
104
+ const NOW = 1_700_000_000_000;
105
+
106
+ function makeRegisterEntry(id: string, extra: Partial<PendingEntry> = {}): MockSessionEntry {
107
+ return {
108
+ customType: "pending:register",
109
+ data: {
110
+ id,
111
+ type: "workflow",
112
+ name: `op-${id}`,
113
+ registeredAt: NOW,
114
+ expiresAt: NOW + 3_600_000,
115
+ sessionId: "sess-current",
116
+ ...extra,
117
+ },
118
+ };
119
+ }
120
+
121
+ function makeUnregisterEntry(id: string): MockSessionEntry {
122
+ return { customType: "pending:unregister", data: { id } };
123
+ }
124
+
125
+ function rebuild(
126
+ entries: MockSessionEntry[],
127
+ currentSessionId: string,
128
+ now: number,
129
+ ): { activeIds: string[]; expiredToFlush: Array<{ id: string; status: string }> } {
130
+ return rebuildFromEntries(createRegistry(), entries as unknown[], currentSessionId, now);
131
+ }
132
+
133
+ // ────────────────────────────────────────────────────
134
+ // state.ts 纯函数测试
135
+ // ────────────────────────────────────────────────────
136
+
137
+ describe("state pure functions", () => {
138
+ describe("register", () => {
139
+ it("registers a new active operation", () => {
140
+ const r = createRegistry();
141
+ const op: PendingEntry = {
142
+ id: "w-1", type: "workflow", name: "test", status: "active",
143
+ registeredAt: NOW, expiresAt: NOW + 3_600_000, sessionId: "s",
144
+ };
145
+ register(r, op);
146
+ expect(getActive(r).map((o) => o.id)).toEqual(["w-1"]);
147
+ });
148
+
149
+ it("ignores duplicate active id (U6)", () => {
150
+ const r = createRegistry();
151
+ const op: PendingEntry = {
152
+ id: "w-1", type: "workflow", name: "test", status: "active",
153
+ registeredAt: NOW, expiresAt: NOW + 3_600_000, sessionId: "s",
154
+ };
155
+ register(r, op);
156
+ register(r, { ...op, name: "dup" });
157
+ expect(getActive(r)).toHaveLength(1);
158
+ expect(getActive(r)[0].name).toBe("test");
159
+ });
160
+ });
161
+
162
+ describe("unregister", () => {
163
+ it("marks existing op non-active (U7)", () => {
164
+ const r = createRegistry();
165
+ register(r, {
166
+ id: "w-1", type: "workflow", name: "test", status: "active",
167
+ registeredAt: NOW, expiresAt: NOW + 3_600_000, sessionId: "s",
168
+ });
169
+ unregister(r, "w-1", "completed");
170
+ expect(getActive(r)).toHaveLength(0);
171
+ });
172
+
173
+ it("ignores unknown id without error (U8)", () => {
174
+ const r = createRegistry();
175
+ expect(() => unregister(r, "nope", "completed")).not.toThrow();
176
+ });
177
+ });
178
+
179
+ describe("rebuildFromEntries", () => {
180
+ it("U1: register without unregister → 1 active", () => {
181
+ const comp = rebuild([makeRegisterEntry("w-1")], "sess-current", NOW);
182
+ expect(comp.activeIds).toEqual(["w-1"]);
183
+ expect(comp.expiredToFlush).toEqual([]);
184
+ });
185
+
186
+ it("U2: register + matching unregister → 0 active", () => {
187
+ const comp = rebuild([makeRegisterEntry("w-1"), makeUnregisterEntry("w-1")], "sess-current", NOW);
188
+ expect(comp.activeIds).toEqual([]);
189
+ expect(comp.expiredToFlush).toEqual([]);
190
+ });
191
+
192
+ it("U3: register expired → flush unregister with status=expired", () => {
193
+ const comp = rebuild(
194
+ [makeRegisterEntry("w-1", { expiresAt: NOW - 1 })],
195
+ "sess-current",
196
+ NOW,
197
+ );
198
+ expect(comp.activeIds).toEqual([]);
199
+ expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
200
+ });
201
+
202
+ it("U4: register with different sessionId → flush unregister with status=expired", () => {
203
+ const comp = rebuild(
204
+ [makeRegisterEntry("w-1", { sessionId: "sess-other" })],
205
+ "sess-current",
206
+ NOW,
207
+ );
208
+ expect(comp.activeIds).toEqual([]);
209
+ expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
210
+ });
211
+ });
212
+
213
+ describe("normalizeRegisterEntry defaults (via rebuild)", () => {
214
+ it("entry with only {id} → type=workflow, name=id, sessionId=current, expiresAt=registeredAt+TTL", () => {
215
+ const r = createRegistry();
216
+ const result = rebuildFromEntries(
217
+ r,
218
+ [{ customType: "pending:register", data: { id: "w-min" } }],
219
+ "sess-current",
220
+ Date.now(),
221
+ );
222
+ expect(result.activeIds).toEqual(["w-min"]);
223
+ const entry = r.operations.get("w-min")!;
224
+ expect(entry.type).toBe("workflow");
225
+ expect(entry.name).toBe("w-min");
226
+ expect(entry.sessionId).toBe("sess-current");
227
+ expect(entry.status).toBe("active");
228
+ expect(entry.expiresAt).toBe(entry.registeredAt + 3_600_000);
229
+ });
230
+ });
231
+ });
232
+
233
+ // ────────────────────────────────────────────────────
234
+ // index.ts 工厂集成测试(U1-U11)
235
+ // ────────────────────────────────────────────────────
236
+
237
+ describe("pendingNotificationsExtension factory", () => {
238
+ let setup: MockSetup;
239
+
240
+ beforeEach(() => {
241
+ vi.useFakeTimers();
242
+ vi.setSystemTime(NOW);
243
+ setup = createMockPi();
244
+ pendingNotificationsExtension(setup.pi);
245
+ });
246
+
247
+ afterEach(() => {
248
+ vi.useRealTimers();
249
+ });
250
+
251
+ describe("session_start rebuild (U1-U4)", () => {
252
+ it("U1: 1 register no unregister → 1 active, no flush", async () => {
253
+ fireSessionStart(setup, createMockCtx([makeRegisterEntry("w-1")]));
254
+ expect(await getCount(setup)).toBe(1);
255
+ const stateChangeCalls = setup.appendEntryMock.mock.calls.filter(
256
+ (c) => c[0] === "pending:register" || c[0] === "pending:unregister",
257
+ );
258
+ expect(stateChangeCalls).toHaveLength(0);
259
+ });
260
+
261
+ it("U2: register + unregister → 0 active", async () => {
262
+ fireSessionStart(setup, createMockCtx([makeRegisterEntry("w-1"), makeUnregisterEntry("w-1")]));
263
+ expect(await getCount(setup)).toBe(0);
264
+ });
265
+
266
+ it("U3: expired register → flush pending:unregister entry", async () => {
267
+ vi.setSystemTime(NOW + 3_700_000);
268
+ fireSessionStart(setup, createMockCtx([makeRegisterEntry("w-1", { expiresAt: NOW })]));
269
+ expect(await getCount(setup)).toBe(0);
270
+ expect(setup.appendEntryMock).toHaveBeenCalledWith(
271
+ "pending:unregister",
272
+ expect.objectContaining({ id: "w-1", status: "expired" }),
273
+ );
274
+ });
275
+
276
+ it("U4: different sessionId → flush pending:unregister entry", async () => {
277
+ fireSessionStart(setup, createMockCtx([makeRegisterEntry("w-1", { sessionId: "sess-old" })], "sess-current"));
278
+ expect(await getCount(setup)).toBe(0);
279
+ expect(setup.appendEntryMock).toHaveBeenCalledWith(
280
+ "pending:unregister",
281
+ expect.objectContaining({ id: "w-1", status: "expired" }),
282
+ );
283
+ });
284
+ });
285
+
286
+ describe("events.on pending:register (U5-U6)", () => {
287
+ it("U5: register event → active + appendEntry", async () => {
288
+ fireSessionStart(setup, createMockCtx([]));
289
+
290
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "test" });
291
+
292
+ expect(await getCount(setup)).toBe(1);
293
+ expect(setup.appendEntryMock).toHaveBeenCalledWith(
294
+ "pending:register",
295
+ expect.objectContaining({ id: "w-1", type: "workflow", name: "test" }),
296
+ );
297
+ });
298
+
299
+ it("U6: duplicate register event → ignored", async () => {
300
+ fireSessionStart(setup, createMockCtx([]));
301
+
302
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "first" });
303
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "second" });
304
+
305
+ expect(await getCount(setup)).toBe(1);
306
+ const registerCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:register");
307
+ expect(registerCalls).toHaveLength(1);
308
+ });
309
+ });
310
+
311
+ describe("events.on pending:unregister (U7-U8)", () => {
312
+ it("U7: unregister event → non-active + appendEntry", async () => {
313
+ fireSessionStart(setup, createMockCtx([]));
314
+
315
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "test" });
316
+ setup.appendEntryMock.mockClear();
317
+ setup.handlers.pendingUnregister!({ id: "w-1", reason: "completed" });
318
+
319
+ expect(await getCount(setup)).toBe(0);
320
+ expect(setup.appendEntryMock).toHaveBeenCalledWith(
321
+ "pending:unregister",
322
+ expect.objectContaining({ id: "w-1", reason: "completed" }),
323
+ );
324
+ });
325
+
326
+ it("U8: unregister unknown id → ignored, no appendEntry, no throw", () => {
327
+ fireSessionStart(setup, createMockCtx([]));
328
+ setup.appendEntryMock.mockClear();
329
+
330
+ expect(() => setup.handlers.pendingUnregister!({ id: "nope", reason: "completed" })).not.toThrow();
331
+ const stateChangeCalls = setup.appendEntryMock.mock.calls.filter(
332
+ (c) => c[0] === "pending:register" || c[0] === "pending:unregister",
333
+ );
334
+ expect(stateChangeCalls).toHaveLength(0);
335
+ });
336
+ });
337
+
338
+ describe("tool count/list (U9-U10)", () => {
339
+ it("U9: count returns active count", async () => {
340
+ fireSessionStart(setup, createMockCtx([]));
341
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "a" });
342
+
343
+ const res = await runTool(setup, { action: "count" });
344
+ expect(res.content[0].text).toContain("1");
345
+ });
346
+
347
+ it("U10: list returns active list", async () => {
348
+ fireSessionStart(setup, createMockCtx([]));
349
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "a" });
350
+ setup.handlers.pendingRegister!({ id: "s-1", type: "subagent", name: "b" });
351
+
352
+ const res = await runTool(setup, { action: "list" });
353
+ const ids = (res.details as { items: PendingEntry[] }).items.map((i) => i.id);
354
+ expect(ids.sort()).toEqual(["s-1", "w-1"]);
355
+ });
356
+ });
357
+
358
+ describe("session_shutdown (U11)", () => {
359
+ it("U11: marks all active as cancelled + flushes unregister entries", () => {
360
+ fireSessionStart(setup, createMockCtx([]));
361
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "a" });
362
+ setup.handlers.pendingRegister!({ id: "s-1", type: "subagent", name: "b" });
363
+ setup.appendEntryMock.mockClear();
364
+
365
+ if (!setup.handlers.sessionShutdown) throw new Error("session_shutdown not registered");
366
+ void setup.handlers.sessionShutdown({ type: "session_shutdown" }, createMockCtx([]));
367
+
368
+ const unregisterCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
369
+ expect(unregisterCalls).toHaveLength(2);
370
+ const flushedIds = unregisterCalls.map((c) => (c[1] as { id: string }).id).sort();
371
+ expect(flushedIds).toEqual(["s-1", "w-1"]);
372
+ for (const c of unregisterCalls) {
373
+ expect((c[1] as { status: string }).status).toBe("cancelled");
374
+ }
375
+ });
376
+ });
377
+
378
+ describe("safeAppendEntry error handling", () => {
379
+ it("appendEntry throwing (stale context) does not break register listener, registry still updated", async () => {
380
+ // listener 先 register(registry) 更新内存,再 safeAppendEntry;appendEntry 抛错被 catch,registry 仍已更新
381
+ fireSessionStart(setup, createMockCtx([]));
382
+ setup.appendEntryMock.mockImplementationOnce(() => {
383
+ throw new Error("stale context");
384
+ });
385
+
386
+ expect(() => setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "test" })).not.toThrow();
387
+
388
+ expect(await getCount(setup)).toBe(1);
389
+ });
390
+ });
391
+
392
+ describe("parse null/malformed events", () => {
393
+ it("parseRegisterEvent: null and missing-id data → no throw, no register entry", () => {
394
+ fireSessionStart(setup, createMockCtx([]));
395
+ setup.appendEntryMock.mockClear();
396
+
397
+ expect(() => setup.handlers.pendingRegister!(null)).not.toThrow();
398
+ expect(() => setup.handlers.pendingRegister!({ type: "workflow" })).not.toThrow();
399
+
400
+ const registerCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:register");
401
+ expect(registerCalls).toHaveLength(0);
402
+ });
403
+
404
+ it("parseUnregisterEvent: null and missing-id data → no throw, no unregister entry", () => {
405
+ fireSessionStart(setup, createMockCtx([]));
406
+ setup.appendEntryMock.mockClear();
407
+
408
+ expect(() => setup.handlers.pendingUnregister!(null)).not.toThrow();
409
+ expect(() => setup.handlers.pendingUnregister!({ reason: "completed" })).not.toThrow();
410
+
411
+ const unregisterCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
412
+ expect(unregisterCalls).toHaveLength(0);
413
+ });
414
+ });
415
+ });
package/src/index.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Pending Notifications Extension — 跨 extension 的异步操作注册/查询机制。
3
+ *
4
+ * 设计定位:解决 workflow/subagent 运行时 goal 持续注入消息的悖论。
5
+ * workflow/subagent 运行时通过 EventBus(pi.events.emit)广播 register/unregister,
6
+ * 本扩展监听这些事件、将状态写入 session entries(pi.appendEntry),
7
+ * 让 goal 的 before_agent_start 从 entries 读取活跃异步操作并注入等待消息。
8
+ *
9
+ * 文件职责:
10
+ * - state.ts: PendingEntry / PendingRegistry + 纯函数(register/unregister/rebuild)
11
+ * - index.ts(本文件): 工厂入口(注册 events.on 监听 + session 生命周期 + 查询 tool)
12
+ *
13
+ * 事件契约(与 workflow launcher.ts / subagent-service.ts 对齐):
14
+ * - emit("pending:register", { id, type, name })
15
+ * - emit("pending:unregister", { id, reason })
16
+ *
17
+ * entry 契约(与 goal before-agent-start.ts 对齐,读取端按 e.data.id 算差集):
18
+ * - pending:register → { id, type, name, registeredAt, expiresAt, sessionId }
19
+ * - pending:unregister → { id, reason, status }
20
+ *
21
+ * 监听方式:pi.events.on(Pi 的 EventBus,真实 SDK 为 EventBus.on,非 optional)。
22
+ * workflow 侧通过 deps.eventBus 注入 pi.events(同一总线)。
23
+ */
24
+
25
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
26
+ import { Type } from "@sinclair/typebox";
27
+
28
+ import {
29
+ createRegistry,
30
+ getActive,
31
+ PENDING_TTL_MS,
32
+ type PendingEntry,
33
+ type PendingRegistry,
34
+ type PendingStatus,
35
+ type PendingType,
36
+ rebuildFromEntries,
37
+ register,
38
+ unregister,
39
+ } from "./state.ts";
40
+
41
+ /** 工具参数 schema */
42
+ const PendingNotificationsParams = Type.Object({
43
+ action: Type.Union([
44
+ Type.Literal("count"),
45
+ Type.Literal("list"),
46
+ ]),
47
+ });
48
+
49
+ /** tool 入参形状 */
50
+ interface ToolParams {
51
+ action: "count" | "list";
52
+ }
53
+
54
+ /**
55
+ * 模块级 EventBus 监听器 unsubscribe 函数列表。
56
+ *
57
+ * EventBus 是进程级单例(真实 SDK resource-loader 构造一次,跨 /reload、会话切换复用)。
58
+ * 工厂函数 pendingNotificationsExtension 每次 reload 都重新执行,若不先移除旧监听器,
59
+ * N 次 reload 后 EventBus 上会累积 N 组监听器(>11 后抛 Possible EventEmitter memory leak)。
60
+ *
61
+ * 用模块级变量跨 reload 持久:工厂入口先调用上一轮的 unsubscribe 清理旧监听器,
62
+ * 再注册新的。这同时修复了多 session 串数据问题——reload 后只有当前闭包的监听器存活,
63
+ * currentSessionId 始终是最新 session(旧闭包的过期 currentSessionId 不会再给事件打戳)。
64
+ */
65
+ let unsubscribers: Array<() => void> = [];
66
+
67
+ /** 扩展入口 */
68
+ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
69
+ // ── 清理上一轮 reload 的 EventBus 监听器(H2 防泄漏) ─────
70
+ for (const unsub of unsubscribers) {
71
+ try {
72
+ unsub();
73
+ } catch (err) {
74
+ // unsubscribe 失败不阻断初始化(监听器可能已被 EventBus 内部清理)
75
+ console.debug("[pending-notifications] unsubscribe failed during cleanup", err);
76
+ }
77
+ }
78
+ unsubscribers = [];
79
+
80
+ // ── 闭包内状态(session 隔离,每个 session_start 重建) ─────
81
+ let registry: PendingRegistry = createRegistry();
82
+ let currentSessionId: string = "";
83
+
84
+ // 安全写入 session entry:忽略 stale context 等不可恢复错误(如 subagent 子进程
85
+ // session replacement 后 listener 仍触发)。返回是否成功。
86
+ function safeAppendEntry(customType: string, data: unknown): boolean {
87
+ try {
88
+ pi.appendEntry(customType, data);
89
+ return true;
90
+ } catch {
91
+ // stale context 或 session 已关闭时,静默丢弃。entry 不是关键业务数据,
92
+ // 丢失不会破坏主流程。
93
+ return false;
94
+ }
95
+ }
96
+
97
+ // debug 日志:环境变量 PENDING_DEBUG=1 时输出到 console.debug。
98
+ // 不再写入 session entry(pending:log)——session entries 是 append-only 无法 GC,
99
+ // 12 处 debug 日志会让长 session 的 entries 线性膨胀,而 goal before-agent-start
100
+ // 每 turn 全量扫描 getEntries()。状态数据(pending:register/unregister)仍写 entry。
101
+ const debugEnabled = process.env.PENDING_DEBUG === "1";
102
+ function debugLog(level: string, message: string, data?: unknown): void {
103
+ if (!debugEnabled) return;
104
+ console.debug(`[pending-notifications:${level}] ${message}`, data ?? "");
105
+ }
106
+
107
+ // ── EventBus 监听:pending:register ─────────────────────
108
+ unsubscribers.push(pi.events.on("pending:register", (data: unknown) => {
109
+ debugLog("debug", "listener: pending:register received", data);
110
+ const parsed = parseRegisterEvent(data);
111
+ if (!parsed) {
112
+ debugLog("warn", "listener: pending:register parse failed", data);
113
+ return;
114
+ }
115
+
116
+ debugLog("debug", "listener: pending:register parsed", parsed);
117
+
118
+ const now = Date.now();
119
+ const entry: PendingEntry = {
120
+ id: parsed.id,
121
+ type: parsed.type,
122
+ name: parsed.name,
123
+ status: "active",
124
+ registeredAt: now,
125
+ expiresAt: now + PENDING_TTL_MS,
126
+ sessionId: currentSessionId,
127
+ };
128
+
129
+ // 重复注册忽略(U6)
130
+ const added = register(registry, entry);
131
+ if (!added) {
132
+ debugLog("debug", "listener: pending:register ignored (duplicate)", { id: parsed.id });
133
+ return;
134
+ }
135
+
136
+ safeAppendEntry("pending:register", {
137
+ id: entry.id,
138
+ type: entry.type,
139
+ name: entry.name,
140
+ registeredAt: entry.registeredAt,
141
+ expiresAt: entry.expiresAt,
142
+ sessionId: entry.sessionId,
143
+ });
144
+
145
+ debugLog("debug", "listener: pending:register appended", { id: parsed.id });
146
+ }));
147
+
148
+ // ── EventBus 监听:pending:unregister ───────────────────
149
+ unsubscribers.push(pi.events.on("pending:unregister", (data: unknown) => {
150
+ debugLog("debug", "listener: pending:unregister received", data);
151
+ const parsed = parseUnregisterEvent(data);
152
+ if (!parsed) {
153
+ debugLog("warn", "listener: pending:unregister parse failed", data);
154
+ return;
155
+ }
156
+
157
+ debugLog("debug", "listener: pending:unregister parsed", parsed);
158
+
159
+ const status = mapReasonToStatus(parsed.reason);
160
+ const changed = unregister(registry, parsed.id, status);
161
+ if (!changed) {
162
+ debugLog("debug", "listener: pending:unregister ignored (unknown id)", { id: parsed.id });
163
+ return;
164
+ }
165
+
166
+ safeAppendEntry("pending:unregister", {
167
+ id: parsed.id,
168
+ reason: parsed.reason,
169
+ status,
170
+ });
171
+
172
+ debugLog("debug", "listener: pending:unregister appended", { id: parsed.id });
173
+ }));
174
+
175
+ // ── session_start:从持久化 entries 重建 registry ────────
176
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
177
+ registry = createRegistry();
178
+ currentSessionId = ctx.sessionManager.getSessionId();
179
+
180
+ const entries = ctx.sessionManager.getEntries();
181
+ const now = Date.now();
182
+ const { expiredToFlush } = rebuildFromEntries(registry, entries, currentSessionId, now);
183
+
184
+ debugLog("debug", "session_start: registry rebuilt", {
185
+ sessionId: currentSessionId,
186
+ totalEntries: entries.length,
187
+ activeAfterRebuild: getActive(registry).length,
188
+ expiredToFlush: expiredToFlush.length,
189
+ });
190
+
191
+ // 补 expired/跨 session 残留的 unregister entry(U3/U4)
192
+ for (const item of expiredToFlush) {
193
+ safeAppendEntry("pending:unregister", {
194
+ id: item.id,
195
+ status: item.status,
196
+ });
197
+ }
198
+ });
199
+
200
+ // ── session_shutdown:所有 active → cancelled + 补 entry(U11) ──
201
+ pi.on("session_shutdown", (_event, _ctx: ExtensionContext) => {
202
+ const active = getActive(registry);
203
+ for (const op of active) {
204
+ const changed = unregister(registry, op.id, "cancelled");
205
+ if (changed) {
206
+ safeAppendEntry("pending:unregister", {
207
+ id: op.id,
208
+ status: "cancelled",
209
+ });
210
+ }
211
+ }
212
+ });
213
+
214
+ // ── 查询 tool ─────────────────────────────────────────
215
+ pi.registerTool({
216
+ name: "pending_notifications",
217
+ label: "Pending Notifications",
218
+ description:
219
+ "查询当前活跃的异步操作(workflow/subagent)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
220
+ parameters: PendingNotificationsParams,
221
+ execute: async (params: unknown) => {
222
+ const p = params as ToolParams;
223
+ const active = getActive(registry);
224
+
225
+ debugLog("debug", `tool ${p.action} requested`, { action: p.action, activeCount: active.length });
226
+
227
+ if (p.action === "count") {
228
+ return {
229
+ content: [{ type: "text" as const, text: `${active.length} pending operation(s)` }],
230
+ details: { action: "count", count: active.length },
231
+ };
232
+ }
233
+
234
+ // action === "list"
235
+ return {
236
+ content: [{ type: "text" as const, text: formatList(active) }],
237
+ details: { action: "list", count: active.length, items: active },
238
+ };
239
+ },
240
+ });
241
+ }
242
+
243
+ // ── 事件解析 helper ─────────────────────────────────
244
+
245
+ interface ParsedRegister {
246
+ id: string;
247
+ type: PendingType;
248
+ name: string;
249
+ }
250
+
251
+ /** 解析 pending:register 事件 data(容错缺失/类型错误字段) */
252
+ function parseRegisterEvent(data: unknown): ParsedRegister | null {
253
+ if (typeof data !== "object" || data === null) return null;
254
+ const d = data as Record<string, unknown>;
255
+ if (typeof d.id !== "string") return null;
256
+ return {
257
+ id: d.id,
258
+ type: d.type === "subagent" ? "subagent" : "workflow",
259
+ name: typeof d.name === "string" ? d.name : d.id,
260
+ };
261
+ }
262
+
263
+ interface ParsedUnregister {
264
+ id: string;
265
+ reason: string;
266
+ }
267
+
268
+ /** 解析 pending:unregister 事件 data(容错缺失/类型错误字段) */
269
+ function parseUnregisterEvent(data: unknown): ParsedUnregister | null {
270
+ if (typeof data !== "object" || data === null) return null;
271
+ const d = data as Record<string, unknown>;
272
+ if (typeof d.id !== "string") return null;
273
+ return {
274
+ id: d.id,
275
+ reason: typeof d.reason === "string" ? d.reason : "completed",
276
+ };
277
+ }
278
+
279
+ /** 将事件 reason 映射为内部 PendingStatus */
280
+ function mapReasonToStatus(reason: string): PendingStatus {
281
+ switch (reason) {
282
+ case "completed": return "completed";
283
+ case "failed": return "failed";
284
+ case "cancelled": return "cancelled";
285
+ case "expired": return "expired";
286
+ case "time_limited": return "time_limited";
287
+ case "budget_limited": return "failed";
288
+ case "aborted": return "aborted";
289
+ default: return "completed";
290
+ }
291
+ }
292
+
293
+ /** 格式化 active 列表为可读文本 */
294
+ function formatList(active: PendingEntry[]): string {
295
+ if (active.length === 0) return "No pending operations";
296
+ const lines = active.map((op) => `- [${op.type}] ${op.name} (id=${op.id})`);
297
+ return `${active.length} pending operation(s):\n${lines.join("\n")}`;
298
+ }
package/src/state.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pending Notifications State — 数据模型和纯函数。
3
+ *
4
+ * 职责:
5
+ * - PendingEntry: 一个异步操作的完整描述(与 pending:register entry data 对齐)
6
+ * - PendingRegistry: 内存中的活跃操作注册表(Map<id, PendingEntry>)
7
+ * - register/unregister: 运行时事件驱动的状态变更
8
+ * - rebuildFromEntries: session_start 从持久化 entries 重建 registry + 识别需要补注销的 expired/跨 session 残留
9
+ *
10
+ * 设计要点:
11
+ * - 纯函数,不依赖 Pi 运行时(ExtensionAPI/appendEntry),可独立单元测试
12
+ * - 所有时间戳由调用方传入(now),便于测试
13
+ * - rebuildFromEntries 返回 activeIds + expiredToFlush(需要 index.ts 补 appendEntry 的列表),
14
+ * 不直接写 entry —— 写 entry 是副作用,由 index.ts 负责
15
+ */
16
+
17
+ /** 异步操作类型(来源:workflow / subagent) */
18
+ export type PendingType = "workflow" | "subagent";
19
+
20
+ /** 异步操作终态/过渡状态。active = 仍在运行;其他都视为已结束 */
21
+ export type PendingStatus = "active" | "completed" | "failed" | "cancelled" | "expired" | "time_limited" | "aborted";
22
+
23
+ /** 一个异步操作的完整描述(= pending:register entry 的 data 字段) */
24
+ export interface PendingEntry {
25
+ /** 操作唯一标识(workflow runId / subagent id) */
26
+ id: string;
27
+ /** 操作来源类型 */
28
+ type: PendingType;
29
+ /** 可读名称(workflow name / subagent name) */
30
+ name: string;
31
+ /** 注册时状态(恒为 active,由 register 设置) */
32
+ status: PendingStatus;
33
+ /** 注册时间戳 ms */
34
+ registeredAt: number;
35
+ /** 过期时间戳 ms(registeredAt + TTL) */
36
+ expiresAt: number;
37
+ /** 注册时的 sessionId(用于跨 session 残留检测) */
38
+ sessionId: string;
39
+ }
40
+
41
+ /** pending:register entry 在 entries 里的最小可识别形状 */
42
+ interface RegisterEntryData {
43
+ id: unknown;
44
+ type: unknown;
45
+ name: unknown;
46
+ registeredAt: unknown;
47
+ expiresAt: unknown;
48
+ sessionId: unknown;
49
+ }
50
+
51
+ /** pending:unregister entry 在 entries 里的最小可识别形状 */
52
+ interface UnregisterEntryData {
53
+ id: unknown;
54
+ }
55
+
56
+ /** SessionEntry 的最小可识别形状(duck-typed,避免依赖 SDK 具体类型) */
57
+ interface EntryLike {
58
+ customType?: string;
59
+ data?: unknown;
60
+ }
61
+
62
+ /** pending:register entry 的 TTL(1 小时) */
63
+ export const PENDING_TTL_MS = 3_600_000;
64
+
65
+ /** 注册表:内存中的活跃操作(session 隔离,由 index.ts 在闭包内持有) */
66
+ export interface PendingRegistry {
67
+ /** 所有已注册操作(含已注销的,便于去重判断) */
68
+ operations: Map<string, PendingEntry>;
69
+ }
70
+
71
+ /** 创建空注册表 */
72
+ export function createRegistry(): PendingRegistry {
73
+ return { operations: new Map() };
74
+ }
75
+
76
+ /**
77
+ * 注册操作。已存在(任何 status)的同 id 操作被忽略(U6 重复注册)。
78
+ * 返回是否实际新增(true = 新注册,false = 被忽略)。
79
+ */
80
+ export function register(registry: PendingRegistry, entry: PendingEntry): boolean {
81
+ if (registry.operations.has(entry.id)) {
82
+ return false;
83
+ }
84
+ registry.operations.set(entry.id, entry);
85
+ return true;
86
+ }
87
+
88
+ /**
89
+ * 注销操作。不存在则忽略不报错(U8)。
90
+ * 返回是否实际变更(true = 注销了 active 操作,false = 不存在或已注销)。
91
+ */
92
+ export function unregister(registry: PendingRegistry, id: string, status: PendingStatus): boolean {
93
+ const op = registry.operations.get(id);
94
+ if (!op || op.status !== "active") {
95
+ return false;
96
+ }
97
+ op.status = status;
98
+ return true;
99
+ }
100
+
101
+ /** 返回当前所有 active 操作(按注册顺序) */
102
+ export function getActive(registry: PendingRegistry): PendingEntry[] {
103
+ return Array.from(registry.operations.values()).filter((op) => op.status === "active");
104
+ }
105
+
106
+ /** rebuildFromEntries 的结果:重建后的活跃列表 + 需要补注销的 entry */
107
+ export interface RebuildResult {
108
+ /** 重建后识别为 active 的 id 列表(已写入 registry) */
109
+ activeIds: string[];
110
+ /** 需要补 pending:unregister entry 的操作(expired/跨 session 残留) */
111
+ expiredToFlush: Array<{ id: string; status: PendingStatus }>;
112
+ }
113
+
114
+ /**
115
+ * 从持久化 entries 重建 registry(session_start 时调用)。
116
+ *
117
+ * 算法(对齐 goal before-agent-start.ts 的读取契约):
118
+ * 1. 收集所有 pending:register entry,按 id 算差集(减去 pending:unregister 的 id)
119
+ * 前提:id 全局唯一(workflow runId=`wf-<ts>-<rand>`、subagent id=`bg-/run-<tag>-<seq>-<ts>`)。
120
+ * 若未来 id 复用(register→unregister→register 同 id),全局 Set 差集会误跳第二次 register。
121
+ * 2. 对每个活跃的 register entry 检查:
122
+ * - sessionId 不符当前 session → expired(U4 跨 session 残留)
123
+ * - expiresAt <= now → expired(U3 过期)
124
+ * 3. 仍活跃的写入 registry,expired 的进入 expiredToFlush(由 index.ts 补 appendEntry)
125
+ *
126
+ * 注意:本函数只重建 registry + 计算需补的 entry,不写 entry(副作用归 index.ts)。
127
+ */
128
+ export function rebuildFromEntries(
129
+ registry: PendingRegistry,
130
+ entries: unknown[],
131
+ currentSessionId: string,
132
+ now: number,
133
+ ): RebuildResult {
134
+ const registerEntries: Array<{ data: RegisterEntryData }> = [];
135
+ const unregisteredIds = new Set<string>();
136
+
137
+ for (const raw of entries as EntryLike[]) {
138
+ if (raw.customType === "pending:register") {
139
+ registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
140
+ } else if (raw.customType === "pending:unregister") {
141
+ const data = (raw.data ?? {}) as UnregisterEntryData;
142
+ if (typeof data.id === "string") {
143
+ unregisteredIds.add(data.id);
144
+ }
145
+ }
146
+ }
147
+
148
+ const activeIds: string[] = [];
149
+ const expiredToFlush: Array<{ id: string; status: PendingStatus }> = [];
150
+
151
+ for (const { data } of registerEntries) {
152
+ if (typeof data.id !== "string") continue;
153
+ if (unregisteredIds.has(data.id)) continue;
154
+
155
+ const entry = normalizeRegisterEntry(data, currentSessionId);
156
+ // 跨 session 残留(U4)
157
+ if (entry.sessionId !== currentSessionId) {
158
+ expiredToFlush.push({ id: entry.id, status: "expired" });
159
+ continue;
160
+ }
161
+ // 过期(U3)
162
+ if (entry.expiresAt <= now) {
163
+ expiredToFlush.push({ id: entry.id, status: "expired" });
164
+ continue;
165
+ }
166
+ // 仍活跃
167
+ registry.operations.set(entry.id, entry);
168
+ activeIds.push(entry.id);
169
+ }
170
+
171
+ return { activeIds, expiredToFlush };
172
+ }
173
+
174
+ /** 从 entry data 归一化为 PendingEntry(补默认值,容错缺失字段) */
175
+ function normalizeRegisterEntry(data: RegisterEntryData, currentSessionId: string): PendingEntry {
176
+ const registeredAt = typeof data.registeredAt === "number" ? data.registeredAt : Date.now();
177
+ return {
178
+ id: data.id as string,
179
+ type: (data.type === "subagent" ? "subagent" : "workflow") as PendingType,
180
+ name: typeof data.name === "string" ? data.name : (data.id as string),
181
+ status: "active",
182
+ registeredAt,
183
+ expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : registeredAt + PENDING_TTL_MS,
184
+ sessionId: typeof data.sessionId === "string" ? data.sessionId : currentSessionId,
185
+ };
186
+ }