dsh-autorestart 0.0.0-stage → 0.2.0

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 ADDED
@@ -0,0 +1,575 @@
1
+ /**
2
+ * dsh-autorestart browser half: a compact "pending restart" pill and a
3
+ * matching dot in the left sidebar session row.
4
+ *
5
+ * Classic script loaded by dsh-client-modules via `window.__ModuleLoader__.load`.
6
+ * No JSX, no build step. Two surfaces read the Host's loopback-only health
7
+ * route (`GET /__dsh_autorestart/health?state=1`):
8
+ *
9
+ * - the pill in the composer tool row (`conversation.input.left` slot);
10
+ * - an 8px dot in the leading 16px state cell of a left-sidebar session row
11
+ * (`sidebar.session.row.leading` slot).
12
+ *
13
+ * Both are visible only for the session that is actually queued for the
14
+ * restart (the session id must appear in the route's `waiters` list), and both
15
+ * read ONE shared poller: the route is fetched once every 2s no matter how
16
+ * many surfaces are mounted.
17
+ *
18
+ * Two upgrades on top of the plain marker:
19
+ *
20
+ * 1. an "imminent" pulse: when the restart is pending AND the Host's idle
21
+ * gate is open (`allowRestart`), the dot of both surfaces breathes. One
22
+ * shared `@keyframes` rule is injected into the document head (guarded by
23
+ * an id, removed when the last subscriber leaves) and applied through the
24
+ * `animation` style property, so it never changes the layout. Users who
25
+ * ask for reduced motion never see it;
26
+ * 2. a click-to-open details panel: the pill is a keyboard-operable
27
+ * button that reveals why the restart is still pending, which sessions
28
+ * block it (by title when the Host provided one) and how long this
29
+ * session has been waiting.
30
+ *
31
+ * Everything here is side-effect free until `apply(ctx)` runs: no timers, no
32
+ * fetches, no DOM access at module scope.
33
+ */
34
+ window.__ModuleLoader__.load({
35
+ id: 'dsh-autorestart',
36
+ factory: function (require) {
37
+ const React = require('react')
38
+ const h = React.createElement
39
+ const { useState, useEffect, useRef } = React
40
+
41
+ const NS = 'dshAutorestart'
42
+ const HEALTH_URL = '/__dsh_autorestart/health?state=1'
43
+ const POLL_MS = 2000
44
+ // The brand accent (blue-purple). The pending-restart marker is a brand
45
+ // signal, not a warning: no warning/yellow token is used here.
46
+ const BRAND = 'var(--dsw-alias-brand-primary, #5b8def)'
47
+
48
+ // The shared "imminent restart" pulse. The keyframes live in ONE <style>
49
+ // element so the two surfaces (pill dot + sidebar dot) never animate with
50
+ // two different rules.
51
+ const PULSE_STYLE_ID = 'dsh-autorestart-pulse'
52
+ const PULSE_ANIMATION_NAME = 'dsh-autorestart-pulse'
53
+ const PULSE_ANIMATION = PULSE_ANIMATION_NAME + ' 1.8s ease-in-out infinite'
54
+ // Only `transform` and `opacity` are animated: the dot must not resize and
55
+ // must never push the composer row around.
56
+ const PULSE_CSS = [
57
+ '@keyframes ' + PULSE_ANIMATION_NAME + ' {',
58
+ ' 0%, 100% { transform: scale(1); opacity: 1; }',
59
+ ' 50% { transform: scale(1.5); opacity: 0.55; }',
60
+ '}',
61
+ ].join('\n')
62
+
63
+ const ZH = {
64
+ label: '待重启',
65
+ busyPrefix: ' · 被 ',
66
+ busySuffix: ' 个会话占用',
67
+ idle: ' · 空闲后自动重启',
68
+ tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
69
+ dotAria: '待重启',
70
+ panelHeading: '待重启',
71
+ panelStateBusy: '等待其他会话空闲后重启',
72
+ panelStateIdle: '等待空闲窗口后重启',
73
+ panelBlockedTitle: '占用会话:{title}',
74
+ panelBlockedGeneric: '有一个会话正在运行',
75
+ panelWaiting: '已等待 {duration}',
76
+ panelResume: '重启后本会话会自动继续进度',
77
+ durationSeconds: '{n} 秒',
78
+ durationMinutes: '{n} 分 {m} 秒',
79
+ durationHours: '{n} 小时 {m} 分',
80
+ }
81
+
82
+ const EN = {
83
+ label: 'Pending restart',
84
+ busyPrefix: ' · blocked by ',
85
+ busySuffix: ' session(s)',
86
+ idle: ' · restarts once idle',
87
+ tooltip:
88
+ 'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
89
+ dotAria: 'Pending restart',
90
+ panelHeading: 'Pending restart',
91
+ panelStateBusy: 'Waiting for the other sessions to become idle',
92
+ panelStateIdle: 'Waiting for the idle window',
93
+ panelBlockedTitle: 'Blocking session: {title}',
94
+ panelBlockedGeneric: 'One session is running',
95
+ panelWaiting: 'Waiting for {duration}',
96
+ panelResume: 'This session resumes its progress automatically after the restart.',
97
+ durationSeconds: '{n}s',
98
+ durationMinutes: '{n}m {m}s',
99
+ durationHours: '{n}h {m}m',
100
+ }
101
+
102
+ // Bound during `apply`. The slots are registered without a locale
103
+ // namespace, so the components read the translator through this
104
+ // factory-scoped handle instead of a standard `t` prop.
105
+ let translate = null
106
+
107
+ /**
108
+ * ONE shared poller, shared by every surface that shows the pending
109
+ * restart state (the composer pill and the sidebar session-row dot).
110
+ *
111
+ * The store lives at factory scope, so the first subscriber starts ONE
112
+ * 2s interval plus an immediate fetch, and the last one to leave clears
113
+ * the interval and aborts the in-flight request. A failed fetch keeps the
114
+ * last known value, so a transient error never blanks a live marker.
115
+ */
116
+ const stateStore = {
117
+ info: null,
118
+ listeners: new Set(),
119
+ timer: null,
120
+ inFlight: false,
121
+ controller: null,
122
+ // When the pending state was FIRST observed, so the details panel can
123
+ // say how long this session has been waiting.
124
+ pendingSince: null,
125
+ }
126
+
127
+ function notifyListeners() {
128
+ stateStore.listeners.forEach((listener) => {
129
+ try {
130
+ listener(stateStore.info)
131
+ } catch {
132
+ // One broken listener must not stop the others.
133
+ }
134
+ })
135
+ }
136
+
137
+ async function pollOnce() {
138
+ if (stateStore.inFlight) return
139
+ stateStore.inFlight = true
140
+ try {
141
+ const response = await fetch(HEALTH_URL, {
142
+ cache: 'no-store',
143
+ signal: stateStore.controller ? stateStore.controller.signal : undefined,
144
+ })
145
+ if (!response.ok) return
146
+ const body = await response.json()
147
+ if (!body || typeof body !== 'object') return
148
+ stateStore.info = body
149
+ if (body.pendingRestart === true) {
150
+ if (stateStore.pendingSince === null) stateStore.pendingSince = Date.now()
151
+ } else {
152
+ stateStore.pendingSince = null
153
+ }
154
+ notifyListeners()
155
+ } catch {
156
+ // Ignored on purpose: the marker must never break the composer or the
157
+ // sidebar. The last known value is kept.
158
+ } finally {
159
+ stateStore.inFlight = false
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Adds a listener and starts the shared poll when it is the first one.
165
+ * Returns the unsubscribe callback, which stops the shared poll when the
166
+ * last listener leaves.
167
+ */
168
+ function subscribe(listener) {
169
+ stateStore.listeners.add(listener)
170
+ if (stateStore.listeners.size === 1) {
171
+ stateStore.controller = new AbortController()
172
+ pollOnce()
173
+ stateStore.timer = setInterval(pollOnce, POLL_MS)
174
+ }
175
+ return () => {
176
+ stateStore.listeners.delete(listener)
177
+ if (stateStore.listeners.size === 0) {
178
+ if (stateStore.timer !== null) {
179
+ clearInterval(stateStore.timer)
180
+ stateStore.timer = null
181
+ }
182
+ if (stateStore.controller) {
183
+ stateStore.controller.abort()
184
+ stateStore.controller = null
185
+ }
186
+ // The last subscriber left: the shared keyframes rule goes away too.
187
+ removePulseStyle()
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Hook-like reader for the shared state: returns the current info and
194
+ * re-renders the caller whenever the shared poll lands a new value.
195
+ */
196
+ function useSharedInfo(enabled) {
197
+ const active = enabled !== false
198
+ const [info, setInfo] = useState(stateStore.info)
199
+ useEffect(() => {
200
+ if (!active) return undefined
201
+ setInfo(stateStore.info)
202
+ return subscribe(setInfo)
203
+ }, [active])
204
+ return info
205
+ }
206
+
207
+ /**
208
+ * Inject the shared pulse keyframes exactly once. The id keeps a second
209
+ * injection from duplicating the rule, and the element is removed again
210
+ * when the last subscriber leaves.
211
+ */
212
+ function ensurePulseStyle() {
213
+ if (typeof document === 'undefined' || !document.head) return
214
+ try {
215
+ if (document.getElementById(PULSE_STYLE_ID)) return
216
+ const style = document.createElement('style')
217
+ style.id = PULSE_STYLE_ID
218
+ style.textContent = PULSE_CSS
219
+ document.head.appendChild(style)
220
+ } catch {
221
+ // Without the rule the dot simply does not pulse; nothing else breaks.
222
+ }
223
+ }
224
+
225
+ /** Remove the shared pulse keyframes (the last subscriber left). */
226
+ function removePulseStyle() {
227
+ if (typeof document === 'undefined') return
228
+ try {
229
+ const node = document.getElementById(PULSE_STYLE_ID)
230
+ if (node && node.parentNode) node.parentNode.removeChild(node)
231
+ } catch {
232
+ // Nothing to clean up.
233
+ }
234
+ }
235
+
236
+ /** True when the user asked the OS to reduce motion. */
237
+ function prefersReducedMotion() {
238
+ try {
239
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
240
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches === true
241
+ } catch {
242
+ return false
243
+ }
244
+ }
245
+
246
+ /** The dot style, with the pulse applied when the restart is imminent. */
247
+ function dotStyle(base, pulsing) {
248
+ if (!pulsing) return base
249
+ return {
250
+ ...base,
251
+ animation: PULSE_ANIMATION,
252
+ transformOrigin: 'center',
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Fill `{name}` placeholders in a locale template. The locale API is not
258
+ * assumed to interpolate, so the panel does it itself.
259
+ */
260
+ function fill(template, vars) {
261
+ if (typeof template !== 'string') return ''
262
+ return template.replace(/\{(\w+)\}/g, (match, name) => (
263
+ vars && Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
264
+ ))
265
+ }
266
+
267
+ /** How long the pending restart has been waiting, as a locale string. */
268
+ function formatWaiting(ms, t) {
269
+ const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000))
270
+ if (totalSeconds < 60) return fill(t('durationSeconds'), { n: totalSeconds })
271
+ const minutes = Math.floor(totalSeconds / 60)
272
+ if (minutes < 60) return fill(t('durationMinutes'), { n: minutes, m: totalSeconds % 60 })
273
+ const hours = Math.floor(minutes / 60)
274
+ return fill(t('durationHours'), { n: hours, m: minutes % 60 })
275
+ }
276
+
277
+ // The pill sits in the composer tool row, so it must fit the toolbar
278
+ // height (~22px) and never push that row around: `flex: 'none'` plus a
279
+ // fixed height, and the text is clipped with an ellipsis instead of
280
+ // wrapping. No absolute/fixed positioning, no negative margins. The
281
+ // border stays neutral; only the dot carries the brand accent.
282
+ const PILL_STYLE = {
283
+ display: 'inline-flex',
284
+ alignItems: 'center',
285
+ flex: 'none',
286
+ gap: 6,
287
+ height: 22,
288
+ boxSizing: 'border-box',
289
+ maxWidth: '100%',
290
+ padding: '0 8px',
291
+ borderRadius: 999,
292
+ fontSize: 12,
293
+ lineHeight: '20px',
294
+ whiteSpace: 'nowrap',
295
+ overflow: 'hidden',
296
+ textOverflow: 'ellipsis',
297
+ color: 'var(--dsw-alias-label-secondary, inherit)',
298
+ background: 'var(--dsw-alias-bg-layer-1, transparent)',
299
+ border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.32))',
300
+ cursor: 'pointer',
301
+ }
302
+
303
+ // The wrapper is the pill's anchor: it is the only element that may
304
+ // position absolutely (the details panel), so it stays `relative` while
305
+ // the pill itself keeps its inline layout.
306
+ const WRAPPER_STYLE = {
307
+ position: 'relative',
308
+ display: 'inline-flex',
309
+ alignItems: 'center',
310
+ flex: 'none',
311
+ maxWidth: '100%',
312
+ }
313
+
314
+ const DOT_STYLE = {
315
+ display: 'inline-block',
316
+ width: 6,
317
+ height: 6,
318
+ borderRadius: '50%',
319
+ flexShrink: 0,
320
+ background: BRAND,
321
+ }
322
+
323
+ // The 8px brand dot that occupies the leading 16px state cell of a
324
+ // sidebar session row. `margin: 0 auto` centers it in that cell; it is
325
+ // icon-only, so the full text lives in the `title` tooltip.
326
+ const SIDEBAR_DOT_STYLE = {
327
+ display: 'block',
328
+ width: 8,
329
+ height: 8,
330
+ borderRadius: '50%',
331
+ background: BRAND,
332
+ margin: '0 auto',
333
+ flex: 'none',
334
+ boxSizing: 'border-box',
335
+ }
336
+
337
+ // The label + status text is one clipped line inside the pill.
338
+ const TEXT_STYLE = {
339
+ minWidth: 0,
340
+ overflow: 'hidden',
341
+ textOverflow: 'ellipsis',
342
+ whiteSpace: 'nowrap',
343
+ }
344
+
345
+ // The details panel: anchored to the wrapper, above the pill, left
346
+ // aligned. It uses the host theme tokens (background / border / label
347
+ // color) so it follows the app theme.
348
+ const PANEL_STYLE = {
349
+ position: 'absolute',
350
+ bottom: '100%',
351
+ left: 0,
352
+ zIndex: 30,
353
+ minWidth: 260,
354
+ maxWidth: 320,
355
+ marginBottom: 6,
356
+ padding: '8px 10px',
357
+ borderRadius: 8,
358
+ fontSize: 12,
359
+ lineHeight: '18px',
360
+ textAlign: 'left',
361
+ whiteSpace: 'normal',
362
+ color: 'var(--dsw-alias-label-primary, inherit)',
363
+ background: 'var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1, #fff))',
364
+ border: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.32))',
365
+ boxShadow: '0 6px 16px rgba(0, 0, 0, 0.16)',
366
+ cursor: 'default',
367
+ }
368
+
369
+ const PANEL_HEADING_STYLE = {
370
+ fontWeight: 600,
371
+ marginBottom: 4,
372
+ }
373
+
374
+ const PANEL_LINE_STYLE = {
375
+ margin: '1px 0',
376
+ }
377
+
378
+ const PANEL_MUTED_STYLE = {
379
+ margin: '4px 0 0',
380
+ opacity: 0.75,
381
+ }
382
+
383
+ /**
384
+ * One compact pill in the composer tool row, visible only while THIS
385
+ * session is waiting for a pending restart. Clicking it (or pressing
386
+ * Enter / Space) opens the details panel. Single root element.
387
+ */
388
+ function RestartPill(props) {
389
+ const sessionId = props && props.sessionId !== undefined ? props.sessionId : null
390
+ const hasSession = sessionId !== null && sessionId !== ''
391
+ const info = useSharedInfo(hasSession)
392
+ const [open, setOpen] = useState(false)
393
+ const [, setTick] = useState(0)
394
+ const wrapperRef = useRef(null)
395
+
396
+ const pending = Boolean(info && info.pendingRestart === true)
397
+ // The restart is imminent: the Host's idle gate is open, so the restart
398
+ // happens as soon as this poll cycle ends.
399
+ const pulsing = pending && Boolean(info.allowRestart === true) && !prefersReducedMotion()
400
+
401
+ // The panel never outlives the pending state.
402
+ useEffect(() => {
403
+ if (!pending) setOpen(false)
404
+ }, [pending])
405
+
406
+ // Clicking outside the panel or pressing Escape closes it. Both
407
+ // listeners are removed when the panel closes and on unmount.
408
+ useEffect(() => {
409
+ if (!open) return undefined
410
+ const onPointerDown = (event) => {
411
+ const node = wrapperRef.current
412
+ if (node && event && event.target && typeof node.contains === 'function'
413
+ && node.contains(event.target)) return
414
+ setOpen(false)
415
+ }
416
+ const onKeyDown = (event) => {
417
+ if (event && event.key === 'Escape') setOpen(false)
418
+ }
419
+ document.addEventListener('mousedown', onPointerDown, true)
420
+ document.addEventListener('keydown', onKeyDown, true)
421
+ return () => {
422
+ document.removeEventListener('mousedown', onPointerDown, true)
423
+ document.removeEventListener('keydown', onKeyDown, true)
424
+ }
425
+ }, [open])
426
+
427
+ // A one-second tick keeps the "waiting for" duration in the panel fresh.
428
+ useEffect(() => {
429
+ if (!open) return undefined
430
+ const timer = setInterval(() => setTick((value) => value + 1), 1000)
431
+ return () => clearInterval(timer)
432
+ }, [open])
433
+
434
+ if (!info || info.pendingRestart !== true) return null
435
+
436
+ const waiters = Array.isArray(info.waiters) ? info.waiters : []
437
+ const key = String(sessionId)
438
+ const waiting = waiters.some((waiter) => String(waiter) === key)
439
+ if (!waiting) return null
440
+
441
+ const blockedBy = Array.isArray(info.blockedBy) ? info.blockedBy : []
442
+ const others = blockedBy.filter((entry) => entry && String(entry.sessionId) !== key)
443
+ const otherBusy = others.length
444
+
445
+ const t = translate ?? ((name) => name)
446
+ const status =
447
+ otherBusy > 0 ? `${t('busyPrefix')}${otherBusy}${t('busySuffix')}` : t('idle')
448
+
449
+ if (pulsing) ensurePulseStyle()
450
+
451
+ const toggle = () => setOpen((value) => !value)
452
+ const onKeyDown = (event) => {
453
+ if (!event) return
454
+ const pressed = event.key
455
+ if (pressed === 'Enter' || pressed === ' ' || pressed === 'Spacebar') {
456
+ event.preventDefault()
457
+ toggle()
458
+ }
459
+ }
460
+
461
+ const pendingSince = stateStore.pendingSince
462
+ const waitingMs = pendingSince === null || pendingSince === undefined
463
+ ? 0
464
+ : Math.max(0, Date.now() - pendingSince)
465
+
466
+ const panelLines = [
467
+ h('div', { key: 'heading', style: PANEL_HEADING_STYLE }, t('panelHeading')),
468
+ h(
469
+ 'div',
470
+ { key: 'state', style: PANEL_LINE_STYLE },
471
+ otherBusy > 0 ? t('panelStateBusy') : t('panelStateIdle'),
472
+ ),
473
+ ]
474
+ others.forEach((entry, index) => {
475
+ const title = entry && typeof entry.title === 'string' ? entry.title.trim() : ''
476
+ panelLines.push(h(
477
+ 'div',
478
+ { key: 'blocked-' + index, style: PANEL_LINE_STYLE },
479
+ title ? fill(t('panelBlockedTitle'), { title }) : t('panelBlockedGeneric'),
480
+ ))
481
+ })
482
+ panelLines.push(h(
483
+ 'div',
484
+ { key: 'waiting', style: PANEL_LINE_STYLE },
485
+ fill(t('panelWaiting'), { duration: formatWaiting(waitingMs, t) }),
486
+ ))
487
+ panelLines.push(h(
488
+ 'div',
489
+ { key: 'resume', style: PANEL_MUTED_STYLE },
490
+ t('panelResume'),
491
+ ))
492
+
493
+ return h(
494
+ 'div',
495
+ {
496
+ ref: wrapperRef,
497
+ style: WRAPPER_STYLE,
498
+ role: 'status',
499
+ 'aria-live': 'polite',
500
+ },
501
+ h(
502
+ 'div',
503
+ {
504
+ style: PILL_STYLE,
505
+ title: t('tooltip'),
506
+ role: 'button',
507
+ tabIndex: 0,
508
+ 'aria-haspopup': 'dialog',
509
+ 'aria-expanded': open ? 'true' : 'false',
510
+ onClick: toggle,
511
+ onKeyDown,
512
+ },
513
+ h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
514
+ h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
515
+ ),
516
+ open
517
+ ? h(
518
+ 'div',
519
+ {
520
+ style: PANEL_STYLE,
521
+ role: 'dialog',
522
+ 'aria-label': t('panelHeading'),
523
+ },
524
+ panelLines,
525
+ )
526
+ : null,
527
+ )
528
+ }
529
+
530
+ /**
531
+ * The brand dot in the leading 16px state cell of a left-sidebar session
532
+ * row, visible only while THAT session is waiting for a pending restart.
533
+ * Icon-only: the full explanation is the `title` tooltip. Single root
534
+ * element.
535
+ */
536
+ function RestartDot(props) {
537
+ const sessionId = props && props.sessionId !== undefined ? props.sessionId : null
538
+ const hasSession = sessionId !== null && sessionId !== ''
539
+ const info = useSharedInfo(hasSession)
540
+
541
+ if (!info || info.pendingRestart !== true) return null
542
+
543
+ const waiters = Array.isArray(info.waiters) ? info.waiters : []
544
+ const key = String(sessionId)
545
+ const waiting = waiters.some((waiter) => String(waiter) === key)
546
+ if (!waiting) return null
547
+
548
+ const t = translate ?? ((name) => name)
549
+ const pulsing = info.allowRestart === true && !prefersReducedMotion()
550
+ if (pulsing) ensurePulseStyle()
551
+
552
+ return h('span', {
553
+ style: dotStyle(SIDEBAR_DOT_STYLE, pulsing),
554
+ title: t('tooltip'),
555
+ role: 'status',
556
+ 'aria-live': 'polite',
557
+ 'aria-label': t('dotAria'),
558
+ })
559
+ }
560
+
561
+ return {
562
+ inject: ['slots', 'locale'],
563
+ apply(ctx) {
564
+ ctx.effect(
565
+ () => ctx.locale.register(NS, { zh: ZH, en: EN }),
566
+ 'dsh-autorestart: client locale',
567
+ )
568
+ const t = ctx.locale.bind(NS)
569
+ translate = t
570
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'autorestart-restart-pill', order: 0 }, RestartPill))
571
+ ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ name: 'sidebar.session.row.leading', id: 'autorestart-restart-dot', order: 20 }, RestartDot))
572
+ },
573
+ }
574
+ },
575
+ })
@@ -0,0 +1,33 @@
1
+ # dsh-autorestart bundle layer.
2
+ # Every key below is a documented default; override any of them in the profile's
3
+ # cordis.patch.yml with an `- id: dsh-autorestart` entry that repeats the full config.
4
+ - insert:
5
+ - id: dsh-autorestart
6
+ name: dsh-autorestart
7
+ config:
8
+ enabled: true
9
+ # Empty = <DSH_HOME>/storages/dsh-autorestart/state.json
10
+ stateFile: ''
11
+ resumeOnBoot: true
12
+ recoveryDelayMs: 6000
13
+ maxSessions: 4
14
+ maxResumeAttempts: 2
15
+ deferPollMs: 3000
16
+ deferSoftMs: 300000
17
+ deferHardMs: 900000
18
+ deferPolicy: auto
19
+ # hard deadline only forces a restart over a running conversation when this is
20
+ # enabled; by default it keeps waiting for the other conversations to finish
21
+ forceRestartWhenBusy: false
22
+ # app = relaunch the DeepSeek Harness desktop app; command = run restartCommand
23
+ restartMode: app
24
+ restartCommand: ''
25
+ relaunchDelaySeconds: 2
26
+ # quiet window (ms) after the last conversation activity before a restart may fire
27
+ settleMs: 2000
28
+ # dsh web: reload the page when the host boot token changes
29
+ autoReloadPage: true
30
+ # hard-block shell commands that would kill/relaunch DSH by hand
31
+ blockManualRestart: true
32
+ healthPath: /__dsh_autorestart/health
33
+ continuationText: ''
package/icon.svg ADDED
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Autorestart">
2
+ <defs>
3
+ <linearGradient id="c-flame" x1="32" y1="54" x2="32" y2="10" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#ff7a18"/>
5
+ <stop offset="0.55" stop-color="#ffb020"/>
6
+ <stop offset="1" stop-color="#ffe07a"/>
7
+ </linearGradient>
8
+ <linearGradient id="c-ring" x1="8" y1="32" x2="56" y2="32" gradientUnits="userSpaceOnUse">
9
+ <stop offset="0" stop-color="#5b8def"/>
10
+ <stop offset="1" stop-color="#8a6cff"/>
11
+ </linearGradient>
12
+ </defs>
13
+ <circle cx="32" cy="32" r="26" fill="none" stroke="url(#c-ring)" stroke-width="4" stroke-linecap="round" stroke-dasharray="128 36" transform="rotate(-35 32 32)"/>
14
+ <path d="M55 9l-1.2 12.4-12.2-2.4z" fill="#8a6cff"/>
15
+ <path d="M32 12c5.4 7.4 3.1 11.6.6 15.2-2.3 3.3-1.3 6.6 1.7 8.2 1.3-2.9 2.5-4.6 4.9-6.2 3.1 4 5.5 7.7 5.5 12.1 0 6.6-5.7 11.7-12.7 11.7S19.3 47.9 19.3 41.3c0-5.1 3.2-8.5 6.1-12.3 3.4-4.4 6.6-8.7 6.6-17zm0 22.3c-3.2 2.7-4.3 4.6-4.3 7.1 0 2.6 1.9 4.4 4.3 4.4s4.3-1.8 4.3-4.4c0-2.5-1.1-4.4-4.3-7.1z" fill="url(#c-flame)"/>
16
+ </svg>