pi-windows-notifier 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-windows-notifier contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,137 @@
1
- # Temporary Holding Version
1
+ # pi-windows-notifier
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi 的 Windows 本機通知 extension:需要授權、回答結構化問題,或整次模型回應結束時,提交右下角 Toast 並播放系統提示音。
4
+
5
+ **Windows-only、零額外 runtime dependencies、沒有網路通知。** 只觀察事件,不批准權限、不代答,不改寫第三方工具。
6
+
7
+ ## 環境與安裝
8
+
9
+ - Windows 10/11、64 位元 Node.js 22.19+(x64 或 arm64)。
10
+ - Pi 1.1.0+;開發型別檢查使用 1.1.0。舊版事件 API 不支援,沒有 fallback。
11
+ - 僅互動式 TUI。RPC、print、JSON、非 Windows、32 位元 Node 停用通知,載入不拋錯。
12
+ - 使用 Windows 內建 Windows PowerShell 5.1;不需要 ffplay、node-notifier 或另外下載 executable。
13
+
14
+ 單次載入,不修改 Pi 設定:
15
+
16
+ ```bash
17
+ pi -e D:/Pi/pi-windows-notifier
18
+ ```
19
+
20
+ 確認後,可自行安裝本地 package:
21
+
22
+ ```bash
23
+ pi install D:/Pi/pi-windows-notifier
24
+ ```
25
+
26
+ 目前只有本地 repository,未發布 npm,也沒有自動安裝到你的 Pi。使用此 package 不需要安裝開發依賴;只有開發與測試才需要 `npm ci --ignore-scripts`。
27
+
28
+ ## 提醒事件
29
+
30
+ | 事件 | Toast 標題/內容 | 提示音 |
31
+ |---|---|---|
32
+ | permission | Pi/需要權限確認 | Exclamation |
33
+ | question | Pi/有問題等待回答 | Exclamation |
34
+ | completed | Pi/回應已完成 | Asterisk |
35
+ | aborted | Pi/回應已中止 | Exclamation |
36
+ | failed | Pi/回應失敗 | Exclamation |
37
+
38
+ - **權限**:訂閱 `@gotgenes/pi-permission-system` 的 `permissions:ui_prompt`;自動 allow/deny、session approval 不提醒。支援轉送到父 session 的 subagent 詢問。
39
+ - **提問**:支援 `ask_user_question`(包含 RPIV Lean)與 `plan_mode_question`。追蹤工具執行及真正等待 UI 的訊號,一次 questionnaire 只提醒一次,不按題數重複。
40
+ - **回應結束**:僅在 `agent_settled` 發送。重試/續跑尚未結束時不報完成或失敗;重試成功只報完成,單一工具失敗不等於模型失敗。
41
+ - 不辨識普通文字中的問句,不提醒單純手動設定 UI。不論終端是否在前景都提醒。
42
+ - 提問與權限套件是可選整合來源,不是本 package 的 dependencies;沒有安裝時,回應結束通知仍可使用。
43
+
44
+ ### 提問辨識邊界
45
+
46
+ Pi 的 UI 事件不含 toolCallId。只有恰好一個支援的提問工具正在執行,且沒有權限/已知不明 UI 佔用時才分類;不明時略過並記錄 `UI_AMBIGUOUS`,不攔截第三方 UI。另一個 UI 在提問 call 期間首次開啟時仍可能無法分辨,**不承諾任意並行 UI 的精確歸屬**。
47
+
48
+ `rpiv:ask-user:blocked` 是補充訊號,與共通 UI 共用去重。第三方套件可能自行發出 terminal bell;那不是本 package 的第二次音效,請使用該套件/終端自身的設定調整。
49
+
50
+ ## 設定
51
+
52
+ 唯一位置:`~/.pi/agent/pi-windows-notifier/config.json`。
53
+
54
+ 不讀專案設定,不建立/自動修改檔案,不移植舊套件設定。沒有檔案時預設全部開啟:
55
+
56
+ ```json
57
+ {
58
+ "enabled": true,
59
+ "events": {
60
+ "permission": { "enabled": true, "sound": true },
61
+ "question": { "enabled": true, "sound": true },
62
+ "completed": { "enabled": true, "sound": true },
63
+ "aborted": { "enabled": true, "sound": true },
64
+ "failed": { "enabled": true, "sound": true }
65
+ }
66
+ }
67
+ ```
68
+
69
+ 可以只提供要修改的欄位,例如完成時靜音:
70
+
71
+ ```json
72
+ { "events": { "completed": { "sound": false } } }
73
+ ```
74
+
75
+ 總開關 `enabled` 與事件 `enabled` 控制提交;`sound` 只控制 package 音效。runtime 驗證所有欄位,拒絕未知欄位,檔案上限 16 KiB。讀取/解析/驗證失敗時 **fail closed**,停用通知並顯示固定診斷碼,不輸出原始設定。
76
+
77
+ 修改後執行 `/windows-notifier reload`,取消舊工作並重新讀取,不寫設定。
78
+
79
+ ## 指令
80
+
81
+ ```text
82
+ /windows-notifier status
83
+ /windows-notifier reload
84
+ /windows-notifier test
85
+ /windows-notifier test permission
86
+ /windows-notifier test question
87
+ /windows-notifier test completed
88
+ /windows-notifier test aborted
89
+ /windows-notifier test failed
90
+ ```
91
+
92
+ `test` 預設為 completed,**會產生真實彈窗與提示音**,與自動通知共用開關、佇列、限流,不能繞過 disabled。`status` 只顯示有效開關、backend 狀態、佇列/丟棄數與固定診斷碼,不含工作內容。
93
+
94
+ ## 安全與資源限制
95
+
96
+ - 通知不含問題原文、命令、路徑、session 名稱、模型回答或錯誤原文。沒有 recap、遙測、外部通知服務或模型可呼叫的通知工具。
97
+ - SystemRoot/windir 僅來自啟動 OS 環境;拒絕相對、UNC、device 路徑,PowerShell 使用絕對路徑,不搜尋 cwd/PATH。
98
+ - `spawn` 不開 shell,使用固定 package cwd/腳本、`-NoProfile`、`-NonInteractive`、`-STA`。child environment 僅允許必要 Windows 路徑欄位,不繼承整份 agent environment 或 API tokens。
99
+ - stdin 僅含事件 enum 與音效 boolean;helper 再驗證,從固定字串表生成 DOM text node,不拼接 XML/PowerShell,不使用 Invoke-Expression。
100
+ - `-ExecutionPolicy Bypass` 只限該 child;不取得管理員權限、不更改永久設定,也不把 Execution Policy 當安全邊界。
101
+ - 最多一個 helper、佇列上限 16、啟動至少間隔一秒、等待上限 30 秒。權限/提問優先;滿載先丟棄最舊回應結束項目,否則丟棄最舊等待項目。
102
+ - child timeout 10 秒,stdout/stderr 各最多 8 KiB;只終止自有 child,不按名稱殺程序,不無限重試。若 OS 拒絕終止,等待 close,寧可暫停後續通知,不啟動第二個 helper。
103
+ - 決策、問題結束、新 run、reload、session 重建及 shutdown 取消相應舊工作。已展示的 Toast 不撤回;程序啟動與 Windows 提交仍有取消競態。
104
+ - 不支援背景音樂、自訂音效/音量、遠端通知、定期催答或點擊 Toast 後的自動操作。
105
+
106
+ **威脅模型**:防護不可信專案內容與事件資料;假設 Windows 系統目錄、啟動 OS 環境與安裝的 package 可信。不防禦惡意同程序 extension、被竄改的 SystemRoot 或遭入侵帳號。Pi permission system 不是 extension 的 OS 沙盒。
107
+
108
+ ## Windows 限制與排錯
109
+
110
+ 使用固定 `Microsoft.Windows.PowerShell` AppID,不寫 registry 或捷徑,來源可能顯示 **PowerShell**,文字標示 Pi。Toast 音效 silent,SystemSounds 分開播放,避免 backend 自己重複播放;沒有 NotifyIcon balloon、Console.Beep 或外部播放器 fallback。
111
+
112
+ 勿擾、Windows/PowerShell 通知設定、系統音效方案與靜音有最終控制權。**「已提交」不保證看見彈窗或聽到聲音**;played 只表示音效 API 呼叫成功。Toast 失敗仍嘗試音效,部分成功會如實回報。
113
+
114
+ | 診斷碼 | 意義 |
115
+ |---|---|
116
+ | CONFIG_INVALID/CONFIG_TOO_LARGE/CONFIG_READ_FAILED | 修正全域設定後 reload |
117
+ | ENV_UNSUPPORTED | 目前不是支援的 Windows 64 位元 TUI |
118
+ | BACKEND_UNAVAILABLE | SystemRoot/PowerShell/helper 不可用 |
119
+ | UI_AMBIGUOUS/TOOLS_LIMIT | 歸屬不明或追蹤上限,保守略過 |
120
+ | QUEUE_DROPPED | 滿載丟棄 |
121
+ | HELPER_TIMEOUT/OUTPUT_LIMIT/LAUNCH_FAILED | 超時、輸出超限或啟動失敗 |
122
+ | TOAST_FAILED/SOUND_FAILED/BOTH_FAILED | Windows 提交/音效失敗 |
123
+ | INPUT_INVALID/INTERNAL_ERROR/HELPER_PROTOCOL | helper 協定不符,檢查安裝完整性 |
124
+
125
+ 自動錯誤最多每分鐘一次固定 TUI 警告,累計診斷在 status,不顯示原始 stderr。
126
+
127
+ ## 開發與驗證
128
+
129
+ ```bash
130
+ npm ci --ignore-scripts
131
+ npm run verify
132
+ npm pack --dry-run
133
+ ```
134
+
135
+ Node test runner 搭配假時鐘、假 event bus 與 mock launcher。Windows 額外執行 PowerShell 語法解析及無效輸入路徑,**一般 npm test 不發 Toast、不播放音效**。成功路徑的可見通知驗收另行確認,詳見 [驗證紀錄](docs/verification.md)。
136
+
137
+ MIT;來源與上游授權見 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。上游 checkout 位於 package 外部,不發布;沒有複製 pi-jingle 程式或音效,沒有引入 UniPi core。
@@ -0,0 +1,40 @@
1
+ # 上游來源與授權
2
+
3
+ ## pi-permission-windows-notifier
4
+
5
+ - 來源:https://github.com/zhangyu-ch/pi-permission-windows-notifier
6
+ - 參考:988decb1a28a38a6d5b8aa940ec2ceca22088c54
7
+ - 範圍:權限事件、WinRT DOM text node、固定 PowerShell AppID、系統音效及 Toast silent。
8
+ - 本 package 重寫執行檔定位、stdin 協定、資源限制、取消、設定與狀態機。沿用/改作片段保留以下原始 MIT 授權:
9
+
10
+ ```text
11
+ MIT License
12
+
13
+ Copyright (c) 2026 Yu Zhang
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ ```
33
+
34
+ ## 其他研究參考
35
+
36
+ - UniPi notify:https://github.com/Neuron-Mr-White/UniPi/tree/main/packages/notify,參考 6ac5da3。只參考事件/backend 分層概念,未複製程式碼或引入 UniPi core/node-notifier。
37
+ - pi-jingle:https://github.com/Git-Monke/pi-jingle,參考 7e265ce。只參考事件音效概念,未複製程式碼或音效,因為尚未確認其授權。
38
+ - Pi、permission system、RPIV/Lean、Plan mode 的本機型別及原始碼用於核對契約,不把第三方程式碼放入發布包。
39
+
40
+ 本地上游 checkout 位於 `../references/upstream/`,不受此 repository 管理。
@@ -0,0 +1,26 @@
1
+ # 驗證紀錄
2
+
3
+ ## 自動檢查
4
+
5
+ - 本機 Windows、Node.js 24.18.0;開發型別依賴 Pi 1.1.0。
6
+ - TypeScript noEmit 與 Node test runner 通過:32 個測試,0 失敗、0 跳過。
7
+ - extension 入口可由 Node TypeScript strip-types 匯入;factory 未在此檢查中執行。
8
+ - npm pack --dry-run 通過,發布清單為 13 個核准檔案,不產生/發布 tarball。
9
+ - Windows PowerShell 語法解析與無效輸入路徑已執行,不呼叫 Toast 或音效。
10
+ - mock 接線涵蓋 permission、RPIV/Lean、Plan mode 及完成/中止/失敗。
11
+ - 涵蓋去重、取消、不明 UI、重試/續跑、空 run、開關、最小 payload、cwd/PATH 污染防護、佇列/輸出上限、timeout、reload/shutdown 清理。
12
+ - 開發依賴使用 npm install --ignore-scripts;當次 npm audit 回報 0 個已知漏洞,不代表完整供應鏈或 OS 安全稽核。
13
+ - 發布包只允許 metadata、runtime 原始碼、固定 helper、README、驗證文件及授權,不含 node_modules、測試、設定或上游 checkout。
14
+
15
+ ## 實機驗收狀態
16
+
17
+ 已經使用者明確同意,透過正式 scheduler/backend 依序執行 permission、question、completed、aborted、failed 五種成功路徑。五次皆回傳 submitted/OK;使用者確認五種都有右下角彈窗與提示音。所有自有 child 等待 close 後結束,沒有安裝套件或變更 Windows/Pi 設定。
18
+
19
+ 以下仍未完成實機端到端驗收:
20
+
21
+ 1. 真實互動式 Pi 的 permission ask、RPIV Lean、Plan mode、正常結束、Esc 及模型失敗(目前為 mock 接線驗證)。
22
+ 2. 第三方 terminal bell 是否造成額外聲響。
23
+ 3. 勿擾/通知關閉/靜音行為。
24
+ 4. 不同 extension 載入順序、reload、OS 程序監看及網路活動觀察(資源限制已由自動測試覆蓋)。
25
+
26
+ mock 通過不能替代以上場景;一般提交成功不代表保證送達。這次可見/可聽 backend 結果由使用者確認,不能推論所有 Windows 環境皆通過。未自動安裝到使用者 Pi、未變更通知設定、未發布 npm 或 push。
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "pi-windows-notifier",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Pi 的 Windows Toast 與系統提示音:權限、結構化提問及回應結束通知。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.19.0"
9
+ },
10
+ "keywords": [
11
+ "pi-package",
12
+ "pi-extension",
13
+ "windows",
14
+ "notifications"
15
+ ],
16
+ "files": [
17
+ "src/**/*.ts",
18
+ "src/windows-notify.ps1",
19
+ "README.md",
20
+ "docs/verification.md",
21
+ "LICENSE",
22
+ "THIRD_PARTY_NOTICES.md"
23
+ ],
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/index.ts"
27
+ ]
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-coding-agent": "*"
31
+ },
32
+ "devDependencies": {
33
+ "@earendil-works/pi-coding-agent": "1.1.0",
34
+ "@types/node": "^22.15.3",
35
+ "typescript": "^6.0.3"
36
+ },
37
+ "scripts": {
38
+ "check": "tsc --noEmit",
39
+ "test": "node --experimental-strip-types --test test/*.test.ts",
40
+ "verify": "npm run check && npm test"
41
+ }
42
+ }
package/src/config.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { KINDS, isRecord, type NotificationKind } from "./types.ts";
5
+
6
+ export interface EventConfig { enabled: boolean; sound: boolean }
7
+ export interface Config {
8
+ enabled: boolean;
9
+ events: Record<NotificationKind, EventConfig>;
10
+ }
11
+ export type ConfigResult =
12
+ | { ok: true; config: Config }
13
+ | { ok: false; code: "CONFIG_INVALID" | "CONFIG_TOO_LARGE" | "CONFIG_READ_FAILED"; config: Config };
14
+
15
+ export function defaults(): Config {
16
+ return { enabled: true, events: Object.fromEntries(KINDS.map(kind =>
17
+ [kind, { enabled: true, sound: true }])) as Config["events"] };
18
+ }
19
+ function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
20
+ return Object.keys(value).every(key => keys.includes(key));
21
+ }
22
+ export function parseConfig(value: unknown): ConfigResult {
23
+ const config = defaults();
24
+ const invalid = (): ConfigResult => ({ ok: false, code: "CONFIG_INVALID", config: { ...defaults(), enabled: false } });
25
+ if (!isRecord(value) || !onlyKeys(value, ["enabled", "events"])) return invalid();
26
+ if (Object.hasOwn(value, "enabled")) {
27
+ if (typeof value.enabled !== "boolean") return invalid();
28
+ config.enabled = value.enabled;
29
+ }
30
+ if (Object.hasOwn(value, "events")) {
31
+ if (!isRecord(value.events) || !onlyKeys(value.events, KINDS)) return invalid();
32
+ for (const kind of KINDS) {
33
+ if (!Object.hasOwn(value.events, kind)) continue;
34
+ const item = value.events[kind];
35
+ if (!isRecord(item) || !onlyKeys(item, ["enabled", "sound"])) return invalid();
36
+ for (const field of ["enabled", "sound"] as const) {
37
+ if (!Object.hasOwn(item, field)) continue;
38
+ if (typeof item[field] !== "boolean") return invalid();
39
+ config.events[kind][field] = item[field];
40
+ }
41
+ }
42
+ }
43
+ return { ok: true, config };
44
+ }
45
+ export const CONFIG_LIMIT = 16 * 1024;
46
+ export function configPath(): string {
47
+ return join(homedir(), ".pi", "agent", "pi-windows-notifier", "config.json");
48
+ }
49
+ export function loadConfig(path = configPath()): ConfigResult {
50
+ let fd: number | undefined;
51
+ try {
52
+ fd = openSync(path, "r");
53
+ if (!fstatSync(fd).isFile()) throw new Error("not a file");
54
+ const buffer = Buffer.alloc(CONFIG_LIMIT + 1);
55
+ let size = 0;
56
+ while (size < buffer.length) {
57
+ const count = readSync(fd, buffer, size, buffer.length - size, null);
58
+ if (count === 0) break;
59
+ size += count;
60
+ }
61
+ if (size > CONFIG_LIMIT) return { ok: false, code: "CONFIG_TOO_LARGE", config: { ...defaults(), enabled: false } };
62
+ try { return parseConfig(JSON.parse(buffer.subarray(0, size).toString("utf8"))); }
63
+ catch { return { ok: false, code: "CONFIG_INVALID", config: { ...defaults(), enabled: false } }; }
64
+ } catch (error) {
65
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, config: defaults() };
66
+ return { ok: false, code: "CONFIG_READ_FAILED", config: { ...defaults(), enabled: false } };
67
+ } finally {
68
+ if (fd !== undefined) closeSync(fd);
69
+ }
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { registerNotifier as default } from "./runtime.ts";
@@ -0,0 +1,143 @@
1
+ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ import { statSync } from "node:fs";
3
+ import { dirname, win32 } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { isRecord, type NotificationKind } from "./types.ts";
6
+
7
+ export const HELPER_TIMEOUT = 10_000;
8
+ export const OUTPUT_LIMIT = 8 * 1024;
9
+ export type LaunchCode = "OK" | "TOAST_FAILED" | "SOUND_FAILED" | "BOTH_FAILED" |
10
+ "INPUT_INVALID" | "INTERNAL_ERROR" | "BACKEND_UNAVAILABLE" | "LAUNCH_FAILED" |
11
+ "HELPER_TIMEOUT" | "OUTPUT_LIMIT" | "HELPER_PROTOCOL" | "CANCELLED";
12
+ export interface LaunchResult {
13
+ code: LaunchCode;
14
+ toast: boolean;
15
+ sound: "played" | "disabled" | "failed";
16
+ }
17
+ export interface Backend {
18
+ available: boolean;
19
+ code: string;
20
+ launch(kind: NotificationKind, sound: boolean, signal: AbortSignal): Promise<LaunchResult>;
21
+ }
22
+ const SCRIPT = fileURLToPath(new URL("./windows-notify.ps1", import.meta.url));
23
+ const ENV_KEYS = ["SystemRoot", "windir", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", "USERNAME", "USERDOMAIN"];
24
+ const HELPER_CODES = new Set(["OK", "TOAST_FAILED", "SOUND_FAILED", "BOTH_FAILED", "INPUT_INVALID", "INTERNAL_ERROR"]);
25
+
26
+ /** 僅信任啟動 Pi 的 OS 環境;專案設定與 PATH 不能參與執行檔定位。 */
27
+ export function windowsPaths(env: NodeJS.ProcessEnv): { executable: string; env: NodeJS.ProcessEnv } | undefined {
28
+ const get = (key: string): string | undefined => {
29
+ const actual = Object.keys(env).find(item => item.toLowerCase() === key.toLowerCase());
30
+ return actual ? env[actual] : undefined;
31
+ };
32
+ const root = get("SystemRoot") ?? get("windir");
33
+ if (!root || !/^[a-z]:[\\/]/iu.test(root) || /[<>:"|?*\u0000-\u001f]/u.test(root.slice(2))) return undefined;
34
+ const clean = win32.normalize(root);
35
+ if (!win32.isAbsolute(clean) || clean.startsWith("\\\\")) return undefined;
36
+ const result: NodeJS.ProcessEnv = {};
37
+ for (const key of ENV_KEYS) {
38
+ const value = get(key);
39
+ if (value !== undefined) result[key] = value;
40
+ }
41
+ result.SystemRoot = clean;
42
+ result.windir = clean;
43
+ return { executable: win32.join(clean, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), env: result };
44
+ }
45
+ function failed(code: LaunchCode): LaunchResult { return { code, toast: false, sound: "failed" }; }
46
+ function parseResult(output: Buffer, exitCode: number | null, sound: boolean): LaunchResult {
47
+ try {
48
+ const parsed: unknown = JSON.parse(output.toString("utf8").trim());
49
+ if (!isRecord(parsed) || Object.keys(parsed).length !== 3 ||
50
+ typeof parsed.code !== "string" || !HELPER_CODES.has(parsed.code) || typeof parsed.toast !== "boolean" ||
51
+ typeof parsed.sound !== "string" || !["played", "disabled", "failed"].includes(parsed.sound)) return failed("HELPER_PROTOCOL");
52
+ const code = parsed.code as LaunchCode;
53
+ const audio = parsed.sound as LaunchResult["sound"];
54
+ const expected = parsed.toast
55
+ ? (audio === "failed" ? "SOUND_FAILED" : "OK")
56
+ : (audio === "failed" ? "BOTH_FAILED" : "TOAST_FAILED");
57
+ if (audio === "disabled" && sound || audio === "played" && !sound) return failed("HELPER_PROTOCOL");
58
+ if (["INPUT_INVALID", "INTERNAL_ERROR"].includes(code)) {
59
+ return !parsed.toast && audio === "failed" && exitCode !== 0 ? failed(code) : failed("HELPER_PROTOCOL");
60
+ }
61
+ if (code !== expected || exitCode !== (code === "OK" ? 0 : 1)) return failed("HELPER_PROTOCOL");
62
+ return { code, toast: parsed.toast, sound: audio };
63
+ } catch { return failed("HELPER_PROTOCOL"); }
64
+ }
65
+ export interface BackendOptions {
66
+ env?: NodeJS.ProcessEnv;
67
+ platform?: NodeJS.Platform;
68
+ arch?: string;
69
+ scriptPath?: string;
70
+ isFile?: (path: string) => boolean;
71
+ spawnProcess?: (file: string, args: string[], options: SpawnOptions) => ChildProcess;
72
+ timeoutMs?: number;
73
+ }
74
+ export function createWindowsBackend(options: BackendOptions = {}): Backend {
75
+ const platform = options.platform ?? process.platform;
76
+ const arch = options.arch ?? process.arch;
77
+ const paths = windowsPaths(options.env ?? process.env);
78
+ const script = options.scriptPath ?? SCRIPT;
79
+ const isFile = options.isFile ?? (path => { try { return statSync(path).isFile(); } catch { return false; } });
80
+ const available = platform === "win32" && ["x64", "arm64"].includes(arch) &&
81
+ !!paths && isFile(paths.executable) && isFile(script);
82
+ const spawnProcess = options.spawnProcess ?? spawn;
83
+ const code = available ? "OK" : "BACKEND_UNAVAILABLE";
84
+ return {
85
+ available, code,
86
+ launch(kind, sound, signal) {
87
+ if (!available || !paths) return Promise.resolve(failed("BACKEND_UNAVAILABLE"));
88
+ if (signal.aborted) return Promise.resolve(failed("CANCELLED"));
89
+ return new Promise(resolve => {
90
+ let child: ChildProcess;
91
+ try {
92
+ child = spawnProcess(paths.executable,
93
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-File", script],
94
+ { shell: false, windowsHide: true, cwd: dirname(script), env: { ...paths.env }, stdio: ["pipe", "pipe", "pipe"] });
95
+ } catch { resolve(failed("LAUNCH_FAILED")); return; }
96
+ let reason: LaunchCode | undefined;
97
+ let output = Buffer.alloc(0);
98
+ let stderrSize = 0;
99
+ let done = false;
100
+ const stop = (code: LaunchCode) => {
101
+ if (reason || done) return;
102
+ reason = code;
103
+ // Windows 的 kill 只作用於這個 ChildProcess,不搜尋或終止其他同名程序。
104
+ try { child.kill(); } catch { /* 保留有界輸出並等待 close,不允許再啟動第二個 helper。 */ }
105
+ };
106
+ const onAbort = () => stop("CANCELLED");
107
+ const timer = setTimeout(() => stop("HELPER_TIMEOUT"), options.timeoutMs ?? HELPER_TIMEOUT);
108
+ timer.unref();
109
+ signal.addEventListener("abort", onAbort, { once: true });
110
+ const finish = (result: LaunchResult) => {
111
+ if (done) return;
112
+ done = true;
113
+ clearTimeout(timer);
114
+ signal.removeEventListener("abort", onAbort);
115
+ resolve(result);
116
+ };
117
+ child.on("error", () => {
118
+ reason ??= "LAUNCH_FAILED";
119
+ // spawn 失敗會有 close;等待它以免提前釋放單一 helper 的配額。
120
+ });
121
+ child.stdout?.on("data", (chunk: Buffer | string) => {
122
+ if (reason) return;
123
+ const bytes = Buffer.from(chunk);
124
+ if (output.length + bytes.length > OUTPUT_LIMIT) { stop("OUTPUT_LIMIT"); return; }
125
+ output = Buffer.concat([output, bytes]);
126
+ });
127
+ child.stderr?.on("data", (chunk: Buffer | string) => {
128
+ if (reason) return;
129
+ stderrSize += Buffer.byteLength(chunk);
130
+ if (stderrSize > OUTPUT_LIMIT) stop("OUTPUT_LIMIT");
131
+ // 不保存或展示 PowerShell 的原始錯誤文字。
132
+ });
133
+ child.once("close", exitCode => finish(reason ? failed(reason) : parseResult(output, exitCode, sound)));
134
+ child.stdin?.on("error", () => stop("LAUNCH_FAILED"));
135
+ child.stdout?.on("error", () => stop("LAUNCH_FAILED"));
136
+ child.stderr?.on("error", () => stop("LAUNCH_FAILED"));
137
+ try { child.stdin?.end(JSON.stringify({ kind, sound })); }
138
+ catch { stop("LAUNCH_FAILED"); }
139
+ if (signal.aborted) onAbort();
140
+ });
141
+ },
142
+ };
143
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,160 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { defaults, loadConfig, type ConfigResult } from "./config.ts";
3
+ import { createWindowsBackend, windowsPaths, type Backend } from "./launcher.ts";
4
+ import { NotificationScheduler, systemClock, type Clock, type Submission } from "./scheduler.ts";
5
+ import { NotificationState } from "./state.ts";
6
+ import { KINDS, type NotificationKind } from "./types.ts";
7
+
8
+ export interface RuntimeOptions {
9
+ platform?: NodeJS.Platform;
10
+ arch?: string;
11
+ readConfig?: () => ConfigResult;
12
+ backend?: () => Backend;
13
+ clock?: Clock;
14
+ }
15
+ interface Session {
16
+ context: ExtensionContext;
17
+ state: NotificationState;
18
+ scheduler?: NotificationScheduler;
19
+ result: ConfigResult;
20
+ backendCode: string;
21
+ diagnostics: Set<string>;
22
+ unsubscribe: Array<() => void>;
23
+ disposed: boolean;
24
+ lastWarning: number;
25
+ testId: number;
26
+ }
27
+ const SUBMISSION_MESSAGES: Record<Submission["status"], string> = {
28
+ submitted: "Windows 通知已提交;實際顯示與音效仍由 Windows 設定決定。",
29
+ partial: "Windows 通知僅部分提交;請用 status 查看固定診斷碼。",
30
+ disabled: "通知已停用,或目前不支援此環境/事件。",
31
+ cancelled: "測試通知已取消。",
32
+ dropped: "測試通知因佇列滿載而被丟棄。",
33
+ expired: "測試通知已過期。",
34
+ failed: "Windows 通知提交失敗;請用 status 查看固定診斷碼。",
35
+ };
36
+
37
+ /** factory 只註冊 API;程序、timer 與 event bus 訂閱皆屬於啟動後的 session。 */
38
+ export function registerNotifier(pi: ExtensionAPI, options: RuntimeOptions = {}): void {
39
+ const platform = options.platform ?? process.platform;
40
+ const arch = options.arch ?? process.arch;
41
+ // 僅捕捉必要 OS 環境,不保留 agent 的完整環境或 API token。
42
+ const environment = windowsPaths(process.env)?.env ?? {};
43
+ const makeBackend = options.backend ?? (() => createWindowsBackend({ env: environment, platform, arch }));
44
+ const readConfig = options.readConfig ?? loadConfig;
45
+ const clock = options.clock ?? systemClock;
46
+ let session: Session | undefined;
47
+ let lifecycle = Promise.resolve();
48
+ // reload/session start/shutdown 必須串行,避免前一個 helper 尚未 close 就建立新 session。
49
+ const transition = (action: () => Promise<void>) => {
50
+ const next = lifecycle.then(action);
51
+ lifecycle = next.catch(() => {});
52
+ return next;
53
+ };
54
+
55
+ const diagnose = (target: Session, code: string) => {
56
+ if (target.disposed) return;
57
+ if (target.diagnostics.size < 32) target.diagnostics.add(code);
58
+ const now = clock.now();
59
+ try {
60
+ if (target.context.mode !== "tui" || !target.context.hasUI || now - target.lastWarning < 60_000) return;
61
+ target.lastWarning = now;
62
+ target.context.ui.notify(`Windows notifier:${code}。可用 /windows-notifier status 查看狀態。`, "warning");
63
+ } catch { /* session 更換時的 stale context/UI 不影響 agent。 */ }
64
+ };
65
+ const stop = async () => {
66
+ const previous = session;
67
+ session = undefined;
68
+ if (!previous) return;
69
+ previous.disposed = true;
70
+ for (const unsubscribe of previous.unsubscribe) { try { unsubscribe(); } catch {} }
71
+ previous.unsubscribe = [];
72
+ previous.state.reset();
73
+ await previous.scheduler?.close();
74
+ };
75
+ const start = async (context: ExtensionContext) => {
76
+ await stop();
77
+ let result: ConfigResult;
78
+ try { result = readConfig(); }
79
+ catch { result = { ok: false, code: "CONFIG_READ_FAILED", config: { ...defaults(), enabled: false } }; }
80
+ const eligible = platform === "win32" && ["x64", "arm64"].includes(arch) && context.mode === "tui" && context.hasUI;
81
+ let backend: Backend | undefined;
82
+ try { if (eligible) backend = makeBackend(); } catch { /* 不展示 backend 原始錯誤。 */ }
83
+ const target: Session = {
84
+ context, state: undefined!, result, backendCode: eligible ? backend?.code ?? "BACKEND_UNAVAILABLE" : "ENV_UNSUPPORTED",
85
+ diagnostics: new Set(), unsubscribe: [], disposed: false, lastWarning: -Infinity, testId: 0,
86
+ };
87
+ const scheduler = backend && eligible
88
+ ? new NotificationScheduler(backend, () => target.result.config, code => diagnose(target, code), clock) : undefined;
89
+ target.scheduler = scheduler;
90
+ target.state = new NotificationState(scheduler ?? { enqueue() {}, cancel() {} }, code => diagnose(target, code));
91
+ session = target;
92
+ if (!result.ok) diagnose(target, result.code);
93
+ if (eligible && !backend?.available) diagnose(target, "BACKEND_UNAVAILABLE");
94
+ if (!eligible) return;
95
+ const listen = (channel: string, handler: (raw: unknown) => void) => {
96
+ target.unsubscribe.push(pi.events.on(channel, raw => {
97
+ if (target.disposed || session !== target) return;
98
+ try { handler(raw); } catch { diagnose(target, "EVENT_INVALID"); }
99
+ }));
100
+ };
101
+ listen("permissions:ui_prompt", raw => target.state.permissionPrompt(raw));
102
+ listen("permissions:decision", raw => target.state.permissionDecision(raw));
103
+ listen("rpiv:ask-user:blocked", raw => target.state.rpivBlocked(raw));
104
+ };
105
+ const observe = (handler: (state: NotificationState) => void) => {
106
+ const target = session;
107
+ if (!target || target.disposed || target.backendCode === "ENV_UNSUPPORTED") return;
108
+ try { handler(target.state); } catch { diagnose(target, "EVENT_INVALID"); }
109
+ };
110
+ pi.on("session_start", async (_event, context) => { await transition(() => start(context)); });
111
+ pi.on("session_shutdown", async () => { await transition(stop); });
112
+ pi.on("tool_execution_start", event => { observe(state => state.toolStart(event)); });
113
+ pi.on("tool_execution_end", event => { observe(state => state.toolEnd(event)); });
114
+ pi.on("ui_prompt_start", () => { observe(state => state.uiStart()); });
115
+ pi.on("ui_prompt_end", () => { observe(state => state.uiEnd()); });
116
+ pi.on("agent_start", () => { observe(state => state.agentStart()); });
117
+ pi.on("message_end", event => { observe(state => state.messageEnd(event)); });
118
+ pi.on("turn_end", event => { observe(state => state.boundary(event)); });
119
+ pi.on("agent_before_settle", event => { observe(state => state.boundary(event)); });
120
+ pi.on("agent_settled", event => { observe(state => state.settled(event)); });
121
+
122
+ pi.registerCommand("windows-notifier", {
123
+ description: "Windows 通知:status、reload、test [permission|question|completed|aborted|failed]",
124
+ handler: async (args, context) => {
125
+ const parts = args.trim().split(/\s+/u);
126
+ if (parts.length === 1 && parts[0] === "reload") {
127
+ await transition(() => start(context));
128
+ context.ui.notify("Windows notifier 設定已重新載入;請用 status 查看有效狀態。", "info");
129
+ return;
130
+ }
131
+ const target = session;
132
+ if (!args.trim() || parts.length === 1 && parts[0] === "status") {
133
+ const status = {
134
+ backend: target?.backendCode ?? "NOT_STARTED",
135
+ enabled: target?.result.config.enabled ?? false,
136
+ events: target?.result.config.events,
137
+ ...target?.scheduler?.status(),
138
+ diagnostics: [...(target?.diagnostics ?? [])],
139
+ };
140
+ context.ui.notify(JSON.stringify(status), "info");
141
+ return;
142
+ }
143
+ if (parts[0] === "test" && parts.length <= 2 &&
144
+ (parts[1] === undefined || KINDS.includes(parts[1] as NotificationKind))) {
145
+ if (!target?.scheduler || target.disposed) {
146
+ context.ui.notify(SUBMISSION_MESSAGES.disabled, "warning");
147
+ return;
148
+ }
149
+ const result = await target.scheduler.submit({ key: "test:" + ++target.testId,
150
+ kind: (parts[1] ?? "completed") as NotificationKind,
151
+ valid: () => session === target && !target.disposed });
152
+ // reload/session 切換後不再使用已失效的 command context。
153
+ if (session === target && !target.disposed) context.ui.notify(SUBMISSION_MESSAGES[result.status],
154
+ result.status === "submitted" ? "info" : "warning");
155
+ return;
156
+ }
157
+ context.ui.notify("用法:/windows-notifier status | reload | test [permission|question|completed|aborted|failed]", "warning");
158
+ },
159
+ });
160
+ }
@@ -0,0 +1,137 @@
1
+ import type { Config } from "./config.ts";
2
+ import type { Backend, LaunchResult } from "./launcher.ts";
3
+ import type { Diagnostic, NotificationJob } from "./types.ts";
4
+
5
+ export interface Clock {
6
+ now(): number;
7
+ set(fn: () => void, delay: number): unknown;
8
+ clear(handle: unknown): void;
9
+ }
10
+ export const systemClock: Clock = {
11
+ now: () => performance.now(),
12
+ set: (fn, delay) => { const timer = setTimeout(fn, delay); timer.unref(); return timer; },
13
+ clear: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
14
+ };
15
+ export interface Submission {
16
+ status: "submitted" | "partial" | "disabled" | "cancelled" | "dropped" | "expired" | "failed";
17
+ code?: string;
18
+ }
19
+ interface Pending {
20
+ job: NotificationJob;
21
+ created: number;
22
+ resolve(result: Submission): void;
23
+ }
24
+ export const QUEUE_LIMIT = 16;
25
+ export const MIN_INTERVAL = 1_000;
26
+ export const QUEUE_TTL = 30_000;
27
+ const waiting = (job: NotificationJob) => job.kind === "permission" || job.kind === "question";
28
+
29
+ /** 單一 helper、有界佇列、過期取消;所有 promise 都以固定狀態結束,不外洩原始錯誤。 */
30
+ export class NotificationScheduler {
31
+ private backend: Backend;
32
+ private config: () => Config;
33
+ private diagnose: Diagnostic;
34
+ private clock: Clock;
35
+ private queue: Pending[] = [];
36
+ private timer: unknown;
37
+ private lastStart = -Infinity;
38
+ private active: { key: string; abort: AbortController; finished: Promise<void> } | undefined;
39
+ private closed = false;
40
+ dropped = 0;
41
+
42
+ constructor(backend: Backend, config: () => Config, diagnose: Diagnostic = () => {}, clock: Clock = systemClock) {
43
+ this.backend = backend;
44
+ this.config = config;
45
+ this.diagnose = diagnose;
46
+ this.clock = clock;
47
+ }
48
+ private enabled(job: NotificationJob): boolean {
49
+ const config = this.config();
50
+ return !this.closed && this.backend.available && config.enabled && config.events[job.kind].enabled;
51
+ }
52
+ private valid(job: NotificationJob): boolean {
53
+ try { return job.valid(); } catch { return false; }
54
+ }
55
+ enqueue(job: NotificationJob): void { void this.submit(job); }
56
+ submit(job: NotificationJob): Promise<Submission> {
57
+ if (!this.enabled(job)) return Promise.resolve({ status: "disabled" });
58
+ if (!this.valid(job)) return Promise.resolve({ status: "cancelled" });
59
+ if (this.queue.some(item => item.job.key === job.key) || this.active?.key === job.key) {
60
+ return Promise.resolve({ status: "cancelled" });
61
+ }
62
+ this.prune();
63
+ if (this.queue.length >= QUEUE_LIMIT) {
64
+ const index = this.queue.findIndex(item => !waiting(item.job));
65
+ const [removed] = this.queue.splice(index < 0 ? 0 : index, 1);
66
+ removed.resolve({ status: "dropped" });
67
+ this.dropped++;
68
+ this.diagnose("QUEUE_DROPPED");
69
+ }
70
+ const result = new Promise<Submission>(resolve => this.queue.push({ job, created: this.clock.now(), resolve }));
71
+ this.wake();
72
+ return result;
73
+ }
74
+ cancel(key: string): void {
75
+ this.queue = this.queue.filter(item => {
76
+ if (item.job.key !== key) return true;
77
+ item.resolve({ status: "cancelled" });
78
+ return false;
79
+ });
80
+ if (this.active?.key === key) this.active.abort.abort();
81
+ if (!this.queue.length && this.timer !== undefined) {
82
+ this.clock.clear(this.timer);
83
+ this.timer = undefined;
84
+ }
85
+ }
86
+ private prune(): void {
87
+ const now = this.clock.now();
88
+ this.queue = this.queue.filter(item => {
89
+ if (now - item.created >= QUEUE_TTL) { item.resolve({ status: "expired" }); return false; }
90
+ if (!this.enabled(item.job)) { item.resolve({ status: "disabled" }); return false; }
91
+ if (!this.valid(item.job)) { item.resolve({ status: "cancelled" }); return false; }
92
+ return true;
93
+ });
94
+ }
95
+ private wake(): void {
96
+ if (this.closed || this.active || this.timer !== undefined || !this.queue.length) return;
97
+ this.timer = this.clock.set(() => { this.timer = undefined; this.start(); },
98
+ Math.max(0, this.lastStart + MIN_INTERVAL - this.clock.now()));
99
+ }
100
+ private start(): void {
101
+ if (this.closed || this.active) return;
102
+ this.prune();
103
+ if (!this.queue.length) return;
104
+ const index = this.queue.findIndex(item => waiting(item.job));
105
+ const [item] = this.queue.splice(index < 0 ? 0 : index, 1);
106
+ const abort = new AbortController();
107
+ const active = { key: item.job.key, abort, finished: Promise.resolve() };
108
+ this.active = active;
109
+ this.lastStart = this.clock.now();
110
+ active.finished = this.deliver(item, abort.signal).finally(() => {
111
+ if (this.active === active) this.active = undefined;
112
+ this.wake();
113
+ });
114
+ }
115
+ private async deliver(item: Pending, signal: AbortSignal): Promise<void> {
116
+ let result: LaunchResult;
117
+ try { result = await this.backend.launch(item.job.kind, this.config().events[item.job.kind].sound, signal); }
118
+ catch { result = { code: "LAUNCH_FAILED", toast: false, sound: "failed" }; }
119
+ if (signal.aborted || result.code === "CANCELLED") { item.resolve({ status: "cancelled" }); return; }
120
+ if (result.code !== "OK") this.diagnose(result.code);
121
+ item.resolve({
122
+ status: result.code === "OK" ? "submitted" : result.toast || result.sound === "played" ? "partial" : "failed",
123
+ code: result.code,
124
+ });
125
+ }
126
+ status(): { queued: number; active: boolean; dropped: number } {
127
+ return { queued: this.queue.length, active: !!this.active, dropped: this.dropped };
128
+ }
129
+ async close(): Promise<void> {
130
+ this.closed = true;
131
+ if (this.timer !== undefined) { this.clock.clear(this.timer); this.timer = undefined; }
132
+ for (const item of this.queue) item.resolve({ status: "cancelled" });
133
+ this.queue = [];
134
+ this.active?.abort.abort();
135
+ await this.active?.finished;
136
+ }
137
+ }
package/src/state.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { isRecord, safeId, type Diagnostic, type NotificationSink, type Outcome } from "./types.ts";
2
+
3
+ const QUESTION_TOOLS = new Set(["ask_user_question", "plan_mode_question"]);
4
+ interface Question { name: string; waiting: boolean; notified: boolean }
5
+
6
+ /** 純觀察狀態機:不回傳工具攔截結果、不保存 arguments 或訊息內容。 */
7
+ export class NotificationState {
8
+ private sink: NotificationSink;
9
+ private diagnose: Diagnostic;
10
+ private seen = new Set<string>();
11
+ private permissions = new Set<string>();
12
+ private questions = new Map<string, Question>();
13
+ private ui: { question?: string } | undefined;
14
+ private rpivCall: string | undefined;
15
+ private running = false;
16
+ private generation = 0;
17
+ private terminalKey: string | undefined;
18
+ private assistantSeen = false;
19
+ private outcome: Outcome | undefined;
20
+
21
+ constructor(sink: NotificationSink, diagnose: Diagnostic = () => {}) {
22
+ this.sink = sink;
23
+ this.diagnose = diagnose;
24
+ }
25
+ permissionPrompt(raw: unknown): void {
26
+ if (!isRecord(raw) || !safeId(raw.requestId)) { this.diagnose("EVENT_INVALID"); return; }
27
+ const id = raw.requestId;
28
+ if (this.seen.has(id)) return;
29
+ this.seen.add(id);
30
+ if (this.seen.size > 256) this.seen.delete(this.seen.values().next().value!);
31
+ this.permissions.add(id);
32
+ if (this.permissions.size > 256) {
33
+ const oldest = this.permissions.values().next().value!;
34
+ this.permissions.delete(oldest);
35
+ this.sink.cancel("permission:" + oldest);
36
+ }
37
+ this.sink.enqueue({ key: "permission:" + id, kind: "permission", valid: () => this.permissions.has(id) });
38
+ }
39
+ permissionDecision(raw: unknown): void {
40
+ if (!isRecord(raw) || !safeId(raw.requestId)) return;
41
+ this.permissions.delete(raw.requestId);
42
+ this.sink.cancel("permission:" + raw.requestId);
43
+ }
44
+ toolStart(raw: unknown): void {
45
+ if (!isRecord(raw) || typeof raw.toolName !== "string" || !QUESTION_TOOLS.has(raw.toolName)) return;
46
+ if (!safeId(raw.toolCallId)) { this.diagnose("EVENT_INVALID"); return; }
47
+ if (this.questions.has(raw.toolCallId)) return;
48
+ if (this.questions.size >= 32) { this.diagnose("TOOLS_LIMIT"); return; }
49
+ this.questions.set(raw.toolCallId, { name: raw.toolName, waiting: false, notified: false });
50
+ }
51
+ toolEnd(raw: unknown): void {
52
+ if (!isRecord(raw) || !safeId(raw.toolCallId)) return;
53
+ this.sink.cancel("question:" + raw.toolCallId);
54
+ this.questions.delete(raw.toolCallId);
55
+ if (this.rpivCall === raw.toolCallId) this.rpivCall = undefined;
56
+ }
57
+ private wait(id: string): void {
58
+ const question = this.questions.get(id);
59
+ if (!question) return;
60
+ question.waiting = true;
61
+ if (question.notified) return;
62
+ question.notified = true;
63
+ this.sink.enqueue({ key: "question:" + id, kind: "question",
64
+ valid: () => this.questions.get(id) === question && question.waiting });
65
+ }
66
+ private stopWait(id: string): void {
67
+ const question = this.questions.get(id);
68
+ if (question) question.waiting = false;
69
+ this.sink.cancel("question:" + id);
70
+ }
71
+ uiStart(): void {
72
+ if (this.ui) {
73
+ if (this.ui.question) this.stopWait(this.ui.question);
74
+ if (this.rpivCall) this.stopWait(this.rpivCall);
75
+ this.ui = {};
76
+ this.diagnose("UI_AMBIGUOUS");
77
+ return;
78
+ }
79
+ this.ui = {};
80
+ if (this.permissions.size) return;
81
+ if (this.questions.size !== 1) {
82
+ if (this.questions.size > 1) this.diagnose("UI_AMBIGUOUS");
83
+ return;
84
+ }
85
+ const id = this.questions.keys().next().value!;
86
+ this.ui.question = id;
87
+ this.wait(id);
88
+ }
89
+ uiEnd(): void {
90
+ if (this.ui?.question) this.stopWait(this.ui.question);
91
+ this.ui = undefined;
92
+ }
93
+ rpivBlocked(raw: unknown): void {
94
+ if (!isRecord(raw) || typeof raw.active !== "boolean") return;
95
+ if (!raw.active) {
96
+ if (this.rpivCall) this.stopWait(this.rpivCall);
97
+ this.rpivCall = undefined;
98
+ return;
99
+ }
100
+ if (this.rpivCall) return;
101
+ if (this.permissions.size || this.questions.size !== 1 ||
102
+ (this.ui && !this.ui.question)) { this.diagnose("UI_AMBIGUOUS"); return; }
103
+ const [id, question] = this.questions.entries().next().value!;
104
+ if (question.name !== "ask_user_question") return;
105
+ this.rpivCall = id;
106
+ this.wait(id);
107
+ }
108
+ agentStart(): void {
109
+ if (this.running) return; // retry/續跑仍屬於同一個邏輯 run。
110
+ if (this.terminalKey) this.sink.cancel(this.terminalKey);
111
+ this.terminalKey = undefined;
112
+ this.running = true;
113
+ this.generation++;
114
+ this.assistantSeen = false;
115
+ this.outcome = undefined;
116
+ }
117
+ messageEnd(raw: unknown): void {
118
+ if (!this.running || !isRecord(raw) || !isRecord(raw.message) || raw.message.role !== "assistant") return;
119
+ this.assistantSeen = true;
120
+ this.outcome = raw.message.stopReason === "aborted" ? "aborted" :
121
+ raw.message.stopReason === "error" ? "error" : "completed";
122
+ }
123
+ boundary(raw: unknown): void {
124
+ if (!this.running || !isRecord(raw)) return;
125
+ if (raw.outcome === "completed" || raw.outcome === "aborted" || raw.outcome === "error") {
126
+ this.outcome = raw.outcome;
127
+ }
128
+ }
129
+ settled(raw: unknown): void {
130
+ if (!this.running || !isRecord(raw) || typeof raw.aborted !== "boolean") return;
131
+ this.running = false;
132
+ const kind = raw.aborted || this.outcome === "aborted" ? "aborted" :
133
+ this.outcome === "error" ? "failed" : this.assistantSeen ? "completed" : undefined;
134
+ if (!kind) return;
135
+ const generation = this.generation;
136
+ const key = "run:" + generation;
137
+ this.terminalKey = key;
138
+ this.sink.enqueue({ key, kind, valid: () => this.generation === generation && !this.running });
139
+ }
140
+ reset(): void {
141
+ for (const id of this.permissions) this.sink.cancel("permission:" + id);
142
+ for (const id of this.questions.keys()) this.sink.cancel("question:" + id);
143
+ if (this.terminalKey) this.sink.cancel(this.terminalKey);
144
+ this.seen.clear();
145
+ this.permissions.clear();
146
+ this.questions.clear();
147
+ this.ui = undefined;
148
+ this.rpivCall = undefined;
149
+ this.running = false;
150
+ this.assistantSeen = false;
151
+ this.outcome = undefined;
152
+ this.terminalKey = undefined;
153
+ this.generation++;
154
+ }
155
+ }
package/src/types.ts ADDED
@@ -0,0 +1,21 @@
1
+ export const KINDS = ["permission", "question", "completed", "aborted", "failed"] as const;
2
+ export type NotificationKind = (typeof KINDS)[number];
3
+ export type Outcome = "completed" | "aborted" | "error";
4
+ export interface NotificationJob {
5
+ key: string;
6
+ kind: NotificationKind;
7
+ /** 送出前重新確認事件仍有效,不保留工作內容。 */
8
+ valid(): boolean;
9
+ }
10
+ export interface NotificationSink {
11
+ enqueue(job: NotificationJob): void;
12
+ cancel(key: string): void;
13
+ }
14
+ export type Diagnostic = (code: string) => void;
15
+ export function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+ export function safeId(value: unknown): value is string {
19
+ return typeof value === "string" && value.length > 0 && value.length <= 256 &&
20
+ !/[\u0000-\u001f\u007f]/u.test(value);
21
+ }
@@ -0,0 +1,78 @@
1
+ # 固定 helper:stdin 僅接受事件 enum 與音效開關,不接受任意訊息或命令。
2
+ Set-StrictMode -Version Latest
3
+ $ErrorActionPreference = "Stop"
4
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
5
+
6
+ function Write-Result {
7
+ param([string]$Code, [bool]$Toast, [string]$Sound)
8
+ [Console]::Out.WriteLine((@{ code = $Code; toast = $Toast; sound = $Sound } | ConvertTo-Json -Compress))
9
+ }
10
+
11
+ try {
12
+ $buffer = New-Object char[] 513
13
+ $size = 0
14
+ while ($size -lt $buffer.Length) {
15
+ $count = [Console]::In.Read($buffer, $size, $buffer.Length - $size)
16
+ if ($count -eq 0) { break }
17
+ $size += $count
18
+ }
19
+ if ($size -gt 512) { throw "INPUT_INVALID" }
20
+ $inputData = (-join $buffer[0..($size - 1)]) | ConvertFrom-Json
21
+ $names = @($inputData.PSObject.Properties.Name)
22
+ if ($names.Count -ne 2 -or -not ($names -contains "kind") -or -not ($names -contains "sound") -or
23
+ $inputData.kind -isnot [string] -or $inputData.sound -isnot [bool]) { throw "INPUT_INVALID" }
24
+ $messages = @{
25
+ permission = "需要權限確認"
26
+ question = "有問題等待回答"
27
+ completed = "回應已完成"
28
+ aborted = "回應已中止"
29
+ failed = "回應失敗"
30
+ }
31
+ # PowerShell hashtable 預設不區分大小寫,先以 case-sensitive 比較鎖定 enum。
32
+ if (-not (@("permission", "question", "completed", "aborted", "failed") -ccontains $inputData.kind)) {
33
+ throw "INPUT_INVALID"
34
+ }
35
+ }
36
+ catch {
37
+ Write-Result "INPUT_INVALID" $false "failed"
38
+ exit 1
39
+ }
40
+
41
+ $toastOk = $false
42
+ $soundStatus = "disabled"
43
+ try {
44
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
45
+ [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
46
+ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(
47
+ [Windows.UI.Notifications.ToastTemplateType]::ToastText02)
48
+ $nodes = $xml.GetElementsByTagName("text")
49
+ $nodes.Item(0).AppendChild($xml.CreateTextNode("Pi")) | Out-Null
50
+ $nodes.Item(1).AppendChild($xml.CreateTextNode($messages[$inputData.kind])) | Out-Null
51
+ $audio = $xml.CreateElement("audio")
52
+ $audio.SetAttribute("silent", "true")
53
+ $xml.DocumentElement.AppendChild($audio) | Out-Null
54
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
55
+ $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Microsoft.Windows.PowerShell")
56
+ $notifier.Show($toast)
57
+ $toastOk = $true
58
+ }
59
+ catch { $toastOk = $false }
60
+
61
+ if ($inputData.sound) {
62
+ try {
63
+ if ($inputData.kind -ceq "completed") { [System.Media.SystemSounds]::Asterisk.Play() }
64
+ else { [System.Media.SystemSounds]::Exclamation.Play() }
65
+ $soundStatus = "played"
66
+ # Play 非同步;有界等待不保證聲音實際送達,也不建立其他播放器。
67
+ Start-Sleep -Milliseconds 750
68
+ }
69
+ catch { $soundStatus = "failed" }
70
+ }
71
+
72
+ $code = if ($toastOk) {
73
+ if ($soundStatus -eq "failed") { "SOUND_FAILED" } else { "OK" }
74
+ } else {
75
+ if ($soundStatus -eq "failed") { "BOTH_FAILED" } else { "TOAST_FAILED" }
76
+ }
77
+ Write-Result $code $toastOk $soundStatus
78
+ if ($code -eq "OK") { exit 0 } else { exit 1 }