pi-usereq 0.84.0 → 0.85.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PI-useReq/pi-usereq (0.84.0)
1
+ # PI-useReq/pi-usereq (0.85.0)
2
2
 
3
3
  <p align="center">
4
4
  <img src="https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-usereq",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Ogekuri/PI-useReq.git"
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @file
3
+ * @brief Persists deferred UI notifications across session rebinding for later replay.
4
+ * @details Stores non-agentic command-output notifications whose stale-context-safe delivery failed on one `globalThis` queue because pi rebinds extension modules for `/new`, `/resume`, `/fork`, and `/reload` while the hosting process persists across those operations, so undeliverable payloads survive until a later `session_start` restores a live UI context. Runtime is O(1) per operation. Side effects are limited to process-scoped state mutation; no local or global configuration file is ever written.
5
+ */
6
+
7
+ /**
8
+ * @brief Enumerates the `session_start` reasons that trigger deferred-notification replay.
9
+ * @details Covers the session-restoration boundaries where pi rebuilt the TUI and invalidated prior UI contexts: `new`, `resume`, `reload`, and `fork`. The `startup` reason starts one fresh host process whose process-scoped queue is empty by construction, so it is excluded from replay. Lookup complexity is O(1).
10
+ */
11
+ const DEFERRED_NOTIFICATION_REPLAY_SESSION_START_REASONS = new Set([
12
+ "new",
13
+ "resume",
14
+ "reload",
15
+ "fork",
16
+ ]);
17
+
18
+ /**
19
+ * @brief Describes one deferred notification queued for later replay.
20
+ * @details Pairs the exact notification message text with its original severity so the replayed delivery preserves the deferred payload without reparsing or reconstruction. The interface is compile-time only and introduces no runtime cost.
21
+ */
22
+ export interface DeferredNotificationRecord {
23
+ message: string;
24
+ level: "info" | "error";
25
+ }
26
+
27
+ /**
28
+ * @brief Defines the global property used for deferred-notification replay persistence.
29
+ * @details The property lives on `globalThis` because pi tears down and reloads extension modules on session switches while the host process persists, so the queue survives `/new`, `/resume`, `/fork`, `/reload`, and `req-reset` session restoration. Access complexity is O(1).
30
+ */
31
+ const PI_USEREQ_NOTIFICATION_REPLAY_KEY = "__piUsereqDeferredNotificationReplay";
32
+
33
+ /**
34
+ * @brief Describes the process-scoped deferred-notification replay state persisted across extension rebinds.
35
+ * @details Stores the ordered queue of undelivered notification payloads so a later `session_start` on one restored UI context can redeliver exactly the evidence that stale contexts dropped. The interface is compile-time only and introduces no runtime cost.
36
+ * @satisfies REQ-439
37
+ */
38
+ interface ProcessScopedNotificationReplayStore {
39
+ queue: DeferredNotificationRecord[];
40
+ }
41
+
42
+ /**
43
+ * @brief Returns the process-scoped deferred-notification replay store.
44
+ * @details Lazily initializes one `globalThis` record holding an empty queue so session rebinds reuse the accumulated deferred payloads instead of resetting them. Runtime is O(1). Side effect: initializes process-scoped state on first access.
45
+ * @return {ProcessScopedNotificationReplayStore} Mutable process-scoped replay store.
46
+ * @satisfies REQ-439
47
+ */
48
+ function getProcessScopedNotificationReplayStore(): ProcessScopedNotificationReplayStore {
49
+ const globalState = globalThis as typeof globalThis & {
50
+ __piUsereqDeferredNotificationReplay?: ProcessScopedNotificationReplayStore;
51
+ };
52
+ globalState[PI_USEREQ_NOTIFICATION_REPLAY_KEY] ??= { queue: [] };
53
+ return globalState[PI_USEREQ_NOTIFICATION_REPLAY_KEY]!;
54
+ }
55
+
56
+ /**
57
+ * @brief Queues one undeliverable notification for later replay.
58
+ * @details Appends the exact message plus severity to the tail of the process-scoped replay queue so the payload survives extension rebinding and can be redelivered on the next eligible `session_start`. Already-delivered notifications are never enqueued by contract, so replay can never duplicate them. Runtime is O(1). Side effect: mutates process-scoped state.
59
+ * @param[in] message {string} Exact notification message text whose stale-context delivery failed.
60
+ * @param[in] level {"info" | "error"} Original notification severity preserved for the replayed delivery.
61
+ * @return {void} No return value.
62
+ * @satisfies REQ-439, REQ-442
63
+ */
64
+ export function enqueueDeferredNotification(message: string, level: "info" | "error"): void {
65
+ getProcessScopedNotificationReplayStore().queue.push({ message, level });
66
+ }
67
+
68
+ /**
69
+ * @brief Returns a snapshot of the queued deferred notifications.
70
+ * @details Copies the queued records into one detached array so callers can inspect pending replay payloads without mutating the store. Runtime is O(n) in queued-record count. No external state is mutated.
71
+ * @return {DeferredNotificationRecord[]} Detached copy of the queued records in enqueue order.
72
+ * @satisfies REQ-439
73
+ */
74
+ export function readDeferredNotifications(): DeferredNotificationRecord[] {
75
+ return [...getProcessScopedNotificationReplayStore().queue];
76
+ }
77
+
78
+ /**
79
+ * @brief Atomically removes and returns every queued deferred notification.
80
+ * @details Empties the process-scoped queue and returns the removed records in enqueue order so replay delivery owns each payload exactly once; payloads whose replay delivery fails stale again are re-enqueued by the deferred-delivery helper instead of remaining in this drain result. Runtime is O(n) in queued-record count. Side effect: clears process-scoped state.
81
+ * @return {DeferredNotificationRecord[]} Removed records in enqueue order.
82
+ * @satisfies REQ-440
83
+ */
84
+ export function drainDeferredNotifications(): DeferredNotificationRecord[] {
85
+ const store = getProcessScopedNotificationReplayStore();
86
+ const drained = store.queue;
87
+ store.queue = [];
88
+ return drained;
89
+ }
90
+
91
+ /**
92
+ * @brief Clears every queued deferred notification.
93
+ * @details Resets the process-scoped queue to empty so stale deferred payloads never reach later sessions after explicit cleanup. Runtime is O(1). Side effect: mutates process-scoped state.
94
+ * @return {void} No return value.
95
+ * @satisfies REQ-439
96
+ */
97
+ export function clearDeferredNotifications(): void {
98
+ getProcessScopedNotificationReplayStore().queue = [];
99
+ }
100
+
101
+ /**
102
+ * @brief Tests whether one `session_start` event must trigger deferred-notification replay.
103
+ * @details Reads the loosely-typed `reason` field from the session-start payload and accepts exactly the documented restoration reasons `new`, `resume`, `reload`, and `fork`, excluding `startup` because one fresh host process starts with an empty process-scoped queue. Missing or unknown reasons degrade to `false`. Runtime is O(1). No external state is mutated.
104
+ * @param[in] event {unknown} Session-start payload forwarded by the pi lifecycle wrapper.
105
+ * @return {boolean} `true` when deferred notifications must be replayed for this session-start reason.
106
+ * @satisfies REQ-440
107
+ */
108
+ export function shouldReplayDeferredNotificationsOnSessionStart(event: unknown): boolean {
109
+ const reason = (event as { reason?: unknown } | undefined)?.reason;
110
+ return typeof reason === "string"
111
+ && DEFERRED_NOTIFICATION_REPLAY_SESSION_START_REASONS.has(reason);
112
+ }
package/src/index.ts CHANGED
@@ -168,6 +168,11 @@ import {
168
168
  writePersistedPromptCommandRuntimeState,
169
169
  type PersistedPromptCommandRuntimeApi,
170
170
  } from "./core/prompt-command-state.js";
171
+ import {
172
+ drainDeferredNotifications,
173
+ enqueueDeferredNotification,
174
+ shouldReplayDeferredNotificationsOnSessionStart,
175
+ } from "./core/notification-replay.js";
171
176
  import { ensureBundledResourcesAccessible, readBundledPromptDescription } from "./core/resources.js";
172
177
  import { ReqError } from "./core/errors.js";
173
178
  import {
@@ -1199,13 +1204,13 @@ function resolveDebugProjectBase(cwd: string, statusController: PiUsereqStatusCo
1199
1204
  }
1200
1205
 
1201
1206
  /**
1202
- * @brief Delivers one best-effort UI notification without failing on stale replacement contexts.
1203
- * @details Attempts to use the supplied extension context for UI notification delivery and suppresses the documented stale-extension-context runtime error raised after session replacement, because prompt-orchestration closure can outlive the context that initiated the switch. Runtime is O(n) in message length. Side effects are limited to user notification delivery when the context is still active.
1207
+ * @brief Delivers one best-effort UI notification, deferring the payload when the context is stale.
1208
+ * @details Attempts to use the supplied extension context for UI notification delivery; when no context is available or pi raises the documented stale-extension-context error because the context was invalidated by session replacement or reload, the exact message plus severity is enqueued into the process-scoped deferred-notification replay store so one later eligible `session_start` can redeliver it on the restored context instead of losing the evidence. The payload is enqueued only on failed delivery, so already-delivered notifications are never replayed. Runtime is O(1). Side effects are limited to user notification delivery when the context is still active and process-scoped queue mutation when delivery fails.
1204
1209
  * @param[in] ctx {ExtensionContext | ExtensionCommandContext | undefined} Candidate UI context.
1205
1210
  * @param[in] message {string} Notification message.
1206
1211
  * @param[in] level {"info" | "error"} Notification severity.
1207
- * @return {boolean} `true` when the notification was delivered and `false` when the context was already stale.
1208
- * @satisfies REQ-280
1212
+ * @return {boolean} `true` when the notification was delivered and `false` when delivery failed and the payload was deferred for replay.
1213
+ * @satisfies REQ-280, REQ-439, REQ-442
1209
1214
  */
1210
1215
  function notifyContextSafely(
1211
1216
  ctx: (ExtensionContext | ExtensionCommandContext) | undefined,
@@ -1213,6 +1218,7 @@ function notifyContextSafely(
1213
1218
  level: "info" | "error",
1214
1219
  ): boolean {
1215
1220
  if (ctx === undefined) {
1221
+ enqueueDeferredNotification(message, level);
1216
1222
  return false;
1217
1223
  }
1218
1224
  try {
@@ -1220,6 +1226,7 @@ function notifyContextSafely(
1220
1226
  return true;
1221
1227
  } catch (error) {
1222
1228
  if (isStaleExtensionContextError(error)) {
1229
+ enqueueDeferredNotification(message, level);
1223
1230
  return false;
1224
1231
  }
1225
1232
  throw error;
@@ -1232,7 +1239,7 @@ function notifyContextSafely(
1232
1239
  * @param[in] statusController {PiUsereqStatusController} Mutable status controller supplying the latest normalized context-usage snapshot.
1233
1240
  * @param[in] ctx {(ExtensionContext | ExtensionCommandContext) | undefined} Candidate UI context for notification delivery.
1234
1241
  * @return {void} No return value.
1235
- * @satisfies REQ-360, REQ-361, REQ-362, REQ-363, REQ-364, REQ-365
1242
+ * @satisfies REQ-360, REQ-361, REQ-362, REQ-363, REQ-364, REQ-365, REQ-439, REQ-441
1236
1243
  */
1237
1244
  function notifyContextRetentionReminder(
1238
1245
  statusController: PiUsereqStatusController,
@@ -1494,7 +1501,7 @@ async function finalizeMatchedPromptSuccess(
1494
1501
  * @param[in] event {unknown} Hook payload forwarded by pi.
1495
1502
  * @param[in] ctx {ExtensionContext} Active extension context.
1496
1503
  * @return {Promise<void>} Promise resolved when hook processing completes.
1497
- * @satisfies REQ-117, REQ-118, REQ-119, REQ-131, REQ-132, REQ-133, REQ-166, REQ-167, REQ-168, REQ-169, REQ-172, REQ-176, REQ-178, REQ-184, REQ-185, REQ-186, REQ-187, REQ-208, REQ-209, REQ-221, REQ-228, REQ-229, REQ-230, REQ-244, REQ-245, REQ-246, REQ-247, REQ-276, REQ-277, REQ-278, REQ-279, REQ-280, REQ-291, REQ-292, REQ-421, REQ-422, REQ-425
1504
+ * @satisfies REQ-117, REQ-118, REQ-119, REQ-131, REQ-132, REQ-133, REQ-166, REQ-167, REQ-168, REQ-169, REQ-172, REQ-176, REQ-178, REQ-184, REQ-185, REQ-186, REQ-187, REQ-208, REQ-209, REQ-221, REQ-228, REQ-229, REQ-230, REQ-244, REQ-245, REQ-246, REQ-247, REQ-276, REQ-277, REQ-278, REQ-279, REQ-280, REQ-291, REQ-292, REQ-421, REQ-422, REQ-425, REQ-440
1498
1505
  */
1499
1506
  async function handleExtensionStatusEvent(
1500
1507
  pi: ExtensionAPI,
@@ -1529,6 +1536,12 @@ async function handleExtensionStatusEvent(
1529
1536
  "warning",
1530
1537
  );
1531
1538
  }
1539
+ if (shouldReplayDeferredNotificationsOnSessionStart(event)) {
1540
+ const deferredNotifications = drainDeferredNotifications();
1541
+ for (const deferredNotification of deferredNotifications) {
1542
+ notifyContextSafely(ctx, deferredNotification.message, deferredNotification.level);
1543
+ }
1544
+ }
1532
1545
  }
1533
1546
  if (hookName === "before_agent_start") {
1534
1547
  const requestForActivation = activePromptRequest ?? pendingPromptRequest;
@@ -3409,7 +3422,7 @@ function resolveReqResetPromptRequest(
3409
3422
  * @param[in] pi {ExtensionAPI} Active extension API instance.
3410
3423
  * @param[in,out] statusController {PiUsereqStatusController} Mutable status controller.
3411
3424
  * @return {void} No return value.
3412
- * @satisfies REQ-304, REQ-305, REQ-306, REQ-307, REQ-308, REQ-309, REQ-310, REQ-311, REQ-312, REQ-313
3425
+ * @satisfies REQ-304, REQ-305, REQ-306, REQ-307, REQ-308, REQ-309, REQ-310, REQ-311, REQ-312, REQ-313, REQ-441
3413
3426
  */
3414
3427
  function registerReqResetCommand(
3415
3428
  pi: ExtensionAPI,
@@ -3484,7 +3497,7 @@ function registerReqResetCommand(
3484
3497
  * @param[in] pi {ExtensionAPI} Active extension API instance.
3485
3498
  * @param[in,out] statusController {PiUsereqStatusController} Mutable status controller.
3486
3499
  * @return {void} No return value.
3487
- * @satisfies REQ-200, REQ-221, REQ-224, REQ-298, REQ-299, REQ-300, REQ-301, REQ-302, REQ-303, REQ-437, REQ-438
3500
+ * @satisfies REQ-200, REQ-221, REQ-224, REQ-298, REQ-299, REQ-300, REQ-301, REQ-302, REQ-303, REQ-437, REQ-438, REQ-441
3488
3501
  */
3489
3502
  function registerReqReferencesCommand(
3490
3503
  pi: ExtensionAPI,
@@ -3513,19 +3526,22 @@ function registerReqReferencesCommand(
3513
3526
  const executionResult = executeReqReferencesCommandExecution(executionPlan, config);
3514
3527
  setPiUsereqWorkflowState(statusController, "idle", ctx);
3515
3528
  const referencesDisplayPath = formatRuntimePathForDisplay(executionResult.referencesPath);
3516
- ctx.ui.notify(
3529
+ notifyContextSafely(
3530
+ ctx,
3517
3531
  executionResult.referencesChanged
3518
3532
  ? `req-references: Updated ${referencesDisplayPath}.`
3519
3533
  : `req-references: ${referencesDisplayPath} already matches the generated references content; no file update was needed.`,
3520
3534
  "info",
3521
3535
  );
3522
- ctx.ui.notify(
3536
+ notifyContextSafely(
3537
+ ctx,
3523
3538
  executionResult.commitCreated
3524
3539
  ? `req-references: Created commit ${executionResult.commitMessage}`
3525
3540
  : "req-references: Skipped the git commit because the references content already matched HEAD.",
3526
3541
  "info",
3527
3542
  );
3528
- ctx.ui.notify(
3543
+ notifyContextSafely(
3544
+ ctx,
3529
3545
  executionResult.referencesChanged && executionResult.commitCreated
3530
3546
  ? `SUCCESS: Updated ${referencesDisplayPath} and committed changes.`
3531
3547
  : `SUCCESS: req-references completed with a clean repository (${executionResult.referencesChanged ? "references updated" : "references unchanged"}; ${executionResult.commitCreated ? "commit created" : "commit skipped"}).`,
@@ -3537,7 +3553,7 @@ function registerReqReferencesCommand(
3537
3553
  statusController.state.activePromptRequest = undefined;
3538
3554
  setPiUsereqWorkflowState(statusController, "error", ctx);
3539
3555
  const message = error instanceof Error ? error.message : String(error);
3540
- ctx.ui.notify(message, "error");
3556
+ notifyContextSafely(ctx, message, "error");
3541
3557
  throw error;
3542
3558
  }
3543
3559
  },