pi-usereq 0.5.0 → 0.7.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.
@@ -1,70 +1,93 @@
1
1
  /**
2
2
  * @file
3
- * @brief Implements pi-usereq terminal-beep, external sound-hook, and Pushover notification helpers.
4
- * @details Centralizes configuration defaults, status serialization, agent-end outcome classification, terminal notification dispatch, successful-run external sound-command execution, and successful-run Pushover delivery. Runtime is O(m + c + b) in `agent_end` message count plus command length and Pushover payload size. Side effects include stdout writes, detached child-process spawning, and outbound HTTPS requests.
3
+ * @brief Implements pi-usereq command-notify, sound, and Pushover prompt-end helpers.
4
+ * @details Centralizes configuration defaults, status serialization, prompt-end outcome classification, placeholder substitution, detached shell-command execution, and native Pushover delivery. Runtime is O(m + c + b) in `agent_end` message count plus command length and Pushover payload size. Side effects include detached child-process spawning and outbound HTTPS requests.
5
5
  */
6
6
 
7
- import { execFile, spawn } from "node:child_process";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { spawn } from "node:child_process";
8
10
  import * as https from "node:https";
9
11
  import type { AgentEndEvent } from "@mariozechner/pi-coding-agent";
10
- import { getInstallationPath } from "./path-context.js";
12
+ import { getInstallationPath, normalizePathSlashes } from "./path-context.js";
11
13
  import type { UseReqConfig } from "./config.js";
12
14
 
13
15
  /**
14
- * @brief Enumerates supported successful-run sound levels.
15
- * @details Defines the canonical persisted order used by config normalization, shortcut cycling, status rendering, and external command selection. Access complexity is O(1).
16
+ * @brief Enumerates supported sound levels.
17
+ * @details Defines the canonical persisted order used by config normalization, shortcut cycling, status rendering, and sound-command selection. Access complexity is O(1).
16
18
  */
17
19
  export const PI_NOTIFY_SOUND_LEVELS = ["none", "low", "mid", "high"] as const;
18
20
 
19
21
  /**
20
- * @brief Represents one supported successful-run sound level.
21
- * @details Narrows configuration parsing and runtime command dispatch to the canonical four-state sound-hook domain. Compile-time only and introduces no runtime cost.
22
+ * @brief Represents one supported sound level.
23
+ * @details Narrows configuration parsing and runtime sound-command dispatch to the canonical four-state domain. Compile-time only and introduces no runtime cost.
22
24
  */
23
25
  export type PiNotifySoundLevel = (typeof PI_NOTIFY_SOUND_LEVELS)[number];
24
26
 
25
27
  /**
26
- * @brief Enumerates supported prompt-end notification outcomes.
27
- * @details Distinguishes successful completion, escape-triggered abortion, and error termination for independent beep routing. Access complexity is O(1).
28
+ * @brief Enumerates supported prompt-end outcomes.
29
+ * @details Distinguishes completed, interrupted, and failed prompt termination states for cross-channel notification routing. Access complexity is O(1).
28
30
  */
29
- export const PI_NOTIFY_OUTCOMES = ["end", "esc", "err"] as const;
31
+ export const PI_NOTIFY_OUTCOMES = ["completed", "interrupted", "failed"] as const;
30
32
 
31
33
  /**
32
- * @brief Represents one supported prompt-end notification outcome.
33
- * @details Narrows prompt-end event classification and status serialization to the canonical three-outcome domain. Compile-time only and introduces no runtime cost.
34
+ * @brief Represents one supported prompt-end outcome.
35
+ * @details Narrows prompt-end event classification and per-feature toggle routing to the canonical completed/interrupted/failed domain. Compile-time only and introduces no runtime cost.
34
36
  */
35
37
  export type PiNotifyOutcome = (typeof PI_NOTIFY_OUTCOMES)[number];
36
38
 
37
39
  /**
38
- * @brief Defines the default successful-run sound toggle shortcut.
40
+ * @brief Defines the default sound toggle shortcut.
39
41
  * @details Seeds persisted configuration when no project-specific shortcut exists. Access complexity is O(1).
40
42
  * @satisfies REQ-134
41
43
  */
42
44
  export const DEFAULT_PI_NOTIFY_SOUND_TOGGLE_SHORTCUT = "alt+s";
43
45
 
44
46
  /**
45
- * @brief Defines the default low-level successful-run sound command.
46
- * @details Uses the bundled soft notification asset with a low playback volume and defers `%%INSTALLATION_PATH%%` substitution until runtime execution. Access complexity is O(1).
47
- * @satisfies REQ-133, REQ-142
47
+ * @brief Defines the default command-notify shell command.
48
+ * @details Uses `notify-send`, the bundled icon, the fixed `PI-useReq` application name, and runtime placeholder substitution for prompt name, base path, elapsed time, and terminal outcome text. Access complexity is O(1).
49
+ * @satisfies REQ-175
50
+ */
51
+ export const DEFAULT_PI_NOTIFY_CMD = "notify-send -i %%INSTALLATION_PATH%%/resources/images/pi.dev.png -a \"PI-useReq\" \"%%PROMT%% @ %%BASE%% [%%TIME%%]\" \"%%RESULT%%\"";
52
+
53
+ /**
54
+ * @brief Defines the default low-volume sound command.
55
+ * @details Uses the bundled notification asset with a low playback volume and defers `%%INSTALLATION_PATH%%` substitution until runtime execution. Access complexity is O(1).
56
+ * @satisfies REQ-133
48
57
  */
49
58
  export const DEFAULT_PI_NOTIFY_SOUND_LOW_CMD = "paplay --volume=21845 %%INSTALLATION_PATH%%/resources/sounds/Soft-high-tech-notification-sound-effect.mp3";
50
59
 
51
60
  /**
52
- * @brief Defines the default mid-level successful-run sound command.
53
- * @details Uses the bundled soft notification asset with a mid playback volume and defers `%%INSTALLATION_PATH%%` substitution until runtime execution. Access complexity is O(1).
54
- * @satisfies REQ-133, REQ-143
61
+ * @brief Defines the default mid-volume sound command.
62
+ * @details Uses the bundled notification asset with a mid playback volume and defers `%%INSTALLATION_PATH%%` substitution until runtime execution. Access complexity is O(1).
63
+ * @satisfies REQ-133
55
64
  */
56
65
  export const DEFAULT_PI_NOTIFY_SOUND_MID_CMD = "paplay --volume=43690 %%INSTALLATION_PATH%%/resources/sounds/Soft-high-tech-notification-sound-effect.mp3";
57
66
 
58
67
  /**
59
- * @brief Defines the default high-level successful-run sound command.
60
- * @details Uses the bundled soft notification asset with a high playback volume and defers `%%INSTALLATION_PATH%%` substitution until runtime execution. Access complexity is O(1).
61
- * @satisfies REQ-133, REQ-144
68
+ * @brief Defines the default high-volume sound command.
69
+ * @details Uses the bundled notification asset with a high playback volume and defers `%%INSTALLATION_PATH%%` substitution until runtime execution. Access complexity is O(1).
70
+ * @satisfies REQ-133
62
71
  */
63
72
  export const DEFAULT_PI_NOTIFY_SOUND_HIGH_CMD = "paplay --volume=65535 %%INSTALLATION_PATH%%/resources/sounds/Soft-high-tech-notification-sound-effect.mp3";
64
73
 
74
+ /**
75
+ * @brief Defines the default Pushover title template.
76
+ * @details Reuses the same runtime placeholder contract required by `PI_NOTIFY_CMD` except for `%%INSTALLATION_PATH%%`. Access complexity is O(1).
77
+ * @satisfies REQ-185
78
+ */
79
+ export const DEFAULT_PI_NOTIFY_PUSHOVER_TITLE = "%%PROMT%% @ %%BASE%% [%%TIME%%]";
80
+
81
+ /**
82
+ * @brief Defines the default Pushover text template.
83
+ * @details Prefixes outbound Pushover messages with the terminal outcome token and preserves the raw prompt arguments on the following line so notifications remain traceable to the invoked prompt command. Access complexity is O(1).
84
+ * @satisfies REQ-185
85
+ */
86
+ export const DEFAULT_PI_NOTIFY_PUSHOVER_TEXT = "%%RESULT%%\n%%ARGS%%";
87
+
65
88
  /**
66
89
  * @brief Enumerates supported Pushover priorities.
67
- * @details Restricts persisted Pushover delivery to the canonical normal and high-priority values accepted by the user-facing configuration menu. Access complexity is O(1).
90
+ * @details Restricts persisted Pushover delivery to the canonical normal and high-priority values accepted by the configuration menu. Access complexity is O(1).
68
91
  */
69
92
  export const PI_NOTIFY_PUSHOVER_PRIORITIES = [0, 1] as const;
70
93
 
@@ -75,10 +98,10 @@ export const PI_NOTIFY_PUSHOVER_PRIORITIES = [0, 1] as const;
75
98
  export type PiNotifyPushoverPriority = (typeof PI_NOTIFY_PUSHOVER_PRIORITIES)[number];
76
99
 
77
100
  /**
78
- * @brief Describes one successful prompt-completion payload routed to Pushover.
79
- * @details Stores the prompt command name, substituted prompt arguments, runtime base path, and successful completion duration used to build one Pushover API request. The interface is compile-time only and introduces no runtime side effects.
101
+ * @brief Describes one prompt-end request used for command-notify and Pushover substitution.
102
+ * @details Stores the prompt command name, raw prompt arguments, runtime base path, and final prompt duration required to resolve runtime placeholders. The interface is compile-time only and introduces no runtime cost.
80
103
  */
81
- export interface PiNotifyPushoverRequest {
104
+ export interface PiNotifyEventRequest {
82
105
  promptName: string;
83
106
  promptArgs: string;
84
107
  basePath: string;
@@ -87,31 +110,52 @@ export interface PiNotifyPushoverRequest {
87
110
 
88
111
  /**
89
112
  * @brief Describes the configuration fields consumed by pi-notify helpers.
90
- * @details Narrows the full project config to the persisted notification, sound-hook, and Pushover fields used by status rendering, prompt-end routing, and shortcut toggles. Compile-time only and introduces no runtime cost.
113
+ * @details Narrows the full project config to the persisted notify, sound, and Pushover fields used by status rendering, prompt-end routing, and shortcut toggles. Compile-time only and introduces no runtime cost.
91
114
  */
92
115
  export type PiNotifyConfigFields = Pick<
93
116
  UseReqConfig,
94
- | "notify-beep-on-end"
95
- | "notify-beep-on-esc"
96
- | "notify-beep-on-error"
117
+ | "notify-enabled"
118
+ | "notify-on-completed"
119
+ | "notify-on-interrupted"
120
+ | "notify-on-failed"
97
121
  | "notify-sound"
122
+ | "notify-sound-on-completed"
123
+ | "notify-sound-on-interrupted"
124
+ | "notify-sound-on-failed"
98
125
  | "notify-sound-toggle-shortcut"
99
- | "notify-pushover-global-disable"
100
- | "notify-pushover-on-success"
126
+ | "notify-pushover-enabled"
127
+ | "notify-pushover-on-completed"
128
+ | "notify-pushover-on-interrupted"
129
+ | "notify-pushover-on-failed"
101
130
  | "notify-pushover-user-key"
102
131
  | "notify-pushover-api-token"
103
132
  | "notify-pushover-priority"
133
+ | "notify-pushover-title"
134
+ | "notify-pushover-text"
135
+ | "PI_NOTIFY_CMD"
104
136
  | "PI_NOTIFY_SOUND_LOW_CMD"
105
137
  | "PI_NOTIFY_SOUND_MID_CMD"
106
138
  | "PI_NOTIFY_SOUND_HIGH_CMD"
107
139
  >;
108
140
 
141
+ /**
142
+ * @brief Describes the shell-spawn callback used by prompt-end command dispatch.
143
+ * @details Narrows the injected spawn surface so deterministic tests can capture detached shell invocations without patching global module state externally. Compile-time only and introduces no runtime cost.
144
+ */
145
+ type PiNotifySpawn = typeof spawn;
146
+
109
147
  /**
110
148
  * @brief Stores the currently configured native HTTPS request function used for Pushover delivery.
111
149
  * @details Defaults to `node:https.request` and can be replaced by deterministic tests so Pushover dispatch remains observable without real network I/O. Access complexity is O(1). Side effect: mutated only through the dedicated test hook.
112
150
  */
113
151
  let piNotifyHttpsRequest: typeof https.request = https.request;
114
152
 
153
+ /**
154
+ * @brief Stores the currently configured shell-spawn function used for notify and sound commands.
155
+ * @details Defaults to `node:child_process.spawn` and can be replaced by deterministic tests so detached shell-command execution remains observable without launching real child processes. Access complexity is O(1). Side effect: mutated only through the dedicated test hook.
156
+ */
157
+ let piNotifySpawn: PiNotifySpawn = spawn;
158
+
115
159
  /**
116
160
  * @brief Normalizes one persisted sound level.
117
161
  * @details Accepts only canonical `none|low|mid|high` values and falls back to `none` for missing or invalid payloads. Runtime is O(1). No external state is mutated.
@@ -139,17 +183,29 @@ export function normalizePiNotifyShortcut(value: unknown): string {
139
183
  }
140
184
 
141
185
  /**
142
- * @brief Normalizes one persisted sound command string.
143
- * @details Accepts any non-empty string so project config can override bundled commands verbatim and falls back to the supplied default when the payload is empty or invalid. Runtime is O(n) in command length. No external state is mutated.
186
+ * @brief Normalizes one persisted shell-command string.
187
+ * @details Accepts any non-empty string so project config can override bundled command templates verbatim and falls back to the supplied default when the payload is empty or invalid. Runtime is O(n) in command length. No external state is mutated.
144
188
  * @param[in] value {unknown} Raw persisted command payload.
145
189
  * @param[in] fallback {string} Canonical fallback command.
146
190
  * @return {string} Canonical non-empty command string.
147
- * @satisfies REQ-133
191
+ * @satisfies REQ-133, REQ-175
148
192
  */
149
193
  export function normalizePiNotifyCommand(value: unknown, fallback: string): string {
150
194
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
151
195
  }
152
196
 
197
+ /**
198
+ * @brief Normalizes one persisted template string.
199
+ * @details Accepts any non-empty string so Pushover title and text templates can be user-configured verbatim and falls back to the supplied default when the payload is empty or invalid. Runtime is O(n) in template length. No external state is mutated.
200
+ * @param[in] value {unknown} Raw persisted template payload.
201
+ * @param[in] fallback {string} Canonical fallback template.
202
+ * @return {string} Canonical non-empty template string.
203
+ * @satisfies REQ-185
204
+ */
205
+ export function normalizePiNotifyTemplateValue(value: unknown, fallback: string): string {
206
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
207
+ }
208
+
153
209
  /**
154
210
  * @brief Normalizes one persisted Pushover credential string.
155
211
  * @details Accepts any trimmed string so the project config can store raw Pushover user and token values verbatim and falls back to the empty string for missing or invalid payloads. Runtime is O(n) in credential length. No external state is mutated.
@@ -166,42 +222,32 @@ export function normalizePiNotifyPushoverCredential(value: unknown): string {
166
222
  * @details Accepts only canonical `0` and `1` values, treating numeric-string `"1"` as high priority and every other payload as normal priority. Runtime is O(1). No external state is mutated.
167
223
  * @param[in] value {unknown} Raw persisted priority payload.
168
224
  * @return {PiNotifyPushoverPriority} Canonical priority value.
169
- * @satisfies REQ-163
225
+ * @satisfies REQ-172
170
226
  */
171
227
  export function normalizePiNotifyPushoverPriority(value: unknown): PiNotifyPushoverPriority {
172
228
  return value === 1 || value === "1" ? 1 : 0;
173
229
  }
174
230
 
175
231
  /**
176
- * @brief Formats enabled terminal-beep flags for status rendering.
177
- * @details Emits the canonical comma-ordered enabled outcome tokens `end`, `esc`, and `err`, or `none` when all prompt-end beep flags are disabled. Runtime is O(1). No external state is mutated.
178
- * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
179
- * @return {string} Status-bar beep payload.
180
- * @satisfies REQ-135
232
+ * @brief Formats the global command-notify flag for UI value rendering.
233
+ * @details Serializes only the persisted global command-notify enable state so configuration menus and summaries can report `on|off` independently from per-event toggles. Runtime is O(1). No external state is mutated.
234
+ * @param[in] config {Pick<UseReqConfig, "notify-enabled">} Effective command-notify configuration subset.
235
+ * @return {string} `on` when command-notify is globally enabled; otherwise `off`.
236
+ * @satisfies REQ-196
181
237
  */
182
- export function formatPiNotifyBeepStatus(config: PiNotifyConfigFields): string {
183
- const enabledOutcomes: PiNotifyOutcome[] = [];
184
- if (config["notify-beep-on-end"]) {
185
- enabledOutcomes.push("end");
186
- }
187
- if (config["notify-beep-on-esc"]) {
188
- enabledOutcomes.push("esc");
189
- }
190
- if (config["notify-beep-on-error"]) {
191
- enabledOutcomes.push("err");
192
- }
193
- return enabledOutcomes.length > 0 ? enabledOutcomes.join(",") : "none";
238
+ export function formatPiNotifyStatus(config: Pick<UseReqConfig, "notify-enabled">): string {
239
+ return config["notify-enabled"] ? "on" : "off";
194
240
  }
195
241
 
196
242
  /**
197
- * @brief Formats the Pushover enable flag for status rendering.
198
- * @details Serializes only the persisted successful-prompt enable setting so the status bar reports the dedicated Pushover toggle independently from any global-disable override. Runtime is O(1). No external state is mutated.
199
- * @param[in] config {Pick<UseReqConfig, "notify-pushover-on-success">} Effective Pushover configuration subset.
200
- * @return {string} `on` when successful-prompt Pushover delivery is enabled; otherwise `off`.
201
- * @satisfies REQ-171
243
+ * @brief Formats the global Pushover flag for UI value rendering.
244
+ * @details Serializes only the persisted global Pushover enable state so configuration menus and summaries can report `on|off` independently from per-event toggles. Runtime is O(1). No external state is mutated.
245
+ * @param[in] config {Pick<UseReqConfig, "notify-pushover-enabled">} Effective Pushover configuration subset.
246
+ * @return {string} `on` when Pushover is globally enabled; otherwise `off`.
247
+ * @satisfies REQ-163
202
248
  */
203
- export function formatPiNotifyPushoverStatus(config: Pick<UseReqConfig, "notify-pushover-on-success">): string {
204
- return config["notify-pushover-on-success"] ? "on" : "off";
249
+ export function formatPiNotifyPushoverStatus(config: Pick<UseReqConfig, "notify-pushover-enabled">): string {
250
+ return config["notify-pushover-enabled"] ? "on" : "off";
205
251
  }
206
252
 
207
253
  /**
@@ -220,114 +266,28 @@ export function cyclePiNotifySoundLevel(currentLevel: PiNotifySoundLevel): PiNot
220
266
  }
221
267
 
222
268
  /**
223
- * @brief Escapes one string for single-quoted PowerShell embedding.
224
- * @details Doubles embedded apostrophes so the generated Windows toast script preserves literal title and body text. Runtime is O(n) in text length. No external state is mutated.
225
- * @param[in] value {string} Raw literal text.
226
- * @return {string} PowerShell-safe single-quoted literal content.
227
- */
228
- function escapePowerShellLiteral(value: string): string {
229
- return value.replace(/'/g, "''");
230
- }
231
-
232
- /**
233
- * @brief Builds the PowerShell script used for Windows toast notifications.
234
- * @details Reuses the pi notify example contract, escapes title and body literals, and emits a one-shot script that shows a toast notification through the Windows Runtime API. Runtime is O(n) in payload length. No external state is mutated.
235
- * @param[in] title {string} Notification title.
236
- * @param[in] body {string} Notification body.
237
- * @return {string} PowerShell script passed to `powershell.exe`.
269
+ * @brief Tests whether one outcome-specific toggle is enabled.
270
+ * @details Reuses the canonical completed/interrupted/failed routing order shared by notify, sound, and Pushover event toggles. Runtime is O(1). No external state is mutated.
271
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
272
+ * @param[in] completedEnabled {boolean} Enabled state for completed prompt termination.
273
+ * @param[in] interruptedEnabled {boolean} Enabled state for interrupted prompt termination.
274
+ * @param[in] failedEnabled {boolean} Enabled state for failed prompt termination.
275
+ * @return {boolean} `true` when the selected outcome flag is enabled.
238
276
  */
239
- function windowsToastScript(title: string, body: string): string {
240
- const type = "Windows.UI.Notifications";
241
- const manager = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
242
- const template = `[${type}.ToastTemplateType]::ToastText02`;
243
- const escapedTitle = escapePowerShellLiteral(title);
244
- const escapedBody = escapePowerShellLiteral(body);
245
- return [
246
- `${manager} > $null`,
247
- `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
248
- `$texts = $xml.GetElementsByTagName('text')`,
249
- `$texts[0].AppendChild($xml.CreateTextNode('${escapedTitle}')) > $null`,
250
- `$texts[1].AppendChild($xml.CreateTextNode('${escapedBody}')) > $null`,
251
- `$toast = [${type}.ToastNotification]::new($xml)`,
252
- `[${type}.ToastNotificationManager]::CreateToastNotifier('pi-usereq').Show($toast)`,
253
- ].join("; ");
254
- }
255
-
256
- /**
257
- * @brief Emits one OSC 777 terminal notification.
258
- * @details Writes the Ghostty/iTerm2/WezTerm-compatible OSC 777 escape sequence directly to stdout using the supplied title and body payloads. Runtime is O(n) in payload length. Side effect: writes to stdout.
259
- * @param[in] title {string} Notification title.
260
- * @param[in] body {string} Notification body.
261
- * @return {void} No return value.
262
- * @satisfies REQ-130
263
- */
264
- export function notifyOSC777(title: string, body: string): void {
265
- process.stdout.write(`\u001b]777;notify;${title};${body}\u0007`);
266
- }
267
-
268
- /**
269
- * @brief Emits one Kitty OSC 99 terminal notification.
270
- * @details Writes the two-part Kitty OSC 99 sequence that carries the title and body payloads under one notification identifier. Runtime is O(n) in payload length. Side effect: writes to stdout.
271
- * @param[in] title {string} Notification title.
272
- * @param[in] body {string} Notification body.
273
- * @return {void} No return value.
274
- * @satisfies REQ-130
275
- */
276
- export function notifyOSC99(title: string, body: string): void {
277
- process.stdout.write(`\u001b]99;i=1:d=0;${title}\u001b\\`);
278
- process.stdout.write(`\u001b]99;i=1:p=body;${body}\u001b\\`);
279
- }
280
-
281
- /**
282
- * @brief Emits one OSC 9 terminal notification.
283
- * @details Writes the single-part OSC 9 sequence commonly used by terminals that accept message-only desktop notifications. Runtime is O(n) in payload length. Side effect: writes to stdout.
284
- * @param[in] title {string} Notification title.
285
- * @param[in] body {string} Notification body.
286
- * @return {void} No return value.
287
- * @satisfies REQ-130
288
- */
289
- export function notifyOSC9(title: string, body: string): void {
290
- process.stdout.write(`\u001b]9;${title}: ${body}\u0007`);
291
- }
292
-
293
- /**
294
- * @brief Emits one Windows toast notification.
295
- * @details Spawns `powershell.exe` without waiting, delegates payload rendering to `windowsToastScript(...)`, and ignores transport failures so prompt-end handling remains non-blocking. Runtime is dominated by process spawn. Side effects include child-process execution.
296
- * @param[in] title {string} Notification title.
297
- * @param[in] body {string} Notification body.
298
- * @return {void} No return value.
299
- * @satisfies REQ-130
300
- */
301
- export function notifyWindows(title: string, body: string): void {
302
- void execFile(
303
- "powershell.exe",
304
- ["-NoProfile", "-Command", windowsToastScript(title, body)],
305
- () => undefined,
306
- );
307
- }
308
-
309
- /**
310
- * @brief Routes one prompt-end terminal notification through the detected terminal protocol.
311
- * @details Prefers Windows toast delivery inside Windows Terminal, Kitty OSC 99 inside Kitty, OSC 9 inside iTerm-like terminals, and OSC 777 as the fallback path. Runtime is O(n) in payload length plus optional process spawn cost. Side effects include stdout writes or child-process execution.
312
- * @param[in] title {string} Notification title.
313
- * @param[in] body {string} Notification body.
314
- * @return {void} No return value.
315
- * @satisfies REQ-130
316
- */
317
- export function notifyPiTerminal(title: string, body: string): void {
318
- if (process.env.WT_SESSION) {
319
- notifyWindows(title, body);
320
- return;
321
- }
322
- if (process.env.KITTY_WINDOW_ID) {
323
- notifyOSC99(title, body);
324
- return;
325
- }
326
- if (process.env.TERM_PROGRAM === "iTerm.app" || process.env.TERM_PROGRAM === "Apple_Terminal") {
327
- notifyOSC9(title, body);
328
- return;
277
+ function isPiNotifyOutcomeEnabled(
278
+ outcome: PiNotifyOutcome,
279
+ completedEnabled: boolean,
280
+ interruptedEnabled: boolean,
281
+ failedEnabled: boolean,
282
+ ): boolean {
283
+ switch (outcome) {
284
+ case "interrupted":
285
+ return interruptedEnabled;
286
+ case "failed":
287
+ return failedEnabled;
288
+ default:
289
+ return completedEnabled;
329
290
  }
330
- notifyOSC777(title, body);
331
291
  }
332
292
 
333
293
  /**
@@ -351,120 +311,388 @@ function hasAgentEndStopReason(
351
311
 
352
312
  /**
353
313
  * @brief Classifies one agent-end payload into the canonical pi-notify outcome.
354
- * @details Treats assistant `stopReason=error` as `err`, `stopReason=aborted` as `esc`, and every remaining terminal state as successful `end`. Runtime is O(m) in message count. No external state is mutated.
314
+ * @details Treats assistant `stopReason=error` as `failed`, `stopReason=aborted` as `interrupted`, and every remaining terminal state as `completed`. Runtime is O(m) in message count. No external state is mutated.
355
315
  * @param[in] event {Pick<AgentEndEvent, "messages">} Agent-end payload subset.
356
316
  * @return {PiNotifyOutcome} Canonical prompt-end outcome.
357
- * @satisfies REQ-129
358
317
  */
359
318
  export function classifyPiNotifyOutcome(event: Pick<AgentEndEvent, "messages">): PiNotifyOutcome {
360
319
  if (hasAgentEndStopReason(event.messages, "error")) {
361
- return "err";
320
+ return "failed";
362
321
  }
363
322
  if (hasAgentEndStopReason(event.messages, "aborted")) {
364
- return "esc";
323
+ return "interrupted";
365
324
  }
366
- return "end";
325
+ return "completed";
367
326
  }
368
327
 
369
328
  /**
370
- * @brief Builds the user-visible prompt-end notification title.
371
- * @details Keeps a stable `pi-usereq` title across outcomes so terminal notification stacks remain easy to correlate with this extension. Runtime is O(1). No external state is mutated.
372
- * @return {string} Notification title.
329
+ * @brief Formats one prompt-end duration for runtime placeholders.
330
+ * @details Floors the supplied duration to whole seconds, keeps minutes unbounded above 59, and zero-pads seconds to two digits so `%%TIME%%` aligns with status-bar elapsed formatting. Runtime is O(1). No external state is mutated.
331
+ * @param[in] durationMs {number} Prompt-end duration in milliseconds.
332
+ * @return {string} Duration rendered as `M:SS`.
333
+ * @satisfies REQ-187
373
334
  */
374
- function buildPiNotifyTitle(): string {
375
- return "pi-usereq";
335
+ function formatPiNotifyDuration(durationMs: number): string {
336
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
337
+ const minutes = Math.floor(totalSeconds / 60);
338
+ const seconds = totalSeconds % 60;
339
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
376
340
  }
377
341
 
378
342
  /**
379
- * @brief Builds the user-visible prompt-end notification body.
380
- * @details Maps each canonical outcome to one deterministic English phrase so downstream tests and users can distinguish success, abort, and error notifications. Runtime is O(1). No external state is mutated.
381
- * @param[in] outcome {PiNotifyOutcome} Canonical prompt-end outcome.
382
- * @return {string} Notification body.
343
+ * @brief Formats one runtime base path for placeholder substitution.
344
+ * @details Emits `~` or `~/...` when the supplied path equals or descends from the current user home directory; otherwise emits the slash-normalized absolute path. Runtime is O(p) in path length. No external state is mutated.
345
+ * @param[in] basePath {string} Runtime base path.
346
+ * @return {string} Placeholder-ready base path string.
347
+ * @satisfies REQ-187
383
348
  */
384
- function buildPiNotifyBody(outcome: PiNotifyOutcome): string {
349
+ function formatPiNotifyBasePath(basePath: string): string {
350
+ const normalizedBasePath = path.resolve(basePath);
351
+ const homePath = path.resolve(os.homedir());
352
+ if (normalizedBasePath === homePath) {
353
+ return "~";
354
+ }
355
+ const relativePath = path.relative(homePath, normalizedBasePath);
356
+ if (relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
357
+ return `~/${normalizePathSlashes(relativePath)}`;
358
+ }
359
+ return normalizePathSlashes(normalizedBasePath);
360
+ }
361
+
362
+ /**
363
+ * @brief Formats one prompt-end outcome for `%%RESULT%%` substitution.
364
+ * @details Maps canonical prompt-end outcomes to the persisted human-readable result tokens reused by default notify-command and Pushover templates. Runtime is O(1). No external state is mutated.
365
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
366
+ * @return {string} `successed`, `aborted`, or `failed`.
367
+ * @satisfies REQ-169, REQ-186, REQ-199
368
+ */
369
+ function formatPiNotifyResult(outcome: PiNotifyOutcome): string {
385
370
  switch (outcome) {
386
- case "err":
387
- return "Prompt ended with error";
388
- case "esc":
389
- return "Prompt aborted by escape";
371
+ case "interrupted":
372
+ return "aborted";
373
+ case "failed":
374
+ return "failed";
390
375
  default:
391
- return "Prompt completed";
376
+ return "successed";
392
377
  }
393
378
  }
394
379
 
395
380
  /**
396
- * @brief Formats one successful prompt duration for Pushover titles.
397
- * @details Floors the supplied completion duration to whole seconds, keeps minutes unbounded above 59, and zero-pads seconds to two digits so Pushover titles align with status-bar elapsed formatting. Runtime is O(1). No external state is mutated.
398
- * @param[in] durationMs {number} Successful prompt duration in milliseconds.
399
- * @return {string} Duration rendered as `M:SS`.
400
- * @satisfies REQ-169
381
+ * @brief Builds the raw runtime placeholder map for one prompt-end request.
382
+ * @details Resolves every placeholder value exactly once so notify-command and Pushover template substitution reuse the same prompt name, base path, elapsed time, argument string, and terminal outcome token. Runtime is O(p) in path length. No external state is mutated.
383
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
384
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
385
+ * @return {{ "%%PROMT%%": string; "%%BASE%%": string; "%%TIME%%": string; "%%ARGS%%": string; "%%RESULT%%": string }} Raw placeholder-value map.
401
386
  */
402
- function formatPiNotifyPushoverDuration(durationMs: number): string {
403
- const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
404
- const minutes = Math.floor(totalSeconds / 60);
405
- const seconds = totalSeconds % 60;
406
- return `${minutes}:${String(seconds).padStart(2, "0")}`;
387
+ function buildPiNotifyRuntimeTemplateValues(
388
+ request: PiNotifyEventRequest,
389
+ outcome: PiNotifyOutcome,
390
+ ): Record<string, string> {
391
+ return {
392
+ "%%PROMT%%": request.promptName,
393
+ "%%BASE%%": formatPiNotifyBasePath(request.basePath),
394
+ "%%TIME%%": formatPiNotifyDuration(request.completionTimeMs),
395
+ "%%ARGS%%": request.promptArgs,
396
+ "%%RESULT%%": formatPiNotifyResult(outcome),
397
+ };
407
398
  }
408
399
 
409
400
  /**
410
- * @brief Builds the Pushover notification title for one successful prompt.
411
- * @details Serializes the prompt name, absolute runtime base path, and successful completion duration into the canonical `<prompt> @ <base-path> [<time>]` title required by the repository feature contract. Runtime is O(n) in path length. No external state is mutated.
412
- * @param[in] request {PiNotifyPushoverRequest} Successful prompt metadata.
413
- * @return {string} Pushover title string.
414
- * @satisfies REQ-169
401
+ * @brief Quotes one installation path for shell substitution.
402
+ * @details Emits POSIX single-quoted literals for `sh -lc` execution and CMD double-quoted literals for `cmd.exe /c` execution so `%%INSTALLATION_PATH%%` substitutions preserve whitespace safely. Runtime is O(n) in path length. No external state is mutated.
403
+ * @param[in] installationPath {string} Absolute extension installation path.
404
+ * @return {string} Shell-quoted installation path fragment.
415
405
  */
416
- function buildPiNotifyPushoverTitle(request: PiNotifyPushoverRequest): string {
417
- return `${request.promptName} @ ${request.basePath} [${formatPiNotifyPushoverDuration(request.completionTimeMs)}]`;
406
+ function quotePiNotifyInstallPath(installationPath: string): string {
407
+ if (process.platform === "win32") {
408
+ return `"${installationPath.replace(/"/g, '""')}"`;
409
+ }
410
+ return `'${installationPath.replace(/'/g, `'\\''`)}'`;
418
411
  }
419
412
 
420
413
  /**
421
- * @brief Builds the Pushover message body for one successful prompt.
422
- * @details Reuses the raw prompt argument string substituted into `%%ARGS%%` so the pushed body remains traceable to the executed prompt invocation. Runtime is O(1). No external state is mutated.
423
- * @param[in] request {PiNotifyPushoverRequest} Successful prompt metadata.
424
- * @return {string} Pushover message body.
414
+ * @brief Escapes one placeholder value for double-quoted shell insertion.
415
+ * @details Escapes the characters interpreted specially by POSIX or CMD double-quoted strings so default notify-command templates remain safe when placeholders are embedded inside double quotes. Runtime is O(n) in value length. No external state is mutated.
416
+ * @param[in] value {string} Raw placeholder value.
417
+ * @return {string} Shell-escaped placeholder fragment without surrounding quotes.
418
+ */
419
+ function escapePiNotifyShellTemplateValue(value: string): string {
420
+ if (process.platform === "win32") {
421
+ return value.replace(/%/g, "%%").replace(/"/g, '""');
422
+ }
423
+ return value
424
+ .replace(/\\/g, "\\\\")
425
+ .replace(/\$/g, "\\$")
426
+ .replace(/`/g, "\\`")
427
+ .replace(/"/g, '\\"');
428
+ }
429
+
430
+ /**
431
+ * @brief Substitutes `%%INSTALLATION_PATH%%` inside one shell command.
432
+ * @details Replaces every `%%INSTALLATION_PATH%%` token with a shell-quoted runtime installation path so bundled assets can be addressed safely from external commands. Runtime is O(n) in command length. No external state is mutated.
433
+ * @param[in] command {string} Raw configured shell command.
434
+ * @param[in] installationPath {string} Absolute extension installation path.
435
+ * @return {string} Runtime-ready command string.
425
436
  * @satisfies REQ-169
426
437
  */
427
- function buildPiNotifyPushoverBody(request: PiNotifyPushoverRequest): string {
428
- return request.promptArgs;
438
+ export function substitutePiNotifyInstallPath(command: string, installationPath: string): string {
439
+ return command.replaceAll(
440
+ "%%INSTALLATION_PATH%%",
441
+ quotePiNotifyInstallPath(installationPath),
442
+ );
443
+ }
444
+
445
+ /**
446
+ * @brief Substitutes runtime placeholders inside one shell-command template.
447
+ * @details Applies shell-quoted installation-path substitution plus shell-escaped prompt, base-path, elapsed-time, raw-argument, and outcome-result substitution expected by `PI_NOTIFY_CMD`. Runtime is O(n) in template length. No external state is mutated.
448
+ * @param[in] template {string} Raw shell-command template.
449
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
450
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
451
+ * @param[in] installationPath {string} Absolute extension installation path.
452
+ * @return {string} Runtime-ready shell command.
453
+ * @satisfies REQ-169, REQ-199
454
+ */
455
+ function substitutePiNotifyShellTemplate(
456
+ template: string,
457
+ request: PiNotifyEventRequest,
458
+ outcome: PiNotifyOutcome,
459
+ installationPath: string,
460
+ ): string {
461
+ const runtimeValues = buildPiNotifyRuntimeTemplateValues(request, outcome);
462
+ let result = substitutePiNotifyInstallPath(template, installationPath);
463
+ for (const [token, value] of Object.entries(runtimeValues)) {
464
+ result = result.replaceAll(token, escapePiNotifyShellTemplateValue(value));
465
+ }
466
+ return result;
467
+ }
468
+
469
+ /**
470
+ * @brief Substitutes runtime placeholders inside one text template.
471
+ * @details Applies raw prompt name, home-relative base path, elapsed time, raw prompt arguments, and terminal outcome text without shell escaping so Pushover payloads preserve literal text. Runtime is O(n) in template length. No external state is mutated.
472
+ * @param[in] template {string} Raw text template.
473
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
474
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
475
+ * @return {string} Placeholder-resolved text string.
476
+ * @satisfies REQ-186, REQ-187, REQ-199
477
+ */
478
+ function substitutePiNotifyTextTemplate(
479
+ template: string,
480
+ request: PiNotifyEventRequest,
481
+ outcome: PiNotifyOutcome,
482
+ ): string {
483
+ let result = template;
484
+ for (const [token, value] of Object.entries(buildPiNotifyRuntimeTemplateValues(request, outcome))) {
485
+ result = result.replaceAll(token, value);
486
+ }
487
+ return result;
488
+ }
489
+
490
+ /**
491
+ * @brief Executes one detached shell command without waiting for completion.
492
+ * @details Uses the platform-default shell contract already employed by sound-command execution and ignores transport failures so prompt-end handling remains non-blocking. Runtime is dominated by process spawn. Side effects include detached child-process execution.
493
+ * @param[in] command {string} Runtime-ready shell command.
494
+ * @return {void} No return value.
495
+ */
496
+ function runPiNotifyShellCommand(command: string): void {
497
+ const shell = process.platform === "win32" ? "cmd.exe" : (process.env.SHELL ?? "sh");
498
+ const shellArgs = process.platform === "win32"
499
+ ? ["/d", "/s", "/c", command]
500
+ : ["-lc", command];
501
+ const child = piNotifySpawn(shell, shellArgs, {
502
+ detached: true,
503
+ stdio: "ignore",
504
+ });
505
+ child.once("error", () => undefined);
506
+ child.unref();
429
507
  }
430
508
 
431
509
  /**
432
- * @brief Determines whether one successful prompt should trigger Pushover delivery.
433
- * @details Requires a captured prompt request, the successful-prompt enable flag, the Pushover global-disable flag to remain false, and non-empty user plus token credentials. Runtime is O(1). No external state is mutated.
510
+ * @brief Determines whether one prompt-end outcome should trigger command-notify.
511
+ * @details Requires a prompt-end request, the global command-notify enable flag, and the corresponding per-event notify toggle. Runtime is O(1). No external state is mutated.
434
512
  * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
435
- * @param[in] request {PiNotifyPushoverRequest | undefined} Successful prompt metadata.
513
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
514
+ * @param[in] request {PiNotifyEventRequest | undefined} Prompt-end request metadata.
515
+ * @return {boolean} `true` when command-notify prerequisites are satisfied.
516
+ * @satisfies REQ-174, REQ-176
517
+ */
518
+ function shouldRunPiNotifyCommand(
519
+ config: PiNotifyConfigFields,
520
+ outcome: PiNotifyOutcome,
521
+ request: PiNotifyEventRequest | undefined,
522
+ ): boolean {
523
+ return request !== undefined
524
+ && config["notify-enabled"]
525
+ && isPiNotifyOutcomeEnabled(
526
+ outcome,
527
+ config["notify-on-completed"],
528
+ config["notify-on-interrupted"],
529
+ config["notify-on-failed"],
530
+ );
531
+ }
532
+
533
+ /**
534
+ * @brief Executes the configured command-notify shell command.
535
+ * @details Resolves the runtime installation path, substitutes runtime placeholders into `PI_NOTIFY_CMD`, and spawns the resulting command without waiting for completion. Runtime is dominated by process spawn. Side effects include detached child-process execution.
536
+ * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
537
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
538
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
539
+ * @return {void} No return value.
540
+ * @satisfies REQ-169, REQ-175, REQ-176, REQ-199
541
+ */
542
+ function runPiNotifyCommand(
543
+ config: PiNotifyConfigFields,
544
+ outcome: PiNotifyOutcome,
545
+ request: PiNotifyEventRequest,
546
+ ): void {
547
+ const installationPath = getInstallationPath();
548
+ const command = substitutePiNotifyShellTemplate(
549
+ config.PI_NOTIFY_CMD,
550
+ request,
551
+ outcome,
552
+ installationPath,
553
+ );
554
+ runPiNotifyShellCommand(command);
555
+ }
556
+
557
+ /**
558
+ * @brief Resolves the configured command for one non-`none` sound level.
559
+ * @details Selects the matching persisted command string from config without performing runtime substitution or shell execution. Runtime is O(1). No external state is mutated.
560
+ * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
561
+ * @param[in] soundLevel {Exclude<PiNotifySoundLevel, "none">} Non-disabled sound level.
562
+ * @return {string} Configured command string for the requested level.
563
+ */
564
+ function resolvePiNotifySoundCommand(
565
+ config: PiNotifyConfigFields,
566
+ soundLevel: Exclude<PiNotifySoundLevel, "none">,
567
+ ): string {
568
+ switch (soundLevel) {
569
+ case "low":
570
+ return config.PI_NOTIFY_SOUND_LOW_CMD;
571
+ case "mid":
572
+ return config.PI_NOTIFY_SOUND_MID_CMD;
573
+ case "high":
574
+ return config.PI_NOTIFY_SOUND_HIGH_CMD;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * @brief Determines whether one prompt-end outcome should trigger sound-command execution.
580
+ * @details Requires a non-`none` sound level and the corresponding per-event sound toggle. Runtime is O(1). No external state is mutated.
581
+ * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
582
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
583
+ * @return {boolean} `true` when sound-command prerequisites are satisfied.
584
+ * @satisfies REQ-178
585
+ */
586
+ function shouldRunPiNotifySound(
587
+ config: PiNotifyConfigFields,
588
+ outcome: PiNotifyOutcome,
589
+ ): boolean {
590
+ return config["notify-sound"] !== "none"
591
+ && isPiNotifyOutcomeEnabled(
592
+ outcome,
593
+ config["notify-sound-on-completed"],
594
+ config["notify-sound-on-interrupted"],
595
+ config["notify-sound-on-failed"],
596
+ );
597
+ }
598
+
599
+ /**
600
+ * @brief Executes the configured sound command on an external shell.
601
+ * @details Resolves the runtime installation path, substitutes `%%INSTALLATION_PATH%%`, and spawns the configured command without waiting for completion. Runtime is dominated by process spawn. Side effects include detached child-process execution.
602
+ * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
603
+ * @param[in] soundLevel {Exclude<PiNotifySoundLevel, "none">} Requested non-disabled sound level.
604
+ * @return {void} No return value.
605
+ * @satisfies REQ-132, REQ-133
606
+ */
607
+ export function runPiNotifySoundCommand(
608
+ config: PiNotifyConfigFields,
609
+ soundLevel: Exclude<PiNotifySoundLevel, "none">,
610
+ ): void {
611
+ const rawCommand = resolvePiNotifySoundCommand(config, soundLevel);
612
+ const command = substitutePiNotifyInstallPath(rawCommand, getInstallationPath());
613
+ runPiNotifyShellCommand(command);
614
+ }
615
+
616
+ /**
617
+ * @brief Determines whether one prompt-end outcome should trigger Pushover delivery.
618
+ * @details Requires a prompt-end request, the global Pushover enable flag, the corresponding per-event Pushover toggle, and non-empty user plus token credentials. Runtime is O(1). No external state is mutated.
619
+ * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
620
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
621
+ * @param[in] request {PiNotifyEventRequest | undefined} Prompt-end request metadata.
436
622
  * @return {boolean} `true` when Pushover delivery prerequisites are satisfied.
437
- * @satisfies REQ-166, REQ-168, REQ-172
623
+ * @satisfies REQ-166, REQ-168, REQ-184
438
624
  */
439
625
  function shouldRunPiNotifyPushover(
440
626
  config: PiNotifyConfigFields,
441
- request: PiNotifyPushoverRequest | undefined,
627
+ outcome: PiNotifyOutcome,
628
+ request: PiNotifyEventRequest | undefined,
442
629
  ): boolean {
443
630
  return request !== undefined
444
- && config["notify-pushover-on-success"]
445
- && !config["notify-pushover-global-disable"]
631
+ && config["notify-pushover-enabled"]
632
+ && isPiNotifyOutcomeEnabled(
633
+ outcome,
634
+ config["notify-pushover-on-completed"],
635
+ config["notify-pushover-on-interrupted"],
636
+ config["notify-pushover-on-failed"],
637
+ )
446
638
  && config["notify-pushover-user-key"] !== ""
447
639
  && config["notify-pushover-api-token"] !== "";
448
640
  }
449
641
 
450
642
  /**
451
- * @brief Builds the Pushover API payload for one successful prompt.
452
- * @details Encodes the configured token, user key, canonical title, priority, and substituted prompt-argument body as `application/x-www-form-urlencoded` fields accepted by the Pushover Message API. Runtime is O(n) in payload size. No external state is mutated.
643
+ * @brief Builds the Pushover notification title for one prompt-end request.
644
+ * @details Resolves the configured `notify-pushover-title` template with raw runtime placeholder substitution so the pushed title remains configurable and deterministic. Runtime is O(n) in template length. No external state is mutated.
645
+ * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
646
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
647
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
648
+ * @return {string} Pushover title string.
649
+ * @satisfies REQ-185, REQ-186, REQ-187, REQ-199
650
+ */
651
+ function buildPiNotifyPushoverTitle(
652
+ config: PiNotifyConfigFields,
653
+ outcome: PiNotifyOutcome,
654
+ request: PiNotifyEventRequest,
655
+ ): string {
656
+ return substitutePiNotifyTextTemplate(config["notify-pushover-title"], request, outcome);
657
+ }
658
+
659
+ /**
660
+ * @brief Builds the Pushover message body for one prompt-end request.
661
+ * @details Resolves the configured `notify-pushover-text` template with raw runtime placeholder substitution so the pushed text remains configurable and deterministic. Runtime is O(n) in template length. No external state is mutated.
662
+ * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
663
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
664
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
665
+ * @return {string} Pushover message body.
666
+ * @satisfies REQ-185, REQ-186, REQ-187, REQ-199
667
+ */
668
+ function buildPiNotifyPushoverBody(
669
+ config: PiNotifyConfigFields,
670
+ outcome: PiNotifyOutcome,
671
+ request: PiNotifyEventRequest,
672
+ ): string {
673
+ return substitutePiNotifyTextTemplate(config["notify-pushover-text"], request, outcome);
674
+ }
675
+
676
+ /**
677
+ * @brief Builds the Pushover API payload for one prompt-end request.
678
+ * @details Encodes the configured token, user key, substituted title, priority, and substituted text as `application/x-www-form-urlencoded` fields accepted by the Pushover Message API. Runtime is O(n) in payload size. No external state is mutated.
453
679
  * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
454
- * @param[in] request {PiNotifyPushoverRequest} Successful prompt metadata.
680
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
681
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
455
682
  * @return {URLSearchParams} Encoded Pushover request payload.
456
- * @satisfies REQ-167, REQ-169
683
+ * @satisfies REQ-167, REQ-172, REQ-185, REQ-186, REQ-199
457
684
  */
458
685
  function buildPiNotifyPushoverPayload(
459
686
  config: PiNotifyConfigFields,
460
- request: PiNotifyPushoverRequest,
687
+ outcome: PiNotifyOutcome,
688
+ request: PiNotifyEventRequest,
461
689
  ): URLSearchParams {
462
690
  return new URLSearchParams({
463
691
  token: config["notify-pushover-api-token"],
464
692
  user: config["notify-pushover-user-key"],
465
- title: buildPiNotifyPushoverTitle(request),
693
+ title: buildPiNotifyPushoverTitle(config, outcome, request),
466
694
  priority: String(config["notify-pushover-priority"]),
467
- message: buildPiNotifyPushoverBody(request),
695
+ message: buildPiNotifyPushoverBody(config, outcome, request),
468
696
  });
469
697
  }
470
698
 
@@ -472,16 +700,18 @@ function buildPiNotifyPushoverPayload(
472
700
  * @brief Dispatches one native HTTPS request to the Pushover Message API.
473
701
  * @details Serializes the request body as URL-encoded form data, posts it to `https://api.pushover.net/1/messages.json`, drains the response, and ignores transport failures so prompt-end handling remains non-blocking. Runtime is dominated by outbound I/O. Side effects include one HTTPS request.
474
702
  * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
475
- * @param[in] request {PiNotifyPushoverRequest} Successful prompt metadata.
703
+ * @param[in] outcome {PiNotifyOutcome} Classified prompt-end outcome.
704
+ * @param[in] request {PiNotifyEventRequest} Prompt-end request metadata.
476
705
  * @return {void} No return value.
477
- * @satisfies REQ-167, REQ-169
706
+ * @satisfies REQ-167, REQ-199
478
707
  */
479
708
  function runPiNotifyPushoverRequest(
480
709
  config: PiNotifyConfigFields,
481
- request: PiNotifyPushoverRequest,
710
+ outcome: PiNotifyOutcome,
711
+ request: PiNotifyEventRequest,
482
712
  ): void {
483
713
  const url = new URL("https://api.pushover.net/1/messages.json");
484
- const body = buildPiNotifyPushoverPayload(config, request).toString();
714
+ const body = buildPiNotifyPushoverPayload(config, outcome, request).toString();
485
715
  const httpRequest = piNotifyHttpsRequest(url, {
486
716
  method: "POST",
487
717
  headers: {
@@ -507,111 +737,41 @@ export function setPiNotifyHttpsRequestForTests(requestImpl: typeof https.reques
507
737
  }
508
738
 
509
739
  /**
510
- * @brief Quotes one installation path for shell substitution.
511
- * @details Emits POSIX single-quoted literals for `sh -lc` execution and CMD double-quoted literals for `cmd.exe /c` execution so `%%INSTALLATION_PATH%%` substitutions preserve whitespace safely. Runtime is O(n) in path length. No external state is mutated.
512
- * @param[in] installationPath {string} Absolute extension installation path.
513
- * @return {string} Shell-quoted installation path fragment.
514
- */
515
- function quotePiNotifyInstallPath(installationPath: string): string {
516
- if (process.platform === "win32") {
517
- return `"${installationPath.replace(/"/g, '""')}"`;
518
- }
519
- return `'${installationPath.replace(/'/g, `'\\''`)}'`;
520
- }
521
-
522
- /**
523
- * @brief Substitutes `%%INSTALLATION_PATH%%` inside one sound command.
524
- * @details Replaces every `%%INSTALLATION_PATH%%` token with a shell-quoted runtime installation path so bundled sound assets can be addressed safely from external commands. Runtime is O(n) in command length. No external state is mutated.
525
- * @param[in] command {string} Raw configured sound command.
526
- * @param[in] installationPath {string} Absolute extension installation path.
527
- * @return {string} Runtime-ready command string.
528
- * @satisfies REQ-133
529
- */
530
- export function substitutePiNotifyInstallPath(command: string, installationPath: string): string {
531
- return command.replaceAll("%%INSTALLATION_PATH%%", quotePiNotifyInstallPath(installationPath));
532
- }
533
-
534
- /**
535
- * @brief Resolves the configured command for one non-`none` sound level.
536
- * @details Selects the matching persisted command string from config without performing runtime substitution or shell execution. Runtime is O(1). No external state is mutated.
537
- * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
538
- * @param[in] soundLevel {Exclude<PiNotifySoundLevel, "none">} Non-disabled sound level.
539
- * @return {string} Configured command string for the requested level.
540
- */
541
- function resolvePiNotifySoundCommand(
542
- config: PiNotifyConfigFields,
543
- soundLevel: Exclude<PiNotifySoundLevel, "none">,
544
- ): string {
545
- switch (soundLevel) {
546
- case "low":
547
- return config.PI_NOTIFY_SOUND_LOW_CMD;
548
- case "mid":
549
- return config.PI_NOTIFY_SOUND_MID_CMD;
550
- case "high":
551
- return config.PI_NOTIFY_SOUND_HIGH_CMD;
552
- }
553
- }
554
-
555
- /**
556
- * @brief Executes the configured successful-run sound command on an external shell.
557
- * @details Resolves the runtime installation path, substitutes `%%INSTALLATION_PATH%%`, spawns the configured shell command without waiting, and ignores transport failures so prompt-end handling remains non-blocking. Runtime is dominated by process spawn. Side effects include detached child-process execution.
558
- * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
559
- * @param[in] soundLevel {Exclude<PiNotifySoundLevel, "none">} Requested non-disabled sound level.
740
+ * @brief Replaces the shell-spawn function used for notify and sound commands in deterministic tests.
741
+ * @details Accepts a drop-in `node:child_process.spawn` replacement and restores the native implementation when `undefined` is supplied. Runtime is O(1). Side effect: mutates the module-local shell transport hook.
742
+ * @param[in] spawnImpl {PiNotifySpawn | undefined} Replacement shell-spawn function.
560
743
  * @return {void} No return value.
561
- * @satisfies REQ-132, REQ-133
562
744
  */
563
- export function runPiNotifySoundCommand(
564
- config: PiNotifyConfigFields,
565
- soundLevel: Exclude<PiNotifySoundLevel, "none">,
566
- ): void {
567
- const rawCommand = resolvePiNotifySoundCommand(config, soundLevel);
568
- const command = substitutePiNotifyInstallPath(rawCommand, getInstallationPath());
569
- const shell = process.platform === "win32" ? "cmd.exe" : (process.env.SHELL ?? "sh");
570
- const shellArgs = process.platform === "win32"
571
- ? ["/d", "/s", "/c", command]
572
- : ["-lc", command];
573
- const child = spawn(shell, shellArgs, {
574
- detached: true,
575
- stdio: "ignore",
576
- });
577
- child.once("error", () => undefined);
578
- child.unref();
745
+ export function setPiNotifySpawnForTests(spawnImpl: PiNotifySpawn | undefined): void {
746
+ piNotifySpawn = spawnImpl ?? spawn;
579
747
  }
580
748
 
581
749
  /**
582
- * @brief Dispatches prompt-end beep, sound, and optional Pushover effects for one agent-end payload.
583
- * @details Classifies the terminal outcome, emits the configured terminal notification only for the enabled outcome flag, executes the configured external sound command only for successful completion with a non-disabled sound level, and dispatches the native Pushover request when successful-run Pushover prerequisites are satisfied. Runtime is O(m + c + b) in message count, command length, and Pushover payload size. Side effects include stdout writes, child-process spawning, and outbound HTTPS requests.
750
+ * @brief Dispatches prompt-end notify, sound, and Pushover effects for one agent-end payload.
751
+ * @details Classifies the terminal outcome, executes `PI_NOTIFY_CMD` when command-notify prerequisites are satisfied, executes the configured sound command when sound prerequisites are satisfied, and dispatches the native Pushover request when Pushover prerequisites are satisfied. Runtime is O(m + c + b) in message count, command length, and Pushover payload size. Side effects include child-process spawning and outbound HTTPS requests.
584
752
  * @param[in] config {PiNotifyConfigFields} Effective notification configuration.
585
753
  * @param[in] event {Pick<AgentEndEvent, "messages">} Agent-end payload subset.
586
- * @param[in] pushoverRequest {PiNotifyPushoverRequest | undefined} Optional successful prompt metadata used for Pushover delivery.
754
+ * @param[in] request {PiNotifyEventRequest | undefined} Optional prompt-end request metadata used for command-notify and Pushover substitution.
587
755
  * @return {void} No return value.
588
- * @satisfies REQ-129, REQ-130, REQ-131, REQ-132, REQ-133, REQ-166, REQ-167, REQ-168, REQ-169, REQ-172
756
+ * @satisfies 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-199
589
757
  */
590
758
  export function runPiNotifyEffects(
591
759
  config: PiNotifyConfigFields,
592
760
  event: Pick<AgentEndEvent, "messages">,
593
- pushoverRequest?: PiNotifyPushoverRequest,
761
+ request?: PiNotifyEventRequest,
594
762
  ): void {
595
763
  const outcome = classifyPiNotifyOutcome(event);
596
- const shouldNotify = (
597
- (outcome === "end" && config["notify-beep-on-end"])
598
- || (outcome === "esc" && config["notify-beep-on-esc"])
599
- || (outcome === "err" && config["notify-beep-on-error"])
600
- );
601
- if (shouldNotify) {
602
- notifyPiTerminal(buildPiNotifyTitle(), buildPiNotifyBody(outcome));
603
- }
604
- if (outcome !== "end") {
605
- return;
764
+ if (shouldRunPiNotifyCommand(config, outcome, request)) {
765
+ runPiNotifyCommand(config, outcome, request as PiNotifyEventRequest);
606
766
  }
607
- if (config["notify-sound"] !== "none") {
767
+ if (shouldRunPiNotifySound(config, outcome)) {
608
768
  runPiNotifySoundCommand(
609
769
  config,
610
770
  config["notify-sound"] as Exclude<PiNotifySoundLevel, "none">,
611
771
  );
612
772
  }
613
- if (!shouldRunPiNotifyPushover(config, pushoverRequest)) {
773
+ if (!shouldRunPiNotifyPushover(config, outcome, request)) {
614
774
  return;
615
775
  }
616
- runPiNotifyPushoverRequest(config, pushoverRequest as PiNotifyPushoverRequest);
776
+ runPiNotifyPushoverRequest(config, outcome, request as PiNotifyEventRequest);
617
777
  }