pi-smart-voice-notify 0.3.2 → 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 CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## [0.3.2] - 2026-04-01
4
16
 
5
17
  ### Changed
@@ -79,7 +91,7 @@
79
91
  - Rewrote README.md with professional documentation standards
80
92
  - Added comprehensive feature documentation, configuration reference, and usage examples
81
93
 
82
- ## 0.1.0
94
+ ## [0.1.0] - 2026-03-02
83
95
 
84
96
  - Standardized repository structure to `index.ts` shim + `src/` implementation.
85
97
  - Added config template and package metadata/scripts aligned with Pi extension conventions.
package/README.md CHANGED
@@ -52,7 +52,7 @@ Windows-optimized smart notification extension for the Pi coding agent.
52
52
 
53
53
  Place this folder in either location (Pi auto-discovers both):
54
54
 
55
- - **Global:** `~/.pi/agent/extensions/pi-smart-voice-notify`
55
+ - **Global default:** `~/.pi/agent/extensions/pi-smart-voice-notify` (respects `PI_CODING_AGENT_DIR`)
56
56
  - **Project:** `.pi/extensions/pi-smart-voice-notify`
57
57
 
58
58
  ### As an npm Package
@@ -93,8 +93,9 @@ pi install git:github.com/MasuRii/pi-smart-voice-notify
93
93
 
94
94
  Configuration is stored at:
95
95
 
96
- ```
97
- ~/.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
98
99
  ```
99
100
 
100
101
  A starter template is provided in `config/config.example.json`. On startup, the extension creates `config.json` with defaults if missing.
@@ -125,7 +126,7 @@ A starter template is provided in `config/config.example.json`. On startup, the
125
126
  | `enablePermissionNotification` | boolean | `true` | Notify on permission blocks |
126
127
  | `enableForwardedPermissionWatcher` | boolean | `true` | Watch forwarded permission request files and notify when new requests arrive |
127
128
  | `includeForwardedPermissionAgentName` | boolean | `true` | Include sanitized requester agent name in forwarded permission notification text |
128
- | `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 |
129
130
  | `enableQuestionNotification` | boolean | `true` | Notify when agent asks a question* |
130
131
  | `enableErrorNotification` | boolean | `true` | Notify on errors |
131
132
  | `suppressIdleAfterError` | boolean | `true` | Skip idle notification if turn had errors |
@@ -253,8 +254,7 @@ src/
253
254
 
254
255
  | Event | Behavior |
255
256
  |-------|----------|
256
- | `session_start` | Load config, reset state, update status bar |
257
- | `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`). |
258
258
  | `session_shutdown` | Cancel reminders, clear status |
259
259
  | `input` | Track user activity, cancel pending reminders |
260
260
  | `agent_start` | Reset error tracking |
@@ -267,7 +267,7 @@ src/
267
267
  When `debugLog: true`, JSONL events are written to:
268
268
 
269
269
  ```
270
- ~/.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)
271
271
  ```
272
272
 
273
273
  Events include: config changes, notifications triggered, audio dispatch, reminders, and errors.
@@ -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,70 +1,70 @@
1
- {
2
- "name": "pi-smart-voice-notify",
3
- "version": "0.3.2",
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.64.0",
65
- "@mariozechner/pi-tui": "^0.64.0"
66
- },
67
- "dependencies": {
68
- "node-notifier": "^10.0.1"
69
- }
70
- }
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
+ }
@@ -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(homedir(), ".pi", "agent", "extensions", EXTENSION_ID);
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 (!config.enabled || !config.reminderEnabled || !config.enableTts || config.notificationMode === "sound-only") {
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
- dispatchWebhook(type, spokenMessage);
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("session_start", async (_event, ctx) => {
1584
- activeSessionContext = ctx;
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
- logger.debug("session.shutdown", {});
1630
- activeSessionContext = null;
1631
- permissionForwardingWatcher.stop();
1632
- pendingPermissionToolCallIds.clear();
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 PRIMARY_REQUESTS_DIR = join(homedir(), ".pi", "agent", "sessions", "permission-forwarding", "requests");
12
- const LEGACY_REQUESTS_DIR = join(homedir(), ".pi", "agent", "permission-forwarding", "requests");
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 delay(waitUntil - now);
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
- await this.waitForRateLimit(item.target);
572
- const result = await this.sendOnce(item.target, item.event);
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 delay(retryDelay);
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
  }