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.
Files changed (3) hide show
  1. package/client.js +34 -7
  2. package/index.js +114 -2
  3. 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 response = await fetch(`${ROUTE_POLL}?since=${since}&wait=1`, {
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
- ...(controller === null ? {} : { signal: controller.signal }),
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) return false
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]) waiter.cancel()
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.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>",