@erica-s/ai-agent-notify 2.1.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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@erica-s/ai-agent-notify",
3
+ "version": "2.1.5",
4
+ "description": "Windows Toast notifications for Claude Code and Codex",
5
+ "bin": {
6
+ "ai-agent-notify": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "lib/",
11
+ "scripts/",
12
+ "assets/",
13
+ ".published",
14
+ "postinstall.js"
15
+ ],
16
+ "scripts": {
17
+ "prepublishOnly": "node -e \"require('fs').writeFileSync('.published', JSON.stringify({version: require('./package.json').version, date: new Date().toISOString()}))\"",
18
+ "postpack": "node -e \"require('fs').rmSync('.published', {force:true})\"",
19
+ "postinstall": "node postinstall.js",
20
+ "test": "node test/test-cli.js"
21
+ },
22
+ "keywords": [
23
+ "claude",
24
+ "claude-code",
25
+ "codex",
26
+ "notification",
27
+ "toast",
28
+ "windows",
29
+ "hooks"
30
+ ],
31
+ "author": "EricaLi",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/EricaLi123/ai-tools.git",
36
+ "directory": "packages/ai-agent-notify"
37
+ },
38
+ "engines": {
39
+ "node": ">=16"
40
+ }
41
+ }
package/postinstall.js ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+
3
+ if (process.platform !== "win32") {
4
+ process.exit(0);
5
+ }
6
+
7
+ const { execFileSync } = require("child_process");
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+
11
+ const tasks = [
12
+ {
13
+ kind: "powershell",
14
+ path: path.join(__dirname, "scripts", "register-protocol.ps1"),
15
+ warning: "ai-agent-notify: protocol registration skipped (non-fatal)",
16
+ },
17
+ ];
18
+
19
+ for (const task of tasks) {
20
+ try {
21
+ if (task.kind === "powershell") {
22
+ execFileSync(
23
+ "powershell",
24
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", task.path],
25
+ { stdio: "inherit" }
26
+ );
27
+ continue;
28
+ }
29
+
30
+ execFileSync("node", [task.path], { stdio: "inherit" });
31
+ } catch {
32
+ console.warn(task.warning);
33
+ }
34
+ }
35
+
36
+ try {
37
+ installCodexNotifyWrapper();
38
+ } catch {
39
+ console.warn("ai-agent-notify: Codex wrapper install skipped (non-fatal)");
40
+ }
41
+
42
+ function installCodexNotifyWrapper() {
43
+ const localAppData = process.env.LOCALAPPDATA;
44
+ if (!localAppData) {
45
+ throw new Error("LOCALAPPDATA is not set");
46
+ }
47
+
48
+ const targetDir = path.join(localAppData, "ai-agent-notify");
49
+ const sourcePath = path.join(__dirname, "scripts", "codex-notify-wrapper.vbs");
50
+ const targetPath = path.join(targetDir, "codex-notify-wrapper.vbs");
51
+
52
+ fs.mkdirSync(targetDir, { recursive: true });
53
+ fs.copyFileSync(sourcePath, targetPath);
54
+ }
@@ -0,0 +1,26 @@
1
+ # 激活窗口脚本 - 由 ai-agent-notify 协议处理器调用
2
+ # 参数: 协议 URL,格式为 erica-s.ai-agent-notify.activate-window://<窗口句柄>
3
+
4
+ $url = $args[0]
5
+ $handleString = $url -replace '^[^:]+://', '' -replace '[/\\"]', ''
6
+ $handleString = $handleString.Trim()
7
+ if (-not $handleString) { exit 1 }
8
+ try {
9
+ $handleInt = [long]$handleString
10
+ $hwnd = [IntPtr]$handleInt
11
+ } catch { exit 1 }
12
+
13
+ Add-Type -TypeDefinition @"
14
+ using System;
15
+ using System.Runtime.InteropServices;
16
+ public class WinAPI {
17
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
18
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
19
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd);
20
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
21
+ }
22
+ "@ -ErrorAction SilentlyContinue
23
+
24
+ if (-not [WinAPI]::IsWindow($hwnd)) { exit 1 }
25
+ if ([WinAPI]::IsIconic($hwnd)) { [WinAPI]::ShowWindow($hwnd, 9) | Out-Null }
26
+ [WinAPI]::SetForegroundWindow($hwnd) | Out-Null
@@ -0,0 +1,13 @@
1
+ ' VBScript wrapper to completely hide PowerShell window
2
+ ' When invoked via URL protocol, this script runs PowerShell without any window flash
3
+
4
+ Dim shell, scriptPath, psCommand
5
+
6
+ Set shell = CreateObject("WScript.Shell")
7
+
8
+ ' Get the directory where this script is located
9
+ scriptPath = Replace(WScript.ScriptFullName, WScript.ScriptName, "")
10
+ psCommand = "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File """ & scriptPath & "activate-window.ps1"" """ & WScript.Arguments(0) & """"
11
+
12
+ ' Run with window style 0 = completely hidden
13
+ shell.Run psCommand, 0, False
@@ -0,0 +1,29 @@
1
+ Option Explicit
2
+
3
+ Dim shell
4
+ Dim env
5
+ Dim command
6
+ Dim comspec
7
+ Dim exitCode
8
+ Dim payload
9
+
10
+ If WScript.Arguments.Count < 1 Then
11
+ WScript.Quit 1
12
+ End If
13
+
14
+ payload = WScript.Arguments.Item(0)
15
+
16
+ Set shell = CreateObject("WScript.Shell")
17
+ Set env = shell.Environment("Process")
18
+ comspec = shell.ExpandEnvironmentStrings("%ComSpec%")
19
+
20
+ env("AI_AGENT_NOTIFY_PAYLOAD") = payload
21
+ command = comspec & " /d /c ai-agent-notify.cmd"
22
+ exitCode = shell.Run(command, 0, True)
23
+ If exitCode = 9009 Then
24
+ command = comspec & " /d /c npx.cmd @erica-s/ai-agent-notify"
25
+ exitCode = shell.Run(command, 0, True)
26
+ End If
27
+ env("AI_AGENT_NOTIFY_PAYLOAD") = ""
28
+
29
+ WScript.Quit exitCode
@@ -0,0 +1,144 @@
1
+ # 从指定 PID 向上遍历父进程链,输出第一个有窗口 handle 的进程的 hwnd。
2
+ # 由 cli.js 在 spawn notify.ps1 之前调用,结果通过 TOAST_NOTIFY_HWND 环境变量传入。
3
+ # -IncludeShellPid 启用时,输出格式为 hwnd|shellPid|isWindowsTerminal(1/0)
4
+ param([int]$StartPid, [switch]$IncludeShellPid)
5
+
6
+ $ErrorActionPreference = 'SilentlyContinue'
7
+
8
+ Add-Type -TypeDefinition @'
9
+ using System;
10
+ using System.Collections.Generic;
11
+ using System.Runtime.InteropServices;
12
+ public class PH {
13
+ [DllImport("kernel32.dll", SetLastError=true)]
14
+ static extern IntPtr CreateToolhelp32Snapshot(uint f, uint p);
15
+ [DllImport("kernel32.dll")]
16
+ static extern bool Process32First(IntPtr h, ref PE e);
17
+ [DllImport("kernel32.dll")]
18
+ static extern bool Process32Next(IntPtr h, ref PE e);
19
+ [DllImport("kernel32.dll")]
20
+ static extern bool CloseHandle(IntPtr h);
21
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Auto)]
22
+ public struct PE {
23
+ public uint sz, u, pid; public IntPtr heap;
24
+ public uint mod, thr, ppid; public int pri; public uint fl;
25
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=260)] public string exe;
26
+ }
27
+ public static Dictionary<int,int> Map() {
28
+ var m = new Dictionary<int,int>();
29
+ IntPtr s = CreateToolhelp32Snapshot(2, 0);
30
+ PE e = new PE(); e.sz = (uint)System.Runtime.InteropServices.Marshal.SizeOf(e);
31
+ if (Process32First(s, ref e)) do { m[(int)e.pid] = (int)e.ppid; } while (Process32Next(s, ref e));
32
+ CloseHandle(s);
33
+ return m;
34
+ }
35
+ }
36
+ '@ 2>$null
37
+
38
+ $map = [PH]::Map()
39
+ $cur = $StartPid
40
+ $shellPid = 0
41
+ $isWindowsTerminal = $false
42
+ $shellNames = @('bash','powershell','pwsh','cmd','zsh','fish')
43
+ $wtNames = @('WindowsTerminal','OpenConsole')
44
+
45
+ [Console]::Error.WriteLine("find-hwnd: StartPid=$StartPid IncludeShellPid=$IncludeShellPid")
46
+ for ($i = 0; $i -lt 50; $i++) {
47
+ try {
48
+ $p = Get-Process -Id $cur -ErrorAction Stop
49
+ $pName = $p.ProcessName
50
+ [Console]::Error.WriteLine("find-hwnd: depth=$i pid=$cur name=$pName hwnd=$($p.MainWindowHandle)")
51
+ # 记录最近的 shell 进程 PID。
52
+ # 不取“第一个 shell”,因为全局命令经常会经过短命的 cmd/volta 包装层;
53
+ # watcher 需要附着到离终端最近、仍然存活的交互 shell。
54
+ if ($IncludeShellPid -and $shellNames -contains $pName) {
55
+ $shellPid = $cur
56
+ [Console]::Error.WriteLine("find-hwnd: shellPid=$shellPid")
57
+ }
58
+ # 检测 Windows Terminal
59
+ if ($IncludeShellPid -and -not $isWindowsTerminal -and $wtNames -contains $pName) {
60
+ $isWindowsTerminal = $true
61
+ [Console]::Error.WriteLine("find-hwnd: isWindowsTerminal=true (detected $pName)")
62
+ }
63
+ if ($p.MainWindowHandle -ne 0) {
64
+ [Console]::Error.WriteLine("find-hwnd: found hwnd=$($p.MainWindowHandle)")
65
+ if ($IncludeShellPid) {
66
+ $wtFlag = if ($isWindowsTerminal) { '1' } else { '0' }
67
+ Write-Output "$($p.MainWindowHandle)|$shellPid|$wtFlag"
68
+ } else {
69
+ Write-Output $p.MainWindowHandle
70
+ }
71
+ exit
72
+ }
73
+ } catch {
74
+ [Console]::Error.WriteLine("find-hwnd: depth=$i pid=$cur dead")
75
+ }
76
+ if (-not $map.ContainsKey($cur) -or $map[$cur] -eq 0) {
77
+ [Console]::Error.WriteLine("find-hwnd: chain broken at pid=$cur")
78
+ break
79
+ }
80
+ $cur = $map[$cur]
81
+ }
82
+ # Fallback: VSCode/Cursor integrated terminal injects VSCODE_GIT_IPC_HANDLE,
83
+ # a named pipe owned by the specific editor instance. Use GetNamedPipeServerProcessId
84
+ # to get the exact owner PID without any process-name guessing.
85
+ if ($env:VSCODE_GIT_IPC_HANDLE) {
86
+ [Console]::Error.WriteLine("find-hwnd: trying VSCODE_GIT_IPC_HANDLE=$($env:VSCODE_GIT_IPC_HANDLE)")
87
+ try {
88
+ Add-Type -TypeDefinition @'
89
+ using System;
90
+ using System.Runtime.InteropServices;
91
+ using Microsoft.Win32.SafeHandles;
92
+ public class PipeHelper {
93
+ [DllImport("kernel32.dll", CharSet=CharSet.Auto, SetLastError=true)]
94
+ static extern SafeFileHandle CreateFile(
95
+ string lpFileName, uint dwDesiredAccess, uint dwShareMode,
96
+ IntPtr securityAttributes, uint dwCreationDisposition,
97
+ uint dwFlagsAndAttributes, IntPtr hTemplateFile);
98
+ [DllImport("kernel32.dll", SetLastError=true)]
99
+ static extern bool GetNamedPipeServerProcessId(SafeHandle hPipe, out uint ServerProcessId);
100
+ public static int GetServerPid(string pipeName) {
101
+ // GENERIC_READ|GENERIC_WRITE=0xC0000000, FILE_SHARE_READ|WRITE=3, OPEN_EXISTING=3
102
+ SafeFileHandle h = CreateFile(pipeName, 0xC0000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);
103
+ if (h.IsInvalid) return -1;
104
+ try { uint pid = 0; return GetNamedPipeServerProcessId(h, out pid) ? (int)pid : -1; }
105
+ finally { h.Close(); }
106
+ }
107
+ }
108
+ '@ -ErrorAction Stop
109
+ $serverPid = [PipeHelper]::GetServerPid($env:VSCODE_GIT_IPC_HANDLE)
110
+ [Console]::Error.WriteLine("find-hwnd: pipe server pid=$serverPid")
111
+ if ($serverPid -gt 0) {
112
+ $cur = $serverPid
113
+ for ($i = 0; $i -lt 10; $i++) {
114
+ try {
115
+ $p = Get-Process -Id $cur -ErrorAction Stop
116
+ [Console]::Error.WriteLine("find-hwnd: pipe-chain depth=$i pid=$cur name=$($p.ProcessName) hwnd=$($p.MainWindowHandle)")
117
+ if ($p.MainWindowHandle -ne 0) {
118
+ [Console]::Error.WriteLine("find-hwnd: found via pipe hwnd=$($p.MainWindowHandle)")
119
+ if ($IncludeShellPid) {
120
+ $wtFlag = if ($isWindowsTerminal) { '1' } else { '0' }
121
+ Write-Output "$($p.MainWindowHandle)|$shellPid|$wtFlag"
122
+ } else {
123
+ Write-Output $p.MainWindowHandle
124
+ }
125
+ exit
126
+ }
127
+ } catch {
128
+ [Console]::Error.WriteLine("find-hwnd: pipe-chain depth=$i pid=$cur dead")
129
+ }
130
+ if (-not $map.ContainsKey($cur) -or $map[$cur] -eq 0) { break }
131
+ $cur = $map[$cur]
132
+ }
133
+ }
134
+ } catch {
135
+ [Console]::Error.WriteLine("find-hwnd: pipe approach failed: $_")
136
+ }
137
+ }
138
+ [Console]::Error.WriteLine("find-hwnd: not found, returning 0")
139
+ if ($IncludeShellPid) {
140
+ $wtFlag = if ($isWindowsTerminal) { '1' } else { '0' }
141
+ Write-Output "0|$shellPid|$wtFlag"
142
+ } else {
143
+ Write-Output 0
144
+ }
@@ -0,0 +1,69 @@
1
+ param([int]$StartPid)
2
+
3
+ $ErrorActionPreference = 'SilentlyContinue'
4
+
5
+ Add-Type -TypeDefinition @'
6
+ using System;
7
+ using System.Runtime.InteropServices;
8
+
9
+ public class ConsoleProcList {
10
+ [DllImport("kernel32.dll", SetLastError=true)]
11
+ public static extern uint GetConsoleProcessList(uint[] processList, uint processCount);
12
+ }
13
+ '@ 2>$null
14
+
15
+ $shellNames = @('bash', 'powershell', 'pwsh', 'cmd', 'zsh', 'fish')
16
+
17
+ function Write-DebugLine($msg) {
18
+ [Console]::Error.WriteLine("get-shell-pid: $msg")
19
+ }
20
+
21
+ Write-DebugLine("StartPid=$StartPid")
22
+
23
+ $size = 16
24
+ $buffer = New-Object uint[] $size
25
+ $count = [ConsoleProcList]::GetConsoleProcessList($buffer, $size)
26
+ if ($count -gt $size) {
27
+ $size = [int]$count
28
+ $buffer = New-Object uint[] $size
29
+ $count = [ConsoleProcList]::GetConsoleProcessList($buffer, $size)
30
+ }
31
+
32
+ if ($count -le 0) {
33
+ Write-DebugLine("GetConsoleProcessList failed")
34
+ Write-Output 0
35
+ exit
36
+ }
37
+
38
+ $consolePids = @($buffer[0..($count - 1)] | Where-Object { $_ -gt 0 })
39
+ Write-DebugLine("console pids=$($consolePids -join ',')")
40
+
41
+ $candidates = foreach ($pid in $consolePids) {
42
+ if ($pid -eq $StartPid) { continue }
43
+ try {
44
+ $proc = Get-Process -Id $pid -ErrorAction Stop
45
+ Write-DebugLine("candidate pid=$pid name=$($proc.ProcessName) start=$($proc.StartTime.ToString('o'))")
46
+ if ($shellNames -contains $proc.ProcessName) {
47
+ [PSCustomObject]@{
48
+ Id = $proc.Id
49
+ Name = $proc.ProcessName
50
+ StartTime = $proc.StartTime
51
+ }
52
+ }
53
+ } catch {
54
+ Write-DebugLine("candidate pid=$pid dead")
55
+ }
56
+ }
57
+
58
+ $selected = $candidates |
59
+ Sort-Object StartTime, Id |
60
+ Select-Object -First 1
61
+
62
+ if ($selected) {
63
+ Write-DebugLine("selected shell pid=$($selected.Id) name=$($selected.Name)")
64
+ Write-Output $selected.Id
65
+ exit
66
+ }
67
+
68
+ Write-DebugLine("no shell candidate found")
69
+ Write-Output 0
@@ -0,0 +1,193 @@
1
+ # Notification Script — Native WinRT Toast (zero module dependencies)
2
+ # Toast fires FIRST (fast), then window detection + flash (slower)
3
+ #
4
+ # All hook data (event, session_id, log file path, hwnd) is passed via environment
5
+ # variables by cli.js, which reads stdin once before spawning this script.
6
+
7
+ # log 路径由 cli.js 计算并传入
8
+ $LogFile = $env:TOAST_NOTIFY_LOG_FILE
9
+ function Write-Log($msg) {
10
+ $line = "[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fff')] [ps1 pid=$PID] $msg"
11
+ [Console]::Error.WriteLine($line)
12
+ try { Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } catch {}
13
+ }
14
+
15
+ Write-Log "started"
16
+
17
+ # isDev 由 cli.js 通过环境变量传入
18
+ $isDev = $env:TOAST_NOTIFY_IS_DEV -ne "0"
19
+ $source = if ($env:TOAST_NOTIFY_SOURCE) { $env:TOAST_NOTIFY_SOURCE } else { '' }
20
+
21
+ # 1. 从 cli.js 传入的环境变量确定通知标题和内容
22
+ $eventName = if ($env:TOAST_NOTIFY_EVENT) { $env:TOAST_NOTIFY_EVENT } else { '' }
23
+ $baseTitle = if ($env:TOAST_NOTIFY_TITLE) { $env:TOAST_NOTIFY_TITLE } else { '' }
24
+ $message = if ($env:TOAST_NOTIFY_MESSAGE) { $env:TOAST_NOTIFY_MESSAGE } else { '' }
25
+
26
+ if (-not $baseTitle) {
27
+ switch ($eventName) {
28
+ 'Stop' {
29
+ $baseTitle = 'Done'
30
+ }
31
+ 'PermissionRequest' {
32
+ $baseTitle = 'Needs Approval'
33
+ }
34
+ default {
35
+ $baseTitle = 'Notification'
36
+ }
37
+ }
38
+ }
39
+
40
+ if (-not $message) {
41
+ switch ($eventName) {
42
+ 'Stop' {
43
+ $message = 'Task finished'
44
+ }
45
+ 'PermissionRequest' {
46
+ $message = 'Waiting for your approval'
47
+ }
48
+ default {
49
+ $message = 'Notification'
50
+ }
51
+ }
52
+ }
53
+
54
+ if ($source) {
55
+ $Title = "[$source] $baseTitle"
56
+ } else {
57
+ $Title = $baseTitle
58
+ }
59
+ $Message = $message
60
+ Write-Log "source=$source event=$eventName title=$Title message=$Message"
61
+
62
+ # 2. 窗口检测
63
+ $hwnd = $null
64
+ $terminalName = 'Terminal'
65
+ $terminalExePath = $null
66
+
67
+ # 3a. 优先使用 cli.js 预先找好的 hwnd(通过 find-hwnd.ps1 在 Node 侧查父链得到)。
68
+ # 这样可以绕过 MSYS2 断链问题:git bash 里 PowerShell 自身的父链走不到编辑器窗口,
69
+ # 但 Node → cmd → Claude Code Node → Code.exe 这条链在 Node 侧是完整的。
70
+ if ($env:TOAST_NOTIFY_HWND) {
71
+ $hwnd = [IntPtr][long]$env:TOAST_NOTIFY_HWND
72
+ try {
73
+ $proc = Get-Process -Id (Get-Process | Where-Object { $_.MainWindowHandle -eq $hwnd } | Select-Object -First 1 -ExpandProperty Id) -ErrorAction Stop
74
+ $terminalName = if ($proc.Product) { $proc.Product } elseif ($proc.Description) { $proc.Description } else { $proc.ProcessName }
75
+ $terminalExePath = $proc.Path
76
+ } catch {}
77
+ Write-Log "hwnd from cli.js: $hwnd terminal=$terminalName exe=$terminalExePath"
78
+ }
79
+
80
+ Write-Log "hwnd=$hwnd terminal=$terminalName"
81
+
82
+ # 合成通知图标:底层 exe 图标 + 上层静态符号 PNG
83
+ # 缓存到 scripts/icons-cache/{hookName}-{exeSlug}.png,npm install 重建包目录时随之清空
84
+ function Get-NotifyIcon($hookName, $exePath) {
85
+ $staticIcon = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($PSScriptRoot, "..", "assets", "icons", "$hookName.png"))
86
+ if (-not ($exePath -and (Test-Path $exePath))) { return $staticIcon }
87
+
88
+ $exeSlug = [System.IO.Path]::GetFileNameWithoutExtension($exePath).ToLower()
89
+ $cacheDir = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($PSScriptRoot, "..", ".cache"))
90
+ $iconPath = [System.IO.Path]::Combine($cacheDir, "$hookName-$exeSlug.png")
91
+ if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir | Out-Null }
92
+ if (Test-Path $iconPath) { return $iconPath }
93
+
94
+ try {
95
+ Add-Type -AssemblyName System.Drawing -ErrorAction Stop
96
+ $bmp = [System.Drawing.Bitmap]::new(48, 48)
97
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
98
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
99
+ $g.Clear([System.Drawing.Color]::Transparent)
100
+
101
+ # 底层:exe 图标铺满画布
102
+ $appIcon = [System.Drawing.Icon]::ExtractAssociatedIcon($exePath)
103
+ if ($appIcon) {
104
+ $appBmp = $appIcon.ToBitmap()
105
+ $appIcon.Dispose()
106
+ $g.DrawImage($appBmp, [System.Drawing.Rectangle]::new(0, 0, 48, 48))
107
+ $appBmp.Dispose()
108
+ }
109
+
110
+ # 上层:叠加静态符号 PNG
111
+ $overlay = [System.Drawing.Bitmap]::new($staticIcon)
112
+ $g.DrawImage($overlay, [System.Drawing.Rectangle]::new(0, 0, 48, 48))
113
+ $overlay.Dispose()
114
+ $g.Dispose()
115
+
116
+ $bmp.Save($iconPath, [System.Drawing.Imaging.ImageFormat]::Png)
117
+ $bmp.Dispose()
118
+ Write-Log "icon cached: $iconPath"
119
+ return $iconPath
120
+ } catch {
121
+ Write-Log "icon generation failed: $_"
122
+ return $staticIcon
123
+ }
124
+ }
125
+
126
+ $hookName = switch ($eventName) {
127
+ 'Stop' { 'stop' }
128
+ 'PermissionRequest' { 'permission' }
129
+ default { 'info' }
130
+ }
131
+ $iconPath = Get-NotifyIcon $hookName $terminalExePath
132
+
133
+ # 3. 构建 toast 通知内容
134
+ # dev 版本在标题前添加 [DEV] 标记
135
+ $devMarker = if ($isDev) { "[DEV] " } else { "" }
136
+ $notificationTitle = "$devMarker$Title ($terminalName)"
137
+ $escapedTitle = [System.Security.SecurityElement]::Escape($notificationTitle)
138
+ $escapedMessage = [System.Security.SecurityElement]::Escape($Message)
139
+
140
+ $actionsXml = ''
141
+ if ($hwnd) {
142
+ $activateUrl = "erica-s.ai-agent-notify.activate-window://$hwnd"
143
+ $actionsXml = "<actions><action activationType=`"protocol`" arguments=`"$activateUrl`" content=`"Open`"/></actions>"
144
+ }
145
+
146
+ # 图标 XML(路径无效时为空字符串,保证降级安全)
147
+ $iconXml = ''
148
+ if ($iconPath -and (Test-Path $iconPath)) {
149
+ $uriPath = $iconPath.Replace('\', '/')
150
+ $escapedIconSrc = [System.Security.SecurityElement]::Escape("file:///$uriPath")
151
+ $iconXml = "<image placement=`"appLogoOverride`" src=`"$escapedIconSrc`"/>"
152
+ }
153
+
154
+ # 4. 发送 toast 通知
155
+ try {
156
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
157
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null
158
+
159
+ $toastXml = @"
160
+ <toast>
161
+ <visual>
162
+ <binding template="ToastGeneric">
163
+ $iconXml
164
+ <text>$escapedTitle</text>
165
+ <text>$escapedMessage</text>
166
+ </binding>
167
+ </visual>
168
+ $actionsXml
169
+ </toast>
170
+ "@
171
+
172
+ $xml = [Windows.Data.Xml.Dom.XmlDocument]::new()
173
+ $xml.LoadXml($toastXml)
174
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
175
+ $appId = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe"
176
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
177
+ Write-Log "toast sent: $notificationTitle"
178
+ } catch { Write-Log "toast failed: $_" }
179
+
180
+ # 5. 任务栏闪烁
181
+ if ($hwnd) {
182
+ try {
183
+ Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public class FlashW { [DllImport("user32.dll")] public static extern bool FlashWindowEx(ref FLASHWINFO p); [StructLayout(LayoutKind.Sequential)] public struct FLASHWINFO { public uint cbSize; public IntPtr hwnd; public uint dwFlags; public uint uCount; public uint dwTimeout; } }' -ErrorAction SilentlyContinue
184
+ $flash = New-Object FlashW+FLASHWINFO
185
+ $flash.cbSize = [System.Runtime.InteropServices.Marshal]::SizeOf($flash)
186
+ $flash.hwnd = $hwnd
187
+ $flash.dwFlags = 15
188
+ $flash.uCount = 0
189
+ $flash.dwTimeout = 0
190
+ [FlashW]::FlashWindowEx([ref]$flash) | Out-Null
191
+ Write-Log "flash sent"
192
+ } catch { Write-Log "flash failed: $_" }
193
+ }
@@ -0,0 +1,18 @@
1
+ # Register erica-s.ai-agent-notify.activate-window:// protocol handler
2
+ # Uses VBScript wrapper to completely hide PowerShell window
3
+
4
+ # Get the full path to activate-window.vbs
5
+ $vbsPath = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "activate-window.vbs"))
6
+
7
+ # Build the command string: wscript.exe runs VBScript which then invokes PowerShell
8
+ # Using wscript (GUI-based) instead of cscript to avoid any console window
9
+ $command = "wscript.exe `"$vbsPath`" `"%1`""
10
+
11
+ # Write registry entries
12
+ $regPath = "HKCU:\Software\Classes\erica-s.ai-agent-notify.activate-window"
13
+ New-Item -Path "$regPath\shell\open\command" -Force | Out-Null
14
+ Set-ItemProperty -Path $regPath -Name "(default)" -Value "URL:Claude Code Activate Window Protocol" -Force
15
+ New-ItemProperty -Path $regPath -Name "URL Protocol" -Value "" -Force -ErrorAction SilentlyContinue | Out-Null
16
+ Set-ItemProperty -Path "$regPath\shell\open\command" -Name "(default)" -Value $command -Force
17
+
18
+ Write-Host "Protocol registered: erica-s.ai-agent-notify.activate-window"
@@ -0,0 +1,24 @@
1
+ ' Launch any command passed as argv with a hidden window.
2
+ ' Used when ai-agent-notify needs to start a background watcher without a visible console.
3
+
4
+ Dim shell, command, index
5
+
6
+ If WScript.Arguments.Count = 0 Then
7
+ WScript.Quit 1
8
+ End If
9
+
10
+ Set shell = CreateObject("WScript.Shell")
11
+ command = ""
12
+
13
+ For index = 0 To WScript.Arguments.Count - 1
14
+ If index > 0 Then
15
+ command = command & " "
16
+ End If
17
+ command = command & QuoteArg(WScript.Arguments(index))
18
+ Next
19
+
20
+ shell.Run command, 0, False
21
+
22
+ Function QuoteArg(value)
23
+ QuoteArg = """" & Replace(value, """", """""") & """"
24
+ End Function
@@ -0,0 +1,41 @@
1
+ param(
2
+ [Parameter(Mandatory)][int]$TargetPid,
3
+ [string]$HookEvent = '',
4
+ [long]$TerminalHwnd = 0,
5
+ [string]$WatcherPidFile = ''
6
+ )
7
+
8
+ $ErrorActionPreference = 'Stop'
9
+
10
+ function Write-Log($msg) {
11
+ if (-not $env:TOAST_NOTIFY_LOG_FILE) { return }
12
+ $line = "[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffZ')] [watcher-launcher pid=$PID] $msg"
13
+ try { [System.IO.File]::AppendAllText($env:TOAST_NOTIFY_LOG_FILE, "$line`n") } catch {}
14
+ }
15
+
16
+ try {
17
+ $powershellExe = Join-Path $PSHOME 'powershell.exe'
18
+ $watcherScript = Join-Path $PSScriptRoot 'tab-color-watcher.ps1'
19
+ $argumentList = @(
20
+ '-NoProfile',
21
+ '-ExecutionPolicy', 'Bypass',
22
+ '-File', $watcherScript,
23
+ '-TargetPid', [string]$TargetPid
24
+ )
25
+ if (-not [string]::IsNullOrEmpty($HookEvent)) {
26
+ $argumentList += @('-HookEvent', $HookEvent)
27
+ }
28
+ if ($TerminalHwnd -gt 0) {
29
+ $argumentList += @('-TerminalHwnd', [string]$TerminalHwnd)
30
+ }
31
+
32
+ $proc = Start-Process -FilePath $powershellExe -ArgumentList $argumentList -NoNewWindow -PassThru
33
+ if (-not [string]::IsNullOrEmpty($WatcherPidFile)) {
34
+ [System.IO.File]::WriteAllText($WatcherPidFile, [string]$proc.Id)
35
+ }
36
+ Write-Log "started watcher pid=$($proc.Id) targetPid=$TargetPid hwnd=$TerminalHwnd noNewWindow=true"
37
+ }
38
+ catch {
39
+ Write-Log "launcher failed: $_"
40
+ throw
41
+ }