dsh-reminding 1.0.0 → 1.0.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.
- package/client.js +34 -7
- package/index.js +114 -2
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -183,8 +183,18 @@ window.__ModuleLoader__.load({
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** Ask the Host to restore the desktop window; a browser Host has no window to restore. */
|
|
187
|
+
function requestWindowFocus() {
|
|
188
|
+
try {
|
|
189
|
+
void fetch('reminding/focus', { method: 'POST', cache: 'no-store' }).catch(() => {})
|
|
190
|
+
} catch {
|
|
191
|
+
// The Host route is an enhancement; window.focus() remains the fallback.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
186
195
|
/** Open the conversation that produced one reminder. */
|
|
187
196
|
function openConversation(sessionId) {
|
|
197
|
+
requestWindowFocus()
|
|
188
198
|
try {
|
|
189
199
|
window.focus()
|
|
190
200
|
} catch {
|
|
@@ -278,6 +288,9 @@ window.__ModuleLoader__.load({
|
|
|
278
288
|
/** Live toasts by feed key, so a resolved approval/question can close its own toast. */
|
|
279
289
|
const liveToasts = new Map()
|
|
280
290
|
|
|
291
|
+
/** TEMPORARY: last showReminder decision, reported through the next poll for diagnosis. */
|
|
292
|
+
let lastDecision = ''
|
|
293
|
+
|
|
281
294
|
/** Close one toast by its feed key. */
|
|
282
295
|
function closeToast(key) {
|
|
283
296
|
const entry = liveToasts.get(key)
|
|
@@ -299,23 +312,26 @@ window.__ModuleLoader__.load({
|
|
|
299
312
|
*/
|
|
300
313
|
function showReminder(t, item, options = {}) {
|
|
301
314
|
if (options.force !== true) {
|
|
302
|
-
if (!enabled) return false
|
|
303
|
-
if (pageFocused() && viewingSession(item?.sessionId)) return false
|
|
315
|
+
if (!enabled) { lastDecision = `${item?.kind}:off`; return false }
|
|
316
|
+
if (pageFocused() && viewingSession(item?.sessionId)) { lastDecision = `${item?.kind}:focus`; return false }
|
|
304
317
|
}
|
|
305
|
-
if (permissionState() !== 'granted') return false
|
|
318
|
+
if (permissionState() !== 'granted') { lastDecision = `${item?.kind}:perm=${permissionState()}`; return false }
|
|
306
319
|
const copy = describe(t, item)
|
|
307
|
-
if (copy === null) return false
|
|
320
|
+
if (copy === null) { lastDecision = `${item?.kind}:nokind`; return false }
|
|
308
321
|
try {
|
|
309
322
|
const notification = new Notification(copy.title, {
|
|
310
323
|
body: copy.body,
|
|
311
|
-
tag: typeof item.key === 'string' ? item.key : undefined,
|
|
324
|
+
tag: item?.kind === 'completed' ? undefined : typeof item.key === 'string' ? item.key : undefined,
|
|
312
325
|
})
|
|
326
|
+
lastDecision = `${item?.kind}:ok`
|
|
327
|
+
notification.onshow = () => { lastDecision = `${item?.kind}:shown` }
|
|
313
328
|
const key = typeof item.key === 'string' ? item.key : ''
|
|
314
329
|
if (key !== '') {
|
|
315
330
|
closeToast(key)
|
|
316
331
|
liveToasts.set(key, notification)
|
|
317
332
|
}
|
|
318
333
|
notification.onclick = () => {
|
|
334
|
+
lastDecision = `click:${item?.sessionId ?? ''}`
|
|
319
335
|
openConversation(item?.sessionId)
|
|
320
336
|
try {
|
|
321
337
|
notification.close()
|
|
@@ -328,10 +344,12 @@ window.__ModuleLoader__.load({
|
|
|
328
344
|
if (key !== '') liveToasts.delete(key)
|
|
329
345
|
}
|
|
330
346
|
notification.onerror = () => {
|
|
347
|
+
lastDecision = `${item?.kind}:onerror`
|
|
331
348
|
if (key !== '') liveToasts.delete(key)
|
|
332
349
|
}
|
|
333
350
|
return true
|
|
334
351
|
} catch (err) {
|
|
352
|
+
lastDecision = `${item?.kind}:error=${err?.message ?? String(err)}`
|
|
335
353
|
console.warn('[dsh-reminding] could not show the notification:', err)
|
|
336
354
|
return false
|
|
337
355
|
}
|
|
@@ -370,9 +388,18 @@ window.__ModuleLoader__.load({
|
|
|
370
388
|
while (!stopped) {
|
|
371
389
|
controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
372
390
|
try {
|
|
373
|
-
const
|
|
391
|
+
const diag = `&page=${encodeURIComponent(window.location.href)}&perm=${encodeURIComponent(permissionState())}&on=${enabled ? 1 : 0}&focus=${pageFocused() ? 1 : 0}&dec=${encodeURIComponent(lastDecision)}`
|
|
392
|
+
// A poll the Host never answers (for example while its plugin
|
|
393
|
+
// reloads) must not freeze the feed: give every request a bound,
|
|
394
|
+
// above the Host's own 25s hold, and retry after it.
|
|
395
|
+
const timeout = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(30_000) : null
|
|
396
|
+
let signal
|
|
397
|
+
if (controller === null) signal = timeout ?? undefined
|
|
398
|
+
else if (timeout !== null && typeof AbortSignal?.any === 'function') signal = AbortSignal.any([controller.signal, timeout])
|
|
399
|
+
else signal = controller.signal
|
|
400
|
+
const response = await fetch(`${ROUTE_POLL}?since=${since}&wait=1${diag}`, {
|
|
374
401
|
cache: 'no-store',
|
|
375
|
-
...(
|
|
402
|
+
...(signal === undefined ? {} : { signal }),
|
|
376
403
|
})
|
|
377
404
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
378
405
|
const payload = await response.json()
|
package/index.js
CHANGED
|
@@ -25,10 +25,37 @@
|
|
|
25
25
|
* starts a fresh baseline and never replays old reminders.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { spawn } from 'node:child_process'
|
|
29
|
+
import { appendFileSync } from 'node:fs'
|
|
30
|
+
import { createRequire } from 'node:module'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
|
|
28
33
|
export const inject = ['webServer']
|
|
29
34
|
|
|
35
|
+
/** Resolves Electron's main-process module when the Host runs inside the desktop shell. */
|
|
36
|
+
const requireFromHost = createRequire(import.meta.url)
|
|
37
|
+
|
|
38
|
+
/** TEMPORARY diagnostic log used while tracking a missing-notification report. */
|
|
39
|
+
const DEBUG_LOG = fileURLToPath(new URL('./dsh-reminding-debug.log', import.meta.url))
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Append one diagnostic line to the temporary log; never fails the plugin.
|
|
43
|
+
* @param parts - values joined into the line.
|
|
44
|
+
*/
|
|
45
|
+
function debug(...parts) {
|
|
46
|
+
try {
|
|
47
|
+
appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${parts.join(' ')}\n`)
|
|
48
|
+
} catch {
|
|
49
|
+
// Diagnostics must never disturb the plugin.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
debug('module loaded')
|
|
54
|
+
|
|
30
55
|
/** Loopback route the browser half long-polls. */
|
|
31
56
|
const ROUTE_POLL = '/reminding/poll'
|
|
57
|
+
/** Route the browser half calls when a toast is clicked, to restore the desktop window. */
|
|
58
|
+
const ROUTE_FOCUS = '/reminding/focus'
|
|
32
59
|
/** How many feed items stay available for a briefly disconnected page. */
|
|
33
60
|
const MAX_FEED = 80
|
|
34
61
|
/** How long one poll is held open before an empty answer releases it. */
|
|
@@ -99,17 +126,71 @@ function rejected(ctx, req, res) {
|
|
|
99
126
|
} catch {
|
|
100
127
|
rejection = undefined
|
|
101
128
|
}
|
|
102
|
-
if (rejection === undefined)
|
|
129
|
+
if (rejection === undefined) {
|
|
130
|
+
debug('poll admitted', req.method ?? '', req.url ?? '', 'referer=' + String(req.headers?.referer ?? '-'))
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
debug('poll rejected', String(rejection), req.method ?? '', req.url ?? '')
|
|
103
134
|
res.statusCode = rejection
|
|
104
135
|
res.end()
|
|
105
136
|
return true
|
|
106
137
|
}
|
|
107
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Restore and raise the desktop window after a toast click. The renderer's
|
|
141
|
+
* `window.focus()` cannot un-minimize an Electron window, and the desktop Host
|
|
142
|
+
* runs the Electron binary as Node (`ELECTRON_RUN_AS_NODE=1`), so it owns no
|
|
143
|
+
* windows. Launching the shell once more is the desktop's own focus path: the
|
|
144
|
+
* new process fails the single-instance lock and exits, and the running
|
|
145
|
+
* instance's `second-instance` handler restores and focuses its primary window.
|
|
146
|
+
* When the Host itself is the Electron main process, the window objects are
|
|
147
|
+
* used directly. A plain `dsh web` Host answers false without side effects.
|
|
148
|
+
* @returns true when a focus request was issued.
|
|
149
|
+
*/
|
|
150
|
+
async function focusDesktopWindow() {
|
|
151
|
+
// The desktop Host runs inside Electron with ELECTRON_RUN_AS_NODE=1, where
|
|
152
|
+
// `process.versions.electron` is still set but `require('electron')` is only
|
|
153
|
+
// the executable path, so the Node-mode branch must come first.
|
|
154
|
+
if (process.env.ELECTRON_RUN_AS_NODE === '1') {
|
|
155
|
+
try {
|
|
156
|
+
const env = { ...process.env }
|
|
157
|
+
delete env.ELECTRON_RUN_AS_NODE
|
|
158
|
+
debug('focus mode', 'relaunch', process.execPath)
|
|
159
|
+
const child = spawn(process.execPath, [], { env, detached: true, stdio: 'ignore' })
|
|
160
|
+
child.unref()
|
|
161
|
+
return true
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (process.versions?.electron === undefined) return false
|
|
167
|
+
try {
|
|
168
|
+
const { BrowserWindow } = requireFromHost('electron')
|
|
169
|
+
if (typeof BrowserWindow?.getAllWindows !== 'function') return false
|
|
170
|
+
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed())
|
|
171
|
+
const target = windows.find((window) => {
|
|
172
|
+
try {
|
|
173
|
+
return String(window.webContents?.getURL?.() ?? '').startsWith('dsh-app://app/')
|
|
174
|
+
} catch {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
}) ?? windows[0]
|
|
178
|
+
if (target === undefined) return false
|
|
179
|
+
if (target.isMinimized()) target.restore()
|
|
180
|
+
target.show()
|
|
181
|
+
target.focus()
|
|
182
|
+
return true
|
|
183
|
+
} catch {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
108
188
|
/**
|
|
109
189
|
* Mount the reminder feed.
|
|
110
190
|
* @param ctx - host context with `webServer`.
|
|
111
191
|
*/
|
|
112
192
|
export function apply(ctx) {
|
|
193
|
+
debug('apply', 'pid=' + String(process.pid))
|
|
113
194
|
let seq = 0
|
|
114
195
|
let disposed = false
|
|
115
196
|
/** Published feed items, oldest first. */
|
|
@@ -127,6 +208,7 @@ export function apply(ctx) {
|
|
|
127
208
|
*/
|
|
128
209
|
function publish(item) {
|
|
129
210
|
if (disposed) return
|
|
211
|
+
debug('publish', item.kind ?? '', String(item.sessionId ?? ''), String(item.key ?? ''))
|
|
130
212
|
seq += 1
|
|
131
213
|
const entry = { seq, at: Date.now(), ...item }
|
|
132
214
|
feed.push(entry)
|
|
@@ -174,6 +256,9 @@ export function apply(ctx) {
|
|
|
174
256
|
clearTimeout(timer)
|
|
175
257
|
waiters.delete(waiter)
|
|
176
258
|
}
|
|
259
|
+
// A retiring Host answers instead of leaving the page's request hanging, so
|
|
260
|
+
// the browser half re-polls the replacement instance immediately.
|
|
261
|
+
waiter.end = () => finish([])
|
|
177
262
|
waiters.add(waiter)
|
|
178
263
|
req.once('close', waiter.cancel)
|
|
179
264
|
res.once('close', waiter.cancel)
|
|
@@ -183,6 +268,7 @@ export function apply(ctx) {
|
|
|
183
268
|
function cancelCompletion(sessionId) {
|
|
184
269
|
const timer = completionTimers.get(sessionId)
|
|
185
270
|
if (timer === undefined) return
|
|
271
|
+
debug('cancelCompletion', sessionId)
|
|
186
272
|
clearTimeout(timer)
|
|
187
273
|
completionTimers.delete(sessionId)
|
|
188
274
|
}
|
|
@@ -213,6 +299,14 @@ export function apply(ctx) {
|
|
|
213
299
|
function onSessionEvent(session, event) {
|
|
214
300
|
try {
|
|
215
301
|
if (disposed) return
|
|
302
|
+
const header = session?.header
|
|
303
|
+
debug(
|
|
304
|
+
'event',
|
|
305
|
+
String(event?.type ?? ''),
|
|
306
|
+
String(header?.id ?? session?.id ?? ''),
|
|
307
|
+
'origin=' + String(header?.origin ?? '-'),
|
|
308
|
+
'depth=' + String(header?.delegationDepth ?? 0),
|
|
309
|
+
)
|
|
216
310
|
if (!shouldNotifySession(session)) return
|
|
217
311
|
const sessionId = String(session?.header?.id ?? session?.id ?? '')
|
|
218
312
|
if (sessionId === '') return
|
|
@@ -311,6 +405,7 @@ export function apply(ctx) {
|
|
|
311
405
|
ctx.on('session/event', onSessionEvent)
|
|
312
406
|
ctx.on('agent/disposed', (payload) => {
|
|
313
407
|
const id = payload?.agent?.id
|
|
408
|
+
debug('agent/disposed', String(id ?? ''))
|
|
314
409
|
if (id !== undefined) cancelCompletion(String(id))
|
|
315
410
|
})
|
|
316
411
|
|
|
@@ -322,7 +417,10 @@ export function apply(ctx) {
|
|
|
322
417
|
completionTimers.clear()
|
|
323
418
|
for (const timer of approvalTimers.values()) clearTimeout(timer)
|
|
324
419
|
approvalTimers.clear()
|
|
325
|
-
for (const waiter of [...waiters])
|
|
420
|
+
for (const waiter of [...waiters]) {
|
|
421
|
+
if (typeof waiter.end === 'function') waiter.end()
|
|
422
|
+
else waiter.cancel()
|
|
423
|
+
}
|
|
326
424
|
waiters.clear()
|
|
327
425
|
}
|
|
328
426
|
}, 'dsh-reminding: lifecycle')
|
|
@@ -353,4 +451,18 @@ export function apply(ctx) {
|
|
|
353
451
|
hold(req, res, since)
|
|
354
452
|
},
|
|
355
453
|
}), 'dsh-reminding: /reminding/poll')
|
|
454
|
+
|
|
455
|
+
ctx.effect(() => ctx.webServer.register({
|
|
456
|
+
kind: 'exact',
|
|
457
|
+
path: ROUTE_FOCUS,
|
|
458
|
+
handler: (req, res) => {
|
|
459
|
+
if (rejected(ctx, req, res)) return
|
|
460
|
+
debug('focus requested', req.method ?? '')
|
|
461
|
+
void focusDesktopWindow().then((focused) => {
|
|
462
|
+
debug('focus result', String(focused))
|
|
463
|
+
res.statusCode = 204
|
|
464
|
+
res.end()
|
|
465
|
+
})
|
|
466
|
+
},
|
|
467
|
+
}), 'dsh-reminding: /reminding/focus')
|
|
356
468
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-reminding",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "System reminders for DeepSeek Harness: OS notifications when a step needs your approval, the model asks a question, or a turn finishes; click a toast to jump back to that conversation. Works on Windows, macOS and Linux.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "liuchaoxu <ischaoxu@163.com>",
|