pi-smart-voice-notify 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-03-07
4
+
5
+ ### Added
6
+ - Added multi-engine TTS support with auto selection plus Edge, espeak-ng, ElevenLabs, OpenAI-compatible, and SAPI backends.
7
+ - Added forwarded permission request watching, reminder playback control, Linux wake/focus helpers, per-project sound discovery, webhook delivery, and AI-generated notification message support.
8
+ - Added targeted tests for abortable commands, reminder playback, and forwarded-permission notification flows.
9
+
10
+ ### Changed
11
+ - Expanded configuration normalization and example config to cover nested reminder, webhook, AI-message, focus-detection, and sound-theme settings while keeping legacy keys compatible.
12
+ - Improved notification orchestration so reminder scheduling, focused-terminal suppression, and desktop/audio delivery can share richer runtime context.
13
+ - Switched the test runner to Node's built-in TypeScript stripping support and raised the documented runtime baseline to Node.js 24.
14
+
15
+ ### Fixed
16
+ - Restored the package test workflow so the published repo can run its checked-in TypeScript tests without a missing loader file.
17
+ - Updated README documentation to cover the new engines, watchers, webhook/AI integrations, and sound-theme behavior.
18
+
19
+ ## [0.1.2] - 2026-03-04
20
+
21
+ ### Fixed
22
+ - Use absolute GitHub raw URL for README image to fix npm display
23
+
3
24
  ## [0.1.1] - 2026-03-04
4
25
 
5
26
  ### Changed
package/README.md CHANGED
@@ -2,31 +2,41 @@
2
2
 
3
3
  Windows-optimized smart notification extension for the Pi coding agent.
4
4
 
5
- **pi-smart-voice-notify** monitors Pi session and tool events to alert you via **Windows SAPI TTS**, **sound playback**, and **desktop toast notifications** when the agent requires your attention.
5
+ **pi-smart-voice-notify** monitors Pi session and tool events to alert you via **multi-engine TTS**, **sound playback**, **desktop toast notifications**, and optional **webhook/AI-assisted messaging** when the agent requires your attention.
6
6
 
7
- ![pi-smart-voice-notify configuration modal](assets/pi-smart-voice-notify.png)
7
+ ![pi-smart-voice-notify configuration modal](https://raw.githubusercontent.com/MasuRii/pi-smart-voice-notify/main/assets/pi-smart-voice-notify.png)
8
8
 
9
9
  ## Features
10
10
 
11
11
  - **Multi-channel notifications**
12
- - **Sound** – Windows audio playback via PowerShell (with beep fallback)
13
- - **Voice** – Windows SAPI text-to-speech with configurable voice and rate
14
- - **Desktop toasts** – Cross-platform notifications via `node-notifier` (Windows/macOS/Linux)
12
+ - **Sound** – local sound playback with fallback beeps and reusable reminder playback control
13
+ - **Voice** – auto-selectable TTS engines: Edge, espeak-ng, ElevenLabs, OpenAI-compatible, and Windows SAPI
14
+ - **Desktop toasts** – cross-platform notifications via `node-notifier` (Windows/macOS/Linux)
15
+ - **Webhook delivery** – optional Discord or generic HTTP webhook notifications
15
16
 
16
17
  - **Intelligent event detection**
17
18
  - Task completion (idle)
18
- - Permission blocks
19
+ - Direct permission blocks plus forwarded subagent permission requests
19
20
  - Questions requiring input (when custom `question` tool is loaded)
20
21
  - Errors
21
22
 
22
23
  - **Reminder system**
23
- - Configurable reminder delays with follow-up scheduling
24
+ - Configurable per-event reminder delays with follow-up scheduling
24
25
  - Exponential backoff multiplier for follow-ups
25
- - Auto-cancel reminders on user activity
26
+ - Auto-cancel reminders on user activity or resolution
26
27
 
27
- - **Wake monitor support**
28
+ - **Focus and wake handling**
28
29
  - Wakes display from sleep before notifications
29
- - Cross-platform: Windows (SendKeys), macOS (caffeinate), Linux (xset/GNOME)
30
+ - Optional focused-terminal suppression on Linux
31
+ - Cross-platform wake strategies for Windows, macOS, and Linux sessions
32
+
33
+ - **Sound customization**
34
+ - Direct per-event sound files
35
+ - Theme-based sound selection and optional per-project sound discovery
36
+ - Theme randomization and default volume controls
37
+
38
+ - **AI message generation**
39
+ - Optional AI-generated notification text with caching and template fallback
30
40
 
31
41
  - **Interactive settings UI**
32
42
  - `/voice-notify` command opens a modal for live configuration
@@ -93,14 +103,18 @@ A starter template is provided in `config/config.example.json`. On startup, the
93
103
  | Option | Type | Default | Description |
94
104
  |--------|------|---------|-------------|
95
105
  | `enabled` | boolean | `true` | Master on/off switch |
96
- | `windowsOptimized` | boolean | `true` | Show warning on non-Windows platforms |
106
+ | `windowsOptimized` | boolean | `true` | Show a compatibility notice on platforms other than Windows/Linux |
97
107
  | `notificationMode` | string | `"sound-first"` | Mode: `sound-first`, `tts-first`, `both`, `sound-only` |
98
- | `enableSound` | boolean | `true` | Enable sound playback (Windows) |
99
- | `enableTts` | boolean | `true` | Enable text-to-speech (Windows) |
108
+ | `enableSound` | boolean | `true` | Enable sound playback |
109
+ | `enableTts` | boolean | `true` | Enable text-to-speech delivery |
110
+ | `ttsEngine` | string | `"auto"` | Engine: `auto`, `edge`, `espeak-ng`, `elevenlabs`, `openai`, `sapi` |
100
111
  | `enableDesktopNotification` | boolean | `true` | Enable desktop toast notifications |
101
112
  | `desktopNotificationTimeout` | number | `8` | Toast display duration in seconds (1–60) |
102
113
  | `wakeMonitor` | boolean | `true` | Wake display from sleep before notifying |
103
114
  | `idleThresholdSeconds` | number | `30` | System idle threshold before waking monitor (5–600) |
115
+ | `skipWhenFocused` | boolean | `false` | Suppress notifications while the active Linux terminal/editor is focused |
116
+
117
+ `windowsOptimized` keeps compatibility messaging for platforms that do not have Linux/Windows-native behavior. Linux users no longer see this notice.
104
118
 
105
119
  ### Event Toggles
106
120
 
@@ -108,12 +122,17 @@ A starter template is provided in `config/config.example.json`. On startup, the
108
122
  |--------|------|---------|-------------|
109
123
  | `enableIdleNotification` | boolean | `true` | Notify when agent finishes a task |
110
124
  | `enablePermissionNotification` | boolean | `true` | Notify on permission blocks |
125
+ | `enableForwardedPermissionWatcher` | boolean | `true` | Watch forwarded permission request files and notify when new requests arrive |
126
+ | `includeForwardedPermissionAgentName` | boolean | `true` | Include sanitized requester agent name in forwarded permission notification text |
127
+ | `watchLegacyForwardedPermissionPath` | boolean | `true` | Also watch legacy `~/.pi/agent/permission-forwarding/requests` when present |
111
128
  | `enableQuestionNotification` | boolean | `true` | Notify when agent asks a question* |
112
129
  | `enableErrorNotification` | boolean | `true` | Notify on errors |
113
130
  | `suppressIdleAfterError` | boolean | `true` | Skip idle notification if turn had errors |
114
131
 
115
132
  *Question notifications only work when a custom `question` tool is loaded.
116
133
 
134
+ Forwarded permission watcher notifications use privacy-safe text and never include raw forwarded `message` content.
135
+
117
136
  ### Reminder Settings
118
137
 
119
138
  | Option | Type | Default | Description |
@@ -124,12 +143,20 @@ A starter template is provided in `config/config.example.json`. On startup, the
124
143
  | `maxFollowUps` | number | `3` | Maximum follow-up count (1–10) |
125
144
  | `followUpBackoffMultiplier` | number | `1.5` | Delay multiplier for each follow-up |
126
145
 
127
- ### TTS Settings (Windows)
146
+ ### TTS Settings
128
147
 
129
148
  | Option | Type | Default | Description |
130
149
  |--------|------|---------|-------------|
131
- | `ttsVoice` | string | `"Microsoft Zira Desktop"` | SAPI voice name |
132
- | `ttsRate` | number | `-1` | Speech rate (-10 to 10) |
150
+ | `voice` | string | `"Microsoft Zira Desktop"` | Generic preferred voice label |
151
+ | `rate` | number | `-1` | Generic speaking rate |
152
+ | `volume` | number | `85` | Preferred playback volume percentage |
153
+ | `fallbackChain` | array | `["edge", "espeak-ng", "sapi"]` | TTS engines tried when `ttsEngine` is `auto` |
154
+ | `ttsVoice` | string | `"Microsoft Zira Desktop"` | Legacy SAPI-compatible alias |
155
+ | `ttsRate` | number | `-1` | Legacy SAPI-compatible alias |
156
+ | `edgeVoice` | string | `"en-US-JennyNeural"` | Microsoft Edge voice |
157
+ | `espeakVoice` | string | `"en"` | `espeak-ng` voice for Linux fallback |
158
+ | `elevenLabsVoiceId` | string | `"cgSgspJ2msm6clMCkdW9"` | ElevenLabs voice id |
159
+ | `openaiTtsVoice` | string | `"alloy"` | OpenAI-compatible voice |
133
160
 
134
161
  ### Sound File Paths
135
162
 
@@ -142,10 +169,18 @@ A starter template is provided in `config/config.example.json`. On startup, the
142
169
 
143
170
  Paths can be absolute or relative to the extension directory.
144
171
 
145
- ### Advanced Settings
172
+ ### Sound, Webhook, and AI Settings
146
173
 
147
174
  | Option | Type | Default | Description |
148
175
  |--------|------|---------|-------------|
176
+ | `themeName` | string | `"default"` | Preferred sound theme name |
177
+ | `enablePerProjectSounds` | boolean | `false` | Search the current project for matching notification sounds |
178
+ | `randomizeThemeSounds` | boolean | `true` | Randomize among matching themed sounds |
179
+ | `webhook.enabled` | boolean | `false` | Enable Discord/generic webhook delivery |
180
+ | `webhook.events` | array | `["idle", "permission", "question", "error"]` | Notification types sent through webhooks |
181
+ | `aiMessages.enabled` | boolean | `false` | Enable AI-generated notification copy |
182
+ | `aiMessages.model` | string | `"llama3"` | Model id used for AI notification generation |
183
+ | `aiMessages.caching.enabled` | boolean | `true` | Cache generated messages to reduce repeat calls |
149
184
  | `minNotificationIntervalMs` | number | `1500` | Throttle interval between same-type notifications |
150
185
  | `debugLog` | boolean | `false` | Enable debug logging to file |
151
186
 
@@ -196,10 +231,19 @@ Ensure system idle time exceeds `idleThresholdSeconds` for wake to trigger.
196
231
  index.ts → Extension entrypoint (re-exports src/index.ts)
197
232
  src/
198
233
  ├── index.ts → Main extension logic, event handlers, command registration
199
- ├── config-store.ts → Config paths, normalization, load/save utilities
200
- ├── types.ts → TypeScript interfaces and types
201
- ├── notify-audio.ts → Windows sound + SAPI TTS + monitor wake service
234
+ ├── config-store.ts → Config paths, normalization, env overrides, load/save utilities
235
+ ├── types.ts → Shared configuration and runtime types
236
+ ├── notify-audio.ts → Audio dispatch and Windows/SAPI playback integration
237
+ ├── tts.ts → Multi-engine TTS selection and speech dispatch
202
238
  ├── desktop-notify.ts → Desktop toast notifications via node-notifier
239
+ ├── permission-forwarding-watcher.ts → Watches forwarded permission request directories
240
+ ├── reminder-playback.ts → Deduplicates/cancels overlapping reminder playback
241
+ ├── sound-theme.ts → Theme and sound file resolution
242
+ ├── per-project-sound.ts → Project-local sound discovery helpers
243
+ ├── webhook.ts → Discord and generic HTTP webhook delivery
244
+ ├── ai-messages.ts → AI-generated notification message generation and caching
245
+ ├── linux.ts → Linux wake/audio/focus helpers
246
+ ├── focus-detect.ts → Terminal focus detection cache
203
247
  ├── logging.ts → Debug logger with JSONL output
204
248
  └── zellij-modal.ts → Settings modal UI components
205
249
  ```
@@ -232,12 +276,12 @@ Events include: config changes, notifications triggered, audio dispatch, reminde
232
276
  ```bash
233
277
  npm install
234
278
  npm run build # TypeScript compilation
235
- npm run lint # Run linter
236
- npm run test # Run tests
237
- npm run check # lint + test
279
+ npm run lint # Alias for build
280
+ npm run test # Node test runner with built-in TypeScript stripping
281
+ npm run check # build + test
238
282
  ```
239
283
 
240
- **Requirements:** Node.js ≥ 20
284
+ **Requirements:** Node.js ≥ 24
241
285
 
242
286
  ## License
243
287
 
@@ -1,7 +1,12 @@
1
1
  {
2
+ "_comment_0": "pi-smart-voice-notify example config. Copy this file to ~/.pi/agent/extensions/pi-smart-voice-notify/config.json and edit values.",
3
+ "_comment_1": "Secrets should be provided via environment variables (ELEVENLABS_API_KEY, OPENAI_TTS_API_KEY, OPENAI_API_KEY, DISCORD_WEBHOOK_URL, WEBHOOK_URL).",
4
+
2
5
  "version": 1,
3
6
  "enabled": true,
4
7
  "windowsOptimized": true,
8
+
9
+ "_comment_notifications": "Notification delivery and channel toggles.",
5
10
  "notificationMode": "sound-first",
6
11
  "enableSound": true,
7
12
  "enableTts": true,
@@ -11,20 +16,155 @@
11
16
  "idleThresholdSeconds": 30,
12
17
  "enableIdleNotification": true,
13
18
  "enablePermissionNotification": true,
19
+ "enableForwardedPermissionWatcher": true,
20
+ "includeForwardedPermissionAgentName": true,
21
+ "watchLegacyForwardedPermissionPath": true,
14
22
  "enableQuestionNotification": true,
15
23
  "enableErrorNotification": true,
24
+ "minNotificationIntervalMs": 1500,
25
+ "suppressIdleAfterError": true,
26
+
27
+ "_comment_focus": "Focus detection (Linux): when true, suppress notification while terminal/editor is focused.",
28
+ "skipWhenFocused": false,
29
+ "focusCacheTtl": 400,
30
+ "focusCacheTtlMs": 400,
31
+
32
+ "_comment_reminders": "Legacy reminder keys remain supported. New nested reminderIntervals/reminderEscalation are preferred.",
16
33
  "reminderEnabled": true,
17
34
  "reminderDelaySeconds": 30,
18
35
  "followUpEnabled": true,
19
36
  "maxFollowUps": 3,
20
37
  "followUpBackoffMultiplier": 1.5,
21
- "minNotificationIntervalMs": 1500,
22
- "suppressIdleAfterError": true,
38
+ "reminderIntervals": {
39
+ "_comment": "Per-event reminder delays (seconds).",
40
+ "defaultSeconds": 30,
41
+ "idleSeconds": 30,
42
+ "permissionSeconds": 20,
43
+ "questionSeconds": 25,
44
+ "errorSeconds": 20
45
+ },
46
+ "reminderEscalation": {
47
+ "_comment": "Escalation behavior for repeat reminders.",
48
+ "enabled": true,
49
+ "maxFollowUps": 3,
50
+ "backoffMultiplier": 1.5
51
+ },
52
+
53
+ "_comment_tts_core": "Generic TTS controls.",
54
+ "ttsEngine": "auto",
55
+ "voice": "Microsoft Zira Desktop",
56
+ "rate": -1,
57
+ "volume": 85,
58
+ "fallbackChain": ["edge", "espeak-ng", "sapi"],
59
+ "commandTimeoutMs": 30000,
60
+
61
+ "_comment_tts_legacy": "Legacy SAPI-compatible aliases (kept for backward compatibility).",
23
62
  "ttsVoice": "Microsoft Zira Desktop",
24
63
  "ttsRate": -1,
64
+ "sapiVoice": "Microsoft Zira Desktop",
65
+ "sapiRate": -1,
66
+ "sapiPitch": "medium",
67
+ "sapiVolume": "loud",
68
+
69
+ "_comment_tts_edge": "Edge TTS settings.",
70
+ "edgeVoice": "en-US-JennyNeural",
71
+ "edgeRate": "+10%",
72
+ "edgePitch": "+0Hz",
73
+ "edgeVolume": "+0%",
74
+
75
+ "_comment_tts_espeak": "espeak-ng settings (Linux fallback).",
76
+ "espeakVoice": "en",
77
+ "espeakRate": 175,
78
+ "espeakPitch": 50,
79
+
80
+ "_comment_tts_elevenlabs": "ElevenLabs API settings. Keep api key empty here and use ELEVENLABS_API_KEY env var.",
81
+ "elevenLabsApiKey": "",
82
+ "elevenLabsVoiceId": "cgSgspJ2msm6clMCkdW9",
83
+ "elevenLabsModel": "eleven_turbo_v2_5",
84
+ "elevenLabsStability": 0.5,
85
+ "elevenLabsSimilarity": 0.75,
86
+ "elevenLabsStyle": 0.5,
87
+
88
+ "_comment_tts_openai": "OpenAI-compatible TTS endpoint settings. Keep api key empty here and use OPENAI_TTS_API_KEY/OPENAI_API_KEY env vars.",
89
+ "openaiTtsEndpoint": "",
90
+ "openaiTtsApiKey": "",
91
+ "openaiTtsModel": "tts-1",
92
+ "openaiTtsVoice": "alloy",
93
+ "openaiTtsFormat": "mp3",
94
+ "openaiTtsSpeed": 1,
95
+
96
+ "_comment_sounds": "Direct sound file fallbacks.",
25
97
  "idleSoundFile": "assets/Soft-high-tech-notification-sound-effect.mp3",
26
98
  "permissionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
27
99
  "questionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
28
100
  "errorSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
101
+
102
+ "_comment_sound_theme": "Sound theme settings.",
103
+ "themePath": "",
104
+ "themeName": "default",
105
+ "themesRootPath": "",
106
+ "themeConfigPath": "",
107
+ "customSoundDirectories": [],
108
+ "perProjectSounds": false,
109
+ "enablePerProjectSounds": false,
110
+ "randomizeThemeSounds": true,
111
+ "themeDefaultVolume": 100,
112
+
113
+ "_comment_webhook": "Webhook notifications (Discord and generic HTTP webhooks).",
114
+ "webhook": {
115
+ "enabled": false,
116
+ "discordUrl": "",
117
+ "genericUrl": "",
118
+ "events": ["idle", "permission", "question", "error"],
119
+ "mentionOnPermission": false,
120
+ "username": "Pi Smart Notify",
121
+ "minIntervalMs": 1500,
122
+ "maxRetries": 3,
123
+ "requestTimeoutMs": 8000
124
+ },
125
+ "_comment_webhook_legacy": "Legacy flat webhook keys (kept for backward compatibility).",
126
+ "enableWebhook": false,
127
+ "webhookEnabled": false,
128
+ "discordWebhookUrl": "",
129
+ "genericWebhookUrl": "",
130
+ "webhookEvents": ["idle", "permission", "question", "error"],
131
+
132
+ "_comment_ai": "AI-generated message settings. Keep api key empty here and use PI_SMART_NOTIFY_AI_API_KEY or OPENAI_API_KEY env var.",
133
+ "aiMessages": {
134
+ "enabled": false,
135
+ "endpoint": "http://localhost:11434/v1",
136
+ "model": "llama3",
137
+ "apiKey": "",
138
+ "timeoutMs": 15000,
139
+ "temperature": 0.7,
140
+ "maxTokens": 120,
141
+ "fallbackToTemplates": true,
142
+ "personality": "helpful assistant",
143
+ "tone": "friendly and concise",
144
+ "caching": {
145
+ "enabled": true,
146
+ "ttlMs": 60000,
147
+ "maxEntries": 200
148
+ },
149
+ "templates": {}
150
+ },
151
+ "_comment_ai_legacy": "Legacy flat AI keys (kept for backward compatibility).",
152
+ "enableAIMessages": false,
153
+ "aiEndpoint": "http://localhost:11434/v1",
154
+ "aiModel": "llama3",
155
+ "aiApiKey": "",
156
+ "aiTimeoutMs": 15000,
157
+ "aiTemperature": 0.7,
158
+ "aiMaxTokens": 120,
159
+ "aiFallbackToTemplates": true,
160
+ "personality": "helpful assistant",
161
+ "tone": "friendly and concise",
162
+ "aiPersonality": "helpful assistant",
163
+ "aiTone": "friendly and concise",
164
+ "enableMessageCache": true,
165
+ "messageCacheTtlMs": 60000,
166
+ "maxCacheEntries": 200,
167
+ "aiTemplates": {},
168
+
29
169
  "debugLog": false
30
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-smart-voice-notify",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -17,10 +17,10 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "scripts": {
20
- "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
20
+ "build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
21
21
  "lint": "npm run build",
22
- "test": "node --test",
23
- "check": "npm run lint && npm run test"
22
+ "test": "node --import ./test-ts-register.mjs --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
23
+ "check": "npm run build && npm run test"
24
24
  },
25
25
  "keywords": [
26
26
  "pi-package",
@@ -33,7 +33,7 @@
33
33
  "author": "MasuRii",
34
34
  "license": "MIT",
35
35
  "engines": {
36
- "node": ">=20"
36
+ "node": ">=24"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { runAbortableCommand } from "./abortable-command.ts";
5
+
6
+ test("runAbortableCommand returns an aborted result when the signal is already aborted", async () => {
7
+ const controller = new AbortController();
8
+ controller.abort();
9
+
10
+ const result = await runAbortableCommand(process.execPath, ["-e", "process.exit(0)"], {
11
+ signal: controller.signal,
12
+ timeoutMs: 1_000,
13
+ });
14
+
15
+ assert.equal(result.aborted, true);
16
+ assert.equal(result.code, 1);
17
+ assert.match(result.errorMessage ?? "", /aborted before start/i);
18
+ });
19
+
20
+ test("runAbortableCommand stops an in-flight process when aborted", async () => {
21
+ const controller = new AbortController();
22
+ const startedAt = Date.now();
23
+ const command = runAbortableCommand(
24
+ process.execPath,
25
+ [
26
+ "-e",
27
+ "process.on('SIGTERM', () => process.exit(0)); setTimeout(() => process.exit(0), 10000);",
28
+ ],
29
+ {
30
+ signal: controller.signal,
31
+ timeoutMs: 5_000,
32
+ },
33
+ );
34
+
35
+ setTimeout(() => {
36
+ controller.abort();
37
+ }, 100);
38
+
39
+ const result = await command;
40
+ const elapsedMs = Date.now() - startedAt;
41
+
42
+ assert.equal(result.aborted, true);
43
+ assert.equal(result.timedOut, false);
44
+ assert.ok(elapsedMs < 4_000, `Expected aborted command to stop quickly, received ${elapsedMs}ms`);
45
+ });
@@ -0,0 +1,151 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export interface AbortableCommandOptions {
4
+ timeoutMs?: number;
5
+ signal?: AbortSignal;
6
+ env?: NodeJS.ProcessEnv;
7
+ cwd?: string;
8
+ }
9
+
10
+ export interface AbortableCommandResult {
11
+ code: number;
12
+ stdout: string;
13
+ stderr: string;
14
+ timedOut: boolean;
15
+ aborted: boolean;
16
+ errorMessage?: string;
17
+ }
18
+
19
+ function stringifyError(error: unknown): string {
20
+ if (error instanceof Error && error.message.trim().length > 0) {
21
+ return error.message;
22
+ }
23
+ return String(error);
24
+ }
25
+
26
+ function buildCommandString(command: string, args: readonly string[]): string {
27
+ return args.length > 0 ? `${command} ${args.join(" ")}` : command;
28
+ }
29
+
30
+ function stopChildProcess(child: ReturnType<typeof spawn>, force = false): void {
31
+ if (child.killed) {
32
+ return;
33
+ }
34
+
35
+ try {
36
+ if (process.platform === "win32") {
37
+ child.kill();
38
+ return;
39
+ }
40
+ child.kill(force ? "SIGKILL" : "SIGTERM");
41
+ } catch {
42
+ // noop
43
+ }
44
+ }
45
+
46
+ export async function runAbortableCommand(
47
+ command: string,
48
+ args: readonly string[] = [],
49
+ options: AbortableCommandOptions = {},
50
+ ): Promise<AbortableCommandResult> {
51
+ const normalizedCommand = command.trim();
52
+ if (!normalizedCommand) {
53
+ throw new Error("runAbortableCommand: command must be a non-empty string");
54
+ }
55
+ if (!Array.isArray(args)) {
56
+ throw new Error("runAbortableCommand: args must be an array of strings");
57
+ }
58
+
59
+ const commandLabel = buildCommandString(normalizedCommand, args);
60
+ if (options.signal?.aborted) {
61
+ return {
62
+ code: 1,
63
+ stdout: "",
64
+ stderr: "",
65
+ timedOut: false,
66
+ aborted: true,
67
+ errorMessage: `Command aborted before start: ${commandLabel}`,
68
+ };
69
+ }
70
+
71
+ return await new Promise<AbortableCommandResult>((resolve) => {
72
+ const child = spawn(normalizedCommand, [...args], {
73
+ env: options.env ?? process.env,
74
+ cwd: options.cwd,
75
+ });
76
+ let stdout = "";
77
+ let stderr = "";
78
+ let timedOut = false;
79
+ let aborted = false;
80
+ let spawnError: Error | null = null;
81
+ let forceKillTimer: NodeJS.Timeout | null = null;
82
+
83
+ const cleanup = (): void => {
84
+ if (timeoutTimer) {
85
+ clearTimeout(timeoutTimer);
86
+ }
87
+ if (forceKillTimer) {
88
+ clearTimeout(forceKillTimer);
89
+ forceKillTimer = null;
90
+ }
91
+ options.signal?.removeEventListener("abort", onAbort);
92
+ };
93
+
94
+ const scheduleForceKill = (): void => {
95
+ if (process.platform === "win32" || forceKillTimer) {
96
+ return;
97
+ }
98
+ forceKillTimer = setTimeout(() => {
99
+ stopChildProcess(child, true);
100
+ }, 750);
101
+ };
102
+
103
+ const onAbort = (): void => {
104
+ aborted = true;
105
+ stopChildProcess(child);
106
+ scheduleForceKill();
107
+ };
108
+
109
+ const timeoutMs = typeof options.timeoutMs === "number" ? Math.max(1, Math.floor(options.timeoutMs)) : 0;
110
+ const timeoutTimer =
111
+ timeoutMs > 0
112
+ ? setTimeout(() => {
113
+ timedOut = true;
114
+ stopChildProcess(child);
115
+ scheduleForceKill();
116
+ }, timeoutMs)
117
+ : null;
118
+
119
+ options.signal?.addEventListener("abort", onAbort, { once: true });
120
+
121
+ child.stdout?.on("data", (chunk: Buffer | string) => {
122
+ stdout += chunk.toString();
123
+ });
124
+
125
+ child.stderr?.on("data", (chunk: Buffer | string) => {
126
+ stderr += chunk.toString();
127
+ });
128
+
129
+ child.on("error", (error) => {
130
+ spawnError = error;
131
+ });
132
+
133
+ child.on("close", (code) => {
134
+ cleanup();
135
+ resolve({
136
+ code: code ?? (spawnError || timedOut || aborted ? 1 : 0),
137
+ stdout,
138
+ stderr,
139
+ timedOut,
140
+ aborted,
141
+ errorMessage: spawnError
142
+ ? stringifyError(spawnError)
143
+ : timedOut
144
+ ? `Command timed out after ${timeoutMs}ms: ${commandLabel}`
145
+ : aborted
146
+ ? `Command aborted: ${commandLabel}`
147
+ : undefined,
148
+ });
149
+ });
150
+ });
151
+ }