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/README.md +150 -34
- package/client.js +805 -133
- package/cordis.patch.yml +18 -8
- package/docs/README.zh.md +93 -21
- package/index.js +456 -74
- package/lib/activity.js +124 -0
- package/lib/lifecycle.js +17 -4
- package/lib/queued.js +146 -0
- package/lib/semver.js +49 -0
- package/locale/en.json +10 -6
- package/locale/zh.json +10 -6
- package/package.json +1 -1
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
|
|
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`
|
|
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
|
|
120
|
+
* The RESTART reserve the settings page offers (a dropdown, in seconds).
|
|
119
121
|
*
|
|
120
|
-
* It
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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,
|
|
127
|
+
const GRACE_CHOICES_SECONDS = Object.freeze([0, 5, 10, 20, 30, 60])
|
|
125
128
|
|
|
126
|
-
/** The
|
|
127
|
-
const GRACE_DEFAULT_MS =
|
|
128
|
-
const GRACE_DEFAULT_SECONDS =
|
|
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.
|
|
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.
|
|
200
|
-
'settings.
|
|
201
|
-
'settings.
|
|
202
|
-
'settings.
|
|
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.
|
|
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': '
|
|
343
|
-
'settings.
|
|
344
|
-
'settings.
|
|
345
|
-
'settings.
|
|
346
|
-
'settings.
|
|
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
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const label =
|
|
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
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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:
|
|
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`.
|
|
2344
|
-
*
|
|
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
|
-
*
|
|
2347
|
-
*
|
|
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
|
|
2382
|
-
//
|
|
2383
|
-
|
|
2384
|
-
|
|
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
|
|
3008
|
+
const restartGraceSeconds = GRACE_CHOICES_SECONDS.indexOf(Math.round(reportedRestartGraceMs / 1000)) === -1
|
|
2388
3009
|
? GRACE_DEFAULT_SECONDS
|
|
2389
|
-
: Math.round(
|
|
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
|
-
'
|
|
2434
|
-
{
|
|
2435
|
-
|
|
3057
|
+
'div',
|
|
3058
|
+
{
|
|
3059
|
+
className: 'dsh-autorestart-settings__group',
|
|
3060
|
+
'data-dsh-autorestart-group': 'restart',
|
|
3061
|
+
},
|
|
2436
3062
|
h(
|
|
2437
|
-
'
|
|
2438
|
-
{
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
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
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
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
|
-
'
|
|
2463
|
-
{
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
2490
|
-
{
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
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
|
-
'
|
|
2507
|
-
{
|
|
2508
|
-
|
|
3140
|
+
'div',
|
|
3141
|
+
{
|
|
3142
|
+
className: 'dsh-autorestart-settings__group',
|
|
3143
|
+
'data-dsh-autorestart-group': 'shutdown',
|
|
3144
|
+
},
|
|
2509
3145
|
h(
|
|
2510
|
-
'
|
|
2511
|
-
{
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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
|
-
{
|
|
3028
|
-
|
|
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
|
),
|