dsh-notify-me 1.1.4 → 1.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/lib/client.js +224 -40
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ All notable changes to **dsh-notify-me** are documented here.
6
6
 
7
7
  ### 文档
8
8
  - 新增 `docs/listing.md`:记录各插件目录/市场的收录方式(提交物、合并方式),以及本插件当前的收录状态;并记下 `awesome-dsh-plugin` 站点构建失败的排查入口(`build-site.yml` 最近一次运行 / issue #4731)。
9
+ - issue #1 / #2 报告的「等待审批 / 提问 / 方案确认不提醒」查清并修复(见 1.1.5)。
10
+
11
+ ## [1.1.5] — 2026-09-14
12
+
13
+ ### 修复
14
+ - **「模型在等你操作」这一路提醒在 DSH `0.1.2-alpha.2` 及以后的运行时上完全不触发**(现象:审批卡片出现、模型停下等待时,没有系统通知、没有提示音、标签页标题也不变;而「回复完成」提醒一直正常)。根因是两个待办数据源同时被上游搬走:
15
+ - 选中会话的快照 —— `SessionSnapshot.pending` 已不存在(0.1.2-alpha.2 起只剩 `queue / pendingSubmissions / running / …`);
16
+ - 会话列表行摘要 —— `SessionSummary.pendingInteraction` 已不存在(只剩 `running / completed`)。
17
+
18
+ 待办交互改为由 `dsh-client-ui-approval` / `dsh-client-ui-user-questions` 经 `ctx.uiSession.registerPendingInteraction()` 发布到**独立 store** `ctx.uiSession.pendingInteractions`(`Map<sessionId, interaction>`,`interaction.kind` 为 `approval | question | plan-review`)。本版改为订阅该 store:新出现的交互弹一次提醒(`PendingApproval` 取 `toolName` / `reason`,`PendingQuestion` 取 `questions[0].question`),交互消失时释放标签页标记,挂载时已存在的等待也提醒一次。
19
+ - **为什么不把 `uiSession` 写进 `inject`**:客户端条目的 `inject` 是**硬门控**——服务不存在时条目会永远停在 `pending`,而 web 启动断言会因「有条目未激活」直接抛错(这正是 1.1.3/1.1.4 两次踩过的坑)。`uiSession` 只存在于 `0.1.2-alpha.2` 及以后,写进 `inject` 会让本插件在 `0.1.1-rc.2` 上彻底失效。因此改为**惰性查找**:拿得到就订阅交互 store,拿不到就静默走旧的快照路径,两代宿主都保留完整功能。
20
+ - 保留 `@deepseek-ai/dsh-client-ui-session` 在 `dsh.client.inject` 中(它是客户端加载图里的 bundle 条目,与服务门控无关);实测该条目在加载图顺序上位于 `@deepseek-ai/dsh-client-ui-session` 之后(54 个条目中第 44 位 vs 第 10 位),且官方 `dsh-client-ui-approval` / `dsh-client-ui-user-questions` / `dsh-client-ui-open-in-app` / `dsh-client-ui-layout` 等同样以 `"uiSession"` 为服务依赖。
21
+
22
+ ### 变更
23
+ - 两条来源同时可用时按交互 key 去重:同一次等待只提醒一次、也只有一个标签页标记;标题标记的归属固定给交互 store(可用时),避免两路各记一个标记。
24
+ - 测试:`smoke/smoke-test.cjs` 拆成**两代宿主**各跑一遍——旧宿主(无 `uiSession`,快照带 `pending`)与新宿主(`uiSession.pendingInteractions`),新增「重复通知不重复提醒」「挂载前已存在的等待仍提醒一次」「多会话并发等待各提醒一次并逐个释放标记」「两路去重」「主开关关闭时交互通道同样静音」等回归用例。
9
25
 
10
26
  ## [1.1.4] — 2026-09-13
11
27
 
package/lib/client.js CHANGED
@@ -10,9 +10,16 @@
10
10
  // slots/locale services are absent, keeping the reminder core self-contained.
11
11
  //
12
12
  // What this plugin does (all client-side, no settings transport needed):
13
- // - watches the "sessions" service (SessionRuntime): the selected
14
- // session's ConversationSnapshot (running / pending interactions) and the
15
- // session-list summaries of every other session;
13
+ // - watches two services, because pending interactions and the running bit
14
+ // have different owners on current hosts:
15
+ // * "uiSession" -> pendingInteractions (a Map<sessionId, interaction>
16
+ // snapshot whose values carry kind 'approval' | 'plan-review' |
17
+ // 'question'). Present since DSH 0.1.2-alpha.2; the controller
18
+ // snapshots stopped carrying `pending` in the same release, so this is
19
+ // the only live source of "the agent is waiting on you" on 0.1.2+;
20
+ // * "sessions" -> the selected session's ConversationSnapshot (running)
21
+ // and the session-list summaries of every other session (running /
22
+ // completed), which carry the "reply finished" edge on every version;
16
23
  // - fires a Windows desktop Notification (+ optional sound) when the agent
17
24
  // needs the user's input — pending interaction kinds 'approval',
18
25
  // 'plan-review' or 'question' — and when a reply/turn finishes;
@@ -227,14 +234,32 @@ window.__ModuleLoader__.load({
227
234
  window.addEventListener("keydown", onFirstGesture, true);
228
235
 
229
236
  // ────────────────────────── title marker ─────────────────────────
230
- var originalTitle = null;
237
+ // The host owns document.title (it tracks the session/workspace name), so
238
+ // the base title is re-read on every apply instead of being frozen once —
239
+ // and the exact string we last wrote is remembered, so the host taking the
240
+ // title over (or an earlier load leaving a marker behind) can never make us
241
+ // mistake our own marker for the base title.
231
242
  var attentionActive = 0;
243
+ var hostBase = null; // document.title as the host last left it
244
+ var writtenTitle = null; // exact string this plugin last put into document.title
245
+ // Our own marker prefix never belongs to the base title — strip a leftover
246
+ // one (an earlier load that was closed while a wait was pending, or a title
247
+ // the host tracked while a marker was up) before storing the base.
248
+ function cleanBase(t) {
249
+ var d = texts();
250
+ var prefixes = [d.marker, TXT.zh.marker, TXT.en.marker];
251
+ for (var i = 0; i < prefixes.length; i++) {
252
+ while (t.indexOf(prefixes[i]) === 0) t = t.slice(prefixes[i].length);
253
+ }
254
+ return t;
255
+ }
232
256
  function applyTitle() {
233
257
  try {
234
- if (originalTitle === null) originalTitle = document.title;
235
- document.title = (config.enabled && attentionActive > 0)
236
- ? texts().marker + originalTitle
237
- : originalTitle;
258
+ var current = String(document.title || "");
259
+ if (current !== writtenTitle) hostBase = cleanBase(current);
260
+ var next = (config.enabled && attentionActive > 0) ? texts().marker + hostBase : hostBase;
261
+ writtenTitle = next;
262
+ if (current !== next) document.title = next;
238
263
  } catch (e) {}
239
264
  }
240
265
  function acquireTitleMark() {
@@ -245,21 +270,39 @@ window.__ModuleLoader__.load({
245
270
  if (attentionActive > 0) attentionActive -= 1;
246
271
  applyTitle();
247
272
  }
273
+ // Title marker ownership: on hosts that publish pending interactions
274
+ // (uiSession) that source owns the marker, but whichever source saw the wait
275
+ // first must still mark it (the other one may never report that key at all),
276
+ // so the mark is acquired by the first reporter and released only by its
277
+ // owner. Without this, the two sources would either double-mark or never
278
+ // clear the mark.
279
+ function markAttention(key) {
280
+ if (attentionKeysMarked[key]) return;
281
+ attentionKeysMarked[key] = true;
282
+ acquireTitleMark();
283
+ }
284
+ function unmarkAttention(key) {
285
+ if (!attentionKeysMarked[key]) return;
286
+ delete attentionKeysMarked[key];
287
+ releaseTitleMark();
288
+ }
248
289
 
249
290
  // ─────────────────────── shared alert core ───────────────────────
250
291
  var attentionKeysFired = Object.create(null); // dedupe per pending key
292
+ var attentionKeysMarked = Object.create(null); // keys currently holding a title mark
251
293
  var lastAttentionAt = 0;
252
294
 
253
295
  function hidden() { return document.hidden || document.visibilityState === "hidden"; }
254
296
 
255
297
  // Deliver one alert regardless of visibility rules (used by the test
256
- // buttons); respects the master switch and permission state.
298
+ // buttons); respects the master switch and permission state. The title
299
+ // marker is NOT touched here — its ownership belongs to the watchers
300
+ // (markAttention / unmarkAttention), which keeps the counter balanced.
257
301
  function deliver(kind, title, body) {
258
302
  if (!config.enabled) return;
259
303
  showToast(kind, title, body);
260
304
  if (kind === "attention") {
261
305
  playAttentionSound();
262
- acquireTitleMark();
263
306
  } else {
264
307
  playDoneSound();
265
308
  }
@@ -282,6 +325,20 @@ window.__ModuleLoader__.load({
282
325
  if (!raw || raw === "current") return texts().current;
283
326
  return raw;
284
327
  }
328
+ // Display label for a session id we only know from the interaction map:
329
+ // the list row's title, the localized "current" placeholder for the selected
330
+ // session, the raw id as a last resort.
331
+ function labelForSessionId(sid) {
332
+ try {
333
+ var st = listStore && listStore.getSnapshot();
334
+ if (st) {
335
+ var sum = st.byId && st.byId[sid];
336
+ if (sum) return sessionLabelText({ label: sum.displayTitle || sum.title || "" });
337
+ if (st.current === sid) return texts().current;
338
+ }
339
+ } catch (e) {}
340
+ return sid;
341
+ }
285
342
  function fireAttention(kind, sessionLabel, detail) {
286
343
  var d = texts();
287
344
  var label = d.kind[kind] || d.kindDefault;
@@ -302,11 +359,13 @@ window.__ModuleLoader__.load({
302
359
  // also drops its title marker again shortly after, so a test never leaves a
303
360
  // standing "waiting on you" mark in the tab.
304
361
  var TEST_MARK_RELEASE_MS = 6000;
362
+ var TEST_MARK_KEY = "test:attention";
305
363
  function testAlert(kind) {
306
364
  var d = texts();
307
365
  if (kind === "attention") {
308
366
  deliver("attention", "DSH · " + d.kind.question, d.testAttention);
309
- setTimeout(function () { releaseTitleMark(); }, TEST_MARK_RELEASE_MS);
367
+ markAttention(TEST_MARK_KEY);
368
+ setTimeout(function () { unmarkAttention(TEST_MARK_KEY); }, TEST_MARK_RELEASE_MS);
310
369
  } else {
311
370
  deliver("done", d.doneTitle, d.testDone);
312
371
  }
@@ -320,16 +379,52 @@ window.__ModuleLoader__.load({
320
379
  var currentFaceId = null;
321
380
  var listStore = null;
322
381
  var rootCtx = null; // client cordis context, captured at apply()
382
+ // uiSession pending-interaction watcher (DSH >= 0.1.2-alpha.2)
383
+ var uiPending = false; // true once the interaction snapshot is readable
384
+ var uiSub = null;
385
+ var uiBaseline = Object.create(null); // sessionId -> { key: string }
323
386
 
387
+ // One-line detail for a pending interaction. Two shapes reach this:
388
+ // * the pre-0.1.2 controller payload (approval/question requested frames)
389
+ // — flat, its text under `text`/`question`/…;
390
+ // * the 0.1.2+ uiSession interaction objects — class instances whose own
391
+ // fields are the detail (PendingApproval: toolName/reason/callId,
392
+ // PendingQuestion: questions[]).
324
393
  function textFromPayload(payload) {
325
394
  if (!payload || typeof payload !== "object") return "";
326
- var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "name", "tool"];
395
+ function clip(s) {
396
+ s = String(s).replace(/\s+/g, " ").trim();
397
+ if (!s) return "";
398
+ return s.length > 160 ? s.slice(0, 160) + "…" : s;
399
+ }
400
+ // scalar fields (covers the flat pre-0.1.2 payloads and PendingApproval)
401
+ var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "toolName", "reason", "name", "tool"];
327
402
  for (var i = 0; i < keys.length; i++) {
328
403
  var v = payload[keys[i]];
329
- if (typeof v === "string" && v.trim()) return v.length > 160 ? v.slice(0, 160) + "…" : v;
330
- if (typeof v === "object" && v !== null && typeof v.text === "string" && v.text.trim()) {
331
- var s = v.text;
332
- return s.length > 160 ? s.slice(0, 160) + "…" : s;
404
+ if (typeof v === "string") {
405
+ var s = clip(v);
406
+ if (s) {
407
+ // PendingApproval carries both the tool and the human-readable
408
+ // reason — "pwsh" alone says nothing useful in a toast.
409
+ if (keys[i] === "toolName" && typeof payload.reason === "string" && clip(payload.reason)) {
410
+ return s + " · " + clip(payload.reason);
411
+ }
412
+ return s;
413
+ }
414
+ } else if (v && typeof v === "object" && typeof v.text === "string" && clip(v.text)) {
415
+ return clip(v.text);
416
+ }
417
+ }
418
+ // nested batches (PendingQuestion.questions -> { question, detail, options })
419
+ var qs = payload.questions;
420
+ if (Array.isArray(qs) && qs.length > 0) {
421
+ var q = qs[0];
422
+ if (typeof q === "string") return clip(q);
423
+ if (q && typeof q === "object") {
424
+ var qk = ["question", "title", "text", "detail"];
425
+ for (var j = 0; j < qk.length; j++) {
426
+ if (typeof q[qk[j]] === "string" && clip(q[qk[j]])) return clip(q[qk[j]]);
427
+ }
333
428
  }
334
429
  }
335
430
  return "";
@@ -372,12 +467,14 @@ window.__ModuleLoader__.load({
372
467
  // First evaluation of a session also lands here (prevPendingKeys empty),
373
468
  // so a wait that already existed when the plugin (or page) mounted still
374
469
  // alerts once.
470
+ // On hosts that expose uiSession this is the fallback path only: the
471
+ // interaction snapshot there is the authoritative source, and it drives
472
+ // the title marker itself (see the uiSession watcher below).
375
473
  if (newKeys.length > 0) {
376
474
  var it = newKeys[0];
377
- var detail = textFromPayload(it.payload);
378
- if (!detail) detail = "";
379
- fireAttention(it.kind, sessionLabelText(row), detail);
475
+ fireAttention(it.kind, sessionLabelText(row), textFromPayload(it.payload));
380
476
  lastAttentionAt = Date.now();
477
+ markAttention(it.key);
381
478
  }
382
479
 
383
480
  // 2) a non-selected session finished while we were away (sidebar "done")
@@ -406,8 +503,8 @@ window.__ModuleLoader__.load({
406
503
  };
407
504
 
408
505
  // a wait we alerted about has been resolved -> drop the title marker
409
- if (prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
410
- releaseTitleMark();
506
+ if (!uiPending && prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
507
+ for (var r = 0; r < prevPendingKeys.length; r++) unmarkAttention(prevPendingKeys[r]);
411
508
  }
412
509
  }
413
510
 
@@ -476,6 +573,7 @@ window.__ModuleLoader__.load({
476
573
  var st = listStore.getSnapshot();
477
574
  var cur = st && st.current;
478
575
  bindCurrentFace();
576
+ bindUiSession();
479
577
  var byId = (st && st.byId) || {};
480
578
  for (var sid in byId) {
481
579
  if (sid === cur) continue; // current handled by the face watcher
@@ -487,25 +585,98 @@ window.__ModuleLoader__.load({
487
585
  } catch (e) {}
488
586
  }
489
587
 
490
- // ──────────────────────── global control API ─────────────────────
491
- var api = {
492
- // keep in sync with package.json "version" on every release
493
- version: "1.1.4",
494
- get config() { return readConfig(); },
495
- setConfig: function (patch) { return setConfigLocal(patch); },
496
- resetConfig: function () {
497
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
498
- config = readConfig();
499
- return config;
500
- },
501
- // manual test; run it after clicking anywhere so permission can be granted.
502
- // Test alerts fire regardless of the page-open visibility rules.
503
- test: function (kind) {
504
- kind = kind === "attention" ? "attention" : "done";
505
- unlockOnce();
506
- testAlert(kind);
507
- return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
588
+ // ─────────────── uiSession pending-interaction watcher ───────────
589
+ // DSH >= 0.1.2-alpha.2 moved pending interactions out of the Controller
590
+ // snapshots into their own store (uiSession.pendingInteractions), a
591
+ // Map<sessionId, interaction> whose values carry kind + key + sessionId and
592
+ // the domain's own detail fields. It is the only live "the agent is waiting
593
+ // on you" source on those hosts.
594
+ //
595
+ // The service is looked up lazily rather than declared in `inject`:
596
+ // a cordis `inject` entry is a hard gate (the entry never activates while
597
+ // the service is missing, and a never-activated entry makes the whole web
598
+ // boot audit fail), while hosts older than 0.1.2-alpha.2 have no uiSession
599
+ // at all and must keep working through the controller path above.
600
+ function bindUiSession() {
601
+ if (uiSub) return;
602
+ var svc = null;
603
+ try { svc = rootCtx && rootCtx.uiSession; } catch (e) { svc = null; }
604
+ var store = svc && svc.pendingInteractions;
605
+ if (!store || typeof store.getSnapshot !== "function" || typeof store.subscribe !== "function") return;
606
+ uiPending = true; // this watcher now owns the title marker
607
+ uiSub = store.subscribe(function () { try { evalUiPending(); } catch (e) {} });
608
+ evalUiPending(); // seed: a wait that predates this plugin still alerts once
609
+ }
610
+ function evalUiPending() {
611
+ var svc = null;
612
+ try { svc = rootCtx && rootCtx.uiSession; } catch (e) { svc = null; }
613
+ var store = svc && svc.pendingInteractions;
614
+ if (!store || typeof store.getSnapshot !== "function") return;
615
+ var snap = store.getSnapshot(); // Map<sessionId, interaction>
616
+ if (!snap || typeof snap.forEach !== "function") return;
617
+ var seen = Object.create(null);
618
+ snap.forEach(function (it, sid) {
619
+ if (!it) return;
620
+ seen[sid] = true;
621
+ var key = it.key || (it.kind + ":" + sid);
622
+ var prev = uiBaseline[sid];
623
+ // new interactions alert once; a wait that already existed on bind
624
+ // (no baseline yet for that session) alerts here too.
625
+ if (prev && prev.key === key) return;
626
+ uiBaseline[sid] = { key: key };
627
+ // a wait replaced by a newer one in the same session: the old mark goes
628
+ // with it, otherwise the tab would keep one stale marker per replacement
629
+ if (prev) unmarkAttention(prev.key);
630
+ if (attentionKeysFired[key]) { markAttention(key); return; } // already alerted, still needs its mark
631
+ attentionKeysFired[key] = true;
632
+ fireAttention(it.kind, labelForSessionId(sid), textFromPayload(it));
633
+ lastAttentionAt = Date.now();
634
+ markAttention(key);
635
+ });
636
+ for (var sid2 in uiBaseline) {
637
+ if (seen[sid2]) continue;
638
+ // interaction answered, cancelled, or its session went away
639
+ unmarkAttention(uiBaseline[sid2].key);
640
+ delete uiBaseline[sid2];
508
641
  }
642
+ }
643
+ function detachUiSession() {
644
+ if (uiSub) { try { uiSub(); } catch (e) {} uiSub = null; }
645
+ // drop every mark this watcher holds; hosts without the interaction store
646
+ // go back to the controller marker path
647
+ for (var s in uiBaseline) unmarkAttention(uiBaseline[s].key);
648
+ uiBaseline = Object.create(null);
649
+ uiPending = false;
650
+ }
651
+
652
+ // ──────────────────────── global control API ─────────────────────
653
+ // Expose the control API. Merge instead of replace: a consumer (or a test
654
+ // harness) may have registered onEvent before the plugin applied, and that
655
+ // hook must survive — binding the interaction store can deliver an alert
656
+ // synchronously, in the middle of apply().
657
+ var api = window.__dshNotifyMe;
658
+ if (!api || typeof api !== "object") api = {};
659
+ try {
660
+ Object.defineProperty(api, "config", {
661
+ get: function () { return readConfig(); },
662
+ configurable: true
663
+ });
664
+ } catch (e) {}
665
+ // keep in sync with package.json "version" on every release
666
+ api.version = "1.1.5";
667
+ api.setConfig = function (patch) { return setConfigLocal(patch); };
668
+ api.resetConfig = function () {
669
+ try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
670
+ config = readConfig();
671
+ return config;
672
+ };
673
+ // manual test; run it after clicking anywhere so permission can be granted.
674
+ // Test alerts fire regardless of the page-open visibility rules.
675
+ api.test = function (kind) {
676
+ kind = kind === "attention" ? "attention" : "done";
677
+ unlockOnce();
678
+ testAlert(kind);
679
+ return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
509
680
  };
510
681
  window.__dshNotifyMe = api;
511
682
 
@@ -739,6 +910,14 @@ window.__ModuleLoader__.load({
739
910
  }
740
911
 
741
912
  // ────────────────────────── plugin body ──────────────────────────
913
+ // Hard service requirements only. `uiSession` is deliberately NOT listed:
914
+ // a cordis inject entry is a hard gate (the entry stays pending while the
915
+ // service is missing, and the web boot audit fails on entries that never
916
+ // activate), while hosts older than 0.1.2-alpha.2 have no uiSession at all.
917
+ // It is therefore looked up lazily in bindUiSession(), with the controller
918
+ // path above as the fallback. The bundle entry
919
+ // "@deepseek-ai/dsh-client-ui-session" stays in package.json's
920
+ // dsh.client.inject so the load order keeps the bundle in the graph.
742
921
  var inject = ["sessions", "locale", "slots"];
743
922
 
744
923
  /**
@@ -788,12 +967,16 @@ window.__ModuleLoader__.load({
788
967
  }
789
968
  listStore = sessionsSvc.list;
790
969
  unsubs.push(sessionsSvc.list.subscribe(onListChanged));
791
- // initial scan (baseline only; no alerts for pre-existing state)
970
+ // Interaction source first: it owns the title marker once present, so
971
+ // binding it before the controller scan keeps a single mark per wait.
972
+ bindUiSession();
973
+ // initial scan (baseline only; no alerts for pre-existing sessions)
792
974
  onListChanged();
793
975
  // binding may resolve a tick later; re-check periodically while idle
794
976
  retryTimer = setInterval(function () {
795
977
  if (disposed) return;
796
978
  try { bindCurrentFace(); } catch (e) {}
979
+ try { bindUiSession(); } catch (e) {}
797
980
  }, 1500);
798
981
  try { console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (enabled=" + config.enabled + ", lang=" + effectiveLang() + ", toast=" + config.toast + ", sound=" + config.sound + ")"); } catch (e) {}
799
982
  } catch (e) {
@@ -804,6 +987,7 @@ window.__ModuleLoader__.load({
804
987
  if (retryTimer) clearInterval(retryTimer);
805
988
  for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
806
989
  detachFace();
990
+ detachUiSession();
807
991
  };
808
992
  }, "dsh-notify-me: watchers");
809
993
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "DSH 桌面消息提醒插件:模型需要你操作(审批/方案确认/提问的可操作提醒)或回复在后台完成时,系统通知+提示音+标签页标题提醒你;可在设置页开关并切换通知语言。Desktop notification & message alerts for DeepSeek Harness web — toasts, sounds and a tab-title marker when the agent needs your input (approval / plan review / question) or a reply finishes in the background; toggleable from Settings with zh/en notification language.",
5
5
  "license": "MIT",
6
6
  "type": "module",