dsh-autorestart 0.6.0 → 0.6.2

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 CHANGED
@@ -75,13 +75,15 @@
75
75
  *
76
76
  * This file is a CLASSIC script: the client module loader evaluates a
77
77
  * plugin bundle as a same-origin external classic script, so the ESM
78
- * `export` keyword is NOT available here. The four pure helpers are
78
+ * `export` keyword is NOT available here. The pure helpers are
79
79
  * therefore published in the two places a test can reach them:
80
80
  *
81
81
  * 1. as NAMED properties of this module's exports — the client module
82
82
  * system materializes `factory(require) -> exports`, so the exports
83
83
  * object carries `pickSleepCaption`, `shouldShowSleepButton`,
84
- * `buildChoiceBody` and `formatRemainingMs`;
84
+ * `buildChoiceBody`, `formatRemainingMs`, `formatLongWaitNote`,
85
+ * `formatLongWaitNoteParts`, `groupBlockers`, `summarizeBlockers`
86
+ * and `truncateBlockerName`;
85
87
  * 2. on the global namespace `globalThis.__dshAutorestartClient`, which
86
88
  * exists as soon as this file is evaluated (no factory call needed).
87
89
  *
@@ -115,17 +117,18 @@ const SLEEP_DEFAULT_COUNTDOWN_SECONDS = 20
115
117
  const SLEEP_DRY_RUN_NOTICE_MS = 8_000
116
118
 
117
119
  /**
118
- * The grace window the settings page offers (a dropdown, in seconds).
120
+ * The RESTART reserve the settings page offers (a dropdown, in seconds).
119
121
  *
120
- * It is the extra wait after the last conversation finished, so that work
121
- * other plugins queue with a timer (a memory plugin writing its notes, a
122
- * summary) still gets its chance — this plugin cannot see that work.
122
+ * It belongs to the restart only: a restart is a daily operation, so it
123
+ * should happen as soon as the conversations are done. The graceful shutdown
124
+ * has no reserve of its own — its VISIBLE countdown is the wait before the
125
+ * machine goes off.
123
126
  */
124
- const GRACE_CHOICES_SECONDS = Object.freeze([0, 3, 5, 10, 20, 30])
127
+ const GRACE_CHOICES_SECONDS = Object.freeze([0, 5, 10, 20, 30, 60])
125
128
 
126
- /** The grace window used when the Host did not report one (ms / seconds). */
127
- const GRACE_DEFAULT_MS = 10_000
128
- const GRACE_DEFAULT_SECONDS = 10
129
+ /** The RESTART reserve used when the Host did not report one (ms / seconds). */
130
+ const GRACE_DEFAULT_MS = 0
131
+ const GRACE_DEFAULT_SECONDS = 0
129
132
 
130
133
  const ZH = {
131
134
  label: '待重启',
@@ -134,6 +137,7 @@ const ZH = {
134
137
  idle: ' · 空闲后自动重启',
135
138
  goalActive: ' · 被某个自动续跑的 goal 占着',
136
139
  goalExhausted: ' · 某个任务的 goal 轮次额度已用完,无法续跑',
140
+ settling: ' · 还在等这段安静期',
137
141
  parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
138
142
  parkButton: '停泊并重启',
139
143
  parkButtonAria: '停泊该会话的 goal,并立即请求重启',
@@ -155,6 +159,10 @@ const ZH = {
155
159
  panelBlockedSession: '会话「{label}」正在运行',
156
160
  panelBlockedSubagent: '{name}正在运行',
157
161
  panelBlockedSubagentGeneric: '有一个子智能体正在运行',
162
+ // The N subagents of ONE session are one line: the session is named once
163
+ // and the number of its subagents says how many there are.
164
+ panelBlockedSubagentGroup: '会话「{label}」的 {n} 个子智能体正在运行',
165
+ panelBlockedSubagentGroupGeneric: '有 {n} 个子智能体正在运行',
158
166
  panelBlockedRestartPending: '等这次重启完成后再关机',
159
167
  panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
160
168
  panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
@@ -162,9 +170,30 @@ const ZH = {
162
170
  panelParkedGoal: '会话 {session} · {goal}',
163
171
  panelWaiting: '已等待 {duration}',
164
172
  panelResume: '重启后本会话会自动继续进度',
173
+ // The long wait: the restart passed the hard deadline while a conversation
174
+ // was still running. The capsule is ONE clipped line of 22px: it holds a
175
+ // number and a duration, NEVER a name. Every name goes into the tooltip,
176
+ // where there is room for all of them.
177
+ longWaitPill: ' · 已等 {duration} · {n} 项任务在跑',
178
+ longWaitTooltip: '已等 {duration}。在跑:{blockers}',
179
+ // The blockers of one list. They are grouped by the session that created
180
+ // them (N subagents of one session are ONE item), the tooltip names them
181
+ // ALL and the shutdown card lists them. One rule, two surfaces: the
182
+ // tooltip of the capsule and the list of the shutdown card.
183
+ blockerGeneric: '有会话',
184
+ blockerJoin: '、',
185
+ blockerGroupSubagents: '{label}({n} 个子智能体)',
186
+ blockerGroupSubagentsGeneric: '{n} 个子智能体',
187
+ blockerMore: '…等 {n} 项',
188
+ blockersTitleLine: '占用会话:{blockers}',
165
189
  durationSeconds: '{n} 秒',
166
190
  durationMinutes: '{n} 分 {m} 秒',
167
191
  durationHours: '{n} 小时 {m} 分',
192
+ // The COMPACT duration of the capsule: it can afford "16 分钟", not
193
+ // "16 分 37 秒" — the capsule is one clipped line.
194
+ durationMinutesShort: '{n} 分钟',
195
+ durationHoursShort: '{n} 小时',
196
+ durationHoursMinutesShort: '{n} 小时 {m} 分',
168
197
  // The one-time install prompt (the choice is asked exactly once).
169
198
  // "Backup" is the verb and BOTH options share the "configuration" base:
170
199
  // the plugins are an ADDITION to the configuration, never a rival of it.
@@ -192,14 +221,16 @@ const ZH = {
192
221
  'sleep.error': '没能联系上关机服务:{message}',
193
222
  // The settings section: the entry in the settings sidebar.
194
223
  'nav': '自动重启与关机',
195
- 'settings.seconds.label': '倒计时秒数',
224
+ 'settings.groups.restart': '重启相关设置',
225
+ 'settings.groups.shutdown': '关机相关设置',
226
+ 'settings.seconds.label': '关机倒数秒数',
196
227
  'settings.seconds.immediate': '不倒数(立即关机)',
197
228
  'settings.seconds.value': '{n} 秒',
198
- 'settings.seconds.hint': '按下按钮后,等所有会话收工,再倒数这么多秒才关机;「不倒数」表示会话一收工就关机。',
199
- 'settings.grace.label': '收工后再等几秒(给记忆/总结类插件留时间)',
200
- 'settings.grace.hint': '最后一个会话收工后,再等这么久才真正重启或关机——我们只看得见 DSH 的会话,看不见别的插件用定时器排队的活。等待期间如果又有会话开始干活,会重新计时。',
201
- 'settings.grace.value': '{n} 秒',
202
- 'settings.grace.immediate': '不等(立即重启/关机)',
229
+ 'settings.seconds.hint': '所有会话收工后,屏幕上会显示这段倒数(可点「不关了」取消),数完才关机;想要更多落盘时间就把倒数调大。',
230
+ 'settings.restartGrace.label': '重启前等待',
231
+ 'settings.restartGrace.hint': '重启没有倒数环节,会话一收工就会立刻重启;如果你装了“会话结束后才写”的记忆/总结类插件,把这里调大(默认 0 秒)。',
232
+ 'settings.restartGrace.value': '{n} 秒',
233
+ 'settings.restartGrace.immediate': '不等(立即重启)',
203
234
  'settings.backup.label': '重启前备份',
204
235
  'settings.backup.hint': '每次重启前先快照一次当前配置;万一重启后起不来,能自动回退。',
205
236
  'settings.backupPlugins.label': '备份包含已安装插件',
@@ -275,6 +306,7 @@ const EN = {
275
306
  idle: ' · restarts once idle',
276
307
  goalActive: ' · held by a self-continuing goal',
277
308
  goalExhausted: ' · a goal ran out of rounds',
309
+ settling: ' · still waiting for the quiet window',
278
310
  parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
279
311
  parkButton: 'Park & restart',
280
312
  parkButtonAria: 'Park that session goal and request the restart now',
@@ -299,6 +331,9 @@ const EN = {
299
331
  panelBlockedSession: 'Session "{label}" is running',
300
332
  panelBlockedSubagent: '{name} is running',
301
333
  panelBlockedSubagentGeneric: 'One subagent is running',
334
+ // The N subagents of ONE session are one line.
335
+ panelBlockedSubagentGroup: 'The {n} subagents of session "{label}" are running',
336
+ panelBlockedSubagentGroupGeneric: '{n} subagents are running',
302
337
  panelBlockedRestartPending: 'Waiting for the pending restart to finish before shutting down',
303
338
  panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
304
339
  panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
@@ -306,9 +341,29 @@ const EN = {
306
341
  panelParkedGoal: 'session {session} · {goal}',
307
342
  panelWaiting: 'Waiting for {duration}',
308
343
  panelResume: 'This session resumes its progress automatically after the restart.',
344
+ // The long wait: the restart passed the hard deadline while a conversation
345
+ // was still running. The capsule is ONE clipped line of 22px: it holds a
346
+ // number and a duration, NEVER a name. Every name goes into the tooltip,
347
+ // where there is room for all of them.
348
+ longWaitPill: ' · waiting for {duration} · {n} task(s) running',
349
+ longWaitTooltip: 'Waiting for {duration}. Running: {blockers}',
350
+ // The blockers of one list: they are grouped by the session that created
351
+ // them (N subagents of one session are ONE item), the tooltip names them
352
+ // ALL and the shutdown card lists them. One rule, two surfaces.
353
+ blockerGeneric: 'another session',
354
+ blockerJoin: ', ',
355
+ blockerGroupSubagents: '{label} ({n} subagents)',
356
+ blockerGroupSubagentsGeneric: '{n} subagents',
357
+ blockerMore: '… and {n} more',
358
+ blockersTitleLine: 'Blocking sessions: {blockers}',
309
359
  durationSeconds: '{n}s',
310
360
  durationMinutes: '{n}m {m}s',
311
361
  durationHours: '{n}h {m}m',
362
+ // The COMPACT duration of the capsule: it can afford "16 min", not
363
+ // "16m 37s" — the capsule is one clipped line.
364
+ durationMinutesShort: '{n} min',
365
+ durationHoursShort: '{n}h',
366
+ durationHoursMinutesShort: '{n}h {m}m',
312
367
  // The one-time install prompt (the choice is asked exactly once).
313
368
  // "Backup" is the verb and BOTH options share the "configuration" base:
314
369
  // the plugins are an ADDITION to the configuration, never a rival of it.
@@ -336,14 +391,16 @@ const EN = {
336
391
  'sleep.error': 'Could not reach the sleep service: {message}',
337
392
  // The settings section: the entry in the settings sidebar.
338
393
  'nav': 'Auto-restart & shut down',
339
- 'settings.seconds.label': 'Countdown seconds',
394
+ 'settings.groups.restart': 'Restart settings',
395
+ 'settings.groups.shutdown': 'Shutdown settings',
396
+ 'settings.seconds.label': 'Shutdown countdown seconds',
340
397
  'settings.seconds.immediate': 'No countdown (switch off right away)',
341
398
  'settings.seconds.value': '{n} seconds',
342
- 'settings.seconds.hint': 'After the button: wait for every conversation to be done, count down this many seconds and switch off. "No countdown" switches off as soon as the conversations are done.',
343
- 'settings.grace.label': 'Wait a few more seconds after the work is done',
344
- 'settings.grace.hint': 'After the last conversation finished, wait this long before the host is really restarted or switched off — this plugin only sees the DSH conversations, not the work other plugins queue with a timer. If a conversation starts working again while waiting, the timer starts over.',
345
- 'settings.grace.value': '{n} seconds',
346
- 'settings.grace.immediate': 'Do not wait (restart/switch off right away)',
399
+ 'settings.seconds.hint': 'Once every conversation is done, this countdown is shown on screen (pick "Never mind, keep it on" to cancel); the machine goes off when it reaches zero. Raise it when you want more time to write.',
400
+ 'settings.restartGrace.label': 'Wait before the restart',
401
+ 'settings.restartGrace.hint': 'A restart has no countdown: the host restarts as soon as the conversations are done. Raise this if you rely on a memory or summary plugin that writes its notes after the conversation ended (default 0 seconds).',
402
+ 'settings.restartGrace.value': '{n} seconds',
403
+ 'settings.restartGrace.immediate': 'Do not wait (restart right away)',
347
404
  'settings.backup.label': 'Backup before a restart',
348
405
  'settings.backup.hint': 'Take a snapshot of the current configuration before every restart, so a host that cannot boot again is rolled back automatically.',
349
406
  'settings.backupPlugins.label': 'Include the installed plugins in the backup',
@@ -535,11 +592,454 @@ function formatRemainingMs(ms) {
535
592
  return Math.ceil(raw / 1000)
536
593
  }
537
594
 
595
+ /**
596
+ * Fill `{name}` placeholders in a locale template.
597
+ *
598
+ * The locale API is not assumed to interpolate, so the helpers below do it
599
+ * themselves. This is the module-level twin of the panel's own `fill`.
600
+ *
601
+ * @param {string} template
602
+ * @param {Record<string, unknown>} vars
603
+ * @returns {string}
604
+ */
605
+ function fillTemplate(template, vars) {
606
+ if (typeof template !== 'string') return ''
607
+ return template.replace(/\{(\w+)\}/g, (match, name) => (
608
+ vars && Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
609
+ ))
610
+ }
611
+
612
+ /**
613
+ * A duration as a locale string: "45 秒" / "3 分 12 秒" / "2 小时 5 分".
614
+ *
615
+ * @param {number} ms
616
+ * @param {(name: string) => string} [t] the translator
617
+ * @returns {string}
618
+ */
619
+ function formatDurationMs(ms, t) {
620
+ const translate = typeof t === 'function' ? t : (name) => name
621
+ const totalSeconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000))
622
+ if (totalSeconds < 60) return fillTemplate(translate('durationSeconds'), { n: totalSeconds })
623
+ const minutes = Math.floor(totalSeconds / 60)
624
+ if (minutes < 60) return fillTemplate(translate('durationMinutes'), { n: minutes, m: totalSeconds % 60 })
625
+ const hours = Math.floor(minutes / 60)
626
+ return fillTemplate(translate('durationHours'), { n: hours, m: minutes % 60 })
627
+ }
628
+
629
+ /**
630
+ * A duration as a COMPACT locale string: "45 秒" / "3 分钟" / "2 小时 5 分".
631
+ *
632
+ * The capsule of the pending restart is ONE clipped line of 22px: it can
633
+ * afford "16 分钟", not "16 分 37 秒". The precise duration belongs into the
634
+ * tooltip, where there is room for it.
635
+ *
636
+ * @param {number} ms
637
+ * @param {(name: string) => string} [t] the translator
638
+ * @returns {string}
639
+ */
640
+ function formatCompactDurationMs(ms, t) {
641
+ const translate = typeof t === 'function' ? t : (name) => name
642
+ const totalSeconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000))
643
+ if (totalSeconds < 60) return fillTemplate(translate('durationSeconds'), { n: totalSeconds })
644
+ const minutes = Math.floor(totalSeconds / 60)
645
+ if (minutes < 60) return fillTemplate(translate('durationMinutesShort'), { n: minutes })
646
+ const hours = Math.floor(minutes / 60)
647
+ const rest = minutes % 60
648
+ return rest === 0
649
+ ? fillTemplate(translate('durationHoursShort'), { n: hours })
650
+ : fillTemplate(translate('durationHoursMinutesShort'), { n: hours, m: rest })
651
+ }
652
+
653
+ /* ------------------------------------------------------------------ *
654
+ * The blocker lists: ONE rule for every surface that names them.
655
+ *
656
+ * The defect the real machine showed: the pending-restart bubble grew a
657
+ * line that named EVERY blocker — and every AgentTeams member is a
658
+ * subagent, so the names of one team turned into a string of a dozen
659
+ * "…的子智能体" phrases that did not fit into a 22px pill any more and
660
+ * were clipped away.
661
+ *
662
+ * The rule, in one place:
663
+ *
664
+ * 1. the subagents of ONE session are ONE item ("<that session>
665
+ * (N subagents)"); a plain session is an item of its own;
666
+ * 2. a list names at most `BLOCKER_SUMMARY_LIMIT` items and says
667
+ * "… and N more" about the rest — never an endless list;
668
+ * 3. the SHORT list shortens a name that is too long;
669
+ * 4. the COMPLETE list goes into the tooltip (`title`).
670
+ *
671
+ * WHERE THE NAMES LIVE. The capsule of the pending restart is 22px high
672
+ * and ONE line wide: a name in it is a name that gets clipped away. So
673
+ * the capsule says NUMBERS ONLY ("已等 16 分钟 · 3 项任务在跑") and every
674
+ * name goes into the tooltip (`title`) — where the COMPLETE list lives.
675
+ * The shutdown card has room, so it keeps the readable list.
676
+ *
677
+ * THE TWO REAL SHAPES. The Host does not report the blockers in one shape,
678
+ * so the rule has to read every field the Host actually sends:
679
+ *
680
+ * A. the gate / the long wait / the state route (`index.js`,
681
+ * `describeBlocker` + `busyAgents`):
682
+ *
683
+ * { sessionId, reason, busyReason?, kind, label, name,
684
+ * parentTitle, parentSessionId }
685
+ *
686
+ * `label` is the title of the session — for a SUBAGENT it is the title
687
+ * of the session that created it, and `name` is the composed phrase
688
+ * ("<that session>'s subagent"). `parentSessionId` says which session
689
+ * it belongs to.
690
+ *
691
+ * B. the shutdown card (`lib/sleep.js`, `blockerEntry`):
692
+ *
693
+ * { sessionId, kind, label, name, parentTitle, title }
694
+ *
695
+ * The SAME information, but WITHOUT `parentSessionId` — so the
696
+ * grouping falls back to the parent's title (`parentTitle`).
697
+ *
698
+ * A rule that only knows `parentSessionId` therefore groups the card
699
+ * wrongly: every member of a team becomes an item of its own, which is
700
+ * exactly the endless list the rule is there to prevent.
701
+ * ------------------------------------------------------------------ */
702
+
703
+ /**
704
+ * How many blockers a bubble names at once.
705
+ *
706
+ * The bubble is a 22px high pill: a long list is clipped away, so the list
707
+ * is capped and the complete list goes into the tooltip.
708
+ */
709
+ const BLOCKER_SUMMARY_LIMIT = 2
710
+
711
+ /**
712
+ * How many characters one name may have in the bubble.
713
+ *
714
+ * The bubble is small, so a long session title is shortened instead of
715
+ * pushing the rest of the line out of the pill. The "…" is part of the
716
+ * budget: the bubble never shows MORE than this many characters of one
717
+ * name.
718
+ */
719
+ const BLOCKER_NAME_MAX_LENGTH = 12
720
+
721
+ /** The character a shortened name ends with. */
722
+ const BLOCKER_ELLIPSIS = '…'
723
+
724
+ /**
725
+ * A name that is at most `maxLength` characters long.
726
+ *
727
+ * The characters are counted, not the code units, so a name of Chinese
728
+ * characters is cut after the requested number of characters. The ellipsis
729
+ * counts towards the limit: a name of 12 characters is shown as 11
730
+ * characters plus "…", never as 13.
731
+ *
732
+ * @param {string} name
733
+ * @param {number} [maxLength]
734
+ * @returns {string}
735
+ */
736
+ function truncateBlockerName(name, maxLength) {
737
+ const text = typeof name === 'string' ? name.trim() : ''
738
+ if (text === '') return ''
739
+ const limit = Number.isFinite(maxLength) && maxLength > 0
740
+ ? Math.floor(maxLength)
741
+ : BLOCKER_NAME_MAX_LENGTH
742
+ const characters = Array.from(text)
743
+ if (characters.length <= limit) return text
744
+ if (limit === 1) return BLOCKER_ELLIPSIS
745
+ return characters.slice(0, limit - 1).join('') + BLOCKER_ELLIPSIS
746
+ }
747
+
748
+ /**
749
+ * One trimmed string field of a blocker, or '' when the field is missing.
750
+ *
751
+ * @param {any} entry
752
+ * @param {string} key
753
+ * @returns {string}
754
+ */
755
+ function blockerField(entry, key) {
756
+ if (!entry || typeof entry !== 'object') return ''
757
+ const value = entry[key]
758
+ return typeof value === 'string' ? value.trim() : ''
759
+ }
760
+
761
+ /**
762
+ * WHAT a blocker is: a session, a subagent or a pending restart.
763
+ *
764
+ * The Host says it (`kind`), but the kind is NOT the only evidence: an
765
+ * entry that names a parent session (`parentSessionId` / `parentTitle`) IS
766
+ * a subagent, whatever the Host called it. A Host that does not label the
767
+ * kind must not turn a team of subagents into a list of single items.
768
+ *
769
+ * @param {any} entry
770
+ * @returns {'session'|'subagent'|'restart-pending'}
771
+ */
772
+ function blockerEntryKind(entry) {
773
+ if (!entry || typeof entry !== 'object') return 'session'
774
+ if (entry.kind === 'restart-pending') return 'restart-pending'
775
+ if (entry.kind === 'subagent') return 'subagent'
776
+ if (entry.kind === 'session') return 'session'
777
+ if (blockerField(entry, 'parentSessionId') !== '' || blockerField(entry, 'parentTitle') !== '') {
778
+ return 'subagent'
779
+ }
780
+ return 'session'
781
+ }
782
+
783
+ /**
784
+ * The title of the session a subagent belongs to.
785
+ *
786
+ * For a subagent the Host's `label` IS the title of the session that
787
+ * created it (that is what the "…的子智能体" name is built from);
788
+ * `parentTitle` is the same title under its own name, and `title` is the
789
+ * old alias of `label`.
790
+ *
791
+ * @param {any} entry
792
+ * @returns {string}
793
+ */
794
+ function blockerParentTitle(entry) {
795
+ return blockerField(entry, 'label')
796
+ || blockerField(entry, 'parentTitle')
797
+ || blockerField(entry, 'title')
798
+ }
799
+
800
+ /**
801
+ * The identity of the session that created a subagent — the key its
802
+ * subagents are grouped by.
803
+ *
804
+ * The parent session id is the exact identity. The parent's title is the
805
+ * fallback for the surfaces that do not carry the id: the shutdown card
806
+ * reports `parentTitle` but NO `parentSessionId`, so without this
807
+ * fallback every member of a team would become an item of its own.
808
+ *
809
+ * @param {any} entry
810
+ * @returns {string|null}
811
+ */
812
+ function blockerParentKey(entry) {
813
+ const id = blockerField(entry, 'parentSessionId')
814
+ if (id !== '') return 'id:' + id
815
+ const title = blockerField(entry, 'parentTitle')
816
+ if (title !== '') return 'title:' + title
817
+ const label = blockerField(entry, 'label')
818
+ if (label !== '') return 'label:' + label
819
+ return null
820
+ }
821
+
822
+ /**
823
+ * The readable name of one blocker.
824
+ *
825
+ * The Host names a session by its title (`label`, also carried as `name`)
826
+ * and a subagent by the whole phrase ("<that session>'s subagent"). A
827
+ * surface that carries the title under the old name (`title`) still gets a
828
+ * name; an entry without any of them has nothing to show.
829
+ *
830
+ * @param {any} entry
831
+ * @returns {string}
832
+ */
833
+ function blockerEntryName(entry) {
834
+ if (!entry || typeof entry !== 'object') return ''
835
+ return blockerField(entry, 'name')
836
+ || blockerField(entry, 'label')
837
+ || blockerField(entry, 'title')
838
+ || blockerField(entry, 'parentTitle')
839
+ }
840
+
841
+ /**
842
+ * The blockers of one list, grouped by the session that created them.
843
+ *
844
+ * A subagent belongs to the session that spawned it, so N subagents of one
845
+ * session are ONE item. A plain session is an item of its own: it is a
846
+ * conversation in its own right, even when its own subagents are busy too.
847
+ *
848
+ * The group key is the parent session id when the surface reports it, and
849
+ * the parent's TITLE when it does not (the shutdown card reports
850
+ * `parentTitle` but no id).
851
+ *
852
+ * @param {any[]} entries the blockers the Host reported
853
+ * @returns {Array<{ key: string, kind: string, label: string, name: string, count: number, entry: any, sessionIds: string[] }>}
854
+ */
855
+ function groupBlockers(entries) {
856
+ const list = Array.isArray(entries) ? entries : []
857
+ const groups = []
858
+ const byKey = new Map()
859
+ for (const entry of list) {
860
+ if (!entry || typeof entry !== 'object') continue
861
+ const kind = blockerEntryKind(entry)
862
+ // A subagent of a KNOWN session joins that session's group. A subagent
863
+ // whose parent is unknown joins the one generic group of parentless
864
+ // subagents — they are counted ("N subagents"), not listed one by one.
865
+ const parent = kind === 'subagent' ? (blockerParentKey(entry) || 'unknown') : null
866
+ const sessionId = entry.sessionId == null ? '' : String(entry.sessionId)
867
+ const key = parent !== null ? 'parent:' + parent : 'own:' + kind + ':' + sessionId
868
+ let group = byKey.get(key)
869
+ if (!group) {
870
+ group = {
871
+ key,
872
+ kind,
873
+ parentSessionId: blockerField(entry, 'parentSessionId') || null,
874
+ // The label of a subagent group is the title of the session that
875
+ // created it; the label of a session is its own title.
876
+ label: kind === 'subagent'
877
+ ? blockerParentTitle(entry)
878
+ : (blockerField(entry, 'label') || blockerField(entry, 'title')),
879
+ name: blockerEntryName(entry),
880
+ count: 0,
881
+ entry,
882
+ sessionIds: [],
883
+ }
884
+ byKey.set(key, group)
885
+ groups.push(group)
886
+ }
887
+ group.count += 1
888
+ group.sessionIds.push(sessionId)
889
+ if (group.label === '') {
890
+ group.label = kind === 'subagent'
891
+ ? blockerParentTitle(entry)
892
+ : (blockerField(entry, 'label') || blockerField(entry, 'title'))
893
+ }
894
+ if (group.name === '') group.name = blockerEntryName(entry)
895
+ }
896
+ return groups
897
+ }
898
+
899
+ /**
900
+ * The name of one group, as it is shown.
901
+ *
902
+ * A group of more than one subagent is named after the session that
903
+ * created them ("<that session> (N subagents)"); a lone subagent keeps its
904
+ * own name ("<that session>'s subagent").
905
+ *
906
+ * @param {any} group one group of `groupBlockers`
907
+ * @param {(name: string) => string} t the translator
908
+ * @param {number} maxLength how many characters the name may have
909
+ * @param {boolean} shorten whether the name may be shortened
910
+ * @returns {string}
911
+ */
912
+ function blockerGroupName(group, t, maxLength, shorten) {
913
+ if (!group) return ''
914
+ const translate = typeof t === 'function' ? t : (name) => name
915
+ if (group.kind === 'subagent') {
916
+ // A group of MORE THAN ONE subagent is named after the session that
917
+ // created them ("<that session> (N subagents)"); a lone subagent keeps
918
+ // its own name ("<that session>'s subagent").
919
+ if (group.count > 1) {
920
+ const label = shorten ? truncateBlockerName(group.label, maxLength) : group.label
921
+ return label === ''
922
+ ? fillTemplate(translate('blockerGroupSubagentsGeneric'), { n: group.count })
923
+ : fillTemplate(translate('blockerGroupSubagents'), { label, n: group.count })
924
+ }
925
+ const own = group.name || group.label
926
+ if (own !== '') return shorten ? truncateBlockerName(own, maxLength) : own
927
+ // A lone subagent without any name at all: the generic line still says
928
+ // something — an empty name never reaches the interface.
929
+ return fillTemplate(translate('blockerGroupSubagentsGeneric'), { n: group.count })
930
+ }
931
+ const name = group.name || group.label
932
+ return shorten ? truncateBlockerName(name, maxLength) : name
933
+ }
934
+
935
+ /**
936
+ * The names of the blockers: a SHORT summary for the bubble and the
937
+ * COMPLETE list for the tooltip.
938
+ *
939
+ * The summary never grows without a bound: at most `limit` items, every
940
+ * name shortened to `maxNameLength` characters, and a "… and N more" for
941
+ * the rest. The complete list names every group with its full name — it is
942
+ * only ever shown in a tooltip, where there is room for it.
943
+ *
944
+ * @param {any[]} entries the blockers the Host reported
945
+ * @param {{ t?: Function, limit?: number, maxNameLength?: number, joiner?: string }} [options]
946
+ * @returns {{ groups: any[], summary: string, full: string, total: number }}
947
+ */
948
+ function summarizeBlockers(entries, options) {
949
+ const opts = options && typeof options === 'object' ? options : {}
950
+ const translate = typeof opts.t === 'function' ? opts.t : (name) => name
951
+ const limit = Number.isFinite(opts.limit) && opts.limit > 0
952
+ ? Math.floor(opts.limit)
953
+ : BLOCKER_SUMMARY_LIMIT
954
+ const maxNameLength = Number.isFinite(opts.maxNameLength) && opts.maxNameLength > 0
955
+ ? Math.floor(opts.maxNameLength)
956
+ : BLOCKER_NAME_MAX_LENGTH
957
+ const joiner = typeof opts.joiner === 'string' ? opts.joiner : translate('blockerJoin')
958
+ const groups = groupBlockers(entries)
959
+
960
+ const full = groups
961
+ .map((group) => blockerGroupName(group, translate, maxNameLength, false))
962
+ .filter((name) => name !== '')
963
+ .join(joiner)
964
+
965
+ const shown = groups.slice(0, limit)
966
+ const names = shown
967
+ .map((group) => blockerGroupName(group, translate, maxNameLength, true))
968
+ .filter((name) => name !== '')
969
+ const rest = groups.length - shown.length
970
+ const more = rest > 0 ? fillTemplate(translate('blockerMore'), { n: rest }) : ''
971
+
972
+ return {
973
+ groups,
974
+ total: groups.length,
975
+ // How many of the groups carry a name at all: a list of unnamed blockers
976
+ // has nothing to show, so a caller can leave its clause out.
977
+ named: groups.filter((group) => blockerGroupName(group, translate, maxNameLength, false) !== '').length,
978
+ summary: names.length === 0 ? translate('blockerGeneric') : (names.join(joiner) + more),
979
+ full: full === '' ? translate('blockerGeneric') : full,
980
+ }
981
+ }
982
+
983
+ /**
984
+ * The note of a LONG wait.
985
+ *
986
+ * The Host says that the pending restart passed the hard deadline while a
987
+ * conversation was still running. That is not "a little longer" any more, so
988
+ * the bubble says it out loud: how long the restart has been waiting and who
989
+ * holds it. The note is empty when there is no long wait.
990
+ *
991
+ * @param {any} info the state payload of the Host
992
+ * @param {(name: string) => string} [t] the translator
993
+ * @returns {string}
994
+ */
995
+ function formatLongWaitNote(info, t) {
996
+ return formatLongWaitNoteParts(info, t).note
997
+ }
998
+
999
+ /**
1000
+ * The note of a LONG wait, in both shapes: the CAPSULE shows the numbers
1001
+ * only, the tooltip the COMPLETE list.
1002
+ *
1003
+ * The capsule of the pending restart is 22px high and ONE line wide: a name
1004
+ * in it is a name that gets clipped away. So the capsule says HOW LONG the
1005
+ * restart has been waiting and HOW MANY tasks are running — never WHO they
1006
+ * are — and every name goes into the tooltip.
1007
+ *
1008
+ * @param {any} info the state payload of the Host
1009
+ * @param {(name: string) => string} [t] the translator
1010
+ * @returns {{ note: string, full: string, pill: string, summary: string, count: number, hasLongWait: boolean }}
1011
+ */
1012
+ function formatLongWaitNoteParts(info, t) {
1013
+ const translate = typeof t === 'function' ? t : (name) => name
1014
+ const longWait = info && typeof info === 'object' ? info.longWait : null
1015
+ if (!longWait || typeof longWait !== 'object') {
1016
+ return { note: '', full: '', pill: '', summary: '', count: 0, hasLongWait: false }
1017
+ }
1018
+ const blockedBy = Array.isArray(longWait.blockedBy) ? longWait.blockedBy : []
1019
+ const summary = summarizeBlockers(blockedBy, { t: translate })
1020
+ // The precise duration belongs into the tooltip, the COMPACT one into the
1021
+ // capsule: the capsule cannot afford "16 分 37 秒".
1022
+ const duration = formatDurationMs(longWait.waitedMs, translate)
1023
+ const compact = formatCompactDurationMs(longWait.waitedMs, translate)
1024
+ const count = blockedBy.length
1025
+ // The tooltip: the whole story, every name, nothing cut.
1026
+ const sentence = fillTemplate(translate('longWaitTooltip'), { duration, blockers: summary.full })
1027
+ return {
1028
+ // The CAPSULE: numbers only, no name at all.
1029
+ pill: fillTemplate(translate('longWaitPill'), { duration: compact, n: count }),
1030
+ note: sentence,
1031
+ full: sentence,
1032
+ summary: summary.summary,
1033
+ count,
1034
+ hasLongWait: true,
1035
+ }
1036
+ }
1037
+
538
1038
  /**
539
1039
  * The named exports of this module.
540
1040
  *
541
1041
  * The client module system materializes `factory(require) -> exports`, so
542
- * these four helpers ARE the module's exports; they are mirrored on
1042
+ * these helpers ARE the module's exports; they are mirrored on
543
1043
  * `globalThis.__dshAutorestartClient` for the tests that evaluate the file
544
1044
  * without a loader.
545
1045
  */
@@ -548,6 +1048,14 @@ const SLEEP_EXPORTS = {
548
1048
  shouldShowSleepButton,
549
1049
  buildChoiceBody,
550
1050
  formatRemainingMs,
1051
+ formatLongWaitNote,
1052
+ formatLongWaitNoteParts,
1053
+ formatCompactDurationMs,
1054
+ groupBlockers,
1055
+ summarizeBlockers,
1056
+ truncateBlockerName,
1057
+ // The two dictionaries, so a test can build a translator without a Host.
1058
+ dictionaries: { zh: ZH, en: EN },
551
1059
  }
552
1060
 
553
1061
  if (typeof globalThis !== 'undefined' && globalThis !== null) {
@@ -782,16 +1290,18 @@ if (moduleLoader) moduleLoader.load({
782
1290
  */
783
1291
  function blockerLine(entry) {
784
1292
  const t = translate ?? ((name) => name)
785
- const kind = entry && entry.kind === 'subagent'
786
- ? 'subagent'
787
- : (entry && entry.kind === 'restart-pending' ? 'restart-pending' : 'session')
788
- const label = entry && typeof entry.label === 'string' ? entry.label.trim() : ''
1293
+ // The SAME rule as the grouping: an entry that names a parent is a
1294
+ // subagent, whatever the Host called it.
1295
+ const kind = blockerEntryKind(entry)
1296
+ const label = kind === 'subagent'
1297
+ ? blockerParentTitle(entry)
1298
+ : (blockerField(entry, 'label') || blockerField(entry, 'title'))
789
1299
  if (kind === 'restart-pending') {
790
1300
  // A pending restart goes first: the shutdown waits for it.
791
1301
  return t('panelBlockedRestartPending')
792
1302
  }
793
1303
  if (kind === 'subagent') {
794
- const name = entry && typeof entry.name === 'string' ? entry.name.trim() : ''
1304
+ const name = blockerField(entry, 'name')
795
1305
  if (name === '' || label === '') return t('panelBlockedSubagentGeneric')
796
1306
  return fill(t('panelBlockedSubagent'), { name })
797
1307
  }
@@ -813,6 +1323,27 @@ if (moduleLoader) moduleLoader.load({
813
1323
  return Boolean(entry) && entry.kind === 'restart-pending'
814
1324
  }
815
1325
 
1326
+ /**
1327
+ * The line of one GROUP of blockers.
1328
+ *
1329
+ * The N subagents of ONE session are one line: the session is named once
1330
+ * and the number of its subagents says how many there are. A group of one
1331
+ * keeps the ordinary line (a session, or a lone subagent).
1332
+ *
1333
+ * @param {any} group one group of `groupBlockers`
1334
+ * @returns {string}
1335
+ */
1336
+ function blockerGroupLine(group) {
1337
+ const t = translate ?? ((name) => name)
1338
+ if (!group) return ''
1339
+ if (group.kind === 'subagent' && group.count > 1) {
1340
+ return group.label === ''
1341
+ ? fill(t('panelBlockedSubagentGroupGeneric'), { n: group.count })
1342
+ : fill(t('panelBlockedSubagentGroup'), { label: group.label, n: group.count })
1343
+ }
1344
+ return blockerLine(group.entry)
1345
+ }
1346
+
816
1347
  /** How long the pending restart has been waiting, as a locale string. */
817
1348
  function formatWaiting(ms, t) {
818
1349
  const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000))
@@ -952,6 +1483,18 @@ if (moduleLoader) moduleLoader.load({
952
1483
  whiteSpace: 'nowrap',
953
1484
  }
954
1485
 
1486
+ // The extra line of a LONG wait: how long the restart has been waiting and
1487
+ // who holds it. It is a separate element (not more text in the same span)
1488
+ // so the bubble can tell "the ordinary status" from "the long wait", and
1489
+ // it stays inside the clipped pill — the composer row never grows.
1490
+ const LONG_WAIT_STYLE = {
1491
+ minWidth: 0,
1492
+ overflow: 'hidden',
1493
+ textOverflow: 'ellipsis',
1494
+ whiteSpace: 'nowrap',
1495
+ opacity: 0.85,
1496
+ }
1497
+
955
1498
  // The details panel: anchored to the wrapper, above the pill, left
956
1499
  // aligned. It uses the host theme tokens (background / border / label
957
1500
  // color) so it follows the app theme.
@@ -1260,14 +1803,52 @@ if (moduleLoader) moduleLoader.load({
1260
1803
  const goalActive = others.filter((entry) => String(entry.reason) === 'goal-active')
1261
1804
  const goalExhausted = others.filter((entry) => String(entry.reason) === 'goal-exhausted')
1262
1805
  const otherBusy = others.length - goalActive.length - goalExhausted.length
1806
+ // The tasks that are NOT a goal: the count and the names of the waiting
1807
+ // sentence talk about the same set.
1808
+ const busyBlockers = others.filter(
1809
+ (entry) => String(entry.reason) !== 'goal-active' && String(entry.reason) !== 'goal-exhausted',
1810
+ )
1263
1811
 
1264
- const status = goalActive.length > 0
1265
- ? t('goalActive')
1266
- : goalExhausted.length > 0
1267
- ? t('goalExhausted')
1268
- : otherBusy > 0
1269
- ? `${t('busyPrefix')}${otherBusy}${t('busySuffix')}`
1270
- : t('idle')
1812
+ // WHY the gate is closed: the Host reports the reason next to the
1813
+ // blockers. With no blocker but the reason "settling" the restart is
1814
+ // waiting for the quiet window — not for a conversation.
1815
+ const gateReason = info && typeof info.reason === 'string' ? info.reason : ''
1816
+
1817
+ // The long wait: the Host says that the restart passed the hard
1818
+ // deadline while a conversation was still running. The capsule shows one
1819
+ // more line about it — how long it has been waiting and how many tasks
1820
+ // are running.
1821
+ const longWait = formatLongWaitNoteParts(info, t)
1822
+ // The blockers of the ordinary list: the SAME rule as the long wait
1823
+ // (grouped by the session that created them).
1824
+ const blockers = summarizeBlockers(busyBlockers, { t })
1825
+
1826
+ // The capsule is ONE clipped line of 22px: it holds a NUMBER and a
1827
+ // duration, never a NAME. Every name goes into the tooltip, where
1828
+ // there is room for all of them.
1829
+ //
1830
+ // A long wait says it ONCE: its own line carries the duration and the
1831
+ // count, so the ordinary "waiting for N tasks" sentence is left out —
1832
+ // it would say the same thing twice and take twice the width.
1833
+ const status = longWait.hasLongWait
1834
+ ? ''
1835
+ : goalActive.length > 0
1836
+ ? t('goalActive')
1837
+ : goalExhausted.length > 0
1838
+ ? t('goalExhausted')
1839
+ : otherBusy > 0
1840
+ ? `${t('busyPrefix')}${otherBusy}${t('busySuffix')}`
1841
+ : (gateReason === 'settling' ? t('settling') : t('idle'))
1842
+
1843
+ const tooltipText = otherBusy > 0 ? t('tooltipBusy') : t('tooltip')
1844
+ // The tooltip carries the COMPLETE list of the blockers: the capsule
1845
+ // shows numbers only, the tooltip every name.
1846
+ const detail = longWait.hasLongWait
1847
+ ? longWait.full
1848
+ : (blockers.total === 0 ? '' : fill(t('blockersTitleLine'), { blockers: blockers.full }))
1849
+ const titleText = parkError
1850
+ ? fill(t('tooltipWithError'), { message: parkError })
1851
+ : (detail === '' ? tooltipText : `${tooltipText} ${detail}`)
1271
1852
 
1272
1853
  if (pulsing) ensurePulseStyle()
1273
1854
 
@@ -1330,12 +1911,25 @@ if (moduleLoader) moduleLoader.load({
1330
1911
  'div',
1331
1912
  {
1332
1913
  style: PILL_STYLE,
1333
- title: parkError
1334
- ? fill(t('tooltipWithError'), { message: parkError })
1335
- : (otherBusy > 0 ? t('tooltipBusy') : t('tooltip')),
1914
+ title: titleText,
1336
1915
  },
1337
1916
  h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
1338
1917
  h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
1918
+ // One more line for the long wait: how long the restart has been
1919
+ // waiting and how many tasks are running. It is only there when the
1920
+ // Host says so. The line carries NO name at all — it is one
1921
+ // clipped line — and its own tooltip carries the COMPLETE list.
1922
+ longWait.pill === ''
1923
+ ? null
1924
+ : h(
1925
+ 'span',
1926
+ {
1927
+ style: LONG_WAIT_STYLE,
1928
+ 'data-dsh-autorestart-long-wait': 'true',
1929
+ title: longWait.full,
1930
+ },
1931
+ longWait.pill,
1932
+ ),
1339
1933
  // One park button per goal-driven blocker that can be parked. It
1340
1934
  // lives inside the pill, so the slot still has a single root.
1341
1935
  goalActive.map((entry, index) => {
@@ -1393,9 +1987,14 @@ if (moduleLoader) moduleLoader.load({
1393
1987
  const pulsing = info.allowRestart === true && !prefersReducedMotion()
1394
1988
  if (pulsing) ensurePulseStyle()
1395
1989
 
1990
+ // The long wait is worth a word in the tooltip too: the dot is
1991
+ // icon-only, so the text is the only place where it can be read.
1992
+ // The tooltip carries the COMPLETE list of the blockers.
1993
+ const longWait = formatLongWaitNoteParts(info, t)
1994
+
1396
1995
  return h('span', {
1397
1996
  style: dotStyle(SIDEBAR_DOT_STYLE, pulsing),
1398
- title: t('tooltip'),
1997
+ title: longWait.hasLongWait ? `${t('tooltip')} ${longWait.full}` : t('tooltip'),
1399
1998
  role: 'status',
1400
1999
  'aria-live': 'polite',
1401
2000
  'aria-label': t('dotAria'),
@@ -2146,6 +2745,9 @@ if (moduleLoader) moduleLoader.load({
2146
2745
  '.dsh-autorestart-sleep-waiting__body{margin:0 0 6px;}',
2147
2746
  '.dsh-autorestart-sleep-waiting__list{margin:0 0 6px;padding-left:2px;list-style:none;}',
2148
2747
  '.dsh-autorestart-sleep-waiting__list li{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
2748
+ // The line that says how many blockers are NOT shown: it is a note,
2749
+ // not a blocker of its own.
2750
+ '.dsh-autorestart-sleep-waiting__more{opacity:.7;}',
2149
2751
  '.dsh-autorestart-sleep-waiting__none{margin:0 0 6px;opacity:.8;}',
2150
2752
  // The command a rehearsal WOULD have run: it has to stay readable,
2151
2753
  // whatever the shell command looks like.
@@ -2182,6 +2784,21 @@ if (moduleLoader) moduleLoader.load({
2182
2784
  '}',
2183
2785
  // The configuration page of the plugin row.
2184
2786
  '.dsh-autorestart-settings{display:flex;flex-direction:column;gap:14px;}',
2787
+ // The two titled groups (restart first, shutdown after it).
2788
+ '.dsh-autorestart-settings__group{',
2789
+ ' display:flex;',
2790
+ ' flex-direction:column;',
2791
+ ' gap:12px;',
2792
+ ' padding:12px 14px;',
2793
+ ' border:1px solid var(--dsw-alias-border-l1);',
2794
+ ' border-radius:10px;',
2795
+ '}',
2796
+ '.dsh-autorestart-settings__group-title{',
2797
+ ' font-size:12px;',
2798
+ ' font-weight:700;',
2799
+ ' line-height:18px;',
2800
+ ' color:var(--dsw-alias-label-secondary);',
2801
+ '}',
2185
2802
  '.dsh-autorestart-settings__field{display:flex;flex-direction:column;gap:4px;}',
2186
2803
  '.dsh-autorestart-settings__label{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);}',
2187
2804
  '.dsh-autorestart-settings__select{',
@@ -2340,12 +2957,17 @@ if (moduleLoader) moduleLoader.load({
2340
2957
  * in the settings sidebar).
2341
2958
  *
2342
2959
  * The Host renders this section for the slot `settings.section` with the
2343
- * id `dsh-autorestart`. It carries exactly the three settings that
2344
- * matter:
2960
+ * id `dsh-autorestart`. The settings are split into two titled groups:
2961
+ *
2962
+ * - "Restart settings": how long the host waits before a restart (a
2963
+ * dropdown), whether the pre-restart backup is taken at all (a
2964
+ * switch) and whether that backup also snapshots the installed
2965
+ * plugins (a switch);
2966
+ * - "Shutdown settings": how long the visible countdown of the
2967
+ * graceful shutdown runs (a dropdown).
2345
2968
  *
2346
- * - how long the countdown runs (a dropdown),
2347
- * - whether the pre-restart backup is taken at all (a switch),
2348
- * - whether that backup also snapshots the installed plugins (a switch).
2969
+ * The restart group comes first. The shutdown has no invisible reserve:
2970
+ * its countdown on screen IS the wait before the machine goes off.
2349
2971
  *
2350
2972
  * A change is written to the profile configuration at once (through the
2351
2973
  * plugin's own choice route, which uses the official `configEditor`
@@ -2378,15 +3000,14 @@ if (moduleLoader) moduleLoader.load({
2378
3000
  const backupIncludePlugins = status && typeof status === 'object' && typeof status.backupIncludePlugins === 'boolean'
2379
3001
  ? status.backupIncludePlugins
2380
3002
  : true
2381
- // The grace window: how long the host waits after the last conversation
2382
- // finished. The Host reports it in milliseconds; the dropdown works in
2383
- // seconds.
2384
- const reportedGraceMs = status && typeof status === 'object' && typeof status.graceMs === 'number'
2385
- ? status.graceMs
3003
+ // The RESTART reserve: the Host reports it in milliseconds, the
3004
+ // dropdown works in seconds.
3005
+ const reportedRestartGraceMs = status && typeof status === 'object' && typeof status.restartGraceMs === 'number'
3006
+ ? status.restartGraceMs
2386
3007
  : GRACE_DEFAULT_MS
2387
- const graceSeconds = GRACE_CHOICES_SECONDS.indexOf(Math.round(reportedGraceMs / 1000)) === -1
3008
+ const restartGraceSeconds = GRACE_CHOICES_SECONDS.indexOf(Math.round(reportedRestartGraceMs / 1000)) === -1
2388
3009
  ? GRACE_DEFAULT_SECONDS
2389
- : Math.round(reportedGraceMs / 1000)
3010
+ : Math.round(reportedRestartGraceMs / 1000)
2390
3011
 
2391
3012
  /**
2392
3013
  * Write one change into the profile configuration.
@@ -2429,101 +3050,128 @@ if (moduleLoader) moduleLoader.load({
2429
3050
  return h(
2430
3051
  'div',
2431
3052
  { className: 'dsh-autorestart-settings' },
3053
+ // The panel is split into two titled groups: the RESTART settings
3054
+ // come first, the SHUTDOWN settings after them. The two situations
3055
+ // are different, so they do not share one flat list.
2432
3056
  h(
2433
- 'label',
2434
- { className: 'dsh-autorestart-settings__field' },
2435
- h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.seconds.label')),
3057
+ 'div',
3058
+ {
3059
+ className: 'dsh-autorestart-settings__group',
3060
+ 'data-dsh-autorestart-group': 'restart',
3061
+ },
2436
3062
  h(
2437
- 'select',
2438
- {
2439
- className: 'dsh-autorestart-settings__select',
2440
- value: String(seconds),
2441
- disabled: busy,
2442
- 'data-dsh-autorestart-setting': 'sleepCountdownSeconds',
2443
- onChange: (event) => {
2444
- const value = Number(event && event.target ? event.target.value : NaN)
2445
- if (!Number.isFinite(value) || value === seconds) return
2446
- void save({ sleepCountdownSeconds: value })
3063
+ 'div',
3064
+ { className: 'dsh-autorestart-settings__group-title' },
3065
+ t('settings.groups.restart'),
3066
+ ),
3067
+ h(
3068
+ 'label',
3069
+ { className: 'dsh-autorestart-settings__field' },
3070
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.restartGrace.label')),
3071
+ h(
3072
+ 'select',
3073
+ {
3074
+ className: 'dsh-autorestart-settings__select',
3075
+ value: String(restartGraceSeconds),
3076
+ disabled: busy,
3077
+ 'data-dsh-autorestart-setting': 'restartGraceMs',
3078
+ onChange: (event) => {
3079
+ const value = Number(event && event.target ? event.target.value : NaN)
3080
+ if (!Number.isFinite(value) || value === restartGraceSeconds) return
3081
+ void save({ restartGraceMs: value * 1000 })
3082
+ },
2447
3083
  },
2448
- },
2449
- SLEEP_COUNTDOWN_CHOICES.map((choice) => h(
2450
- 'option',
2451
- { key: String(choice), value: String(choice) },
2452
- choice === 0 ? t('settings.seconds.immediate') : fill(t('settings.seconds.value'), { n: choice }),
2453
- )),
3084
+ GRACE_CHOICES_SECONDS.map((choice) => h(
3085
+ 'option',
3086
+ { key: String(choice), value: String(choice) },
3087
+ choice === 0
3088
+ ? t('settings.restartGrace.immediate')
3089
+ : fill(t('settings.restartGrace.value'), { n: choice }),
3090
+ )),
3091
+ ),
3092
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.restartGrace.hint')),
2454
3093
  ),
2455
- h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.seconds.hint')),
2456
- ),
2457
- h(
2458
- 'label',
2459
- { className: 'dsh-autorestart-settings__field' },
2460
- h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.grace.label')),
2461
3094
  h(
2462
- 'select',
2463
- {
2464
- className: 'dsh-autorestart-settings__select',
2465
- value: String(graceSeconds),
2466
- disabled: busy,
2467
- 'data-dsh-autorestart-setting': 'restartGraceMs',
2468
- onChange: (event) => {
2469
- const value = Number(event && event.target ? event.target.value : NaN)
2470
- if (!Number.isFinite(value) || value === graceSeconds) return
2471
- void save({ restartGraceMs: value * 1000 })
3095
+ 'label',
3096
+ { className: 'dsh-autorestart-settings__field' },
3097
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backup.label')),
3098
+ h(
3099
+ 'input',
3100
+ {
3101
+ type: 'checkbox',
3102
+ className: 'dsh-autorestart-settings__switch',
3103
+ checked: backupOnRestart,
3104
+ disabled: busy,
3105
+ 'data-dsh-autorestart-setting': 'backupOnRestart',
3106
+ onChange: (event) => {
3107
+ const value = Boolean(event && event.target ? event.target.checked : false)
3108
+ if (value === backupOnRestart) return
3109
+ void save({ backupOnRestart: value })
3110
+ },
2472
3111
  },
2473
- },
2474
- GRACE_CHOICES_SECONDS.map((choice) => h(
2475
- 'option',
2476
- { key: String(choice), value: String(choice) },
2477
- choice === 0
2478
- ? t('settings.grace.immediate')
2479
- : fill(t('settings.grace.value'), { n: choice }),
2480
- )),
3112
+ ),
3113
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backup.hint')),
2481
3114
  ),
2482
- h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.grace.hint')),
2483
- ),
2484
- h(
2485
- 'label',
2486
- { className: 'dsh-autorestart-settings__field' },
2487
- h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backup.label')),
2488
3115
  h(
2489
- 'input',
2490
- {
2491
- type: 'checkbox',
2492
- className: 'dsh-autorestart-settings__switch',
2493
- checked: backupOnRestart,
2494
- disabled: busy,
2495
- 'data-dsh-autorestart-setting': 'backupOnRestart',
2496
- onChange: (event) => {
2497
- const value = Boolean(event && event.target ? event.target.checked : false)
2498
- if (value === backupOnRestart) return
2499
- void save({ backupOnRestart: value })
3116
+ 'label',
3117
+ { className: 'dsh-autorestart-settings__field' },
3118
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backupPlugins.label')),
3119
+ h(
3120
+ 'input',
3121
+ {
3122
+ type: 'checkbox',
3123
+ className: 'dsh-autorestart-settings__switch',
3124
+ checked: backupIncludePlugins,
3125
+ // Without a pre-restart backup there is nothing to extend: the
3126
+ // switch is greyed out (and the Host never reads it).
3127
+ disabled: busy || !backupOnRestart,
3128
+ 'data-dsh-autorestart-setting': 'backupIncludePlugins',
3129
+ onChange: (event) => {
3130
+ const value = Boolean(event && event.target ? event.target.checked : false)
3131
+ if (value === backupIncludePlugins) return
3132
+ void save({ backupIncludePlugins: value })
3133
+ },
2500
3134
  },
2501
- },
3135
+ ),
3136
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backupPlugins.hint')),
2502
3137
  ),
2503
- h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backup.hint')),
2504
3138
  ),
2505
3139
  h(
2506
- 'label',
2507
- { className: 'dsh-autorestart-settings__field' },
2508
- h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.backupPlugins.label')),
3140
+ 'div',
3141
+ {
3142
+ className: 'dsh-autorestart-settings__group',
3143
+ 'data-dsh-autorestart-group': 'shutdown',
3144
+ },
2509
3145
  h(
2510
- 'input',
2511
- {
2512
- type: 'checkbox',
2513
- className: 'dsh-autorestart-settings__switch',
2514
- checked: backupIncludePlugins,
2515
- // Without a pre-restart backup there is nothing to extend: the
2516
- // switch is greyed out (and the Host never reads it).
2517
- disabled: busy || !backupOnRestart,
2518
- 'data-dsh-autorestart-setting': 'backupIncludePlugins',
2519
- onChange: (event) => {
2520
- const value = Boolean(event && event.target ? event.target.checked : false)
2521
- if (value === backupIncludePlugins) return
2522
- void save({ backupIncludePlugins: value })
3146
+ 'div',
3147
+ { className: 'dsh-autorestart-settings__group-title' },
3148
+ t('settings.groups.shutdown'),
3149
+ ),
3150
+ h(
3151
+ 'label',
3152
+ { className: 'dsh-autorestart-settings__field' },
3153
+ h('span', { className: 'dsh-autorestart-settings__label' }, t('settings.seconds.label')),
3154
+ h(
3155
+ 'select',
3156
+ {
3157
+ className: 'dsh-autorestart-settings__select',
3158
+ value: String(seconds),
3159
+ disabled: busy,
3160
+ 'data-dsh-autorestart-setting': 'sleepCountdownSeconds',
3161
+ onChange: (event) => {
3162
+ const value = Number(event && event.target ? event.target.value : NaN)
3163
+ if (!Number.isFinite(value) || value === seconds) return
3164
+ void save({ sleepCountdownSeconds: value })
3165
+ },
2523
3166
  },
2524
- },
3167
+ SLEEP_COUNTDOWN_CHOICES.map((choice) => h(
3168
+ 'option',
3169
+ { key: String(choice), value: String(choice) },
3170
+ choice === 0 ? t('settings.seconds.immediate') : fill(t('settings.seconds.value'), { n: choice }),
3171
+ )),
3172
+ ),
3173
+ h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.seconds.hint')),
2525
3174
  ),
2526
- h('span', { className: 'dsh-autorestart-settings__hint' }, t('settings.backupPlugins.hint')),
2527
3175
  ),
2528
3176
  error === ''
2529
3177
  ? null
@@ -2999,6 +3647,11 @@ if (moduleLoader) moduleLoader.load({
2999
3647
  const subagentCount = tasks.filter(
3000
3648
  (entry) => entry && entry.kind === 'subagent',
3001
3649
  ).length
3650
+ // The list of the card: the SAME rule as the bubble (the subagents of
3651
+ // one session are one item, the list is capped), so a team of a dozen
3652
+ // members does not become a dozen lines.
3653
+ const pending = blockers.filter((entry) => isRestartPending(entry))
3654
+ const grouped = summarizeBlockers(tasks, { t })
3002
3655
  return h(
3003
3656
  'div',
3004
3657
  {
@@ -3024,10 +3677,29 @@ if (moduleLoader) moduleLoader.load({
3024
3677
  )
3025
3678
  : h(
3026
3679
  'ul',
3027
- { className: 'dsh-autorestart-sleep-waiting__list' },
3028
- blockers.map((entry, index) => h(
3680
+ {
3681
+ className: 'dsh-autorestart-sleep-waiting__list',
3682
+ // The tooltip carries the COMPLETE list: the card itself
3683
+ // shows a short one (the same rule as the bubble).
3684
+ title: grouped.full,
3685
+ },
3686
+ grouped.groups.slice(0, BLOCKER_SUMMARY_LIMIT).map((group, index) => h(
3029
3687
  'li',
3030
3688
  { key: 'blocker-' + index },
3689
+ blockerGroupLine(group),
3690
+ )),
3691
+ grouped.groups.length > BLOCKER_SUMMARY_LIMIT
3692
+ ? h(
3693
+ 'li',
3694
+ { key: 'blocker-more', className: 'dsh-autorestart-sleep-waiting__more' },
3695
+ fill(t('blockerMore'), { n: grouped.groups.length - BLOCKER_SUMMARY_LIMIT }),
3696
+ )
3697
+ : null,
3698
+ // A pending restart is not a task: it is what the shutdown waits
3699
+ // for, so it is never part of the grouped list.
3700
+ pending.map((entry, index) => h(
3701
+ 'li',
3702
+ { key: 'pending-' + index },
3031
3703
  blockerLine(entry),
3032
3704
  )),
3033
3705
  ),