@kesike/reminder-dsh 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,19 @@
1
+ import { createWindowsToast } from '../src/modules/popup/toast.js'
2
+
3
+ if (process.platform !== 'win32') {
4
+ console.error('请在 Windows 桌面会话中运行 npm run test:popup。')
5
+ process.exitCode = 1
6
+ } else {
7
+ const notifier = createWindowsToast({
8
+ title: 'DSH 通知测试', message: '任务总结(演示):Windows 系统通知已就绪。',
9
+ })
10
+ let done, timeout
11
+ const reply = new Promise(resolve => { done = resolve })
12
+ try {
13
+ await notifier.notify({ onReply: text => { console.log('收到测试回复', text); done() } })
14
+ await Promise.race([reply, new Promise(resolve => { timeout = setTimeout(resolve, 60_000) })])
15
+ } finally {
16
+ clearTimeout(timeout)
17
+ notifier.dispose()
18
+ }
19
+ }
@@ -0,0 +1,11 @@
1
+ import { createTaskbarNotifier } from '../src/modules/taskbar/windows.js'
2
+
3
+ if (process.platform !== 'win32') {
4
+ console.error('请在 Windows 桌面会话中运行任务栏测试。')
5
+ process.exitCode = 1
6
+ } else {
7
+ const notifier = createTaskbarNotifier({ isEnabled: () => true, getWindowTitle: () => process.argv[2] ?? '' })
8
+ try { await notifier.notify() }
9
+ catch (error) { console.error(error.message); process.exitCode = 1 }
10
+ finally { notifier.dispose() }
11
+ }
package/src/index.js ADDED
@@ -0,0 +1,42 @@
1
+ import Schema from '@deepseek-ai/schemastery'
2
+ import { attachReminder } from './reminder.js'
3
+ import { Config as PopupConfig, createPopupModule } from './modules/popup/index.js'
4
+ import { attachApprovalNotifications } from './modules/popup/approval.js'
5
+ import { Config as TaskbarConfig, createTaskbarModule } from './modules/taskbar/index.js'
6
+
7
+ export const name = 'dsh-reminder'
8
+ export const inject = ['llm']
9
+ export const Config = Schema.object({
10
+ taskbar: TaskbarConfig.default({}).description('任务栏完成角标模块'),
11
+ popup: PopupConfig.default({}).description('Windows 系统通知模块'),
12
+ })
13
+
14
+ /** Mount both independently controlled modules behind one main-session completion observer. */
15
+ export function apply(ctx, config = Config({})) {
16
+ ctx.inject(['settings'], child => {
17
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber))
18
+ })
19
+ if (process.platform !== 'win32') {
20
+ ctx.logger(name).warn('Windows 提醒仅在 Windows 本机后端生效;设置入口仍可使用。')
21
+ return
22
+ }
23
+ startReminderRuntime(ctx, config)
24
+ }
25
+
26
+ /** Share only completion detection; each module keeps its own configuration and lifecycle. */
27
+ export function startReminderRuntime(ctx, config, modules = {
28
+ taskbar: createTaskbarModule(config.taskbar), popup: createPopupModule(ctx, config.popup),
29
+ }) {
30
+ const logger = ctx.logger(name)
31
+ ctx.effect(() => () => { for (const module of Object.values(modules)) module.dispose() })
32
+ ctx.on('loader/volatile-update', () => { for (const module of Object.values(modules)) module.update() })
33
+ if (modules.popup.notifyApproval) {
34
+ ctx.inject(['approval', 'typertGateway'], scope => attachApprovalNotifications(scope, modules.popup.notifyApproval))
35
+ }
36
+ attachReminder(ctx, options => Promise.all(Object.entries(modules).map(async ([name, module]) => {
37
+ try { await module.notify(options) }
38
+ catch (error) {
39
+ if (!options.signal.aborted) logger.warn(`${name} 提醒失败:${error instanceof Error ? error.message : String(error)}`)
40
+ }
41
+ })), error => logger.warn(`会话提醒失败:${error instanceof Error ? error.message : String(error)}`))
42
+ }
@@ -0,0 +1,153 @@
1
+ using System;
2
+ using System.Diagnostics;
3
+ using System.IO;
4
+ using System.IO.Pipes;
5
+ using System.Runtime.InteropServices;
6
+ using System.Security.Principal;
7
+ using System.Text;
8
+ using System.Text.RegularExpressions;
9
+
10
+ namespace DshReminder
11
+ {
12
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
13
+ public struct NotificationInput
14
+ {
15
+ [MarshalAs(UnmanagedType.LPWStr)] public string Key;
16
+ [MarshalAs(UnmanagedType.LPWStr)] public string Value;
17
+ }
18
+
19
+ [ComImport, Guid("53E31837-6600-4A81-9395-75CFFE746F94")]
20
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
21
+ public interface INotificationActivationCallback
22
+ {
23
+ [PreserveSig]
24
+ int Activate([MarshalAs(UnmanagedType.LPWStr)] string appId,
25
+ [MarshalAs(UnmanagedType.LPWStr)] string arguments,
26
+ [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3)] NotificationInput[] input,
27
+ uint count);
28
+ }
29
+
30
+ [ComImport, Guid("00000001-0000-0000-C000-000000000046")]
31
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
32
+ public interface IClassFactory
33
+ {
34
+ [PreserveSig] int CreateInstance(IntPtr outer, ref Guid iid, out IntPtr instance);
35
+ [PreserveSig] int LockServer([MarshalAs(UnmanagedType.Bool)] bool locked);
36
+ }
37
+
38
+ // Run while the DSH backend holds the helper's stdin open.
39
+ [ComVisible(true), ClassInterface(ClassInterfaceType.None)]
40
+ public sealed class ToastActivator : INotificationActivationCallback
41
+ {
42
+ private static string launcher, toastScript;
43
+
44
+ public int Activate(string appId, string arguments, NotificationInput[] input, uint count)
45
+ {
46
+ if (appId != "KeS1Ke.DSH" || String.IsNullOrEmpty(arguments)) return 0;
47
+ var route = Regex.Match(arguments, @"\A([a-f0-9]{32}):([a-f0-9]{32})(:decision|:open)?\z");
48
+ if (!route.Success) return 0;
49
+ var kind = route.Groups[3].Value;
50
+ if (kind == ":open")
51
+ {
52
+ // Navigate the backend first (it answers the GUI long poll), then open the app either way.
53
+ try { Deliver(route.Groups[1].Value, route.Groups[2].Value, null); }
54
+ catch (IOException) { }
55
+ catch (UnauthorizedAccessException) { }
56
+ catch (TimeoutException) { }
57
+ catch (AggregateException) { }
58
+ OpenApp();
59
+ return 0;
60
+ }
61
+ bool approval = kind == ":decision";
62
+ bool delivered;
63
+ try
64
+ {
65
+ var entry = Array.Find(input ?? new NotificationInput[0], item => item.Key == "reply");
66
+ delivered = Deliver(route.Groups[1].Value, route.Groups[2].Value, approval ? "decision" : entry.Value);
67
+ }
68
+ catch (IOException) { delivered = false; }
69
+ catch (UnauthorizedAccessException) { delivered = false; }
70
+ catch (TimeoutException) { delivered = false; }
71
+ catch (AggregateException) { delivered = false; }
72
+ if (!delivered)
73
+ {
74
+ using (Process.Start(new ProcessStartInfo(launcher,
75
+ "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"" + toastScript + "\" "
76
+ + (approval ? "-ApprovalFailed" : "-ReplyFailed"))
77
+ {
78
+ UseShellExecute = false,
79
+ CreateNoWindow = true
80
+ })) { }
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ private static void OpenApp()
86
+ {
87
+ try
88
+ {
89
+ using (Process.Start(new ProcessStartInfo("dsh://open") { UseShellExecute = true })) { }
90
+ }
91
+ catch
92
+ {
93
+ // The protocol may be unregistered (web-only profile); the GUI long poll already ran.
94
+ }
95
+ }
96
+
97
+ private static bool Deliver(string pipeId, string token, string text)
98
+ {
99
+ // A null text marks a body click: it carries the route token only.
100
+ if (text != null && (String.IsNullOrWhiteSpace(text) || text.Length > 8000)) return false;
101
+ using (var pipe = new NamedPipeClientStream(".", "kesike-dsh-reply-" + pipeId,
102
+ PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Identification))
103
+ {
104
+ pipe.Connect(2000);
105
+ var body = text == null ? token + "\n" : token + " " + Convert.ToBase64String(Encoding.UTF8.GetBytes(text)) + "\n";
106
+ var message = Encoding.ASCII.GetBytes(body);
107
+ if (!pipe.WriteAsync(message, 0, message.Length).Wait(2000)) return false;
108
+ using (var reader = new StreamReader(pipe, Encoding.ASCII))
109
+ {
110
+ var acknowledgment = reader.ReadLineAsync();
111
+ return acknowledgment.Wait(3000) && acknowledgment.Result == "ok";
112
+ }
113
+ }
114
+ }
115
+
116
+ public static void Run(Guid clsid, string powershell, string script)
117
+ {
118
+ const uint localServer = 4, multipleUse = 1;
119
+ launcher = powershell;
120
+ toastScript = script;
121
+ Marshal.ThrowExceptionForHR(CoInitializeEx(IntPtr.Zero, 0)); // MTA
122
+ try
123
+ {
124
+ uint registration;
125
+ Marshal.ThrowExceptionForHR(CoRegisterClassObject(ref clsid, new Factory(), localServer, multipleUse, out registration));
126
+ try { Console.WriteLine("ready"); Console.ReadLine(); }
127
+ finally { CoRevokeClassObject(registration); }
128
+ }
129
+ finally { CoUninitialize(); }
130
+ }
131
+
132
+ [ComVisible(true), ClassInterface(ClassInterfaceType.None)]
133
+ public sealed class Factory : IClassFactory
134
+ {
135
+ public int CreateInstance(IntPtr outer, ref Guid iid, out IntPtr instance)
136
+ {
137
+ instance = IntPtr.Zero;
138
+ if (outer != IntPtr.Zero) return unchecked((int)0x80040110); // CLASS_E_NOAGGREGATION
139
+ var unknown = Marshal.GetIUnknownForObject(new ToastActivator());
140
+ try { return Marshal.QueryInterface(unknown, ref iid, out instance); }
141
+ finally { Marshal.Release(unknown); }
142
+ }
143
+
144
+ public int LockServer(bool locked) { return 0; }
145
+ }
146
+
147
+ [DllImport("ole32.dll")] private static extern int CoInitializeEx(IntPtr reserved, uint model);
148
+ [DllImport("ole32.dll")] private static extern void CoUninitialize();
149
+ [DllImport("ole32.dll")] private static extern int CoRegisterClassObject(ref Guid clsid,
150
+ [MarshalAs(UnmanagedType.Interface)] IClassFactory factory, uint context, uint flags, out uint registration);
151
+ [DllImport("ole32.dll")] private static extern int CoRevokeClassObject(uint registration);
152
+ }
153
+ }
@@ -0,0 +1,71 @@
1
+ using System;
2
+ using System.ComponentModel;
3
+ using System.IO;
4
+ using System.Runtime.InteropServices;
5
+ using System.Runtime.InteropServices.ComTypes;
6
+
7
+ public static class DshToastIdentity
8
+ {
9
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
10
+ private static extern bool MoveFileEx(string source, string destination, uint flags);
11
+
12
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
13
+ private static extern void SHChangeNotify(int eventId, uint flags, string path, IntPtr unused);
14
+
15
+ [ComImport, Guid("00021401-0000-0000-C000-000000000046")]
16
+ private class ShellLink { }
17
+
18
+ [StructLayout(LayoutKind.Sequential)]
19
+ private struct PropertyKey { public Guid Format; public uint Id; }
20
+
21
+ [StructLayout(LayoutKind.Sequential)]
22
+ private struct PropertyValue
23
+ {
24
+ public ushort Type, Reserved1, Reserved2, Reserved3;
25
+ public IntPtr Data;
26
+ public int Padding;
27
+ }
28
+
29
+ [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
30
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
31
+ private interface IPropertyStore
32
+ {
33
+ void GetCount(out uint count);
34
+ void GetAt(uint index, out PropertyKey key);
35
+ void GetValue(ref PropertyKey key, out PropertyValue value);
36
+ void SetValue(ref PropertyKey key, ref PropertyValue value);
37
+ void Commit();
38
+ }
39
+
40
+ public static void RegisterShortcut(string staged, string destination, string appId, Guid activator)
41
+ {
42
+ var link = new ShellLink();
43
+ try
44
+ {
45
+ var file = (IPersistFile)link;
46
+ file.Load(staged, 2); // STGM_READWRITE
47
+ var store = (IPropertyStore)link;
48
+ var key = new PropertyKey { Format = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), Id = 5 };
49
+ var value = new PropertyValue { Type = 31, Data = Marshal.StringToCoTaskMemUni(appId) }; // VT_LPWSTR
50
+ try { store.SetValue(ref key, ref value); }
51
+ finally { Marshal.FreeCoTaskMem(value.Data); }
52
+
53
+ key.Id = 26; // AppUserModel.ToastActivatorCLSID
54
+ value = new PropertyValue { Type = 72, Data = Marshal.AllocCoTaskMem(16) }; // VT_CLSID
55
+ try
56
+ {
57
+ Marshal.StructureToPtr(activator, value.Data, false);
58
+ store.SetValue(ref key, ref value);
59
+ }
60
+ finally { Marshal.FreeCoTaskMem(value.Data); }
61
+ store.Commit();
62
+ file.Save(staged, true);
63
+ }
64
+ finally { Marshal.FinalReleaseComObject(link); }
65
+
66
+ bool replaced = File.Exists(destination);
67
+ if (!MoveFileEx(staged, destination, 1)) // MOVEFILE_REPLACE_EXISTING
68
+ throw new Win32Exception(Marshal.GetLastWin32Error());
69
+ SHChangeNotify(replaced ? 0x2000 : 2, 0x1005, destination, IntPtr.Zero); // UPDATEITEM/CREATE, PATHW | FLUSH
70
+ }
71
+ }
@@ -0,0 +1,5 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
3
+ Add-Type -Path (Join-Path $PSScriptRoot 'ToastActivator.cs')
4
+ [DshReminder.ToastActivator]::Run([guid]'{BD86FD58-A2BF-430E-A8D9-8D20238ED61A}',
5
+ (Join-Path $PSHOME 'powershell.exe'), (Join-Path $PSScriptRoot 'toast.ps1'))
@@ -0,0 +1,60 @@
1
+ import { excerpt } from './summary.js'
2
+ import { sessionTitleOf } from '../../reminder.js'
3
+
4
+ /** Share the exact pending Remote event with DSH's existing approval card. */
5
+ export function attachApprovalNotifications(ctx, notify) {
6
+ const { approval, typertGateway: gateway } = ctx
7
+ const logger = ctx.logger('reminder-approval')
8
+ // The gateway's public pending-event list omits request identity and cannot settle events.
9
+ if (!(gateway.pendingRemoteEvents instanceof Map) || typeof gateway.settleRemoteEvent !== 'function') {
10
+ logger.warn('当前 DSH 版本无法将审批请求接入 Windows 通知。')
11
+ return
12
+ }
13
+ const lifetime = new AbortController()
14
+ const requests = new WeakMap()
15
+ ctx.effect(() => () => lifetime.abort())
16
+ ctx.on('approval/request', (request, next) => {
17
+ const previous = requests.get(request)
18
+ if (previous) {
19
+ // Reusing a request object would make its pending event ambiguous.
20
+ previous.abort()
21
+ return next()
22
+ }
23
+ const session = request.agent.session
24
+ const pending = new AbortController()
25
+ requests.set(request, pending)
26
+ const decision = next()
27
+ const signal = AbortSignal.any([lifetime.signal, pending.signal, ...(request.signal ? [request.signal] : [])])
28
+ const finish = () => pending.abort()
29
+ void Promise.resolve(decision).then(finish, finish)
30
+
31
+ // Forwarding to the gateway runs asynchronously. Leave DSH's answerer in charge of the request.
32
+ setImmediate(() => {
33
+ if (signal.aborted) return
34
+ const event = [...gateway.pendingRemoteEvents.values()].find(item =>
35
+ item.frame?.event === 'approval/request' && item.source?.request === request && item.source.context?.subject === request.agent)
36
+ if (!event) return
37
+ const tool = excerpt(request.toolName, 64)
38
+ const description = request.reason
39
+ const reason = typeof description === 'string' && description.trim()
40
+ ? excerpt(description, 220) : '点击通知查看请求详情。'
41
+ void Promise.resolve().then(() => notify({
42
+ sessionId: session.id,
43
+ // The title stays the session title; the approval context moves into the body.
44
+ title: sessionTitleOf(ctx, session) ?? `需要审批 · ${tool}`,
45
+ message: `需要审批(${tool}):${reason}`, signal,
46
+ onDecision(outcome) {
47
+ if (signal.aborted || request.agent.session !== session || approval.effectivePolicy(session) !== 'ask'
48
+ || gateway.pendingRemoteEvents.get(event.id) !== event
49
+ || (outcome !== 'allowed-once' && outcome !== 'rejected')) {
50
+ throw new Error('审批请求已失效,请回到 DSH 查看。')
51
+ }
52
+ gateway.settleRemoteEvent(event, { kind: 'result', value: outcome })
53
+ },
54
+ })).catch(error => {
55
+ if (!signal.aborted) logger.warn(`审批通知失败:${error instanceof Error ? error.message : String(error)}`)
56
+ })
57
+ })
58
+ return decision
59
+ }, { prepend: true })
60
+ }
@@ -0,0 +1,103 @@
1
+ import Schema from '@deepseek-ai/schemastery'
2
+ import { createWindowsToast } from './toast.js'
3
+ import { summarizeCompletion } from './summary.js'
4
+ import { createReminderQueue } from './queue.js'
5
+ import { createSessionOpenRequests } from './open.js'
6
+
7
+ export const Config = Schema.object({
8
+ enabled: Schema.boolean().default(true).description('启用 Windows 完成和审批通知').volatile(),
9
+ durationSeconds: Schema.number().min(1).max(300).step(1).default(10)
10
+ .description('连续提交系统通知的最短间隔(秒)').volatile(),
11
+ showSummary: Schema.boolean().default(true)
12
+ .description('在通知正文中显示压缩到 3 行的任务总结;关闭后只显示会话标题').volatile(),
13
+ title: Schema.string().default('DSH 任务完成').description('无法取得会话标题时的通知标题'),
14
+ message: Schema.string().default('主会话已完成本轮任务。').description('没有最终文字回复时的备用提示'),
15
+ summaryMaxChars: Schema.number().min(20).max(1000).step(1).default(60)
16
+ .description('AI 总结最大字符数;通知正文按 3 行预算展示,最多 60 字'),
17
+ summaryTimeoutSeconds: Schema.number().min(1).max(60).step(1).default(15).description('AI 总结超时秒数'),
18
+ })
19
+
20
+ /**
21
+ * The GUI long-polls this route and navigates to the session a clicked toast belongs to.
22
+ * Registered only where the host owns a web server and connection trust (the web/desktop app).
23
+ */
24
+ function attachSessionOpenRoute(ctx, requests, logger) {
25
+ ctx.inject(['webServer', 'connection'], scope => {
26
+ const webServer = scope.get('webServer')
27
+ const connection = scope.get('connection')
28
+ if (!webServer || !connection) return
29
+ scope.effect(() => {
30
+ try {
31
+ return webServer.register({
32
+ kind: 'exact',
33
+ path: '/reminder-dsh/session-open',
34
+ handler: async (request, response) => {
35
+ if (typeof connection.requestRejection === 'function' && connection.requestRejection(request) !== undefined) {
36
+ response.writeHead(403)
37
+ response.end()
38
+ return
39
+ }
40
+ if (request.method !== 'GET') {
41
+ response.writeHead(405)
42
+ response.end()
43
+ return
44
+ }
45
+ const sessionId = await requests.take()
46
+ if (sessionId === undefined) {
47
+ response.writeHead(204)
48
+ response.end()
49
+ return
50
+ }
51
+ response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
52
+ response.end(JSON.stringify({ sessionId }))
53
+ },
54
+ })
55
+ } catch (error) {
56
+ // Session jumps are a convenience: never let a route conflict break reminders.
57
+ logger.warn(`会话跳转路由不可用:${error instanceof Error ? error.message : String(error)}`)
58
+ return () => {}
59
+ }
60
+ }, 'reminder-dsh: session open route')
61
+ })
62
+ }
63
+
64
+ /** Keep the popup configuration key for existing profiles. */
65
+ export function createPopupModule(ctx, config, notifier = createWindowsToast(config)) {
66
+ const logger = ctx.logger('reminder-popup')
67
+ let approvals = new AbortController()
68
+ const opens = createSessionOpenRequests()
69
+ const openOf = options => options.sessionId
70
+ ? () => opens.request(options.sessionId)
71
+ : undefined
72
+ const queue = createReminderQueue({
73
+ enabled: config.enabled.get(), intervalSeconds: config.durationSeconds.get(),
74
+ prepare: async options => ({
75
+ title: options.sessionTitle || config.title,
76
+ message: config.showSummary.get()
77
+ ? await summarizeCompletion(ctx.llm, config, options, error => {
78
+ logger.warn(`AI 任务总结失败:${error instanceof Error ? error.message : String(error)}`)
79
+ })
80
+ : '',
81
+ }),
82
+ show: options => notifier.notify({ ...options, onOpen: openOf(options) }),
83
+ })
84
+ attachSessionOpenRoute(ctx, opens, logger)
85
+ return {
86
+ notify: options => queue.enqueue(options),
87
+ notifyApproval(options) {
88
+ if (!config.enabled.get()) return
89
+ const signal = AbortSignal.any([options.signal, approvals.signal])
90
+ return notifier.notify({ ...options, signal, onOpen: openOf(options), onDecision(outcome) {
91
+ signal.throwIfAborted()
92
+ return options.onDecision(outcome)
93
+ } })
94
+ },
95
+ update() {
96
+ queue.setEnabled(config.enabled.get())
97
+ queue.setIntervalSeconds(config.durationSeconds.get())
98
+ if (!config.enabled.get()) approvals.abort()
99
+ else if (approvals.signal.aborted) approvals = new AbortController()
100
+ },
101
+ dispose() { queue.dispose(); approvals.abort(); notifier.dispose() },
102
+ }
103
+ }
@@ -0,0 +1,33 @@
1
+ /** One-slot mailbox: a toast click posts the session id, the GUI's long poll consumes it once. */
2
+ export function createSessionOpenRequests({ timeoutMs = 25_000 } = {}) {
3
+ let pending
4
+ const waiters = new Set()
5
+
6
+ return {
7
+ request(sessionId) {
8
+ if (typeof sessionId !== 'string' || sessionId === '') return
9
+ pending = sessionId
10
+ for (const waiter of [...waiters]) waiter()
11
+ },
12
+ /** Resolve with a posted session id, or undefined when the caller gives up first. */
13
+ take() {
14
+ if (pending !== undefined) {
15
+ const sessionId = pending
16
+ pending = undefined
17
+ return Promise.resolve(sessionId)
18
+ }
19
+ return new Promise(resolve => {
20
+ let timer
21
+ const settle = () => {
22
+ clearTimeout(timer)
23
+ waiters.delete(settle)
24
+ const sessionId = pending
25
+ pending = undefined
26
+ resolve(sessionId)
27
+ }
28
+ waiters.add(settle)
29
+ timer = setTimeout(settle, timeoutMs)
30
+ })
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,93 @@
1
+ /** Await preparation or a scheduling delay without retaining an aborted queue entry. */
2
+ function interruptible(promise, signal) {
3
+ if (signal.aborted) return Promise.reject(signal.reason)
4
+ return new Promise((resolve, reject) => {
5
+ const abort = () => reject(signal.reason)
6
+ signal.addEventListener('abort', abort, { once: true })
7
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
8
+ })
9
+ }
10
+
11
+ function waitUntil(time, signal) {
12
+ const delay = Math.max(0, time - Date.now())
13
+ if (!delay) return Promise.resolve()
14
+ let timer
15
+ const waiting = new Promise(resolve => { timer = setTimeout(resolve, delay) })
16
+ return interruptible(waiting, signal).finally(() => clearTimeout(timer))
17
+ }
18
+
19
+ /** FIFO completion queue with a live interval between Windows toast submissions. */
20
+ export function createReminderQueue({ prepare, show, enabled = true, intervalSeconds = 10 }) {
21
+ const pending = [], live = new Set()
22
+ let running = false, disposed = false, lastSentAt, reschedule
23
+
24
+ async function waitForSlot(signal) {
25
+ while (lastSentAt !== undefined && Date.now() < lastSentAt + intervalSeconds * 1000) {
26
+ const delay = new AbortController()
27
+ reschedule = delay
28
+ try { await waitUntil(lastSentAt + intervalSeconds * 1000, AbortSignal.any([signal, delay.signal])) }
29
+ catch (error) { if (signal.aborted || !delay.signal.aborted) throw error }
30
+ finally { if (reschedule === delay) reschedule = undefined }
31
+ }
32
+ }
33
+
34
+ async function pump() {
35
+ if (running) return
36
+ running = true
37
+ try {
38
+ while (pending.length) {
39
+ const item = pending.shift()
40
+ try {
41
+ const prepared = await interruptible(item.prepared, item.signal)
42
+ if (prepared.error) throw prepared.error
43
+ await waitForSlot(item.signal)
44
+ item.signal.throwIfAborted()
45
+ await show({ ...item.options, ...prepared.value, signal: item.signal })
46
+ if (!item.signal.aborted) lastSentAt = Date.now()
47
+ item.resolve()
48
+ } catch (error) {
49
+ if (item.signal.aborted) item.resolve()
50
+ else item.reject(error)
51
+ } finally { live.delete(item) }
52
+ }
53
+ } finally { running = false }
54
+ }
55
+
56
+ function clear() {
57
+ for (const item of live) item.controller.abort()
58
+ for (const item of pending.splice(0)) {
59
+ live.delete(item)
60
+ item.resolve()
61
+ }
62
+ lastSentAt = undefined
63
+ }
64
+
65
+ return {
66
+ enqueue(options) {
67
+ if (disposed || !enabled || options.signal?.aborted) return Promise.resolve()
68
+ const controller = new AbortController()
69
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal
70
+ return new Promise((resolve, reject) => {
71
+ const item = { options, controller, signal, resolve, reject }
72
+ // Prepare concurrently but reserve FIFO position before any model call settles.
73
+ item.prepared = Promise.resolve().then(() => {
74
+ signal.throwIfAborted()
75
+ return prepare({ ...options, signal })
76
+ }).then(value => ({ value }), error => ({ error }))
77
+ live.add(item)
78
+ pending.push(item)
79
+ void pump()
80
+ })
81
+ },
82
+ setEnabled(value) {
83
+ enabled = value
84
+ if (!enabled) clear()
85
+ },
86
+ setIntervalSeconds(value) {
87
+ if (value === intervalSeconds) return
88
+ intervalSeconds = value
89
+ reschedule?.abort()
90
+ },
91
+ dispose() { disposed = true; enabled = false; clear() },
92
+ }
93
+ }