dsh-autorestart 0.3.5 → 0.3.6

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 CHANGED
@@ -195,6 +195,39 @@ The rollback is deliberately conservative: it only runs when the relaunched host
195
195
  own health route. A slow start inside `rollbackTimeoutMs` is never treated as a failure, and a
196
196
  healthy host is never touched.
197
197
 
198
+ ### The one-time install question
199
+
200
+ The first time the plugin is used, the composer dock shows ONE non-modal panel that asks whether the
201
+ pre-restart backup should also snapshot the installed plugin tree:
202
+
203
+ - **"Configuration + installed plugins"** — about 3 seconds and 117 MB more per restart; a plugin
204
+ that breaks the boot can be rolled back too.
205
+ - **"Configuration only"** — instant, about 66 KB; plugin problems cannot be rolled back and need a
206
+ manual reinstall.
207
+
208
+ Both options share the same base: the **configuration** is always backed up. The plugins are an
209
+ addition to it, never a rival of it.
210
+
211
+ - **It is asked exactly once.** The panel is rendered only while
212
+ `localStorage['dsh-autorestart:backup-choice-v1']` is missing; picking an option records the answer and the
213
+ panel never comes back.
214
+ - **Closing the panel counts as answering.** `×` and `Esc` write the very same "already answered" mark as
215
+ picking an option, so that browser is never asked again — and closing keeps the default (configuration *and*
216
+ plugins).
217
+ - **It never blocks you.** The panel is a `role="status"` block in the composer dock: the input box
218
+ stays usable, nothing is modal, and the panel does not disappear on its own. It is keyboard
219
+ reachable and `Esc` closes it.
220
+ - **Where the answer goes.** The host writes it into the profile configuration with the official
221
+ `configEditor` service — the same `backupIncludePlugins` key the settings page edits, so there is
222
+ one source of truth. If that service is unavailable the answer degrades to
223
+ `<DSH_HOME>/storages/dsh-autorestart/install-choice.json` and a warning is logged. The same fallback
224
+ happens in a hosted profile that declares no `dsh-autorestart` row of its own: there is no row for the
225
+ editor to write, so the answer lands in that file instead of the profile. Declare the row (see
226
+ *Hosted `dsh web` deployments* below) to keep the profile configuration as the single source of truth.
227
+ - **How to change your mind later:** Settings → Plugins → dsh-autorestart → `backupIncludePlugins`.
228
+ `continuum_status` always reports the value that is in effect right now
229
+ (`backupIncludePlugins: true | false`).
230
+
198
231
  ## Requirements and limits
199
232
 
200
233
  - **Windows desktop.** `restartMode: app` restarts the DSH desktop app on Windows. Any other
@@ -217,6 +250,37 @@ healthy host is never touched.
217
250
  - **The restart closes and reopens the desktop window.** It is a full app restart, not a hot reload.
218
251
  - **The resumed round consumes API tokens**, exactly like typing "continue" by hand.
219
252
 
253
+ ### Hosted `dsh web` deployments
254
+
255
+ | Deployment | `restartMode` | How the host is restarted |
256
+ | --- | --- | --- |
257
+ | DeepSeek Harness desktop app (Windows) | `app` (the default) | The host's own app restart: the old process tree is killed and the desktop app is relaunched |
258
+ | A hosted `dsh web` deployment | `command` | There is no "restart the app" in a web deployment — you must supply the command yourself |
259
+
260
+ Minimal configuration for a hosted `dsh web` (it **replaces** the whole `config` block, it is not merged):
261
+
262
+ ```yaml
263
+ - id: dsh-autorestart
264
+ config:
265
+ restartMode: command
266
+ restartCommand: systemctl --user restart dsh-web
267
+ ```
268
+
269
+ `restartCommand` may use the placeholders `{pid}` (this host), `{ppid}` (its parent) and `{app}` (the desktop
270
+ app path).
271
+
272
+ **Without that configuration the restart fails, and it says so.** A hosted host is not the desktop app, so
273
+ the plugin has no way to relaunch it: the log gets
274
+ `no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place`,
275
+ the pending restart is rolled back (one retry with a backoff, then it is cancelled instead of retrying
276
+ forever), and nothing is relaunched — the checkpoint simply stays on disk, so the session is picked up again
277
+ when the host is started by hand. `continuum_status` keeps reporting the pending state until it is cancelled.
278
+
279
+ The same profile row is what the one-time install question writes into: a hosted profile that declares no
280
+ `dsh-autorestart` row has no row for the `configEditor` service to edit, so the answer falls back to
281
+ `<DSH_HOME>/storages/dsh-autorestart/install-choice.json` instead of the profile configuration. Declaring the
282
+ row keeps the profile (and therefore the settings page) as the single source of truth.
283
+
220
284
  ## Verify
221
285
 
222
286
  The health route tells you whether the plugin is alive and what it has done:
package/client.js CHANGED
@@ -60,6 +60,11 @@ window.__ModuleLoader__.load({
60
60
  // is only the fallback for an older Host.
61
61
  const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
62
62
  const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
63
+ // The route that persists the one-time install choice.
64
+ const DEFAULT_CHOICE_URL = ROUTE_PREFIX + '/choice'
65
+ // The browser-side record of the one-time install choice. The prompt is
66
+ // shown only while this key is missing, so it is asked exactly once.
67
+ const BACKUP_CHOICE_KEY = 'dsh-autorestart:backup-choice-v1'
63
68
  const POLL_MS = 2000
64
69
  // The brand accent (blue-purple). The pending-restart marker is a brand
65
70
  // signal, not a warning: no warning/yellow token is used here.
@@ -112,6 +117,21 @@ window.__ModuleLoader__.load({
112
117
  durationSeconds: '{n} 秒',
113
118
  durationMinutes: '{n} 分 {m} 秒',
114
119
  durationHours: '{n} 小时 {m} 分',
120
+ // The one-time install prompt (the choice is asked exactly once).
121
+ // "Backup" is the verb and BOTH options share the "configuration" base:
122
+ // the plugins are an ADDITION to the configuration, never a rival of it.
123
+ backupChoiceTitle: '重启前自动备份:要不要把已安装的插件也一起备份?',
124
+ backupChoiceBody: '每次重启前会快照当前配置,万一重启后起不来就能自动回退。',
125
+ backupChoiceWithPlugins: '配置 + 已安装插件',
126
+ backupChoiceWithPluginsHint: '多约 3 秒、约 117 MB;插件把启动搞坏时也能回退。',
127
+ backupChoiceConfigOnly: '只备份配置',
128
+ backupChoiceConfigOnlyHint: '瞬间完成、约 66 KB;插件问题无法回退,需要手动重装。',
129
+ backupChoiceFootnote: '以后可在 设置 → 插件 → dsh-autorestart 修改',
130
+ backupChoicePickPrimary: '用这个',
131
+ backupChoicePickSecondary: '只备份配置',
132
+ backupChoiceRecommended: '推荐',
133
+ backupChoiceDismissAria: '关闭这个一次性提示',
134
+ backupChoiceError: '保存失败:{message}',
115
135
  }
116
136
 
117
137
  const EN = {
@@ -149,6 +169,21 @@ window.__ModuleLoader__.load({
149
169
  durationSeconds: '{n}s',
150
170
  durationMinutes: '{n}m {m}s',
151
171
  durationHours: '{n}h {m}m',
172
+ // The one-time install prompt (the choice is asked exactly once).
173
+ // "Backup" is the verb and BOTH options share the "configuration" base:
174
+ // the plugins are an ADDITION to the configuration, never a rival of it.
175
+ backupChoiceTitle: 'Automatic pre-restart backup: include the installed plugins too?',
176
+ backupChoiceBody: 'A snapshot of the current configuration is taken before every restart, so a host that fails to come back can be rolled back automatically.',
177
+ backupChoiceWithPlugins: 'Configuration + installed plugins',
178
+ backupChoiceWithPluginsHint: 'About 3 seconds and 117 MB more; a plugin that breaks the boot can be rolled back too.',
179
+ backupChoiceConfigOnly: 'Configuration only',
180
+ backupChoiceConfigOnlyHint: 'Instant, about 66 KB; plugin problems cannot be rolled back and need a manual reinstall.',
181
+ backupChoiceFootnote: 'You can change this later in Settings → Plugins → dsh-autorestart',
182
+ backupChoicePickPrimary: 'Use this',
183
+ backupChoicePickSecondary: 'Configuration only',
184
+ backupChoiceRecommended: 'Recommended',
185
+ backupChoiceDismissAria: 'Close this one-time prompt',
186
+ backupChoiceError: 'Could not save the choice: {message}',
152
187
  }
153
188
 
154
189
  // Bound during `apply`. The slots are registered without a locale
@@ -336,6 +371,49 @@ window.__ModuleLoader__.load({
336
371
  return DEFAULT_PARK_URL
337
372
  }
338
373
 
374
+ /**
375
+ * The route that persists the one-time install choice: the one the Host
376
+ * sent with the state payload, or the one derived from the health path.
377
+ */
378
+ function choiceUrlFor(info) {
379
+ const sent = info && typeof info.choiceUrl === 'string' ? info.choiceUrl.trim() : ''
380
+ if (sent.startsWith('/')) return sent
381
+ return DEFAULT_CHOICE_URL
382
+ }
383
+
384
+ /**
385
+ * Whether the one-time install prompt may be shown.
386
+ *
387
+ * It may be shown only while the browser has no record of a choice yet,
388
+ * i.e. while `localStorage['dsh-autorestart:backup-choice-v1']` is
389
+ * missing. A browser without a usable localStorage answers "no": the
390
+ * prompt must not come back on every reload there.
391
+ */
392
+ function backupChoicePending() {
393
+ try {
394
+ if (typeof window === 'undefined' || !window.localStorage) return false
395
+ return window.localStorage.getItem(BACKUP_CHOICE_KEY) === null
396
+ } catch {
397
+ return false
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Record the one-time choice in the browser.
403
+ *
404
+ * The key is written as soon as the user picks an option (or closes the
405
+ * prompt), so the question is asked exactly once.
406
+ */
407
+ function writeBackupChoice(value) {
408
+ try {
409
+ if (typeof window === 'undefined' || !window.localStorage) return false
410
+ window.localStorage.setItem(BACKUP_CHOICE_KEY, String(value))
411
+ return true
412
+ } catch {
413
+ return false
414
+ }
415
+ }
416
+
339
417
  /** A short, human readable description of a failed request. */
340
418
  function describeError(error) {
341
419
  if (!error) return 'unknown error'
@@ -486,6 +564,180 @@ window.__ModuleLoader__.load({
486
564
  color: 'var(--dsw-alias-danger, #d14343)',
487
565
  }
488
566
 
567
+ // The one-time install prompt. It is NOT a modal: it is an ordinary block
568
+ // in the composer dock, so the input box stays usable and nothing is
569
+ // blocked. It never disappears on its own.
570
+ //
571
+ // The look follows the established DSH plugin styling: one 14px/600
572
+ // heading, a muted subtitle, two selectable rows (a name + a note on the
573
+ // left, the action on the right) and a muted footnote. The primary option
574
+ // carries a "recommended" pill and a solid button; the secondary one is
575
+ // outlined. Only theme tokens are used, so light and dark themes both
576
+ // work.
577
+ const CHOICE_STYLE_ID = 'dsh-autorestart-choice'
578
+ const CHOICE_CSS = [
579
+ '.dsh-autorestart-choice{',
580
+ ' display:flex;',
581
+ ' flex-direction:column;',
582
+ ' gap:8px;',
583
+ ' width:min(560px,94vw);',
584
+ ' max-width:100%;',
585
+ ' box-sizing:border-box;',
586
+ ' margin:0 0 6px;',
587
+ ' padding:16px 18px;',
588
+ ' border:1px solid var(--dsw-alias-border-l1);',
589
+ ' border-radius:12px;',
590
+ ' background:var(--dsw-alias-bg-layer-1);',
591
+ ' box-shadow:0 8px 24px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
592
+ ' color:var(--dsw-alias-label-secondary);',
593
+ ' font-size:13px;',
594
+ ' line-height:18px;',
595
+ '}',
596
+ '.dsh-autorestart-choice h3{',
597
+ ' margin:0;',
598
+ ' font-size:14px;',
599
+ ' font-weight:600;',
600
+ ' line-height:20px;',
601
+ ' color:var(--dsw-alias-label-primary);',
602
+ '}',
603
+ '.dsh-autorestart-choice p{margin:0;}',
604
+ '.dsh-autorestart-choice__head{',
605
+ ' display:flex;',
606
+ ' align-items:flex-start;',
607
+ ' justify-content:space-between;',
608
+ ' gap:10px;',
609
+ '}',
610
+ '.dsh-autorestart-choice__subtitle{',
611
+ ' font-size:12.5px;',
612
+ ' line-height:18px;',
613
+ ' color:var(--dsw-alias-label-secondary);',
614
+ '}',
615
+ '.dsh-autorestart-choice__options{',
616
+ ' display:flex;',
617
+ ' flex-direction:column;',
618
+ ' gap:10px;',
619
+ '}',
620
+ '.dsh-autorestart-choice__row{',
621
+ ' display:flex;',
622
+ ' align-items:center;',
623
+ ' justify-content:space-between;',
624
+ ' gap:10px;',
625
+ ' padding:10px 12px;',
626
+ ' border:1px solid transparent;',
627
+ ' border-radius:10px;',
628
+ ' cursor:pointer;',
629
+ ' transition:background-color .14s,border-color .14s;',
630
+ '}',
631
+ '.dsh-autorestart-choice__row:hover,',
632
+ '.dsh-autorestart-choice__row:focus-within{',
633
+ ' border-color:var(--dsw-alias-border-l1);',
634
+ ' background:var(--dsw-alias-bg-layer-2, color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent));',
635
+ '}',
636
+ '.dsh-autorestart-choice__row:focus-visible{',
637
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
638
+ ' outline-offset:2px;',
639
+ '}',
640
+ '.dsh-autorestart-choice__text{flex:1 1 auto;min-width:0;}',
641
+ '.dsh-autorestart-choice__title{',
642
+ ' display:flex;',
643
+ ' align-items:center;',
644
+ ' gap:6px;',
645
+ ' min-width:0;',
646
+ '}',
647
+ '.dsh-autorestart-choice__name{',
648
+ ' font-size:13px;',
649
+ ' font-weight:600;',
650
+ ' line-height:18px;',
651
+ ' color:var(--dsw-alias-label-primary);',
652
+ '}',
653
+ '.dsh-autorestart-choice__hint{',
654
+ ' display:block;',
655
+ ' margin-top:2px;',
656
+ ' font-size:12px;',
657
+ ' line-height:17px;',
658
+ ' color:var(--dsw-alias-label-secondary);',
659
+ '}',
660
+ '.dsh-autorestart-choice__badge{',
661
+ ' flex:none;',
662
+ ' display:inline-block;',
663
+ ' padding:1px 7px;',
664
+ ' border-radius:999px;',
665
+ ' font-size:11px;',
666
+ ' font-weight:600;',
667
+ ' line-height:16px;',
668
+ ' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
669
+ ' background:color-mix(in srgb, var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe)) 12%, transparent);',
670
+ '}',
671
+ '.dsh-autorestart-choice__cta{',
672
+ ' flex:none;',
673
+ ' min-width:28px;',
674
+ ' min-height:28px;',
675
+ ' padding:4px 12px;',
676
+ ' border-radius:8px;',
677
+ ' font:inherit;',
678
+ ' font-size:12.5px;',
679
+ ' font-weight:600;',
680
+ ' line-height:18px;',
681
+ ' cursor:pointer;',
682
+ ' transition:background-color .14s,border-color .14s,color .14s;',
683
+ '}',
684
+ '.dsh-autorestart-choice__cta:focus-visible{',
685
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
686
+ ' outline-offset:2px;',
687
+ '}',
688
+ '.dsh-autorestart-choice__cta--primary{',
689
+ ' border:1px solid transparent;',
690
+ ' background:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
691
+ ' color:var(--dsw-alias-bg-layer-1,#fff);',
692
+ '}',
693
+ '.dsh-autorestart-choice__cta--secondary{',
694
+ ' border:1px solid var(--dsw-alias-border-l1);',
695
+ ' background:transparent;',
696
+ ' color:var(--dsw-alias-label-primary);',
697
+ '}',
698
+ '.dsh-autorestart-choice__cta--secondary:hover{',
699
+ ' border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
700
+ ' color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
701
+ '}',
702
+ '.dsh-autorestart-choice__close{',
703
+ ' flex:none;',
704
+ ' width:28px;',
705
+ ' height:28px;',
706
+ ' padding:0;',
707
+ ' border:1px solid transparent;',
708
+ ' border-radius:8px;',
709
+ ' background:transparent;',
710
+ ' color:var(--dsw-alias-label-secondary);',
711
+ ' font:inherit;',
712
+ ' font-size:16px;',
713
+ ' line-height:1;',
714
+ ' cursor:pointer;',
715
+ ' transition:background-color .14s;',
716
+ '}',
717
+ '.dsh-autorestart-choice__close:hover{',
718
+ ' background:var(--dsw-alias-bg-layer-2, color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent));',
719
+ '}',
720
+ '.dsh-autorestart-choice__close:focus-visible{',
721
+ ' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
722
+ ' outline-offset:2px;',
723
+ '}',
724
+ '.dsh-autorestart-choice__footnote{',
725
+ ' margin-top:10px;',
726
+ ' padding-top:10px;',
727
+ ' border-top:1px solid var(--dsw-alias-border-l1);',
728
+ ' font-size:11px;',
729
+ ' line-height:16px;',
730
+ ' color:var(--dsw-alias-label-secondary);',
731
+ '}',
732
+ '@media (max-width:1023px){',
733
+ ' .dsh-autorestart-choice{width:calc(100vw - 32px);}',
734
+ '}',
735
+ '@media (max-width:640px){',
736
+ ' .dsh-autorestart-choice__row{flex-direction:column;align-items:stretch;}',
737
+ ' .dsh-autorestart-choice__cta{width:100%;}',
738
+ '}',
739
+ ].join('\n')
740
+
489
741
  /**
490
742
  * One compact pill in the composer tool row, visible only while THIS
491
743
  * session is waiting for a pending restart. Clicking it (or pressing
@@ -681,6 +933,220 @@ window.__ModuleLoader__.load({
681
933
  })
682
934
  }
683
935
 
936
+ /**
937
+ * Inject the prompt styles exactly once. The id keeps a second injection
938
+ * from duplicating the rule.
939
+ */
940
+ function ensureChoiceStyle() {
941
+ if (typeof document === 'undefined' || !document.head) return
942
+ try {
943
+ if (document.getElementById(CHOICE_STYLE_ID)) return
944
+ const style = document.createElement('style')
945
+ style.id = CHOICE_STYLE_ID
946
+ style.textContent = CHOICE_CSS
947
+ document.head.appendChild(style)
948
+ } catch {
949
+ // Without the stylesheet the prompt is still readable, just plainer.
950
+ }
951
+ }
952
+
953
+ /**
954
+ * The one-time install prompt.
955
+ *
956
+ * It asks ONE question — should the pre-restart backup also snapshot the
957
+ * installed plugin tree? — and it is asked exactly once: the panel is
958
+ * rendered only while `localStorage['dsh-autorestart:backup-choice-v1']`
959
+ * is missing.
960
+ *
961
+ * "Backup" is the verb and BOTH options share the "configuration" base:
962
+ * the plugins are an addition to the configuration, never a rival of it.
963
+ *
964
+ * It is deliberately NOT a modal: it is an ordinary block in the composer
965
+ * dock (`role="status"`), so the input box stays usable, nothing is
966
+ * blocked, and it never disappears on its own — it stays until the user
967
+ * picks an option or closes it. It is keyboard reachable (focusable, Esc
968
+ * closes it) and adapts to a narrow window.
969
+ *
970
+ * The answer goes to the Host, which writes it into the profile
971
+ * configuration (the same key the settings page edits).
972
+ */
973
+ function BackupChoicePanel() {
974
+ const info = useSharedInfo(true)
975
+ // The prompt is rendered only while the browser has no record yet.
976
+ const [visible, setVisible] = useState(backupChoicePending)
977
+ const [busy, setBusy] = useState(false)
978
+ const [error, setError] = useState('')
979
+ const t = translate ?? ((name) => name)
980
+
981
+ /**
982
+ * Hand the choice to the Host.
983
+ *
984
+ * The browser records the choice FIRST (so the question is never asked
985
+ * twice) and then posts it to the Host, which persists it in the
986
+ * profile configuration.
987
+ */
988
+ const choose = (includePlugins) => {
989
+ if (busy) return
990
+ setBusy(true)
991
+ setError('')
992
+ writeBackupChoice(includePlugins ? 'plugins' : 'config-only')
993
+ setVisible(false)
994
+ void (async () => {
995
+ try {
996
+ const response = await fetch(choiceUrlFor(info), {
997
+ method: 'POST',
998
+ headers: { 'content-type': 'application/json' },
999
+ body: JSON.stringify({ includePlugins }),
1000
+ })
1001
+ if (!response.ok) {
1002
+ setError(fill(t('backupChoiceError'), { message: String(response.status) }))
1003
+ }
1004
+ } catch (failure) {
1005
+ setError(fill(t('backupChoiceError'), { message: describeError(failure) }))
1006
+ } finally {
1007
+ setBusy(false)
1008
+ }
1009
+ })()
1010
+ }
1011
+
1012
+ /**
1013
+ * Close the prompt without answering: the default (the configuration
1014
+ * AND the installed plugins) stays in place, and the question is not
1015
+ * asked again.
1016
+ */
1017
+ const dismiss = () => {
1018
+ if (busy) return
1019
+ writeBackupChoice('dismissed')
1020
+ setVisible(false)
1021
+ }
1022
+
1023
+ /** Esc closes the prompt (the panel itself is focusable). */
1024
+ const onKeyDown = (event) => {
1025
+ if (!event) return
1026
+ if (event.key === 'Escape' || event.key === 'Esc') dismiss()
1027
+ }
1028
+
1029
+ if (!visible) return null
1030
+ ensureChoiceStyle()
1031
+
1032
+ /**
1033
+ * One option row.
1034
+ *
1035
+ * The WHOLE row is the click target (and it is focusable, with the
1036
+ * radio semantics of a one-of-two choice), while the right side carries
1037
+ * the concrete action. The primary row wears a "recommended" pill and a
1038
+ * solid button; the secondary one an outlined button.
1039
+ */
1040
+ const optionRow = (key, name, hint, cta, variant, recommended) => {
1041
+ const pick = () => choose(key === 'plugins')
1042
+ const onRowClick = () => pick()
1043
+ const onRowKeyDown = (event) => {
1044
+ if (!event) return
1045
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
1046
+ if (typeof event.preventDefault === 'function') event.preventDefault()
1047
+ pick()
1048
+ }
1049
+ }
1050
+ // The button inside the row must not trigger the row a second time.
1051
+ const onCtaClick = (event) => {
1052
+ if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
1053
+ pick()
1054
+ }
1055
+ return h(
1056
+ 'div',
1057
+ {
1058
+ key,
1059
+ className: 'dsh-autorestart-choice__row',
1060
+ role: 'radio',
1061
+ 'aria-checked': 'false',
1062
+ 'aria-label': name,
1063
+ tabIndex: busy ? -1 : 0,
1064
+ 'data-backup-choice': key,
1065
+ onClick: onRowClick,
1066
+ onKeyDown: onRowKeyDown,
1067
+ },
1068
+ h(
1069
+ 'div',
1070
+ { className: 'dsh-autorestart-choice__text' },
1071
+ h(
1072
+ 'span',
1073
+ { className: 'dsh-autorestart-choice__title' },
1074
+ recommended
1075
+ ? h('span', { className: 'dsh-autorestart-choice__badge' }, t('backupChoiceRecommended'))
1076
+ : null,
1077
+ h('span', { className: 'dsh-autorestart-choice__name' }, name),
1078
+ ),
1079
+ h('span', { className: 'dsh-autorestart-choice__hint' }, hint),
1080
+ ),
1081
+ h(
1082
+ 'button',
1083
+ {
1084
+ type: 'button',
1085
+ className: 'dsh-autorestart-choice__cta dsh-autorestart-choice__cta--' + variant,
1086
+ 'data-backup-choice': key,
1087
+ 'aria-label': cta,
1088
+ title: cta,
1089
+ disabled: busy,
1090
+ onClick: onCtaClick,
1091
+ },
1092
+ cta,
1093
+ ),
1094
+ )
1095
+ }
1096
+
1097
+ return h(
1098
+ 'div',
1099
+ {
1100
+ className: 'dsh-autorestart-choice',
1101
+ role: 'status',
1102
+ 'aria-live': 'polite',
1103
+ tabIndex: 0,
1104
+ onKeyDown,
1105
+ 'data-dsh-autorestart-backup-choice': 'true',
1106
+ },
1107
+ h(
1108
+ 'div',
1109
+ { className: 'dsh-autorestart-choice__head' },
1110
+ h('h3', null, t('backupChoiceTitle')),
1111
+ h(
1112
+ 'button',
1113
+ {
1114
+ type: 'button',
1115
+ className: 'dsh-autorestart-choice__close',
1116
+ 'aria-label': t('backupChoiceDismissAria'),
1117
+ title: t('backupChoiceDismissAria'),
1118
+ disabled: busy,
1119
+ onClick: dismiss,
1120
+ },
1121
+ '×',
1122
+ ),
1123
+ ),
1124
+ h('p', { className: 'dsh-autorestart-choice__subtitle' }, t('backupChoiceBody')),
1125
+ h(
1126
+ 'div',
1127
+ { className: 'dsh-autorestart-choice__options' },
1128
+ optionRow(
1129
+ 'plugins',
1130
+ t('backupChoiceWithPlugins'),
1131
+ t('backupChoiceWithPluginsHint'),
1132
+ t('backupChoicePickPrimary'),
1133
+ 'primary',
1134
+ true,
1135
+ ),
1136
+ optionRow(
1137
+ 'config-only',
1138
+ t('backupChoiceConfigOnly'),
1139
+ t('backupChoiceConfigOnlyHint'),
1140
+ t('backupChoicePickSecondary'),
1141
+ 'secondary',
1142
+ false,
1143
+ ),
1144
+ ),
1145
+ h('p', { className: 'dsh-autorestart-choice__footnote' }, t('backupChoiceFootnote')),
1146
+ error === '' ? null : h('p', { role: 'alert' }, error),
1147
+ )
1148
+ }
1149
+
684
1150
  return {
685
1151
  inject: ['slots', 'locale'],
686
1152
  apply(ctx) {
@@ -692,6 +1158,7 @@ window.__ModuleLoader__.load({
692
1158
  translate = t
693
1159
  ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'autorestart-restart-pill', order: 0 }, RestartPill))
694
1160
  ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ name: 'sidebar.session.row.leading', id: 'autorestart-restart-dot', order: 20 }, RestartDot))
1161
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'autorestart-backup-choice', order: 7, locale: NS }, BackupChoicePanel))
695
1162
  },
696
1163
  }
697
1164
  },
package/cordis.patch.yml CHANGED
@@ -36,6 +36,9 @@
36
36
  backupOnRestart: true
37
37
  # Include the installed plugin tree in that snapshot (bigger: ~120 MB here,
38
38
  # but it is what lets a rollback undo "a plugin upgrade broke the boot").
39
+ # The first install asks once whether this should be on; the answer is
40
+ # written to THIS key through the official configEditor service, so the
41
+ # settings page and the one-time prompt always show the same value.
39
42
  backupIncludePlugins: true
40
43
  # Keep at most this many snapshots; older ones are deleted automatically.
41
44
  maxBackups: 2
package/docs/README.zh.md CHANGED
@@ -71,6 +71,21 @@ session resume on boot, durable-goal re-arm, and an agent-callable idle-aware gr
71
71
  - 下次启动时 `continuum_status` 会报 `lastRollback`(时间、等了多久、是否回滚、快照路径、崩溃日志名与尾部摘要)。
72
72
  - 开关:`backupOnRestart`(默认 `true`)、`backupIncludePlugins`(默认 `true`,关掉就只备配置文件)、`maxBackups`(默认 `2`)、`rollbackTimeoutMs`(默认 `90000`)。
73
73
 
74
+ ### 首次安装只问一次:备份要不要连已装插件一起备
75
+
76
+ 第一次用这个插件时,输入框上方会出现**一条非模态提示**(不是弹窗,不挡输入框,也不会自己消失),问一句:
77
+
78
+ - **「配置 + 已安装插件」** —— 每次重启多约 3 秒、约 117 MB;插件把启动搞坏时也能一起回退。
79
+ - **「只备份配置」** —— 瞬间完成、约 66 KB;插件问题无法回退,需要手动重装。
80
+
81
+ 两个选项共享同一个基底:**配置一定会被备份**,插件只是"要不要额外带上",不是二选一。
82
+
83
+ - **只问一次**:只有 `localStorage['dsh-autorestart:backup-choice-v1']` 缺失时才渲染;点任一项都会写下这个键,之后不再出现。
84
+ - **关掉也算作答**:按 `×` 或 `Esc` 与点选项写的是同一个「已作答」标记,这个浏览器此后**不会再被问**;关掉等于保留默认(配置 + 已安装插件)。
85
+ - **不打扰**:面板是 `role="status"` 的普通块,放在输入框下方的 dock 里;可聚焦、键盘可达、`Esc` 关闭。
86
+ - **答案存在哪**:宿主用官方 `configEditor` Service 写进 profile 配置(就是设置页里那个 `backupIncludePlugins` 键,**只有一份真相**)。万一这个服务不可用,才退化到写 `<DSH_HOME>/storages/dsh-autorestart/install-choice.json` 并在日志里 `warn`。**托管 profile 如果自己没有声明 `dsh-autorestart` 行**,就没有行可写,答案同样落在这个文件里而不是 profile 里 —— 声明那一行(见「平台与部署」)才能让 profile 配置保持唯一真相。
87
+ - **想改主意**:设置 → 插件 → dsh-autorestart → `backupIncludePlugins`。`continuum_status` 里随时能看到当前生效值(`backupIncludePlugins: true | false`)。
88
+
74
89
  ## 工作方式
75
90
 
76
91
  ```
@@ -492,6 +507,18 @@ or by requesting the health path with ?gate=1.
492
507
  restartCommand: systemctl --user restart dsh-web
493
508
  ```
494
509
 
510
+ `restartCommand` 里可以用占位符 `{pid}`(当前宿主)、`{ppid}`(它的父进程)、`{app}`(桌面端路径)。
511
+
512
+ **不配这个,重启就会失败,而且失败是有提示的**:托管宿主不是桌面端,插件没有「重启应用」的能力,
513
+ 日志里会出现
514
+ `no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place`,
515
+ 待重启会被回退(退避后重试一次,再失败就取消,不会永远重试),**不会有任何进程被拉起** —— 只是把检查点
516
+ 留在盘上,等你手动把宿主再起一次时它会接着跑。`continuum_status` 在取消之前会一直报待重启状态。
517
+
518
+ 同一个 profile 行也是首次安装那次提问的落点:**托管 profile 如果自己没有 `dsh-autorestart` 行**,
519
+ `configEditor` 就没有行可写,答案会退到 `<DSH_HOME>/storages/dsh-autorestart/install-choice.json`
520
+ 而不是 profile 配置;声明这一行才能让 profile(以及设置页)保持唯一真相。
521
+
495
522
  - **浏览器侧的「待重启」小胶囊**依赖 `GET <healthPath>?state=1` 这条状态路由。这条路由现在接受
496
523
  **loopback 调用者**,*或* **宿主 connection 服务判定为「已认证浏览器」的请求**
497
524
  (`connection.requestRejection` 不给拒绝码就放行)。所以:**托管部署必须允许已认证浏览器访问这条状态路由**,