@zhushanwen/pi-pending-notifications 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-pending-notifications",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Cross-extension async operation registration/query mechanism for Pi — prevents message injection during long-running operations.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@earendil-works/pi-coding-agent": "*",
29
- "@sinclair/typebox": "*"
29
+ "typebox": "*"
30
30
  },
31
31
  "scripts": {
32
32
  "typecheck": "npx tsc --noEmit",
@@ -16,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
 
17
17
  import pendingNotificationsExtension from "../index";
18
18
  import type { PendingEntry } from "../state";
19
- import { createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
19
+ import { countActiveFromEntries, createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
20
20
 
21
21
  // ── Mock 工具 ───────────────────────────────────────
22
22
 
@@ -90,9 +90,15 @@ async function runTool(
90
90
  params: Record<string, unknown>,
91
91
  ): Promise<{ content: Array<{ type: string; text: string }>; details: unknown }> {
92
92
  const tool = setup.registerToolMock.mock.calls[0][0] as {
93
- execute: (p: unknown) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
93
+ execute: (
94
+ toolCallId: string,
95
+ p: unknown,
96
+ signal: AbortSignal | undefined,
97
+ onUpdate: unknown,
98
+ ctx: ExtensionContext,
99
+ ) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
94
100
  };
95
- return tool.execute(params);
101
+ return tool.execute("test-call-id", params, undefined, undefined, createMockCtx([]));
96
102
  }
97
103
 
98
104
  async function getCount(setup: MockSetup): Promise<number> {
@@ -209,6 +215,17 @@ describe("state pure functions", () => {
209
215
  expect(comp.activeIds).toEqual([]);
210
216
  expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
211
217
  });
218
+
219
+ it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
220
+ const comp = rebuildFromEntries(
221
+ createRegistry(),
222
+ [null, makeRegisterEntry("w-1"), undefined],
223
+ "sess-current",
224
+ NOW,
225
+ );
226
+ expect(comp.activeIds).toEqual(["w-1"]);
227
+ expect(comp.expiredToFlush).toEqual([]);
228
+ });
212
229
  });
213
230
 
214
231
  describe("normalizeRegisterEntry defaults (via rebuild)", () => {
@@ -415,3 +432,82 @@ describe("pendingNotificationsExtension factory", () => {
415
432
  });
416
433
  });
417
434
  });
435
+
436
+ describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow 复用)", () => {
437
+ const mkRegister = (id: string, type: "subagent" | "workflow" = "subagent", overrides: Record<string, unknown> = {}) => ({
438
+ type: "custom",
439
+ customType: "pending:register",
440
+ data: { id, type, name: id, registeredAt: 1000, expiresAt: 1000 + 3_600_000, sessionId: "s-1", ...overrides },
441
+ });
442
+ const mkUnregister = (id: string, reason = "completed") => ({
443
+ type: "custom",
444
+ customType: "pending:unregister",
445
+ data: { id, reason },
446
+ });
447
+
448
+ it("空 entries → 0 活跃", () => {
449
+ expect(countActiveFromEntries([])).toEqual({ count: 0, ids: [], entries: [] });
450
+ });
451
+
452
+ it("纯 register → count = register 数,返回完整 entry", () => {
453
+ const res = countActiveFromEntries([mkRegister("bg-1"), mkRegister("bg-2", "workflow")]);
454
+ expect(res.count).toBe(2);
455
+ expect(res.ids).toEqual(["bg-1", "bg-2"]);
456
+ expect(res.entries[0]).toMatchObject({ id: "bg-1", type: "subagent" });
457
+ });
458
+
459
+ it("register + unregister 同 id → 差集抵消", () => {
460
+ expect(countActiveFromEntries([mkRegister("bg-1"), mkUnregister("bg-1")]).count).toBe(0);
461
+ });
462
+
463
+ it("混合:部分注销 → 只统计仍活跃的", () => {
464
+ const res = countActiveFromEntries([
465
+ mkRegister("bg-1"),
466
+ mkRegister("bg-2"),
467
+ mkUnregister("bg-1"),
468
+ ]);
469
+ expect(res.count).toBe(1);
470
+ expect(res.ids).toEqual(["bg-2"]);
471
+ });
472
+
473
+ it("types 过滤:只统计指定类型的活跃 pending", () => {
474
+ const entries = [mkRegister("bg-1", "subagent"), mkRegister("wf-1", "workflow")];
475
+ expect(countActiveFromEntries(entries, { types: ["subagent"] }).ids).toEqual(["bg-1"]);
476
+ expect(countActiveFromEntries(entries, { types: ["workflow"] }).ids).toEqual(["wf-1"]);
477
+ expect(countActiveFromEntries(entries, { types: ["subagent", "workflow"] }).count).toBe(2);
478
+ });
479
+
480
+ it("TTL 过期仍判活跃(刻意不校验 expiresAt,对齐 goal continuation 守卫语义)", () => {
481
+ const expired = mkRegister("bg-1", "subagent", { registeredAt: 0, expiresAt: 1 });
482
+ expect(countActiveFromEntries([expired]).count).toBe(1);
483
+ });
484
+
485
+ it("同 id 重复 register → 只算一次", () => {
486
+ expect(countActiveFromEntries([mkRegister("bg-1"), mkRegister("bg-1")]).count).toBe(1);
487
+ });
488
+
489
+ it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
490
+ // S-10 回归:外部调用方可能传入含 null/undefined 的 entries(session 文件坏行/脏数据),
491
+ // 遍历时访问 raw.customType 前必须守卫,否则 TypeError 炸掉整个差集判定。
492
+ const withNulls = [null, undefined, mkRegister("bg-1"), mkUnregister("bg-1"), null];
493
+ expect(countActiveFromEntries(withNulls).count).toBe(0);
494
+ const mixed = [undefined, null, mkRegister("bg-2")];
495
+ expect(countActiveFromEntries(mixed).ids).toEqual(["bg-2"]);
496
+ // register 循环同样守卫:unregister 遍历前的 null 不污染差集
497
+ expect(countActiveFromEntries([null, undefined]).count).toBe(0);
498
+ });
499
+
500
+ it("malformed register(id 非 string)→ 跳过", () => {
501
+ const bad = { type: "custom", customType: "pending:register", data: { id: 42 } };
502
+ const good = mkRegister("bg-1");
503
+ expect(countActiveFromEntries([bad, good]).ids).toEqual(["bg-1"]);
504
+ });
505
+
506
+ it("跨 session 残留(fork 继承的 register)不校验 sessionId——由 session_start 重建流程补 expired unregister 抵消", () => {
507
+ // 模拟 P fork 主 session:继承来主 session 的 register(sessionId=s-0),已被 rebuild 补 unregister(expired)
508
+ const inherited = mkRegister("parent-bg", "subagent", { sessionId: "s-0" });
509
+ const flushed = mkUnregister("parent-bg", "expired");
510
+ const own = mkRegister("my-bg");
511
+ expect(countActiveFromEntries([inherited, flushed, own]).ids).toEqual(["my-bg"]);
512
+ });
513
+ });
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
- import { Type } from "@sinclair/typebox";
26
+ import { Type } from "typebox";
27
27
 
28
28
  import {
29
29
  createRegistry,
@@ -38,6 +38,24 @@ import {
38
38
  unregister,
39
39
  } from "./state.ts";
40
40
 
41
+ // 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
42
+ // 直接 import 本包的导出,避免各自复制差集逻辑。
43
+ export {
44
+ countActiveFromEntries,
45
+ createRegistry,
46
+ getActive,
47
+ PENDING_TTL_MS,
48
+ type CountActiveOptions,
49
+ type CountActiveResult,
50
+ type PendingEntry,
51
+ type PendingRegistry,
52
+ type PendingStatus,
53
+ type PendingType,
54
+ rebuildFromEntries,
55
+ register,
56
+ unregister,
57
+ } from "./state.ts";
58
+
41
59
  /** 工具参数 schema */
42
60
  const PendingNotificationsParams = Type.Object({
43
61
  action: Type.Union([
@@ -46,9 +64,12 @@ const PendingNotificationsParams = Type.Object({
46
64
  ]),
47
65
  });
48
66
 
49
- /** tool 入参形状 */
50
- interface ToolParams {
51
- action: "count" | "list";
67
+ /** pending_notifications 工具的 details 形状。两个分支共用同一结构(items 缺省时 undefined),
68
+ * 让 registerTool 泛型对 TDetails 推断一致,避免 union 细节冲突。 */
69
+ interface PendingToolDetails {
70
+ action: string;
71
+ count: number;
72
+ items?: PendingEntry[];
52
73
  }
53
74
 
54
75
  /**
@@ -218,13 +239,12 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
218
239
  description:
219
240
  "查询当前活跃的异步操作(workflow/subagent)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
220
241
  parameters: PendingNotificationsParams,
221
- execute: async (params: unknown) => {
222
- const p = params as ToolParams;
242
+ execute: async (_toolCallId: string, params: { action: "count" | "list" }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext): Promise<{ content: { type: "text"; text: string }[]; details: PendingToolDetails }> => {
223
243
  const active = getActive(registry);
224
244
 
225
- debugLog("debug", `tool ${p.action} requested`, { action: p.action, activeCount: active.length });
245
+ debugLog("debug", `tool ${params.action} requested`, { action: params.action, activeCount: active.length });
226
246
 
227
- if (p.action === "count") {
247
+ if (params.action === "count") {
228
248
  return {
229
249
  content: [{ type: "text" as const, text: `${active.length} pending operation(s)` }],
230
250
  details: { action: "count", count: active.length },
package/src/state.ts CHANGED
@@ -108,6 +108,66 @@ export function getActive(registry: PendingRegistry): PendingEntry[] {
108
108
  return Array.from(registry.operations.values()).filter((op) => op.status === "active");
109
109
  }
110
110
 
111
+ /** countActiveFromEntries 的过滤选项。 */
112
+ export interface CountActiveOptions {
113
+ /** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow) */
114
+ types?: PendingType[];
115
+ }
116
+
117
+ /** countActiveFromEntries 的结果。 */
118
+ export interface CountActiveResult {
119
+ count: number;
120
+ ids: string[];
121
+ /** 活跃的完整 entry(含类型/名称/TTL,供调用方展示或后续判断) */
122
+ entries: PendingEntry[];
123
+ }
124
+
125
+ /**
126
+ * 从持久化 entries 计算活跃 pending 数(register − unregister 差集)。
127
+ *
128
+ * 与 rebuildFromEntries 的分工:本函数只做「有没有活跃 pending」的只读判断,
129
+ * 不写 registry、不判 sessionId/expiresAt(TTL 刻意不校验——长任务 subagent >1h
130
+ * 仍应视为活跃,对齐 goal agent-end 的 continuation 守卫语义)。
131
+ * 调用方:goal(agent_end 时判断是否发 continuation)、subagent-workflow
132
+ * (agent_end 时判断子进程是否有活跃后代,决定是否保持进程等 steer 唤醒)。
133
+ *
134
+ * 注:跨 session 残留(fork 继承的 register)由 index.ts 的 session_start 重建
135
+ * 流程补 unregister(expired) 抵消;本函数只做纯差集,不重复处理。
136
+ */
137
+ export function countActiveFromEntries(
138
+ entries: unknown[],
139
+ opts?: CountActiveOptions,
140
+ ): CountActiveResult {
141
+ const unregistered = new Set<string>();
142
+ for (const raw of entries as EntryLike[]) {
143
+ // S-10:entries 元素可能是 null/undefined(外部调用方/坏数据),
144
+ // EntryLike 断言前先守卫,避免访问 raw.customType 抛 TypeError 炸掉差集判定。
145
+ if (!raw || typeof raw !== "object") continue;
146
+ if (raw.customType !== "pending:unregister") continue;
147
+ const data = (raw.data ?? {}) as UnregisterEntryData;
148
+ if (typeof data.id === "string") unregistered.add(data.id);
149
+ }
150
+
151
+ const active: PendingEntry[] = [];
152
+ const seen = new Set<string>();
153
+ for (const raw of entries as EntryLike[]) {
154
+ if (!raw || typeof raw !== "object") continue;
155
+ if (raw.customType !== "pending:register") continue;
156
+ const data = (raw.data ?? {}) as RegisterEntryData;
157
+ if (typeof data.id !== "string" || unregistered.has(data.id) || seen.has(data.id)) continue;
158
+ seen.add(data.id);
159
+ const entry = normalizeRegisterEntry(data, "");
160
+ if (opts?.types && !opts.types.includes(entry.type)) continue;
161
+ active.push(entry);
162
+ }
163
+
164
+ return {
165
+ count: active.length,
166
+ ids: active.map((e) => e.id),
167
+ entries: active,
168
+ };
169
+ }
170
+
111
171
  /** rebuildFromEntries 的结果:重建后的活跃列表 + 需要补注销的 entry */
112
172
  export interface RebuildResult {
113
173
  /** 重建后识别为 active 的 id 列表(已写入 registry) */
@@ -140,6 +200,8 @@ export function rebuildFromEntries(
140
200
  const unregisteredIds = new Set<string>();
141
201
 
142
202
  for (const raw of entries as EntryLike[]) {
203
+ // S-10:同 countActiveFromEntries——null/undefined 元素先守卫再访问字段。
204
+ if (!raw || typeof raw !== "object") continue;
143
205
  if (raw.customType === "pending:register") {
144
206
  registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
145
207
  } else if (raw.customType === "pending:unregister") {