@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/.published +1 -0
- package/README.md +100 -0
- package/assets/icons/info.png +0 -0
- package/assets/icons/permission.png +0 -0
- package/assets/icons/stop.png +0 -0
- package/bin/cli.js +3104 -0
- package/lib/codex-sidecar-state.js +314 -0
- package/lib/notification-sources.js +411 -0
- package/package.json +41 -0
- package/postinstall.js +54 -0
- package/scripts/activate-window.ps1 +26 -0
- package/scripts/activate-window.vbs +13 -0
- package/scripts/codex-notify-wrapper.vbs +29 -0
- package/scripts/find-hwnd.ps1 +144 -0
- package/scripts/get-shell-pid.ps1 +69 -0
- package/scripts/notify.ps1 +193 -0
- package/scripts/register-protocol.ps1 +18 -0
- package/scripts/start-hidden.vbs +24 -0
- package/scripts/start-tab-color-watcher.ps1 +41 -0
- package/scripts/tab-color-watcher.ps1 +391 -0
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
|
+
}
|