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 +1 -1
- package/package.json +1 -1
- package/src/core/notification-replay.ts +112 -0
- package/src/index.ts +28 -12
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
1203
|
-
* @details Attempts to use the supplied extension context for UI notification delivery
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3556
|
+
notifyContextSafely(ctx, message, "error");
|
|
3541
3557
|
throw error;
|
|
3542
3558
|
}
|
|
3543
3559
|
},
|