pi-smart-voice-notify 0.3.3 → 0.3.5
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 +14 -9
- package/README.md +7 -5
- package/package.json +4 -4
- package/src/ai-messages.ts +1 -1
- package/src/config-store.ts +1 -10
- package/src/index.test.ts +177 -10
- package/src/index.ts +24 -66
- package/src/logging.ts +2 -3
- package/src/permission-forwarding-watcher.test.ts +180 -0
- package/src/permission-forwarding-watcher.ts +577 -378
- package/src/webhook.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [0.3.5] - 2026-04-27
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
### Fixed
|
|
6
|
+
- Permission notifications now require an authoritative `pi-permission-system:permission-request` waiting event before alerts or reminders are queued, preventing permission-looking `tool_call` and `tool_result` payloads from producing false permission alerts.
|
|
7
|
+
- Forwarded permission notifications now watch only the active session's scoped request/response directories, require matching `targetSessionId`, and ignore unscoped legacy paths, stale requests, resolved requests, malformed files, and mismatched request filenames.
|
|
6
8
|
|
|
7
|
-
|
|
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
|
|
9
|
+
## [0.3.4] - 2026-04-25
|
|
11
10
|
|
|
12
|
-
###
|
|
13
|
-
-
|
|
11
|
+
### Added
|
|
12
|
+
- Added shutdown-aware notification lifecycle handling so pending reminders and queued playback stop cleanly during session shutdown
|
|
13
|
+
- Added `session_start.reason` handling for startup, reload, new, resume, and fork session transitions
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Updated global extension path documentation to account for `PI_CODING_AGENT_DIR`-aware configuration and debug paths
|
|
17
|
+
- Synchronized package metadata and lockfile for the 0.3.4 patch release while preserving `@mariozechner/pi-*` peer dependency range `^0.70.2`
|
|
18
|
+
- Release note context: `v0.3.2` is not on `main`; no tag or history repair is included in this release prep
|
|
14
19
|
|
|
15
20
|
## [0.3.2] - 2026-04-01
|
|
16
21
|
|
|
@@ -91,7 +96,7 @@
|
|
|
91
96
|
- Rewrote README.md with professional documentation standards
|
|
92
97
|
- Added comprehensive feature documentation, configuration reference, and usage examples
|
|
93
98
|
|
|
94
|
-
##
|
|
99
|
+
## 0.1.0
|
|
95
100
|
|
|
96
101
|
- Standardized repository structure to `index.ts` shim + `src/` implementation.
|
|
97
102
|
- Added config template and package metadata/scripts aligned with Pi extension conventions.
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# 🔔 pi-smart-voice-notify
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-smart-voice-notify) [](LICENSE)
|
|
4
|
+
|
|
3
5
|
Windows-optimized smart notification extension for the Pi coding agent.
|
|
4
6
|
|
|
5
7
|
**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.
|
|
@@ -17,7 +19,7 @@ Windows-optimized smart notification extension for the Pi coding agent.
|
|
|
17
19
|
|
|
18
20
|
- **Intelligent event detection**
|
|
19
21
|
- Task completion (idle)
|
|
20
|
-
-
|
|
22
|
+
- Authoritative permission-system wait events plus current-session forwarded subagent permission requests
|
|
21
23
|
- Questions requiring input (when custom `question` tool is loaded)
|
|
22
24
|
- Errors
|
|
23
25
|
|
|
@@ -123,17 +125,17 @@ A starter template is provided in `config/config.example.json`. On startup, the
|
|
|
123
125
|
| Option | Type | Default | Description |
|
|
124
126
|
|--------|------|---------|-------------|
|
|
125
127
|
| `enableIdleNotification` | boolean | `true` | Notify when agent finishes a task |
|
|
126
|
-
| `enablePermissionNotification` | boolean | `true` | Notify
|
|
127
|
-
| `enableForwardedPermissionWatcher` | boolean | `true` | Watch forwarded permission request files and notify when new requests arrive |
|
|
128
|
+
| `enablePermissionNotification` | boolean | `true` | Notify when the permission system reports requests waiting for approval |
|
|
129
|
+
| `enableForwardedPermissionWatcher` | boolean | `true` | Watch current-session forwarded permission request/response files and notify when new requests arrive |
|
|
128
130
|
| `includeForwardedPermissionAgentName` | boolean | `true` | Include sanitized requester agent name in forwarded permission notification text |
|
|
129
|
-
| `watchLegacyForwardedPermissionPath` | boolean | `true` |
|
|
131
|
+
| `watchLegacyForwardedPermissionPath` | boolean | `true` | Retained for config compatibility; unscoped legacy forwarded-permission paths are ignored because they cannot prove the current target session |
|
|
130
132
|
| `enableQuestionNotification` | boolean | `true` | Notify when agent asks a question* |
|
|
131
133
|
| `enableErrorNotification` | boolean | `true` | Notify on errors |
|
|
132
134
|
| `suppressIdleAfterError` | boolean | `true` | Skip idle notification if turn had errors |
|
|
133
135
|
|
|
134
136
|
*Question notifications only work when a custom `question` tool is loaded.
|
|
135
137
|
|
|
136
|
-
Forwarded permission watcher notifications use privacy-safe text and never include raw forwarded `message` content.
|
|
138
|
+
Forwarded permission watcher notifications use privacy-safe text, require the request `targetSessionId` to match the active Pi session, and never include raw forwarded `message` content.
|
|
137
139
|
|
|
138
140
|
### Reminder Settings
|
|
139
141
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-smart-voice-notify",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
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 --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
|
|
22
|
+
"test": "node --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/permission-forwarding-watcher.test.ts src/index.test.ts",
|
|
23
23
|
"check": "npm run build && npm run test"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
]
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
65
|
-
"@mariozechner/pi-tui": "^0.
|
|
64
|
+
"@mariozechner/pi-coding-agent": "^0.70.2",
|
|
65
|
+
"@mariozechner/pi-tui": "^0.70.2"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"node-notifier": "^10.0.1"
|
package/src/ai-messages.ts
CHANGED
|
@@ -70,7 +70,7 @@ const DEFAULT_TEMPLATES: Record<CoreAIEventType, string[]> = {
|
|
|
70
70
|
permission: [
|
|
71
71
|
"Permission needed for {projectName}. Please approve in the terminal.",
|
|
72
72
|
"I need your approval to continue {projectName}.",
|
|
73
|
-
"
|
|
73
|
+
"Permission approval is pending. Please confirm the request.",
|
|
74
74
|
],
|
|
75
75
|
question: [
|
|
76
76
|
"I need your input for {projectName}. Please check the terminal.",
|
package/src/config-store.ts
CHANGED
|
@@ -45,7 +45,7 @@ const DEFAULT_WEBHOOK_EVENTS: NotificationType[] = [...NOTIFICATION_TYPE_VALUES]
|
|
|
45
45
|
|
|
46
46
|
export const INLINE_NOTIFY_TEXT: Record<NotificationType, string> = {
|
|
47
47
|
idle: "✅ Agent finished its current task.",
|
|
48
|
-
permission: "⚠️
|
|
48
|
+
permission: "⚠️ Permission approval is pending.",
|
|
49
49
|
question: "❓ Agent needs your input.",
|
|
50
50
|
error: "❌ Agent encountered an error.",
|
|
51
51
|
};
|
|
@@ -108,15 +108,6 @@ export const MESSAGE_LIBRARY: Record<NotificationType, MessageSet> = {
|
|
|
108
108
|
},
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
export const PERMISSION_HINTS = [
|
|
112
|
-
"permission",
|
|
113
|
-
"not permitted",
|
|
114
|
-
"requires approval",
|
|
115
|
-
"approval",
|
|
116
|
-
"user denied",
|
|
117
|
-
"blocked by",
|
|
118
|
-
];
|
|
119
|
-
|
|
120
111
|
export const QUESTION_HINTS = ["question", "need your input", "please answer", "requires your input"];
|
|
121
112
|
|
|
122
113
|
export const DEFAULT_CONFIG: VoiceNotifyConfig = {
|
package/src/index.test.ts
CHANGED
|
@@ -61,6 +61,7 @@ class FakeEventBus {
|
|
|
61
61
|
|
|
62
62
|
class FakePi {
|
|
63
63
|
private readonly handlers = new Map<string, EventHandler[]>();
|
|
64
|
+
private tools: Array<{ name: string }> = [];
|
|
64
65
|
|
|
65
66
|
public readonly events = new FakeEventBus();
|
|
66
67
|
|
|
@@ -73,8 +74,12 @@ class FakePi {
|
|
|
73
74
|
public registerCommand(): void {
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
public setAvailableTools(tools: Array<{ name: string }>): void {
|
|
78
|
+
this.tools = [...tools];
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
public getAllTools(): Array<{ name: string }> {
|
|
77
|
-
return [];
|
|
82
|
+
return [...this.tools];
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
public sendMessage(): void {
|
|
@@ -397,7 +402,13 @@ test("skipWhenFocused=false still notifies even when the terminal is focused", a
|
|
|
397
402
|
|
|
398
403
|
await pi.emit("session_start", {}, ctx);
|
|
399
404
|
await flushAsyncWork();
|
|
400
|
-
|
|
405
|
+
pi.events.emit(
|
|
406
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
407
|
+
permissionSystemEvent("waiting", "permission-focus-disabled", {
|
|
408
|
+
toolCallId: "call-focus-disabled",
|
|
409
|
+
toolName: "write_file",
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
401
412
|
await flushAsyncWork();
|
|
402
413
|
await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
|
|
403
414
|
|
|
@@ -425,7 +436,13 @@ test("skipWhenFocused=true suppresses focused notifications and uses config focu
|
|
|
425
436
|
|
|
426
437
|
await pi.emit("session_start", {}, ctx);
|
|
427
438
|
await flushAsyncWork();
|
|
428
|
-
|
|
439
|
+
pi.events.emit(
|
|
440
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
441
|
+
permissionSystemEvent("waiting", "permission-focus-enabled", {
|
|
442
|
+
toolCallId: "call-focus-enabled",
|
|
443
|
+
toolName: "write_file",
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
429
446
|
await flushAsyncWork();
|
|
430
447
|
await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
|
|
431
448
|
|
|
@@ -525,7 +542,13 @@ test("permission reminders use the permission-specific reminder interval", async
|
|
|
525
542
|
|
|
526
543
|
await pi.emit("session_start", {}, ctx);
|
|
527
544
|
await flushAsyncWork();
|
|
528
|
-
|
|
545
|
+
pi.events.emit(
|
|
546
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
547
|
+
permissionSystemEvent("waiting", "permission-reminder-delay", {
|
|
548
|
+
toolCallId: "call-permission-reminder-delay",
|
|
549
|
+
toolName: "write_file",
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
529
552
|
await flushAsyncWork();
|
|
530
553
|
await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
|
|
531
554
|
|
|
@@ -539,6 +562,47 @@ test("permission reminders use the permission-specific reminder interval", async
|
|
|
539
562
|
assert.equal(countReminderCalls(ttsCalls), 1);
|
|
540
563
|
});
|
|
541
564
|
|
|
565
|
+
test("blocked tool_call events do not trigger permission notifications without permission-system waiting state", async (t) => {
|
|
566
|
+
disableFocusDetection(t);
|
|
567
|
+
useMockClock(t);
|
|
568
|
+
|
|
569
|
+
const { ctx, pi, ttsCalls } = createHarness();
|
|
570
|
+
|
|
571
|
+
await pi.emit("session_start", {}, ctx);
|
|
572
|
+
await flushAsyncWork();
|
|
573
|
+
await pi.emit("tool_call", permissionEvent("call-no-authoritative-wait"), ctx);
|
|
574
|
+
await flushAsyncWork();
|
|
575
|
+
await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
|
|
576
|
+
|
|
577
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 0);
|
|
578
|
+
assert.equal(countReminderCalls(ttsCalls), 0);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("permission-looking tool_result errors do not trigger permission notifications without permission-system waiting state", async (t) => {
|
|
582
|
+
disableFocusDetection(t);
|
|
583
|
+
useMockClock(t);
|
|
584
|
+
|
|
585
|
+
const { ctx, pi, ttsCalls } = createHarness();
|
|
586
|
+
|
|
587
|
+
await pi.emit("session_start", {}, ctx);
|
|
588
|
+
await flushAsyncWork();
|
|
589
|
+
await pi.emit(
|
|
590
|
+
"tool_result",
|
|
591
|
+
{
|
|
592
|
+
toolCallId: "result-no-authoritative-wait",
|
|
593
|
+
toolName: "permission_guard",
|
|
594
|
+
isError: true,
|
|
595
|
+
content: [{ type: "text", text: "Requires approval from the user before continuing." }],
|
|
596
|
+
},
|
|
597
|
+
ctx,
|
|
598
|
+
);
|
|
599
|
+
await flushAsyncWork();
|
|
600
|
+
await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
|
|
601
|
+
|
|
602
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 0);
|
|
603
|
+
assert.equal(countReminderCalls(ttsCalls), 0);
|
|
604
|
+
});
|
|
605
|
+
|
|
542
606
|
test("permission-system waiting events trigger a permission notification and cancel on resolution", async (t) => {
|
|
543
607
|
disableFocusDetection(t);
|
|
544
608
|
useMockClock(t);
|
|
@@ -605,9 +669,21 @@ test("tool_execution_start only cancels the resolved permission reminder flow",
|
|
|
605
669
|
|
|
606
670
|
await pi.emit("session_start", {}, ctx);
|
|
607
671
|
await flushAsyncWork();
|
|
608
|
-
|
|
672
|
+
pi.events.emit(
|
|
673
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
674
|
+
permissionSystemEvent("waiting", "permission-call-a", {
|
|
675
|
+
toolCallId: "call-a",
|
|
676
|
+
toolName: "write_file",
|
|
677
|
+
}),
|
|
678
|
+
);
|
|
609
679
|
await flushAsyncWork();
|
|
610
|
-
|
|
680
|
+
pi.events.emit(
|
|
681
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
682
|
+
permissionSystemEvent("waiting", "permission-call-b", {
|
|
683
|
+
toolCallId: "call-b",
|
|
684
|
+
toolName: "write_file",
|
|
685
|
+
}),
|
|
686
|
+
);
|
|
611
687
|
await flushAsyncWork();
|
|
612
688
|
|
|
613
689
|
assert.equal(countReminderCalls(ttsCalls), 0);
|
|
@@ -640,9 +716,21 @@ test("tool_result resolution keeps another permission reminder active while drop
|
|
|
640
716
|
|
|
641
717
|
await pi.emit("session_start", {}, ctx);
|
|
642
718
|
await flushAsyncWork();
|
|
643
|
-
|
|
719
|
+
pi.events.emit(
|
|
720
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
721
|
+
permissionSystemEvent("waiting", "permission-result-call-a", {
|
|
722
|
+
toolCallId: "call-a",
|
|
723
|
+
toolName: "write_file",
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
644
726
|
await flushAsyncWork();
|
|
645
|
-
|
|
727
|
+
pi.events.emit(
|
|
728
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
729
|
+
permissionSystemEvent("waiting", "permission-result-call-b", {
|
|
730
|
+
toolCallId: "call-b",
|
|
731
|
+
toolName: "write_file",
|
|
732
|
+
}),
|
|
733
|
+
);
|
|
646
734
|
await flushAsyncWork();
|
|
647
735
|
await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
|
|
648
736
|
await tickAndFlush(1_000);
|
|
@@ -662,7 +750,7 @@ test("tool_result resolution keeps another permission reminder active while drop
|
|
|
662
750
|
assert.equal(countReminderCalls(ttsCalls), 1);
|
|
663
751
|
});
|
|
664
752
|
|
|
665
|
-
test("tool_result with the same toolCallId still classifies after a permission-
|
|
753
|
+
test("tool_result with the same toolCallId still classifies after a permission-system waiting event", async (t) => {
|
|
666
754
|
disableFocusDetection(t);
|
|
667
755
|
useMockClock(t);
|
|
668
756
|
|
|
@@ -672,7 +760,13 @@ test("tool_result with the same toolCallId still classifies after a permission-b
|
|
|
672
760
|
|
|
673
761
|
await pi.emit("session_start", {}, ctx);
|
|
674
762
|
await flushAsyncWork();
|
|
675
|
-
|
|
763
|
+
pi.events.emit(
|
|
764
|
+
PERMISSION_SYSTEM_EVENT_CHANNEL,
|
|
765
|
+
permissionSystemEvent("waiting", "permission-shared", {
|
|
766
|
+
toolCallId: "call-shared",
|
|
767
|
+
toolName: "write_file",
|
|
768
|
+
}),
|
|
769
|
+
);
|
|
676
770
|
await flushAsyncWork();
|
|
677
771
|
await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
|
|
678
772
|
assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
|
|
@@ -692,6 +786,79 @@ test("tool_result with the same toolCallId still classifies after a permission-b
|
|
|
692
786
|
assert.equal(immediateNotificationCalls(ttsCalls).length, 2);
|
|
693
787
|
});
|
|
694
788
|
|
|
789
|
+
test("agent_end triggers an idle notification when idle notifications are enabled", async (t) => {
|
|
790
|
+
disableFocusDetection(t);
|
|
791
|
+
useMockClock(t);
|
|
792
|
+
|
|
793
|
+
const { ctx, pi, ttsCalls } = createHarness({
|
|
794
|
+
enableIdleNotification: true,
|
|
795
|
+
reminderEnabled: false,
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
await pi.emit("session_start", {}, ctx);
|
|
799
|
+
await flushAsyncWork();
|
|
800
|
+
await pi.emit("agent_end", {}, ctx);
|
|
801
|
+
await flushAsyncWork();
|
|
802
|
+
|
|
803
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
|
|
804
|
+
assert.equal(countReminderCalls(ttsCalls), 0);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("question-classified tool_result triggers a question notification when the question tool is available", async (t) => {
|
|
808
|
+
disableFocusDetection(t);
|
|
809
|
+
useMockClock(t);
|
|
810
|
+
|
|
811
|
+
const { ctx, pi, ttsCalls } = createHarness({
|
|
812
|
+
enableQuestionNotification: true,
|
|
813
|
+
reminderEnabled: false,
|
|
814
|
+
});
|
|
815
|
+
pi.setAvailableTools([{ name: "question" }]);
|
|
816
|
+
|
|
817
|
+
await pi.emit("session_start", {}, ctx);
|
|
818
|
+
await flushAsyncWork();
|
|
819
|
+
await pi.emit(
|
|
820
|
+
"tool_result",
|
|
821
|
+
{
|
|
822
|
+
toolCallId: "call-question-available",
|
|
823
|
+
toolName: "custom_tool",
|
|
824
|
+
isError: false,
|
|
825
|
+
content: [{ type: "text", text: "This request requires your input before continuing." }],
|
|
826
|
+
},
|
|
827
|
+
ctx,
|
|
828
|
+
);
|
|
829
|
+
await flushAsyncWork();
|
|
830
|
+
|
|
831
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
|
|
832
|
+
assert.equal(countReminderCalls(ttsCalls), 0);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("question-classified tool_result does not notify when the question tool is unavailable", async (t) => {
|
|
836
|
+
disableFocusDetection(t);
|
|
837
|
+
useMockClock(t);
|
|
838
|
+
|
|
839
|
+
const { ctx, pi, ttsCalls } = createHarness({
|
|
840
|
+
enableQuestionNotification: true,
|
|
841
|
+
reminderEnabled: false,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
await pi.emit("session_start", {}, ctx);
|
|
845
|
+
await flushAsyncWork();
|
|
846
|
+
await pi.emit(
|
|
847
|
+
"tool_result",
|
|
848
|
+
{
|
|
849
|
+
toolCallId: "call-question-unavailable",
|
|
850
|
+
toolName: "custom_tool",
|
|
851
|
+
isError: false,
|
|
852
|
+
content: [{ type: "text", text: "This request requires your input before continuing." }],
|
|
853
|
+
},
|
|
854
|
+
ctx,
|
|
855
|
+
);
|
|
856
|
+
await flushAsyncWork();
|
|
857
|
+
|
|
858
|
+
assert.equal(immediateNotificationCalls(ttsCalls).length, 0);
|
|
859
|
+
assert.equal(countReminderCalls(ttsCalls), 0);
|
|
860
|
+
});
|
|
861
|
+
|
|
695
862
|
test("forwarded permission resolution cancels a queued reminder before it fires", async (t) => {
|
|
696
863
|
disableFocusDetection(t);
|
|
697
864
|
useMockClock(t);
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
isWindows,
|
|
20
20
|
MESSAGE_LIBRARY,
|
|
21
21
|
normalizeConfig,
|
|
22
|
-
PERMISSION_HINTS,
|
|
23
22
|
QUESTION_HINTS,
|
|
24
23
|
readConfigFromDisk,
|
|
25
24
|
SOUND_LOOPS,
|
|
@@ -125,45 +124,9 @@ function classifyToolResult(
|
|
|
125
124
|
return "question";
|
|
126
125
|
}
|
|
127
126
|
|
|
128
|
-
if (normalizedTool.includes("permission") || PERMISSION_HINTS.some((hint) => normalizedText.includes(hint))) {
|
|
129
|
-
return "permission";
|
|
130
|
-
}
|
|
131
|
-
|
|
132
127
|
return "error";
|
|
133
128
|
}
|
|
134
129
|
|
|
135
|
-
function readBlockedReason(value: unknown): string | null {
|
|
136
|
-
const record = toRecord(value);
|
|
137
|
-
const blockValue = record.block;
|
|
138
|
-
const isBlocked = blockValue === true || blockValue === "true";
|
|
139
|
-
if (!isBlocked) {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const reason = record.reason;
|
|
144
|
-
if (typeof reason !== "string") {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const normalizedReason = reason.trim();
|
|
149
|
-
return normalizedReason.length > 0 ? normalizedReason : null;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function extractToolCallBlockReason(event: unknown): string | null {
|
|
153
|
-
const directReason = readBlockedReason(event);
|
|
154
|
-
if (directReason) {
|
|
155
|
-
return directReason;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const record = toRecord(event);
|
|
159
|
-
return readBlockedReason(record.result);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function isPermissionReason(reason: string): boolean {
|
|
163
|
-
const normalizedReason = reason.toLowerCase();
|
|
164
|
-
return PERMISSION_HINTS.some((hint) => normalizedReason.includes(hint));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
130
|
function statusLine(config: VoiceNotifyConfig): string | undefined {
|
|
168
131
|
if (!config.enabled) {
|
|
169
132
|
return "voice:off";
|
|
@@ -215,6 +178,28 @@ function envInteger(defaultValue: number, ...keys: string[]): number {
|
|
|
215
178
|
return Number.isFinite(parsed) ? parsed : defaultValue;
|
|
216
179
|
}
|
|
217
180
|
|
|
181
|
+
function normalizePermissionForwardingSessionId(value: unknown): string | null {
|
|
182
|
+
if (typeof value !== "string") {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const trimmed = value.trim();
|
|
186
|
+
if (!trimmed || trimmed.toLowerCase() === "unknown") {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return trimmed;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getPermissionForwardingSessionId(ctx: ExtensionContext): string | null {
|
|
193
|
+
try {
|
|
194
|
+
const sessionManager = "sessionManager" in ctx
|
|
195
|
+
? ctx.sessionManager as { getSessionId?: () => unknown }
|
|
196
|
+
: null;
|
|
197
|
+
return normalizePermissionForwardingSessionId(sessionManager?.getSessionId?.());
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
218
203
|
function sanitizeAgentName(value: string | null): string | null {
|
|
219
204
|
if (!value) {
|
|
220
205
|
return null;
|
|
@@ -362,7 +347,6 @@ export default function smartVoiceNotifyExtension(
|
|
|
362
347
|
const pendingReminders = new Map<ReminderKey, ReminderState>();
|
|
363
348
|
const reminderPlayback = new ReminderPlaybackController();
|
|
364
349
|
const pendingPermissionToolCallIds = new Set<string>();
|
|
365
|
-
const blockedPermissionToolCallIds = new Set<string>();
|
|
366
350
|
const processedToolResultToolCallIds = new Set<string>();
|
|
367
351
|
const lastNotificationAt = new Map<NotificationType, number>();
|
|
368
352
|
|
|
@@ -590,6 +574,7 @@ export default function smartVoiceNotifyExtension(
|
|
|
590
574
|
permissionForwardingWatcher.start({
|
|
591
575
|
enabled: config.enabled && config.enablePermissionNotification && config.enableForwardedPermissionWatcher,
|
|
592
576
|
watchLegacyPath: config.watchLegacyForwardedPermissionPath,
|
|
577
|
+
targetSessionId: getPermissionForwardingSessionId(activeSessionContext),
|
|
593
578
|
});
|
|
594
579
|
};
|
|
595
580
|
|
|
@@ -1415,7 +1400,6 @@ export default function smartVoiceNotifyExtension(
|
|
|
1415
1400
|
if (event.state === "waiting") {
|
|
1416
1401
|
if (event.toolCallId) {
|
|
1417
1402
|
pendingPermissionToolCallIds.add(event.toolCallId);
|
|
1418
|
-
rememberScopedToolCallId(event.toolCallId, blockedPermissionToolCallIds);
|
|
1419
1403
|
}
|
|
1420
1404
|
logger.debug("permission_system.wait_detected", {
|
|
1421
1405
|
requestId: event.requestId,
|
|
@@ -1687,7 +1671,6 @@ export default function smartVoiceNotifyExtension(
|
|
|
1687
1671
|
hadErrorInTurn = false;
|
|
1688
1672
|
warnedDesktopUnsupported = false;
|
|
1689
1673
|
pendingPermissionToolCallIds.clear();
|
|
1690
|
-
blockedPermissionToolCallIds.clear();
|
|
1691
1674
|
processedToolResultToolCallIds.clear();
|
|
1692
1675
|
lastNotificationAt.clear();
|
|
1693
1676
|
|
|
@@ -1730,7 +1713,6 @@ export default function smartVoiceNotifyExtension(
|
|
|
1730
1713
|
activeSessionContext = null;
|
|
1731
1714
|
permissionForwardingWatcher.stop();
|
|
1732
1715
|
pendingPermissionToolCallIds.clear();
|
|
1733
|
-
blockedPermissionToolCallIds.clear();
|
|
1734
1716
|
processedToolResultToolCallIds.clear();
|
|
1735
1717
|
resetPermissionBatch("session_shutdown");
|
|
1736
1718
|
cancelReminderActivity("session_shutdown");
|
|
@@ -1766,37 +1748,13 @@ export default function smartVoiceNotifyExtension(
|
|
|
1766
1748
|
pi.on("agent_start", async () => {
|
|
1767
1749
|
hadErrorInTurn = false;
|
|
1768
1750
|
pendingPermissionToolCallIds.clear();
|
|
1769
|
-
blockedPermissionToolCallIds.clear();
|
|
1770
1751
|
processedToolResultToolCallIds.clear();
|
|
1771
1752
|
resetPermissionBatch("agent_start");
|
|
1772
1753
|
logger.debug("agent.start", {});
|
|
1773
1754
|
});
|
|
1774
1755
|
|
|
1775
|
-
pi.on("tool_call", async (
|
|
1756
|
+
pi.on("tool_call", async (_event, ctx) => {
|
|
1776
1757
|
activeSessionContext = ctx;
|
|
1777
|
-
if (!config.enabled || !config.enablePermissionNotification) {
|
|
1778
|
-
return {};
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
const reason = extractToolCallBlockReason(event);
|
|
1782
|
-
if (!reason || !isPermissionReason(reason)) {
|
|
1783
|
-
return {};
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
if (!rememberScopedToolCallId(event.toolCallId, blockedPermissionToolCallIds)) {
|
|
1787
|
-
return {};
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
pendingPermissionToolCallIds.add(event.toolCallId);
|
|
1791
|
-
logger.debug("tool_call.permission_blocked", {
|
|
1792
|
-
toolCallId: event.toolCallId,
|
|
1793
|
-
toolName: event.toolName,
|
|
1794
|
-
reason,
|
|
1795
|
-
});
|
|
1796
|
-
queuePermissionNotification(ctx, {
|
|
1797
|
-
reminderKey: permissionReminderKey(event.toolCallId),
|
|
1798
|
-
reason,
|
|
1799
|
-
});
|
|
1800
1758
|
return {};
|
|
1801
1759
|
});
|
|
1802
1760
|
|
package/src/logging.ts
CHANGED
|
@@ -59,14 +59,13 @@ export function createExtensionLogger(options: LoggerOptions): ExtensionLogger {
|
|
|
59
59
|
...details,
|
|
60
60
|
});
|
|
61
61
|
appendFileSync(debugLogPath, `${line}\n`, "utf-8");
|
|
62
|
-
} catch
|
|
63
|
-
|
|
62
|
+
} catch {
|
|
63
|
+
// Debug logging must never write to stdout/stderr from extension code.
|
|
64
64
|
}
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
const error = (cause: unknown): void => {
|
|
68
68
|
debug("runtime.error", { error: cause });
|
|
69
|
-
console.error(`[${extensionId}] ${getErrorMessage(cause)}`);
|
|
70
69
|
};
|
|
71
70
|
|
|
72
71
|
return { debug, error };
|