dsh-plugin-desktop-notify 0.6.3 → 0.6.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/README.en.md CHANGED
@@ -44,7 +44,7 @@ Stored in localStorage under `dsh-plugin-desktop-notify.preferences`:
44
44
  | `notifyError` | `true` | Notify when a turn fails |
45
45
  | `notifyViewed` | `false` | Also notify for the session you are looking at |
46
46
  | `badge` | `true` | Show the badge |
47
- | `pinAttention` | `true` | pin what needs you to the top of its group (uses the app's own pin) |
47
+ | `orderAttention` | `true` | seat what needs you below your pins (writes the view's order, never the pin state) |
48
48
  | `excludeSubagents` | `true` | Ignore subagent sessions |
49
49
  | `titleComplete` / `titlePending` / `titleError` | empty | Custom titles; empty keeps the built-in copy |
50
50
  | `maxBodyChars` | `140` | Body length limit |
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  - **等待你操作**——工具审批、提问、执行计划确认
17
17
  - **任务失败**——这一轮出错时提示
18
18
  - **说清楚怎么结束的**——正常完成、被拦截、输出到上限、被中止,各自一句话,不把中断说成「已完成」
19
- - **需要你的排在前面**——运行中、待你审批/回答、出错、未读完成的会话自动置顶到所属分组第一条,处理完自动放开
19
+ - **还在进行的排在前面**——正在跑、等你审批/回答、刚出错 的会话排到**手动置顶的下面、其余会话的上面**,处理完自动放回原位;完成未读只进小红点,不搬动列表
20
20
  - **小红点**——应用图标上显示「需要你的会话数」,你看过之后才清零
21
21
  - **点击通知**——切到对应会话,而不是只把应用叫到前面
22
22
 
@@ -46,7 +46,7 @@ dsh-plugin-desktop-notify
46
46
  | `notifyViewed` | `false` | 正在看的会话也通知(默认跳过) |
47
47
  | `badge` | `true` | 显示小红点 |
48
48
  | `excludeSubagents` | `true` | 忽略子智能体会话 |
49
- | `pinAttention` | `true` | 把需要你处理的会话自动置顶到所属分组第一条(用 DSH 自带置顶,处理完自动取消) |
49
+ | `orderAttention` | `true` | 把仍在进行中的会话排到手动置顶下面(不改置顶状态;关掉后完全不碰排序) |
50
50
  | `titleComplete` / `titlePending` / `titleError` | 空 | 自定义标题,空 = 用内置文案 |
51
51
  | `maxBodyChars` | `140` | 正文长度上限 |
52
52
 
@@ -62,7 +62,7 @@ dsh-plugin-desktop-notify
62
62
  __DSH_DESKTOP_NOTIFY__.capabilities() // 平台、通知权限、小红点、服务可达性
63
63
  __DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近一次通知/点击
64
64
  __DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
65
- __DSH_DESKTOP_NOTIFY__.pinState() // 插件置顶了哪些、你自己又置顶了哪些
65
+ __DSH_DESKTOP_NOTIFY__.orderState() // 当前被排到前面的是哪几条、按什么顺序
66
66
  ```
67
67
 
68
68
 
@@ -111,9 +111,10 @@ urllib.request.urlopen(request, timeout=3).read()
111
111
  ## 已知限制
112
112
 
113
113
  - 重启或刷新窗口时,已经存在的「未读 / 待审批」只恢复小红点,不再补弹横幅
114
- - 自动置顶用的是 DSH 自己的置顶,所以会话行上会出现置顶标记,右键里也有「取消置顶」。
115
- 插件只取消**自己**置顶的会话,你手动置顶的一律不动;想一次清干净可在控制台跑
116
- `__DSH_DESKTOP_NOTIFY__.clearPins()`
114
+ - 这个排序是插件接管「手动排序」实现的:插件只写「需要你」那几条的顺序,其余会话仍然按最近更新往后排,
115
+ 所以视觉上和「最近更新」一样,只是多了一条仍在进行中的带状区域。切换窗口焦点不会触发重排。
116
+ 代价是侧栏的排序方式会显示成「手动」,
117
+ 而且你把会话拖动排序后,下一次有会话需要你时会被重排覆盖。不想要就 `orderAttention: false`
117
118
 
118
119
  - **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
119
120
  - **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
package/index.js CHANGED
@@ -16,7 +16,6 @@
16
16
 
17
17
  import { execFile, spawn } from "node:child_process"
18
18
  import { randomBytes } from "node:crypto"
19
- import { readFileSync } from "node:fs"
20
19
  import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"
21
20
  import { homedir } from "node:os"
22
21
  import { basename, dirname, join } from "node:path"
@@ -48,8 +47,6 @@ const DEFAULTS = {
48
47
  api: true,
49
48
  /** Where the endpoint URL is written for those tools. */
50
49
  endpointPath: "~/.dsh/desktop-notify.endpoint",
51
- /** Where the sessions this plugin pinned are remembered across window lives. */
52
- pinsPath: "~/.dsh/desktop-notify-pins.json",
53
50
  /** Longest accepted request body. */
54
51
  apiBodyLimit: 8192,
55
52
  /** How many unread requests the interface keeps. */
@@ -68,7 +65,6 @@ const TURN_END_BODIES = {
68
65
  const API_REQUEST_PATH = "/desktop-notify/request"
69
66
  const API_PENDING_PATH = "/desktop-notify/pending"
70
67
  const API_TURN_PATH = "/desktop-notify/turn"
71
- const API_PINS_PATH = "/desktop-notify/pins"
72
68
 
73
69
  /** Read one JSON request body, bounded by `limit` bytes. */
74
70
  function readBody(request, limit) {
@@ -241,37 +237,6 @@ function mountInterface(ctx, config, state) {
241
237
  answer(response, 200, entry === undefined ? {} : { kind: entry.kind, at: entry.at })
242
238
  }
243
239
 
244
- /**
245
- * Remember which sessions this plugin pinned. The renderer's origin (and so
246
- * its localStorage) changes with every launch, while the sidebar pins live
247
- * on the Host; this file is what lets a restart release the pins it owns
248
- * instead of mistaking them for the user's own.
249
- */
250
- const pins = (request, response) => {
251
- if (rejected(request, response, false)) return
252
- if (request.method === "GET") {
253
- answer(response, 200, { ids: [...state.pinnedByPlugin] })
254
- return
255
- }
256
- if (request.method !== "POST") {
257
- answer(response, 405, { ok: false, error: "use GET or POST" })
258
- return
259
- }
260
- void readBody(request, Math.max(1024, config.apiBodyLimit))
261
- .then((payload) => {
262
- const ids = Array.isArray(payload?.ids) ? payload.ids.filter((id) => typeof id === "string" && id !== "") : []
263
- state.pinnedByPlugin = new Set(ids.slice(0, Math.max(1, config.maxTrackedSessions)))
264
- const path = expandHome(config.pinsPath)
265
- if (path !== "") {
266
- void mkdir(dirname(path), { recursive: true })
267
- .then(() => writeFile(path, `${JSON.stringify({ ids: [...state.pinnedByPlugin] })}\n`, "utf8"))
268
- .catch(() => {})
269
- }
270
- answer(response, 200, { ok: true, tracked: state.pinnedByPlugin.size })
271
- })
272
- .catch((error) => answer(response, 400, { ok: false, error: String(error?.message ?? error) }))
273
- }
274
-
275
240
  let disposers
276
241
  try {
277
242
  disposers = [
@@ -292,13 +257,6 @@ function mountInterface(ctx, config, state) {
292
257
  answer(response, 500, {})
293
258
  }
294
259
  } }),
295
- server.register({ kind: "exact", path: API_PINS_PATH, handler: (request, response) => {
296
- try {
297
- pins(request, response)
298
- } catch {
299
- answer(response, 500, { ok: false })
300
- }
301
- } }),
302
260
  ]
303
261
  } catch (error) {
304
262
  void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
@@ -449,19 +407,7 @@ function runDetached(command, args) {
449
407
  export function apply(ctx, rawConfig) {
450
408
  const config = resolveConfig(rawConfig)
451
409
  /** The newest turn-end kind per session, read by the renderer over the local interface. */
452
- const state = { lastTurnEnds: new Map(), pinnedByPlugin: new Set() }
453
- // Seeded synchronously: the renderer asks for it as soon as it applies.
454
- try {
455
- const pinsPath = expandHome(config.pinsPath)
456
- if (pinsPath !== "") {
457
- const remembered = JSON.parse(readFileSync(pinsPath, "utf8"))
458
- if (Array.isArray(remembered?.ids)) {
459
- for (const id of remembered.ids) if (typeof id === "string" && id !== "") state.pinnedByPlugin.add(id)
460
- }
461
- }
462
- } catch {
463
- /* no remembered pins yet */
464
- }
410
+ const state = { lastTurnEnds: new Map() }
465
411
  mountInterface(ctx, config, state)
466
412
 
467
413
  const logPath = expandHome(config.logPath)
package/lib/client.js CHANGED
@@ -30,10 +30,8 @@ window.__ModuleLoader__.load({
30
30
  factory: (require) => {
31
31
  const NS = "dsh-plugin-desktop-notify"
32
32
  /** Reported by capabilities(); keep in step with package.json (cross-machine support). */
33
- const VERSION = "0.6.3"
33
+ const VERSION = "0.6.5"
34
34
  const PREFERENCES_KEY = `${NS}:preferences`
35
- /** Sessions this plugin pinned itself; a reload reads it back so it never drops a user's own pin. */
36
- const PINNED_KEY = `${NS}:pinned`
37
35
 
38
36
  /** Defaults; override per field with the localStorage JSON described in README. */
39
37
  const DEFAULTS = {
@@ -44,8 +42,8 @@ window.__ModuleLoader__.load({
44
42
  notifyViewed: false,
45
43
  badge: true,
46
44
  excludeSubagents: true,
47
- // Keep the sessions that need you at the top of their sidebar group.
48
- pinAttention: true,
45
+ // Seat what needs you directly below the manual pins in its group.
46
+ orderAttention: true,
49
47
  // Empty means "use the built-in copy for the active UI language".
50
48
  titleComplete: "",
51
49
  titlePending: "",
@@ -145,8 +143,9 @@ window.__ModuleLoader__.load({
145
143
  */
146
144
  const TURN_KIND_PATH = "/desktop-notify/turn"
147
145
  const TURN_LOOKUP_TIMEOUT_MS = 800
148
- /** Where the Host remembers the sessions this plugin pinned. */
149
- const PINS_PATH = "/desktop-notify/pins"
146
+ /** The sidebar's flat-list order key, and how long a tier change waits before it is written. */
147
+ const FLAT_ORDER_KEY = "__flat_session_order__"
148
+ const ORDER_SETTLE_MS = 600
150
149
 
151
150
  /** Ask the Host how one turn ended; undefined when it cannot answer in time. */
152
151
  function lookupTurnKind(sessionId) {
@@ -198,18 +197,6 @@ window.__ModuleLoader__.load({
198
197
  return preferences
199
198
  }
200
199
 
201
- /** The session ids this plugin pinned in an earlier window life. */
202
- function readPinnedIds() {
203
- try {
204
- const raw = storage()?.getItem(PINNED_KEY)
205
- if (typeof raw !== "string" || raw === "") return []
206
- const parsed = JSON.parse(raw)
207
- return Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : []
208
- } catch {
209
- return []
210
- }
211
- }
212
-
213
200
  /** Collapse whitespace and cut to the body budget. */
214
201
  function bodyText(text, maxChars) {
215
202
  const collapsed = String(text).replace(/\s+/gu, " ").trim()
@@ -328,128 +315,101 @@ window.__ModuleLoader__.load({
328
315
  /** sessionId -> ms timestamp when the current turn was first seen running. */
329
316
  const turnStartedAt = new Map()
330
317
  let lastBadge
331
- /** Sessions this plugin pinned itself, so unpinning never touches the user's own pins. */
332
- const selfPinned = new Set(readPinnedIds())
333
- /** Whether the Host has answered with its remembered pins; nothing is pinned before that. */
334
- let pinsReady = false
335
- /** The most recent wanted set, so hydration can reconcile the moment it lands. */
336
- let lastWanted = new Set()
337
-
338
318
  /**
339
- * Write the pinned-by-this-plugin set back. localStorage covers a reload of
340
- * the same origin; the Host file covers a new launch, whose URL (and so
341
- * this origin) has changed and taken localStorage with it.
319
+ * The sessions that need you, in the order they started needing you.
320
+ * FIFO on purpose: a new arrival joins the bottom of the tier instead of
321
+ * displacing whoever was already in it, so a few running turns can never
322
+ * shuffle the sidebar under the cursor.
342
323
  */
343
- function persistPins() {
324
+ let tier = []
325
+ /** One coalesced write per settled tier, never one per status tick. */
326
+ let tierTimer
327
+ /** Whether an order was ever written, so an empty tier can clear it once and then stay quiet. */
328
+ let hasWrittenOrder = false
329
+ /** The last order written per account key; an unchanged tier touches nothing. */
330
+ const lastOrderByKey = new Map()
331
+
332
+ /** The sidebar's workspace snapshot, or undefined on a build without the service. */
333
+ function workspaceSnapshot() {
344
334
  try {
345
- storage()?.setItem(PINNED_KEY, JSON.stringify([...selfPinned]))
346
- } catch {
347
- /* a storage-less renderer keeps the set for this life only */
348
- }
349
- if (typeof fetch !== "function") return
350
- try {
351
- void fetch(PINS_PATH, {
352
- method: "POST",
353
- headers: { "content-type": "application/json" },
354
- body: JSON.stringify({ ids: [...selfPinned] }),
355
- }).catch(() => {})
335
+ return ctx.uiWorkspace?.workspaces?.list?.getSnapshot?.()
356
336
  } catch {
357
- /* the local interface may not be mounted */
337
+ return undefined
358
338
  }
359
339
  }
360
340
 
361
- /**
362
- * Read back the pins an earlier launch left behind. Until this answers the
363
- * plugin pins nothing: acting first would make its own pins look like the
364
- * user's and leave them in the sidebar forever.
365
- */
366
- async function hydratePins() {
367
- if (typeof fetch !== "function") {
368
- pinsReady = true
369
- return
370
- }
371
- try {
372
- const response = await fetch(PINS_PATH, { headers: { accept: "application/json" }, cache: "no-store" })
373
- if (response.ok) {
374
- const payload = await response.json()
375
- if (Array.isArray(payload?.ids)) {
376
- for (const sessionId of payload.ids) if (typeof sessionId === "string" && sessionId !== "") selfPinned.add(sessionId)
377
- }
378
- }
379
- } catch {
380
- /* no Host half: this life reconciles from localStorage alone */
381
- }
382
- pinsReady = true
383
- reconcilePins(lastWanted)
341
+ /** True when two id arrays are identical, so a no-op sync never reorders anything. */
342
+ function sameOrder(left, right) {
343
+ if (left.length !== right.length) return false
344
+ for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false
345
+ return true
384
346
  }
385
347
 
386
- /** The sidebar's pinned snapshot, or undefined on a build without the workspace service. */
387
- function pinnedSnapshot() {
388
- try {
389
- return ctx.uiWorkspace?.workspaces?.list?.getSnapshot?.()
390
- } catch {
391
- return undefined
392
- }
348
+ /** Recompute the tier; only a membership change schedules a write. */
349
+ function updateTier(wanted) {
350
+ const next = tier.filter((sessionId) => wanted.has(sessionId))
351
+ for (const sessionId of wanted) if (!next.includes(sessionId)) next.push(sessionId)
352
+ if (sameOrder(next, tier)) return
353
+ tier = next
354
+ scheduleOrderWrite()
393
355
  }
394
356
 
395
- /** Pin or unpin one session, best effort: a refused call must not disturb notifications. */
396
- function callPin(sessionId, pin) {
397
- const service = ctx.uiWorkspace
398
- const method = pin ? "pinSession" : "unpinSession"
399
- if (typeof service?.[method] !== "function") return
400
- try {
401
- const pending = service[method](sessionId)
402
- if (pending !== null && typeof pending === "object" && typeof pending.catch === "function") pending.catch(() => {})
403
- } catch {
404
- /* the view keeps its order */
405
- }
357
+ /** Coalesce a burst of status ticks into one write. */
358
+ function scheduleOrderWrite() {
359
+ if (tierTimer !== undefined) return
360
+ tierTimer = setTimeout(() => {
361
+ tierTimer = undefined
362
+ writeOrder()
363
+ }, ORDER_SETTLE_MS)
406
364
  }
407
365
 
408
366
  /**
409
- * Keep what needs you at the top of its group. The sidebar's own pin seats
410
- * a session ahead of the rest of the group, so this pins exactly the ones
411
- * the plugin would notify about and unpins each once that reason is gone —
412
- * never a session the user pinned by hand, and never one this plugin did
413
- * not pin in the first place.
367
+ * Seat the tier directly below the manual pins in every group. Only the
368
+ * tier ids are written, so everything else keeps falling back to recency:
369
+ * the result reads like the "most recent" sort with a needs-you band.
414
370
  */
415
- function reconcilePins(wanted) {
416
- lastWanted = wanted
417
- if (!pinsReady) return
418
- let changed = false
419
- if (!preferences.pinAttention) {
420
- for (const sessionId of [...selfPinned]) {
421
- selfPinned.delete(sessionId)
422
- callPin(sessionId, false)
423
- changed = true
371
+ function writeOrder() {
372
+ const view = ctx.uiWorkspace?.view
373
+ if (typeof view?.setSessionOrder !== "function") return
374
+ const snapshot = workspaceSnapshot()
375
+ const list = ctx.sessions.list.getSnapshot()
376
+ if (snapshot === undefined || list?.phase !== "ready") return
377
+ // The workspace registry hydrates separately; writing before it is ready
378
+ // would file every session under "" and then rewrite them a moment later.
379
+ if (snapshot.phase !== "ready" || snapshot.state === "loading") return
380
+ const archived = new Set(Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [])
381
+ const live = tier.filter((sessionId) => !archived.has(sessionId))
382
+ const active = preferences.orderAttention && live.length > 0
383
+ if (!active && !hasWrittenOrder) return
384
+ const accounted = new Set()
385
+ const groups = []
386
+ for (const item of Array.isArray(snapshot.items) ? snapshot.items : []) {
387
+ const memberIds = Array.isArray(item?.sessionIds) ? item.sessionIds : []
388
+ for (const sessionId of memberIds) accounted.add(sessionId)
389
+ groups.push([item?.workspaceId ?? "", memberIds])
390
+ }
391
+ const ids = Array.isArray(list.ids) ? list.ids : []
392
+ const known = (sessionId) => list.byId?.[sessionId] !== undefined
393
+ groups.push(["", ids.filter((sessionId) => known(sessionId) && !accounted.has(sessionId))])
394
+ groups.push([FLAT_ORDER_KEY, ids.filter(known)])
395
+ for (const [key, memberIds] of groups) {
396
+ const members = new Set(memberIds)
397
+ const order = active ? live.filter((sessionId) => members.has(sessionId)) : []
398
+ const signature = order.join("\n")
399
+ // Never touch a group this plugin has not seated before: writing an
400
+ // empty order there would wipe the user's own drag arrangement.
401
+ if (lastOrderByKey.get(key) === signature) continue
402
+ if (signature === "" && !lastOrderByKey.has(key)) continue
403
+ try {
404
+ view.setSessionOrder(key, order, {})
405
+ lastOrderByKey.set(key, signature)
406
+ } catch {
407
+ /* a view store that refuses the write keeps its own order */
424
408
  }
425
- if (changed) persistPins()
426
- return
427
409
  }
428
- const snapshot = pinnedSnapshot()
429
- if (snapshot === undefined) return
430
- const pinned = new Set(Array.isArray(snapshot.pinnedSessionIds) ? snapshot.pinnedSessionIds : [])
431
- // An archived session is out of the tree anyway, and pinning it is not
432
- // this plugin's business.
433
- const archived = new Set(Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds : [])
434
- for (const sessionId of wanted) {
435
- if (archived.has(sessionId)) continue
436
- if (selfPinned.has(sessionId) || pinned.has(sessionId)) continue
437
- selfPinned.add(sessionId)
438
- changed = true
439
- callPin(sessionId, true)
440
- }
441
- for (const sessionId of [...selfPinned]) {
442
- if (wanted.has(sessionId) && !archived.has(sessionId)) continue
443
- selfPinned.delete(sessionId)
444
- changed = true
445
- callPin(sessionId, false)
446
- }
447
- if (changed) persistPins()
410
+ hasWrittenOrder = active
448
411
  }
449
412
 
450
- // Ask the Host which pins an earlier launch left; nothing is pinned until it answers.
451
- void hydratePins()
452
-
453
413
  /** The last banner attempt and its outcome — Windows has several ways to swallow it. */
454
414
  let lastNotify
455
415
  /** The last banner click and what it managed to do — Windows/macOS differ here. */
@@ -762,10 +722,14 @@ window.__ModuleLoader__.load({
762
722
  })
763
723
  }
764
724
 
765
- // The pin follows the same facts the banner and badge use, plus a
766
- // running turn: anything that would make you look belongs on top.
725
+ // Only what is still in flight is seated: a running turn, a question
726
+ // waiting on you, or a failure. An unread completion stays in the
727
+ // badge instead — promoting it would move the list every time you
728
+ // read a session, and it is exactly what made the band churn.
729
+ // Focus is deliberately not part of this: a running turn keeps its
730
+ // seat while you tab away, so the band never reshuffles on blur.
767
731
  const current = attention.get(sessionId) ?? {}
768
- if (!looking && (running || current.completed === true || current.pendingKind !== undefined || current.error !== undefined)) {
732
+ if (running || pendingKind !== undefined || current.error !== undefined) {
769
733
  wanted.add(sessionId)
770
734
  }
771
735
 
@@ -777,7 +741,7 @@ window.__ModuleLoader__.load({
777
741
  previous.delete(sessionId)
778
742
  turnStartedAt.delete(sessionId)
779
743
  }
780
- reconcilePins(wanted)
744
+ updateTier(wanted)
781
745
  applyBadge()
782
746
  }
783
747
 
@@ -869,6 +833,21 @@ window.__ModuleLoader__.load({
869
833
  }
870
834
  }, "dsh-plugin-desktop-notify: badge release")
871
835
 
836
+ /* Give the sidebar back the order this plugin took when it unloads. */
837
+ ctx.effect(() => () => {
838
+ const view = ctx.uiWorkspace?.view
839
+ if (typeof view?.setSessionOrder !== "function") return
840
+ for (const [key, signature] of lastOrderByKey) {
841
+ if (signature === "") continue
842
+ try {
843
+ view.setSessionOrder(key, [], {})
844
+ } catch {
845
+ /* a torn-down view keeps whatever it had */
846
+ }
847
+ }
848
+ lastOrderByKey.clear()
849
+ }, "dsh-plugin-desktop-notify: order release")
850
+
872
851
  /*
873
852
  * Diagnostic surface for DevTools (⌘⌥I):
874
853
  * __DSH_DESKTOP_NOTIFY__.capabilities() — what this renderer supports
@@ -912,28 +891,24 @@ window.__ModuleLoader__.load({
912
891
  badge: lastBadge ?? 0,
913
892
  attention: [...attention.entries()].map(([sessionId, entry]) => ({ sessionId, ...entry })),
914
893
  tracked: [...previous.keys()],
915
- pins: [...selfPinned],
894
+ order: [...tier],
916
895
  lastNotify: lastNotify ?? null,
917
896
  lastClick: lastClick ?? null,
918
897
  }
919
898
  },
920
- /** What this plugin pinned, next to what the sidebar currently shows. */
921
- pinState() {
922
- const snapshot = pinnedSnapshot()
899
+ /** Which sessions the plugin seats under the pins, and in what order. */
900
+ orderState() {
923
901
  return {
924
- enabled: preferences.pinAttention,
925
- pinnedByPlugin: [...selfPinned],
926
- pinnedByUser: Array.isArray(snapshot?.pinnedSessionIds) ? snapshot.pinnedSessionIds.filter((id) => !selfPinned.has(id)) : null,
902
+ enabled: preferences.orderAttention,
903
+ tier: [...tier],
904
+ written: Object.fromEntries([...lastOrderByKey].map(([key, value]) => [key, value === "" ? 0 : value.split("\n").length])),
927
905
  }
928
906
  },
929
- /** Release every pin this plugin owns — leaves manual pins alone. */
930
- clearPins() {
931
- for (const sessionId of [...selfPinned]) {
932
- selfPinned.delete(sessionId)
933
- callPin(sessionId, false)
934
- }
935
- persistPins()
936
- return this.pinState()
907
+ /** Drop the whole tier; the next settle clears it from the sidebar. */
908
+ clearOrder() {
909
+ tier = []
910
+ scheduleOrderWrite()
911
+ return this.orderState()
937
912
  },
938
913
  simulateComplete(sessionId = "desktop-notify-simulation") {
939
914
  const table = copy(languageOf())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-desktop-notify",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "description": "Desktop notifications and a Dock/taskbar badge for DeepSeek Harness: a finished turn, a pending approval or question, and a failure, with click-through back to the session",
6
6
  "license": "MIT",