pi-smart-voice-notify 0.3.1 → 0.3.3
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 +20 -1
- package/README.md +16 -8
- package/config/config.example.json +1 -1
- package/package.json +70 -61
- package/src/config-store.ts +2 -2
- package/src/index.ts +153 -39
- package/src/permission-forwarding-watcher.ts +4 -3
- package/src/webhook.ts +63 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.3.3] - 2026-04-22
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Updated README and example config documentation to reflect `PI_CODING_AGENT_DIR`-aware global extension paths and `session_start` lifecycle reasons
|
|
9
|
+
- Resolved extension config/debug paths and forwarded permission watcher paths from `getAgentDir()`
|
|
10
|
+
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.68.1
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Prevented reminders, notification playback, and webhook queue flushing/retry waits from continuing after session shutdown
|
|
14
|
+
|
|
15
|
+
## [0.3.2] - 2026-04-01
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Added Related Pi Extensions cross-linking section to README
|
|
19
|
+
- Aligned npm keywords for improved discoverability
|
|
20
|
+
- Updated README with new image and expanded features documentation
|
|
21
|
+
|
|
3
22
|
## [0.3.1] - 2026-04-01
|
|
4
23
|
|
|
5
24
|
### Changed
|
|
@@ -72,7 +91,7 @@
|
|
|
72
91
|
- Rewrote README.md with professional documentation standards
|
|
73
92
|
- Added comprehensive feature documentation, configuration reference, and usage examples
|
|
74
93
|
|
|
75
|
-
## 0.1.0
|
|
94
|
+
## [0.1.0] - 2026-03-02
|
|
76
95
|
|
|
77
96
|
- Standardized repository structure to `index.ts` shim + `src/` implementation.
|
|
78
97
|
- Added config template and package metadata/scripts aligned with Pi extension conventions.
|
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ Windows-optimized smart notification extension for the Pi coding agent.
|
|
|
4
4
|
|
|
5
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
|
-
|
|
7
|
+
<img width="1360" height="752" alt="image" src="https://github.com/user-attachments/assets/c215a7fe-31b4-4bc4-a89e-992a4847819d" />
|
|
8
|
+
|
|
8
9
|
|
|
9
10
|
## Features
|
|
10
11
|
|
|
@@ -51,7 +52,7 @@ Windows-optimized smart notification extension for the Pi coding agent.
|
|
|
51
52
|
|
|
52
53
|
Place this folder in either location (Pi auto-discovers both):
|
|
53
54
|
|
|
54
|
-
- **Global:** `~/.pi/agent/extensions/pi-smart-voice-notify`
|
|
55
|
+
- **Global default:** `~/.pi/agent/extensions/pi-smart-voice-notify` (respects `PI_CODING_AGENT_DIR`)
|
|
55
56
|
- **Project:** `.pi/extensions/pi-smart-voice-notify`
|
|
56
57
|
|
|
57
58
|
### As an npm Package
|
|
@@ -92,8 +93,9 @@ pi install git:github.com/MasuRii/pi-smart-voice-notify
|
|
|
92
93
|
|
|
93
94
|
Configuration is stored at:
|
|
94
95
|
|
|
95
|
-
```
|
|
96
|
-
~/.pi/agent/extensions/pi-smart-voice-notify/config.json
|
|
96
|
+
```text
|
|
97
|
+
Default global path: ~/.pi/agent/extensions/pi-smart-voice-notify/config.json
|
|
98
|
+
Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-smart-voice-notify/config.json when PI_CODING_AGENT_DIR is set
|
|
97
99
|
```
|
|
98
100
|
|
|
99
101
|
A starter template is provided in `config/config.example.json`. On startup, the extension creates `config.json` with defaults if missing.
|
|
@@ -124,7 +126,7 @@ A starter template is provided in `config/config.example.json`. On startup, the
|
|
|
124
126
|
| `enablePermissionNotification` | boolean | `true` | Notify on permission blocks |
|
|
125
127
|
| `enableForwardedPermissionWatcher` | boolean | `true` | Watch forwarded permission request files and notify when new requests arrive |
|
|
126
128
|
| `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 |
|
|
129
|
+
| `watchLegacyForwardedPermissionPath` | boolean | `true` | Also watch the legacy forwarded-permission directory (default: `~/.pi/agent/permission-forwarding/requests`, respects `PI_CODING_AGENT_DIR`) when present |
|
|
128
130
|
| `enableQuestionNotification` | boolean | `true` | Notify when agent asks a question* |
|
|
129
131
|
| `enableErrorNotification` | boolean | `true` | Notify on errors |
|
|
130
132
|
| `suppressIdleAfterError` | boolean | `true` | Skip idle notification if turn had errors |
|
|
@@ -252,8 +254,7 @@ src/
|
|
|
252
254
|
|
|
253
255
|
| Event | Behavior |
|
|
254
256
|
|-------|----------|
|
|
255
|
-
| `session_start` | Load config, reset state, update status bar |
|
|
256
|
-
| `session_switch` | Reset state, refresh question tool availability |
|
|
257
|
+
| `session_start` | Load config, reset state, update status bar. Handles all session lifecycle transitions via `reason` (`startup`, `reload`, `new`, `resume`, `fork`). |
|
|
257
258
|
| `session_shutdown` | Cancel reminders, clear status |
|
|
258
259
|
| `input` | Track user activity, cancel pending reminders |
|
|
259
260
|
| `agent_start` | Reset error tracking |
|
|
@@ -266,7 +267,7 @@ src/
|
|
|
266
267
|
When `debugLog: true`, JSONL events are written to:
|
|
267
268
|
|
|
268
269
|
```
|
|
269
|
-
~/.pi/agent/extensions/pi-smart-voice-notify/debug/pi-smart-voice-notify.log
|
|
270
|
+
Default global debug log path: ~/.pi/agent/extensions/pi-smart-voice-notify/debug/pi-smart-voice-notify.log (respects PI_CODING_AGENT_DIR)
|
|
270
271
|
```
|
|
271
272
|
|
|
272
273
|
Events include: config changes, notifications triggered, audio dispatch, reminders, and errors.
|
|
@@ -283,6 +284,13 @@ npm run check # build + test
|
|
|
283
284
|
|
|
284
285
|
**Requirements:** Node.js ≥ 24
|
|
285
286
|
|
|
287
|
+
## Related Pi Extensions
|
|
288
|
+
|
|
289
|
+
- [pi-tool-display](https://github.com/MasuRii/pi-tool-display) — Compact tool rendering and diff visualization
|
|
290
|
+
- [pi-permission-system](https://github.com/MasuRii/pi-permission-system) — Permission enforcement for tool and command access
|
|
291
|
+
- [pi-MUST-have-extension](https://github.com/MasuRii/pi-MUST-have-extension) — RFC 2119 keyword normalization for prompts
|
|
292
|
+
- [pi-startup-redraw-fix](https://github.com/MasuRii/pi-startup-redraw-fix) — Fix terminal redraw glitches on startup
|
|
293
|
+
|
|
286
294
|
## License
|
|
287
295
|
|
|
288
296
|
MIT
|
|
@@ -1,5 +1,5 @@
|
|
|
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.",
|
|
2
|
+
"_comment_0": "pi-smart-voice-notify example config. Copy this file to the global Pi extension config path (default: ~/.pi/agent/extensions/pi-smart-voice-notify/config.json; respects PI_CODING_AGENT_DIR) and edit values.",
|
|
3
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
4
|
|
|
5
5
|
"version": 1,
|
package/package.json
CHANGED
|
@@ -1,61 +1,70 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-smart-voice-notify",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./index.ts",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./index.ts"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"index.ts",
|
|
12
|
-
"src",
|
|
13
|
-
"config/config.example.json",
|
|
14
|
-
"assets",
|
|
15
|
-
"README.md",
|
|
16
|
-
"CHANGELOG.md",
|
|
17
|
-
"LICENSE"
|
|
18
|
-
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
|
|
21
|
-
"lint": "npm run build",
|
|
22
|
-
"test": "node --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
|
|
23
|
-
"check": "npm run build && npm run test"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"pi-package",
|
|
27
|
-
"pi",
|
|
28
|
-
"pi-extension",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
},
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-smart-voice-notify",
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"src",
|
|
13
|
+
"config/config.example.json",
|
|
14
|
+
"assets",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"lint": "npm run build",
|
|
22
|
+
"test": "node --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
|
|
23
|
+
"check": "npm run build && npm run test"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"pi-package",
|
|
27
|
+
"pi",
|
|
28
|
+
"pi-extension",
|
|
29
|
+
"pi-coding-agent",
|
|
30
|
+
"coding-agent",
|
|
31
|
+
"voice-notify",
|
|
32
|
+
"desktop-notification",
|
|
33
|
+
"notifications",
|
|
34
|
+
"voice",
|
|
35
|
+
"audio",
|
|
36
|
+
"text-to-speech",
|
|
37
|
+
"tts",
|
|
38
|
+
"windows",
|
|
39
|
+
"linux",
|
|
40
|
+
"macos"
|
|
41
|
+
],
|
|
42
|
+
"author": "MasuRii",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/MasuRii/pi-smart-voice-notify.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/MasuRii/pi-smart-voice-notify#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/MasuRii/pi-smart-voice-notify/issues"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=24"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"pi": {
|
|
59
|
+
"extensions": [
|
|
60
|
+
"./index.ts"
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@mariozechner/pi-coding-agent": "^0.68.1",
|
|
65
|
+
"@mariozechner/pi-tui": "^0.68.1"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"node-notifier": "^10.0.1"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/config-store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
|
|
15
15
|
export const EXTENSION_ID = "pi-smart-voice-notify";
|
|
16
16
|
export const STATUS_KEY = "smart-voice-notify";
|
|
17
|
-
export const CONFIG_DIR = join(
|
|
17
|
+
export const CONFIG_DIR = join(getAgentDir(), "extensions", EXTENSION_ID);
|
|
18
18
|
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
19
19
|
export const DEBUG_DIR = join(CONFIG_DIR, "debug");
|
|
20
20
|
export const DEBUG_LOG_PATH = join(DEBUG_DIR, `${EXTENSION_ID}.log`);
|
package/src/index.ts
CHANGED
|
@@ -51,11 +51,40 @@ import type { TTSConfig, TTSService } from "./types/tts.ts";
|
|
|
51
51
|
import { createWebhookService, type WebhookConfig } from "./webhook.ts";
|
|
52
52
|
import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.ts";
|
|
53
53
|
|
|
54
|
+
type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
|
|
55
|
+
|
|
54
56
|
function pickRandom<T>(items: readonly T[]): T {
|
|
55
57
|
const index = Math.floor(Math.random() * items.length);
|
|
56
58
|
return items[index] ?? items[0];
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
function getSessionStartReason(event: object): SessionStartReason | undefined {
|
|
62
|
+
if (!("reason" in event)) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { reason } = event;
|
|
67
|
+
if (
|
|
68
|
+
reason === "startup"
|
|
69
|
+
|| reason === "reload"
|
|
70
|
+
|| reason === "new"
|
|
71
|
+
|| reason === "resume"
|
|
72
|
+
|| reason === "fork"
|
|
73
|
+
) {
|
|
74
|
+
return reason;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getPreviousSessionFile(event: object): string | undefined {
|
|
81
|
+
if (!("previousSessionFile" in event) || typeof event.previousSessionFile !== "string") {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return event.previousSessionFile;
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
function extractTextContent(content: unknown): string {
|
|
60
89
|
if (!Array.isArray(content)) {
|
|
61
90
|
return "";
|
|
@@ -327,6 +356,8 @@ export default function smartVoiceNotifyExtension(
|
|
|
327
356
|
let audioQueue: Promise<void> = Promise.resolve();
|
|
328
357
|
let questionToolAvailable = false;
|
|
329
358
|
let activeSessionContext: ExtensionContext | null = null;
|
|
359
|
+
let shutdownRequested = false;
|
|
360
|
+
let shutdownPromise: Promise<void> | null = null;
|
|
330
361
|
|
|
331
362
|
const pendingReminders = new Map<ReminderKey, ReminderState>();
|
|
332
363
|
const reminderPlayback = new ReminderPlaybackController();
|
|
@@ -968,7 +999,13 @@ export default function smartVoiceNotifyExtension(
|
|
|
968
999
|
delaySeconds: number,
|
|
969
1000
|
followUpCount: number,
|
|
970
1001
|
): void => {
|
|
971
|
-
if (
|
|
1002
|
+
if (
|
|
1003
|
+
shutdownRequested
|
|
1004
|
+
|| !config.enabled
|
|
1005
|
+
|| !config.reminderEnabled
|
|
1006
|
+
|| !config.enableTts
|
|
1007
|
+
|| config.notificationMode === "sound-only"
|
|
1008
|
+
) {
|
|
972
1009
|
return;
|
|
973
1010
|
}
|
|
974
1011
|
|
|
@@ -984,6 +1021,15 @@ export default function smartVoiceNotifyExtension(
|
|
|
984
1021
|
if (!current || current.scheduledAt !== scheduledAt) {
|
|
985
1022
|
return;
|
|
986
1023
|
}
|
|
1024
|
+
if (shutdownRequested) {
|
|
1025
|
+
pendingReminders.delete(reminderKey);
|
|
1026
|
+
logger.debug("reminder.skipped_shutdown", {
|
|
1027
|
+
reminderKey,
|
|
1028
|
+
type,
|
|
1029
|
+
followUpCount,
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
987
1033
|
|
|
988
1034
|
if (!reminderPlayback.isCurrent(reminderCheckpoint, scheduledAt, lastUserActivityAt)) {
|
|
989
1035
|
pendingReminders.delete(reminderKey);
|
|
@@ -1106,9 +1152,17 @@ export default function smartVoiceNotifyExtension(
|
|
|
1106
1152
|
scheduleReminder?: boolean;
|
|
1107
1153
|
} = {},
|
|
1108
1154
|
): void => {
|
|
1155
|
+
if (shutdownRequested) {
|
|
1156
|
+
logger.debug("notification.skipped_shutdown", {
|
|
1157
|
+
type,
|
|
1158
|
+
reason: options.reason,
|
|
1159
|
+
});
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1109
1163
|
queueTask(
|
|
1110
1164
|
(async () => {
|
|
1111
|
-
if (!config.enabled || !isNotificationEnabled(config, type)) {
|
|
1165
|
+
if (shutdownRequested || !config.enabled || !isNotificationEnabled(config, type)) {
|
|
1112
1166
|
return;
|
|
1113
1167
|
}
|
|
1114
1168
|
if (!options.bypassThrottle && shouldThrottle(type)) {
|
|
@@ -1151,6 +1205,14 @@ export default function smartVoiceNotifyExtension(
|
|
|
1151
1205
|
});
|
|
1152
1206
|
|
|
1153
1207
|
enqueueAudio(async () => {
|
|
1208
|
+
if (shutdownRequested) {
|
|
1209
|
+
logger.debug("notification.playback_skipped", {
|
|
1210
|
+
type,
|
|
1211
|
+
reason: "session_shutdown",
|
|
1212
|
+
});
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1154
1216
|
const mode = config.notificationMode;
|
|
1155
1217
|
const shouldPlaySoundNow =
|
|
1156
1218
|
config.enableSound && (mode === "sound-first" || mode === "both" || mode === "sound-only");
|
|
@@ -1164,6 +1226,10 @@ export default function smartVoiceNotifyExtension(
|
|
|
1164
1226
|
logger.debug("wake.monitor.error", { error: getErrorMessage(error) });
|
|
1165
1227
|
}
|
|
1166
1228
|
|
|
1229
|
+
if (shutdownRequested) {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1167
1233
|
let soundPlayed = false;
|
|
1168
1234
|
if (shouldPlaySoundNow) {
|
|
1169
1235
|
soundPlayed = await playNotificationSound(type);
|
|
@@ -1172,8 +1238,16 @@ export default function smartVoiceNotifyExtension(
|
|
|
1172
1238
|
}
|
|
1173
1239
|
}
|
|
1174
1240
|
|
|
1241
|
+
if (shutdownRequested) {
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1175
1245
|
await dispatchDesktop(type, displayMessage, ctx);
|
|
1176
1246
|
|
|
1247
|
+
if (shutdownRequested) {
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1177
1251
|
if (shouldSpeakNow) {
|
|
1178
1252
|
const spoken = await speakNotification(spokenMessage);
|
|
1179
1253
|
if (!spoken && !shouldPlaySoundNow && config.enableSound) {
|
|
@@ -1181,9 +1255,11 @@ export default function smartVoiceNotifyExtension(
|
|
|
1181
1255
|
}
|
|
1182
1256
|
}
|
|
1183
1257
|
|
|
1184
|
-
|
|
1258
|
+
if (!shutdownRequested) {
|
|
1259
|
+
dispatchWebhook(type, spokenMessage);
|
|
1260
|
+
}
|
|
1185
1261
|
});
|
|
1186
|
-
if (options.scheduleReminder !== false) {
|
|
1262
|
+
if (!shutdownRequested && options.scheduleReminder !== false) {
|
|
1187
1263
|
scheduleReminder(options.reminderKey ?? defaultReminderKey(type), type, getReminderDelaySeconds(type), 0);
|
|
1188
1264
|
}
|
|
1189
1265
|
})(),
|
|
@@ -1580,66 +1656,104 @@ export default function smartVoiceNotifyExtension(
|
|
|
1580
1656
|
},
|
|
1581
1657
|
});
|
|
1582
1658
|
|
|
1583
|
-
pi.on("
|
|
1584
|
-
|
|
1659
|
+
pi.on("resources_discover", (event, _ctx) => {
|
|
1660
|
+
if (event.reason === "reload") {
|
|
1661
|
+
// Clear caches on reload
|
|
1662
|
+
clearFocusDetectCache();
|
|
1663
|
+
clearProjectSoundCache();
|
|
1664
|
+
aiMessageService.clearCache();
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1669
|
+
const reason = getSessionStartReason(event);
|
|
1670
|
+
const previousSessionFile = getPreviousSessionFile(event);
|
|
1671
|
+
|
|
1672
|
+
shutdownRequested = false;
|
|
1673
|
+
shutdownPromise = null;
|
|
1585
1674
|
config = readConfig();
|
|
1586
1675
|
refreshIntegratedServiceConfig();
|
|
1676
|
+
activeSessionContext = ctx;
|
|
1587
1677
|
syncPermissionForwardingWatcher();
|
|
1588
1678
|
refreshQuestionToolAvailability();
|
|
1589
1679
|
clearFocusDetectCache();
|
|
1590
1680
|
clearProjectSoundCache();
|
|
1681
|
+
|
|
1682
|
+
// Clear AI message cache on reload to pick up config changes
|
|
1683
|
+
if (reason === "reload") {
|
|
1684
|
+
aiMessageService.clearCache();
|
|
1685
|
+
}
|
|
1591
1686
|
lastUserActivityAt = Date.now();
|
|
1592
1687
|
hadErrorInTurn = false;
|
|
1593
|
-
warnedNonWindows = false;
|
|
1594
1688
|
warnedDesktopUnsupported = false;
|
|
1595
1689
|
pendingPermissionToolCallIds.clear();
|
|
1596
1690
|
blockedPermissionToolCallIds.clear();
|
|
1597
1691
|
processedToolResultToolCallIds.clear();
|
|
1598
1692
|
lastNotificationAt.clear();
|
|
1693
|
+
|
|
1694
|
+
if (reason === "new" || reason === "resume" || reason === "fork") {
|
|
1695
|
+
resetPermissionBatch(`session_start:${reason}`);
|
|
1696
|
+
cancelReminderActivity(`session_start:${reason}`);
|
|
1697
|
+
updateStatus(ctx);
|
|
1698
|
+
logger.debug("session.start", {
|
|
1699
|
+
reason,
|
|
1700
|
+
previousSessionFile,
|
|
1701
|
+
configPath: CONFIG_PATH,
|
|
1702
|
+
debugLogPath: DEBUG_LOG_PATH,
|
|
1703
|
+
notificationMode: config.notificationMode,
|
|
1704
|
+
});
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
warnedNonWindows = false;
|
|
1599
1709
|
resetPermissionBatch("session_start");
|
|
1600
1710
|
cancelReminderActivity("session_start");
|
|
1601
1711
|
updateStatus(ctx);
|
|
1602
1712
|
logger.debug("session.start", {
|
|
1713
|
+
reason: reason ?? "startup",
|
|
1714
|
+
previousSessionFile,
|
|
1603
1715
|
configPath: CONFIG_PATH,
|
|
1604
1716
|
debugLogPath: DEBUG_LOG_PATH,
|
|
1605
1717
|
notificationMode: config.notificationMode,
|
|
1606
1718
|
});
|
|
1607
1719
|
});
|
|
1608
1720
|
|
|
1609
|
-
pi.on("session_switch", async (_event, ctx) => {
|
|
1610
|
-
activeSessionContext = ctx;
|
|
1611
|
-
syncPermissionForwardingWatcher();
|
|
1612
|
-
refreshQuestionToolAvailability();
|
|
1613
|
-
clearFocusDetectCache();
|
|
1614
|
-
clearProjectSoundCache();
|
|
1615
|
-
lastUserActivityAt = Date.now();
|
|
1616
|
-
hadErrorInTurn = false;
|
|
1617
|
-
warnedDesktopUnsupported = false;
|
|
1618
|
-
pendingPermissionToolCallIds.clear();
|
|
1619
|
-
blockedPermissionToolCallIds.clear();
|
|
1620
|
-
processedToolResultToolCallIds.clear();
|
|
1621
|
-
lastNotificationAt.clear();
|
|
1622
|
-
resetPermissionBatch("session_switch");
|
|
1623
|
-
cancelReminderActivity("session_switch");
|
|
1624
|
-
updateStatus(ctx);
|
|
1625
|
-
logger.debug("session.switch", {});
|
|
1626
|
-
});
|
|
1627
|
-
|
|
1628
1721
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
blockedPermissionToolCallIds.clear();
|
|
1634
|
-
processedToolResultToolCallIds.clear();
|
|
1635
|
-
resetPermissionBatch("session_shutdown");
|
|
1636
|
-
cancelReminderActivity("session_shutdown");
|
|
1637
|
-
clearFocusDetectCache();
|
|
1638
|
-
clearProjectSoundCache();
|
|
1639
|
-
await webhookService.flush();
|
|
1640
|
-
if (ctx.hasUI) {
|
|
1641
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
1722
|
+
shutdownRequested = true;
|
|
1723
|
+
if (shutdownPromise) {
|
|
1724
|
+
await shutdownPromise;
|
|
1725
|
+
return;
|
|
1642
1726
|
}
|
|
1727
|
+
|
|
1728
|
+
shutdownPromise = (async () => {
|
|
1729
|
+
logger.debug("session.shutdown", {});
|
|
1730
|
+
activeSessionContext = null;
|
|
1731
|
+
permissionForwardingWatcher.stop();
|
|
1732
|
+
pendingPermissionToolCallIds.clear();
|
|
1733
|
+
blockedPermissionToolCallIds.clear();
|
|
1734
|
+
processedToolResultToolCallIds.clear();
|
|
1735
|
+
resetPermissionBatch("session_shutdown");
|
|
1736
|
+
cancelReminderActivity("session_shutdown");
|
|
1737
|
+
clearFocusDetectCache();
|
|
1738
|
+
clearProjectSoundCache();
|
|
1739
|
+
try {
|
|
1740
|
+
// Forward abort signal to flush if available (added in pi-coding-agent 0.67.x)
|
|
1741
|
+
const abortSignal = 'signal' in ctx ? (ctx as { signal?: AbortSignal }).signal : undefined;
|
|
1742
|
+
await webhookService.flush(abortSignal);
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
const message = `Failed to flush webhook queue during shutdown: ${getErrorMessage(error)}`;
|
|
1745
|
+
logger.error(new Error(message));
|
|
1746
|
+
if (ctx.hasUI) {
|
|
1747
|
+
ctx.ui.notify(message, "warning");
|
|
1748
|
+
}
|
|
1749
|
+
} finally {
|
|
1750
|
+
if (ctx.hasUI) {
|
|
1751
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
})();
|
|
1755
|
+
|
|
1756
|
+
await shutdownPromise;
|
|
1643
1757
|
});
|
|
1644
1758
|
|
|
1645
1759
|
pi.on("input", async (event) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync, watch, type FSWatcher } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { getErrorMessage } from "./logging.ts";
|
|
@@ -8,8 +8,9 @@ import { toRecord } from "./config-store.ts";
|
|
|
8
8
|
export type PermissionForwardingSource = "primary" | "legacy";
|
|
9
9
|
export type ForwardedPermissionResolutionReason = "request_removed" | "watch_disabled" | "watcher_stopped";
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const AGENT_DIR = getAgentDir();
|
|
12
|
+
const PRIMARY_REQUESTS_DIR = join(AGENT_DIR, "sessions", "permission-forwarding", "requests");
|
|
13
|
+
const LEGACY_REQUESTS_DIR = join(AGENT_DIR, "permission-forwarding", "requests");
|
|
13
14
|
const SCAN_INTERVAL_MS = 1_500;
|
|
14
15
|
|
|
15
16
|
interface WatchDirectoryEntry {
|
package/src/webhook.ts
CHANGED
|
@@ -128,6 +128,30 @@ function delay(ms: number): Promise<void> {
|
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Sleep with abort signal support.
|
|
133
|
+
*/
|
|
134
|
+
function delayWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
135
|
+
return new Promise<void>((resolve, reject) => {
|
|
136
|
+
if (signal?.aborted) {
|
|
137
|
+
reject(new Error("Wait was aborted."));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const handle = setTimeout(() => {
|
|
142
|
+
signal?.removeEventListener("abort", onAbort);
|
|
143
|
+
resolve();
|
|
144
|
+
}, ms);
|
|
145
|
+
|
|
146
|
+
const onAbort = () => {
|
|
147
|
+
clearTimeout(handle);
|
|
148
|
+
reject(new Error("Wait was aborted."));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
131
155
|
function parseBoolean(value: string | undefined): boolean | undefined {
|
|
132
156
|
if (!value) {
|
|
133
157
|
return undefined;
|
|
@@ -481,8 +505,8 @@ export class WebhookService {
|
|
|
481
505
|
return { queued, skipped: queued === 0 };
|
|
482
506
|
}
|
|
483
507
|
|
|
484
|
-
public async flush(): Promise<void> {
|
|
485
|
-
await this.processQueue();
|
|
508
|
+
public async flush(signal?: AbortSignal): Promise<void> {
|
|
509
|
+
await this.processQueue(signal);
|
|
486
510
|
}
|
|
487
511
|
|
|
488
512
|
public getQueueSize(): number {
|
|
@@ -520,13 +544,13 @@ export class WebhookService {
|
|
|
520
544
|
return `${target.provider}:${target.url}`;
|
|
521
545
|
}
|
|
522
546
|
|
|
523
|
-
private async waitForRateLimit(target: ResolvedWebhookTarget): Promise<void> {
|
|
547
|
+
private async waitForRateLimit(target: ResolvedWebhookTarget, signal?: AbortSignal): Promise<void> {
|
|
524
548
|
const key = this.targetKey(target);
|
|
525
549
|
const state = this.rateLimitState.get(key) ?? { lastSentAt: 0, nextAllowedAt: 0 };
|
|
526
550
|
const waitUntil = Math.max(state.nextAllowedAt, state.lastSentAt + this.config.minIntervalMs);
|
|
527
551
|
const now = Date.now();
|
|
528
552
|
if (waitUntil > now) {
|
|
529
|
-
await
|
|
553
|
+
await delayWithAbort(waitUntil - now, signal);
|
|
530
554
|
}
|
|
531
555
|
}
|
|
532
556
|
|
|
@@ -548,32 +572,45 @@ export class WebhookService {
|
|
|
548
572
|
this.config.logger(message, details);
|
|
549
573
|
}
|
|
550
574
|
|
|
551
|
-
private async processQueue(): Promise<void> {
|
|
575
|
+
private async processQueue(signal?: AbortSignal): Promise<void> {
|
|
552
576
|
if (this.processingQueue) {
|
|
553
577
|
return;
|
|
554
578
|
}
|
|
555
579
|
this.processingQueue = true;
|
|
556
580
|
try {
|
|
557
581
|
while (this.queue.length > 0) {
|
|
582
|
+
if (signal?.aborted) {
|
|
583
|
+
// Clear queue on abort to avoid stale items
|
|
584
|
+
this.queue.length = 0;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
558
587
|
const item = this.queue.shift();
|
|
559
588
|
if (!item) {
|
|
560
589
|
continue;
|
|
561
590
|
}
|
|
562
|
-
await this.sendWithRetry(item);
|
|
591
|
+
await this.sendWithRetry(item, signal);
|
|
563
592
|
}
|
|
564
593
|
} finally {
|
|
565
594
|
this.processingQueue = false;
|
|
566
595
|
}
|
|
567
596
|
}
|
|
568
597
|
|
|
569
|
-
private async sendWithRetry(item: QueueItem): Promise<void> {
|
|
598
|
+
private async sendWithRetry(item: QueueItem, signal?: AbortSignal): Promise<void> {
|
|
570
599
|
for (let attempt = 0; attempt <= this.config.maxRetries; attempt += 1) {
|
|
571
|
-
|
|
572
|
-
|
|
600
|
+
if (signal?.aborted) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
await this.waitForRateLimit(item.target, signal);
|
|
604
|
+
const result = await this.sendOnce(item.target, item.event, signal);
|
|
573
605
|
if (result.success) {
|
|
574
606
|
return;
|
|
575
607
|
}
|
|
576
608
|
|
|
609
|
+
// Don't retry on abort
|
|
610
|
+
if (signal?.aborted) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
577
614
|
const targetLabel = maskWebhookUrl(item.target.url);
|
|
578
615
|
const shouldRetryCurrentAttempt = attempt < this.config.maxRetries && shouldRetry(result.statusCode);
|
|
579
616
|
if (!shouldRetryCurrentAttempt) {
|
|
@@ -597,22 +634,32 @@ export class WebhookService {
|
|
|
597
634
|
delayMs: retryDelay,
|
|
598
635
|
statusCode: result.statusCode,
|
|
599
636
|
});
|
|
600
|
-
await
|
|
637
|
+
await delayWithAbort(retryDelay, signal);
|
|
601
638
|
}
|
|
602
639
|
}
|
|
603
640
|
|
|
604
|
-
private async sendOnce(target: ResolvedWebhookTarget, event: WebhookEvent): Promise<SendAttemptResult> {
|
|
641
|
+
private async sendOnce(target: ResolvedWebhookTarget, event: WebhookEvent, externalSignal?: AbortSignal): Promise<SendAttemptResult> {
|
|
605
642
|
this.markRequest(target);
|
|
606
643
|
const payload =
|
|
607
644
|
target.provider === "discord"
|
|
608
645
|
? buildDiscordPayload(target, event, this.config)
|
|
609
646
|
: buildGenericPayload(event);
|
|
610
647
|
|
|
648
|
+
// Check if already aborted before starting
|
|
649
|
+
if (externalSignal?.aborted) {
|
|
650
|
+
return { success: false, error: "Request aborted" };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Create a combined abort controller that respects both timeout and external signal
|
|
611
654
|
const controller = new AbortController();
|
|
612
655
|
const timeout = setTimeout(() => {
|
|
613
656
|
controller.abort();
|
|
614
657
|
}, this.config.requestTimeoutMs);
|
|
615
658
|
|
|
659
|
+
// Wire external signal to abort
|
|
660
|
+
const onExternalAbort = () => controller.abort();
|
|
661
|
+
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
662
|
+
|
|
616
663
|
const headers: HeadersInit = {
|
|
617
664
|
"Content-Type": "application/json",
|
|
618
665
|
...target.headers,
|
|
@@ -647,6 +694,10 @@ export class WebhookService {
|
|
|
647
694
|
error: `HTTP ${response.status}`,
|
|
648
695
|
};
|
|
649
696
|
} catch (error) {
|
|
697
|
+
// Check if this was an external abort
|
|
698
|
+
if (externalSignal?.aborted) {
|
|
699
|
+
return { success: false, error: "Request aborted" };
|
|
700
|
+
}
|
|
650
701
|
const message = getErrorMessage(error);
|
|
651
702
|
return {
|
|
652
703
|
success: false,
|
|
@@ -654,6 +705,7 @@ export class WebhookService {
|
|
|
654
705
|
};
|
|
655
706
|
} finally {
|
|
656
707
|
clearTimeout(timeout);
|
|
708
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
657
709
|
}
|
|
658
710
|
}
|
|
659
711
|
}
|