dsh-autorestart 0.8.4 → 0.8.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/README.md +465 -40
  3. package/assets/icon-9c245e5b.ico +0 -0
  4. package/assets/icon-rescue-1902bb15.ico +0 -0
  5. package/assets/icon-rescue-1902bb15.png +0 -0
  6. package/client.js +3002 -170
  7. package/cordis.patch.yml +12 -0
  8. package/docs/README.zh.md +231 -23
  9. package/docs/platform-matrix.md +171 -55
  10. package/docs/real-machine-verification-0.8.6.md +202 -0
  11. package/index.js +13272 -8513
  12. package/lib/autostart-advice.js +31 -2
  13. package/lib/backup.js +2768 -21
  14. package/lib/checkpoint.js +214 -7
  15. package/lib/cookie-clear.js +260 -0
  16. package/lib/lifecycle.js +23 -40
  17. package/lib/plugin-compare.js +499 -0
  18. package/lib/plugin-failure.js +382 -0
  19. package/lib/plugin-recover.js +1557 -0
  20. package/lib/plugin-watch.js +213 -0
  21. package/lib/rescue-paths.js +982 -56
  22. package/lib/restart-phase.js +5 -0
  23. package/lib/restart-verdict.js +379 -0
  24. package/lib/restart.js +244 -7
  25. package/lib/scheduler-capability.js +19 -3
  26. package/lib/sleep.js +190 -29
  27. package/lib/watchdog-disarm.js +46 -2
  28. package/lib/watchdog-schedule.js +834 -17
  29. package/lib/watchdog-tier.js +8 -1
  30. package/lib/watchdog.js +1222 -47
  31. package/locale/en.json +175 -68
  32. package/locale/zh.json +175 -68
  33. package/package.json +3 -2
  34. package/screenshots.json +6 -6
  35. package/scripts/README.md +6 -3
  36. package/scripts/build-icon.mjs +998 -0
  37. package/scripts/clear-dsh-auth-cookies.mjs +257 -0
  38. package/scripts/emergency-disable.sh +69 -2
  39. package/scripts/emergency-toggle.ps1 +85 -69
  40. package/scripts/rescue-strings.zh.json +36 -18
  41. package/scripts/restore-profile.ps1 +182 -1
  42. package/scripts/watchdog.mjs +432 -79
package/lib/checkpoint.js CHANGED
@@ -59,6 +59,20 @@ const RESTART_KEYS = new Set([
59
59
  'notBefore',
60
60
  'requesters',
61
61
  'parkGoals',
62
+ // THIS RESTART MUST NOT TAKE A SNAPSHOT (user decision, 2026-11-02).
63
+ // The request that follows a manual rollback is exactly a restart of a state
64
+ // that was JUST restored from a copy: snapshotting it again would push an
65
+ // identical duplicate into the list (and squeeze the older, still useful
66
+ // copies out of it). The undo copy of the rollback is the rollback point
67
+ // already, so this restart carries the fact "no copy for me" on the request
68
+ // itself — a flag, not an inference from the reason text.
69
+ 'skipBackup',
70
+ // THE SNAPSHOT THIS REQUEST ALREADY TOOK. It lives on the request, so it is
71
+ // dropped by itself when the request is withdrawn or carried out (the request
72
+ // object is replaced / removed), and it survives a deferral — which is the whole
73
+ // point: a 205 MB copy must not be repeated because a conversation interrupted
74
+ // the restart (user report, 2026-10-30).
75
+ 'snapshot',
62
76
  ]);
63
77
 
64
78
  const ENTRY_KEYS = new Set([
@@ -92,8 +106,16 @@ const CONTEXT_OVERFLOW_KEYS = new Set(['over', 'inputTokens', 'contextWindow', '
92
106
  /** LEGACY record shape written by the pause-based build. */
93
107
  const PARKED_GOAL_KEYS = new Set(['goalId', 'parkedAt', 'resumedAt']);
94
108
 
95
- /** Current record shape: written after a successful `ctx.goals.disarm()`. */
96
- const DISARMED_GOAL_KEYS = new Set(['goalId', 'disarmedAt', 'resumedAt']);
109
+ /**
110
+ * Current record shape: written after a successful `ctx.goals.disarm()`.
111
+ *
112
+ * `reason` says WHY the goal was taken away ("restart-queued" when the pending
113
+ * restart did it). The write side has been writing it since 2026-11-02, and a
114
+ * whitelist that refused it silently threw the answer away on the next boot —
115
+ * the record existed but could not be read. A field the plugin really writes
116
+ * belongs in the shape it is normalized against.
117
+ */
118
+ const DISARMED_GOAL_KEYS = new Set(['goalId', 'disarmedAt', 'resumedAt', 'reason']);
97
119
 
98
120
  const TOP_LEVEL_KEYS = new Set([
99
121
  'version',
@@ -110,6 +132,16 @@ const TOP_LEVEL_KEYS = new Set([
110
132
  'seenFeatureVersions',
111
133
  'onboarding',
112
134
  'sleep',
135
+ // The seed of the FIRST safe copy: what happened when a new host process
136
+ // looked for a known-good backup and found none (user decision, 2026-10-30).
137
+ 'firstInstallBackup',
138
+ // THE BOOT-TIME REPORTS (user decision, 2026-11-02). The Host writes both and
139
+ // the next boot has to be able to read them back: the sweep of leftover
140
+ // watcher scripts, and the state of the restart gate. A whitelist that refused
141
+ // them threw the answer away on the very next boot — the record existed, but
142
+ // could not be read.
143
+ 'watcherHygiene',
144
+ 'restartGate',
113
145
  'updatedAt',
114
146
  ]);
115
147
 
@@ -228,6 +260,10 @@ function normalizeRestartPhase(raw, now) {
228
260
  // that polls every 2 s would otherwise never learn that a backup happened
229
261
  // (user report, 2026-10-29). null = nothing was measured.
230
262
  snapshotBytes: isFiniteNumber(raw.snapshotBytes) ? Math.max(0, Math.trunc(raw.snapshotBytes)) : null,
263
+ // WHY the stage is what it is ("deferred: conversation-active", "snapshot
264
+ // done", …). A stage without a reason cannot be diagnosed after the fact —
265
+ // and the hang this fixes was exactly an unexplained leftover stage.
266
+ reason: typeof raw.reason === 'string' && raw.reason !== '' ? raw.reason : null,
231
267
  blocked: isFiniteNumber(raw.blocked) ? Math.max(0, Math.trunc(raw.blocked)) : 0,
232
268
  };
233
269
  return { record, changed };
@@ -244,7 +280,16 @@ function normalizeRestartPhase(raw, now) {
244
280
  * @param {{ phase: string, now?: number, blocked?: number, restartStage?: boolean }} options
245
281
  * @returns {object|null} the recorded stage
246
282
  */
247
- export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, restartStage = true } = {}) {
283
+ export function setRestartPhase(state, {
284
+ phase,
285
+ now = Date.now(),
286
+ blocked = 0,
287
+ restartStage = true,
288
+ reason = null,
289
+ // undefined = carry what the record already measured, null = forget it, a
290
+ // number = this is the size of the snapshot this restart already paid for.
291
+ snapshotBytes,
292
+ } = {}) {
248
293
  if (!isObject(state)) return null;
249
294
  if (!RESTART_PHASES.includes(phase)) return state.restartPhase ?? null;
250
295
  const moment = isFiniteNumber(now) ? now : Date.now();
@@ -273,8 +318,14 @@ export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, r
273
318
  ? previous.progress
274
319
  : null,
275
320
  // A NEW restart starts with nothing measured; a stage change inside one
276
- // restart keeps the size it already has.
277
- snapshotBytes: sameRestart ? measuredBytes : null,
321
+ // restart keeps the size it already has — and a caller that knows the size
322
+ // (the request's own snapshot) may state it explicitly.
323
+ snapshotBytes: snapshotBytes === null
324
+ ? null
325
+ : (isFiniteNumber(snapshotBytes) ? Math.max(0, Math.trunc(snapshotBytes)) : (sameRestart ? measuredBytes : null)),
326
+ // WHY this stage (see normalizeRestartPhase): "deferred: <reason>" is what
327
+ // tells a reader that the restart went back to waiting on purpose.
328
+ reason: typeof reason === 'string' && reason !== '' ? reason : null,
278
329
  blocked: isFiniteNumber(blocked) ? Math.max(0, Math.trunc(blocked)) : 0,
279
330
  };
280
331
  state.restartPhase = record;
@@ -308,6 +359,7 @@ export function setRestartProgress(state, { files = 0, bytes = 0, total = 0, fil
308
359
  // The live byte counter IS the size of the snapshot so far: keeping it here
309
360
  // means the number survives the moment the copy ends.
310
361
  snapshotBytes: number(bytes),
362
+ reason: previous !== null && typeof previous.reason === 'string' ? previous.reason : null,
311
363
  blocked: previous !== null && isFiniteNumber(previous.blocked) ? previous.blocked : 0,
312
364
  };
313
365
  state.updatedAt = moment;
@@ -489,10 +541,20 @@ function normalizeDisarmedGoals(raw) {
489
541
  goalId: nullableString(value.goalId),
490
542
  disarmedAt: nullableNumber(value.disarmedAt),
491
543
  resumedAt: nullableNumber(value.resumedAt),
544
+ // WHY the goal was taken away. Kept like the timestamps: a record whose
545
+ // reason survives is the difference between "it was disarmed" and "it was
546
+ // disarmed because a restart was queued".
547
+ reason: nullableString(value.reason),
492
548
  };
493
549
  if (typeof value.goalId !== 'string' && value.goalId != null) changed = true;
494
550
  if (value.disarmedAt != null && !isFiniteNumber(value.disarmedAt)) changed = true;
495
551
  if (value.resumedAt != null && !isFiniteNumber(value.resumedAt)) changed = true;
552
+ // A reason that is not a string is not a readable reason: drop the value
553
+ // (the field itself is known, so this is not "an unknown key").
554
+ if (typeof value.reason !== 'string' && value.reason != null) {
555
+ record.reason = null;
556
+ changed = true;
557
+ }
496
558
  if (record.goalId === null && record.disarmedAt === null && record.resumedAt === null) {
497
559
  changed = true;
498
560
  continue;
@@ -715,6 +777,31 @@ function normalizeRequesters(raw) {
715
777
  * @param {unknown} raw
716
778
  * @returns {{ restart: object|null, changed: boolean }}
717
779
  */
780
+ /**
781
+ * Coerce the "this request already took a snapshot" record.
782
+ *
783
+ * It is a pointer plus the numbers of that copy, and nothing else: the profile
784
+ * itself lives in the backup directory, which is checked against the manifest
785
+ * before it is used again.
786
+ *
787
+ * @param {unknown} raw
788
+ * @returns {{ requestedAt: number, dir: string, profileDir: string|null, files: number, bytes: number, total: number, at: number }|null}
789
+ */
790
+ function normalizeRestartSnapshot(raw) {
791
+ if (!isObject(raw)) return null;
792
+ if (typeof raw.dir !== 'string' || raw.dir === '') return null;
793
+ const number = (value) => (isFiniteNumber(value) ? Math.max(0, Math.trunc(value)) : 0);
794
+ return {
795
+ requestedAt: number(raw.requestedAt),
796
+ dir: raw.dir,
797
+ profileDir: typeof raw.profileDir === 'string' && raw.profileDir !== '' ? raw.profileDir : null,
798
+ files: number(raw.files),
799
+ bytes: number(raw.bytes),
800
+ total: number(raw.total),
801
+ at: number(raw.at),
802
+ };
803
+ }
804
+
718
805
  function normalizeRestart(raw) {
719
806
  if (!isObject(raw)) return { restart: null, changed: raw !== null && raw !== undefined };
720
807
  let changed = false;
@@ -735,13 +822,23 @@ function normalizeRestart(raw) {
735
822
  // Disarming the goals of goal-driven sessions is the default: a restart must
736
823
  // never deadlock behind a goal that keeps its own agent busy.
737
824
  parkGoals: raw.parkGoals !== false,
825
+ // A restart that must not snapshot (the one that follows a manual rollback,
826
+ // user decision 2026-11-02). It defaults to FALSE: every other restart keeps
827
+ // taking its copy, and only a request that says so in bytes skips it.
828
+ skipBackup: raw.skipBackup === true,
738
829
  };
830
+ // The snapshot is only carried when there IS one: a checkpoint that never took
831
+ // a snapshot must normalize to exactly the object it was.
832
+ const snapshot = normalizeRestartSnapshot(raw.snapshot);
833
+ if (snapshot !== null) restart.snapshot = snapshot;
834
+ else if (raw.snapshot != null) changed = true;
739
835
  if (!isFiniteNumber(raw.generation) || raw.generation < 0) changed = true;
740
836
  if (!isFiniteNumber(raw.requestedAt)) changed = true;
741
837
  if (!isFiniteNumber(raw.attempt) || raw.attempt < 0) changed = true;
742
838
  if (raw.failedAttempts != null && (!isFiniteNumber(raw.failedAttempts) || raw.failedAttempts < 0)) changed = true;
743
839
  if (raw.notBefore != null && !isFiniteNumber(raw.notBefore)) changed = true;
744
840
  if (typeof raw.parkGoals !== 'boolean' && raw.parkGoals != null) changed = true;
841
+ if (typeof raw.skipBackup !== 'boolean' && raw.skipBackup != null) changed = true;
745
842
  if (typeof raw.resume !== 'boolean' && raw.resume != null) changed = true;
746
843
  if (raw.requesters != null && !Array.isArray(raw.requesters)) changed = true;
747
844
  if (Array.isArray(raw.requesters) && requesters.length !== raw.requesters.length) changed = true;
@@ -845,9 +942,110 @@ export function normalizeState(raw, now = Date.now()) {
845
942
  if (onboarding.changed) repaired = true;
846
943
  }
847
944
 
945
+ // THE FIRST SAFE COPY: what happened when this machine looked for a
946
+ // known-good backup and found none. It survives a restart, so a machine with
947
+ // NO safe copy can say so instead of looking healthy.
948
+ if (raw.firstInstallBackup != null) {
949
+ const seeded = normalizeFirstInstallBackup(raw.firstInstallBackup);
950
+ if (seeded === null) repaired = true;
951
+ else state.firstInstallBackup = seeded;
952
+ }
953
+
954
+ // THE SWEEP OF LEFTOVER WATCHER SCRIPTS: which files this platform could not
955
+ // run and therefore removed. It survives a restart, so "leftover files = 0"
956
+ // stays provable instead of being re-derived from an empty directory.
957
+ if (raw.watcherHygiene != null) {
958
+ const hygiene = normalizeWatcherHygiene(raw.watcherHygiene);
959
+ if (hygiene === null) repaired = true;
960
+ else state.watcherHygiene = hygiene;
961
+ }
962
+
963
+ // THE RESTART GATE: why the restart is (or is not) allowed right now. It is a
964
+ // REPORT, not a permission — the live gate decides every time — but the answer
965
+ // of the boot that wrote it must survive to the next boot.
966
+ if (raw.restartGate != null) {
967
+ const gate = normalizeRestartGate(raw.restartGate);
968
+ if (gate === null) repaired = true;
969
+ else state.restartGate = gate;
970
+ }
971
+
848
972
  return { state, repaired };
849
973
  }
850
974
 
975
+ /**
976
+ * Normalize the report of the legacy-watcher sweep.
977
+ *
978
+ * A value that is not an object says nothing about the sweep, so it is dropped
979
+ * (and the caller marks the state as repaired).
980
+ *
981
+ * @param {unknown} raw
982
+ * @returns {{ skipped: boolean, reason: string|null, removed: string[], kept: string[], scanned: number, error: string|null }|null}
983
+ */
984
+ function normalizeWatcherHygiene(raw) {
985
+ if (!isObject(raw)) return null;
986
+ /** @param {unknown} value */
987
+ const names = (value) => (Array.isArray(value)
988
+ ? value.filter((name) => typeof name === 'string' && name !== '')
989
+ : []);
990
+ return {
991
+ skipped: raw.skipped === true,
992
+ reason: typeof raw.reason === 'string' && raw.reason !== '' ? raw.reason : null,
993
+ removed: names(raw.removed),
994
+ kept: names(raw.kept),
995
+ scanned: isFiniteNumber(raw.scanned) && raw.scanned >= 0 ? Math.trunc(raw.scanned) : 0,
996
+ error: typeof raw.error === 'string' && raw.error !== '' ? raw.error : null,
997
+ };
998
+ }
999
+
1000
+ /**
1001
+ * Normalize the report of the restart gate.
1002
+ *
1003
+ * `allow` and `reason` are the answer; without both there is nothing to read, so
1004
+ * the record is dropped. `blockedBy` names the sessions that were in the way.
1005
+ *
1006
+ * @param {unknown} raw
1007
+ * @returns {{ allow: boolean, reason: string, blockedBy: Array<{ sessionId: string|null, reason: string }>, quietForMs: number }|null}
1008
+ */
1009
+ function normalizeRestartGate(raw) {
1010
+ if (!isObject(raw)) return null;
1011
+ const reason = typeof raw.reason === 'string' && raw.reason !== '' ? raw.reason : null;
1012
+ if (typeof raw.allow !== 'boolean' || reason === null) return null;
1013
+ const blockedBy = Array.isArray(raw.blockedBy)
1014
+ ? raw.blockedBy.filter((entry) => isObject(entry)).map((entry) => ({
1015
+ sessionId: typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null,
1016
+ reason: typeof entry.reason === 'string' ? entry.reason : '',
1017
+ }))
1018
+ : [];
1019
+ return {
1020
+ allow: raw.allow,
1021
+ reason,
1022
+ blockedBy,
1023
+ quietForMs: isFiniteNumber(raw.quietForMs) && raw.quietForMs >= 0 ? raw.quietForMs : 0,
1024
+ };
1025
+ }
1026
+
1027
+ /**
1028
+ * Normalize the record of the first-install safe copy.
1029
+ *
1030
+ * A record without a `result` says nothing, so it is dropped (and the caller
1031
+ * marks the state as repaired).
1032
+ *
1033
+ * @param {unknown} raw
1034
+ * @returns {{ result: string, at: number|null, dir: string|null, bytes: number|null, error: string|null }|null}
1035
+ */
1036
+ function normalizeFirstInstallBackup(raw) {
1037
+ if (!isObject(raw)) return null;
1038
+ const result = typeof raw.result === 'string' && raw.result !== '' ? raw.result : null;
1039
+ if (result === null) return null;
1040
+ return {
1041
+ result,
1042
+ at: isFiniteNumber(raw.at) ? raw.at : null,
1043
+ dir: typeof raw.dir === 'string' && raw.dir !== '' ? raw.dir : null,
1044
+ bytes: isFiniteNumber(raw.bytes) ? raw.bytes : null,
1045
+ error: typeof raw.error === 'string' && raw.error !== '' ? raw.error : null,
1046
+ };
1047
+ }
1048
+
851
1049
  /**
852
1050
  * Read a checkpoint from disk. Never throws and never creates directories.
853
1051
  *
@@ -1437,10 +1635,15 @@ export function setContextOverflow(state, sessionId, { over = false, inputTokens
1437
1635
  * Mutates `state` in place and returns it.
1438
1636
  *
1439
1637
  * @param {object} state
1440
- * @param {{ reason?: string, bySessionId?: string|null, resume?: boolean, parkGoals?: boolean, now?: number }} options
1638
+ * @param {{ reason?: string, bySessionId?: string|null, resume?: boolean, parkGoals?: boolean, skipBackup?: boolean, now?: number }} options
1639
+ * `skipBackup: true` marks a restart that MUST NOT take the pre-restart
1640
+ * snapshot. It is off by default; the manual rollback sets it (user decision,
1641
+ * 2026-11-02) because the state this restart brings up was just restored from
1642
+ * a copy, and copying it again only makes a duplicate that squeezes the older
1643
+ * copies out of the list.
1441
1644
  * @returns {object} state
1442
1645
  */
1443
- export function requestRestart(state, { reason, bySessionId = null, resume = true, parkGoals = true, now = Date.now() } = {}) {
1646
+ export function requestRestart(state, { reason, bySessionId = null, resume = true, parkGoals = true, skipBackup = false, now = Date.now() } = {}) {
1444
1647
  const generation = (isFiniteNumber(state.generation) && state.generation >= 0 ? state.generation : 0) + 1;
1445
1648
  state.generation = generation;
1446
1649
  state.phase = 'deferred';
@@ -1478,6 +1681,10 @@ export function requestRestart(state, { reason, bySessionId = null, resume = tru
1478
1681
  // A goal-driven session must not deadlock the restart: disarming its goal is
1479
1682
  // on by default and can be turned off per restart request.
1480
1683
  parkGoals: parkGoals !== false,
1684
+ // THIS REQUEST ASKS FOR NO SNAPSHOT (default: it does want one). The
1685
+ // restart that carries out a manual rollback sets it, so the state that was
1686
+ // just restored is not copied on top of the list a second time.
1687
+ skipBackup: skipBackup === true,
1481
1688
  };
1482
1689
  for (const requester of requesters) {
1483
1690
  if (requester.resume === false) continue;
@@ -0,0 +1,260 @@
1
+ /**
2
+ * dsh-autorestart - the command that removes the STALE handshake cookies.
3
+ *
4
+ * WHY THIS EXISTS:
5
+ *
6
+ * The desktop app mints ONE `dsh-auth-*` cookie per launch. The name carries
7
+ * the random port, the cookie lives 30 days (Max-Age=2592000), and a cookie
8
+ * does NOT carry a port - so the browser sends ALL of them, on every request,
9
+ * to 127.0.0.1.
10
+ *
11
+ * The host answers with Node's default max header size of 16384 bytes. With 71
12
+ * cookies the Cookie header alone is 16188 bytes; add the browser's own headers
13
+ * and the request is 16559 bytes -> the host answers 431 (Request Header
14
+ * Fields Too Large), the desktop app reports the misleading "desktop welcome:
15
+ * Web authentication failed" and dies on startup. The crash loop then trips
16
+ * the official native-fatal recovery, which wipes the profile and drops every
17
+ * plugin but two.
18
+ *
19
+ * Measured on this machine with the real cookies and the real browser headers:
20
+ * 70 cookies -> 16331 bytes -> 200 OK
21
+ * 71 cookies -> 16559 bytes -> 431 Request Header Fields Too Large
22
+ *
23
+ * WHERE IT RUNS: between the kill and the relaunch. Only then has Chromium
24
+ * left, and only then is the cookie jar unlocked.
25
+ *
26
+ * WHAT IT MATCHES (widened, user decision 2026-10-30): every `dsh-auth-%`
27
+ * cookie of `127.0.0.1`, WHATEVER its path. The official code that mints them
28
+ * always writes `Path=/`, but a leftover cookie with another path would still
29
+ * be sent to `127.0.0.1` and would still count against the 16 KB header — and
30
+ * a missed deletion costs far more than a deleted one. Nothing else in the jar
31
+ * is ever addressed.
32
+ *
33
+ * The command is EMPTY when the interpreter or the script cannot be found: the
34
+ * restart then behaves exactly as it did before, and a cleanup that cannot run
35
+ * never blocks a restart.
36
+ *
37
+ * This module is the SINGLE source of the command: the plugin and the
38
+ * verification script both build it here, so a verification can never prove
39
+ * something the plugin does not actually do.
40
+ *
41
+ * THE RESULT IS WRITTEN DOWN: the cleanup script writes
42
+ * `<stateDir>/last-cookie-clear.json` (`{at, ok, removed, reason}`) because it
43
+ * is the only process that can know how many rows it deleted, and the health
44
+ * route reports that record. It is the result of the LAST cleanup, never a
45
+ * live count: the cookie jar is locked by the running browser, so a live count
46
+ * would be a guess dressed as a fact.
47
+ *
48
+ * ESM only, Node builtins only.
49
+ */
50
+
51
+ import fs from 'node:fs';
52
+ import path from 'node:path';
53
+ import { fileURLToPath } from 'node:url';
54
+
55
+ import { resolveNodeInterpreter } from './node-runtime.js';
56
+
57
+ /** The directory this very file lives in - the plugin directory. */
58
+ const PLUGIN_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
59
+
60
+ /** The cleanup script, next to the other rescue scripts. */
61
+ export const COOKIE_CLEAR_SCRIPT_NAME = 'clear-dsh-auth-cookies.mjs';
62
+
63
+ /** How long the cleanup may run before it is killed, in milliseconds. */
64
+ const CLEANUP_TIMEOUT_MS = 20000;
65
+
66
+ /** The file the cleanup script writes its result into (inside the state dir). */
67
+ export const COOKIE_CLEAR_REPORT_NAME = 'last-cookie-clear.json';
68
+
69
+ /** The version of that record. */
70
+ export const COOKIE_CLEAR_REPORT_VERSION = 1;
71
+
72
+ /**
73
+ * The path of the cleanup result.
74
+ *
75
+ * @param {string} stateDir
76
+ * @returns {string}
77
+ */
78
+ export function cookieClearReportPath(stateDir) {
79
+ const root = typeof stateDir === 'string' ? stateDir.trim() : '';
80
+ if (root === '') return '';
81
+ return path.join(root, COOKIE_CLEAR_REPORT_NAME);
82
+ }
83
+
84
+ /**
85
+ * Write the result of a cleanup.
86
+ *
87
+ * The four fields the health route and the settings page read are `at`, `ok`,
88
+ * `removed` and `reason`; the version and the ISO time are there so a human
89
+ * reading the file does not have to convert an epoch.
90
+ *
91
+ * @param {string} stateDir
92
+ * @param {{ at?: number, ok?: boolean, removed?: number|null, reason?: string }} [payload]
93
+ * @returns {{ ok: boolean, path: string, report: object|null, error: string|null }}
94
+ */
95
+ export function writeCookieClearReport(stateDir, payload = {}) {
96
+ const target = cookieClearReportPath(stateDir);
97
+ if (target === '') {
98
+ return { ok: false, path: '', report: null, error: 'there is no state directory to write the result into' };
99
+ }
100
+ const at = Number.isFinite(Number(payload.at)) ? Number(payload.at) : Date.now();
101
+ const report = {
102
+ version: COOKIE_CLEAR_REPORT_VERSION,
103
+ at,
104
+ atIso: new Date(at).toISOString(),
105
+ ok: payload.ok === true,
106
+ removed: Number.isFinite(Number(payload.removed)) ? Math.max(0, Math.trunc(Number(payload.removed))) : null,
107
+ reason: typeof payload.reason === 'string' ? payload.reason : '',
108
+ };
109
+ try {
110
+ fs.mkdirSync(path.dirname(target), { recursive: true });
111
+ const temporary = target + '.tmp';
112
+ // No byte order mark: every reader of this file is JSON.parse.
113
+ fs.writeFileSync(temporary, JSON.stringify(report, null, 2) + '\n', 'utf8');
114
+ fs.renameSync(temporary, target);
115
+ return { ok: true, path: target, report, error: null };
116
+ } catch (error) {
117
+ return {
118
+ ok: false,
119
+ path: target,
120
+ report,
121
+ error: error && error.message ? String(error.message) : String(error),
122
+ };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Read the result of the last cleanup.
128
+ *
129
+ * A file that is not there is an ANSWER ("no cleanup has run yet"), not an
130
+ * error: the very first launch of a fresh installation has none.
131
+ *
132
+ * @param {string} stateDir
133
+ * @returns {{ ok: boolean, report: object|null, error: string|null }}
134
+ */
135
+ export function readCookieClearReport(stateDir) {
136
+ const target = cookieClearReportPath(stateDir);
137
+ if (target === '') return { ok: false, report: null, error: 'there is no state directory to read the result from' };
138
+ let text = '';
139
+ try {
140
+ text = fs.readFileSync(target, 'utf8');
141
+ } catch (error) {
142
+ const missing = error && error.code === 'ENOENT';
143
+ return {
144
+ ok: false,
145
+ report: null,
146
+ error: missing ? 'no cleanup has run yet' : (error && error.message ? String(error.message) : String(error)),
147
+ };
148
+ }
149
+ try {
150
+ const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
151
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
152
+ return { ok: false, report: null, error: 'the cleanup result is not an object' };
153
+ }
154
+ return { ok: true, report: parsed, error: null };
155
+ } catch (error) {
156
+ return {
157
+ ok: false,
158
+ report: null,
159
+ error: 'the cleanup result cannot be parsed: ' + (error && error.message ? String(error.message) : String(error)),
160
+ };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The cleanup result as the health route reports it.
166
+ *
167
+ * TWO HONESTY RULES, both written down here so nobody "improves" them later:
168
+ *
169
+ * 1. the numbers are those of the LAST cleanup, never a live count. The
170
+ * cookie jar is an open SQLite database of a RUNNING browser; reading it
171
+ * while Chromium holds it either fails or (worse) reports a number that
172
+ * is already stale. `live` is therefore always `null`, and the note says
173
+ * why.
174
+ * 2. "no cleanup has run yet" and "the cleanup could not run" are different
175
+ * answers, and neither is reported as success.
176
+ *
177
+ * @param {object|null} report the parsed `last-cookie-clear.json`, if any
178
+ * @param {{ readError?: string|null }} [meta]
179
+ * @returns {{
180
+ * at: number|null, atIso: string|null, ok: boolean|null, removed: number|null,
181
+ * reason: string, live: null, liveNote: string, source: string,
182
+ * }}
183
+ */
184
+ export function summarizeCookieClear(report, meta = {}) {
185
+ const source = 'last-cookie-clear.json';
186
+ const liveNote = 'the live number of cookies cannot be read: the cookie jar belongs to the running browser and is locked; this is the result of the last cleanup';
187
+ const record = report && typeof report === 'object' ? report : null;
188
+ if (record === null) {
189
+ return {
190
+ at: null,
191
+ atIso: null,
192
+ ok: null,
193
+ removed: null,
194
+ reason: typeof meta.readError === 'string' && meta.readError !== ''
195
+ ? String(meta.readError)
196
+ : 'no cleanup has run yet',
197
+ live: null,
198
+ liveNote,
199
+ source,
200
+ };
201
+ }
202
+ const at = Number.isFinite(Number(record.at)) ? Number(record.at) : null;
203
+ return {
204
+ at,
205
+ atIso: typeof record.atIso === 'string' ? record.atIso : (at === null ? null : new Date(at).toISOString()),
206
+ ok: record.ok === true,
207
+ removed: Number.isFinite(Number(record.removed)) ? Number(record.removed) : null,
208
+ reason: typeof record.reason === 'string' ? record.reason : '',
209
+ live: null,
210
+ liveNote,
211
+ source,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Escape a value for a single-quoted PowerShell string.
217
+ *
218
+ * @param {unknown} value
219
+ * @returns {string}
220
+ */
221
+ export function psQuote(value) {
222
+ return "'" + String(value == null ? '' : value).replace(/'/g, "''") + "'";
223
+ }
224
+
225
+ /**
226
+ * Build the command that removes the stale handshake cookies.
227
+ *
228
+ * @param {{
229
+ * pluginDir?: string,
230
+ * execPath?: string,
231
+ * platform?: string,
232
+ * timeoutMs?: number,
233
+ * }} [options]
234
+ * @returns {string} the command, or '' when it cannot be built
235
+ */
236
+ export function buildCookieClearCommand(options = {}) {
237
+ try {
238
+ const pluginDir = typeof options.pluginDir === 'string' && options.pluginDir !== ''
239
+ ? options.pluginDir
240
+ : PLUGIN_DIR;
241
+ const script = path.join(pluginDir, 'scripts', COOKIE_CLEAR_SCRIPT_NAME);
242
+ if (!fs.existsSync(script)) return '';
243
+ const interpreter = resolveNodeInterpreter({
244
+ execPath: typeof options.execPath === 'string' ? options.execPath : process.execPath,
245
+ platform: typeof options.platform === 'string' ? options.platform : process.platform,
246
+ });
247
+ if (!interpreter.ok) return '';
248
+ const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
249
+ ? Math.trunc(options.timeoutMs)
250
+ : CLEANUP_TIMEOUT_MS;
251
+ // The cleanup runs as its own hidden process with a wait timeout: if it
252
+ // hangs, it is killed and the relaunch continues anyway.
253
+ return 'powershell.exe -NoProfile -NonInteractive -Command '
254
+ + '"$p = Start-Process -FilePath ' + psQuote(interpreter.path)
255
+ + ' -ArgumentList ' + psQuote(script)
256
+ + ' -WindowStyle Hidden -PassThru; if ($null -ne $p) { if (-not $p.WaitForExit(' + timeoutMs + ')) { $p.Kill() } }"';
257
+ } catch {
258
+ return '';
259
+ }
260
+ }
package/lib/lifecycle.js CHANGED
@@ -293,8 +293,17 @@ export function restartDecision(
293
293
  const requestedAt = isFiniteNumber(restart.requestedAt) ? restart.requestedAt : 0;
294
294
  const attempt = isFiniteNumber(restart.attempt) ? restart.attempt : 0;
295
295
  const notBefore = isFiniteNumber(restart.notBefore) ? restart.notBefore : 0;
296
- const softDeadline = requestedAt + (isFiniteNumber(softMs) ? softMs : 0);
297
- const hardDeadline = requestedAt + (isFiniteNumber(hardMs) ? hardMs : 0);
296
+
297
+ // NO DEADLINES (user decision, 2026-11-02).
298
+ //
299
+ // `softMs` / `hardMs` stay in the option list so no caller has to change, and
300
+ // they are IGNORED: a pending restart waits for the idle gate and nothing else.
301
+ // There is no soft window, no hard deadline, no timer and no default that can
302
+ // end the request — it ends in exactly two ways:
303
+ // 1. the gate opens and the restart runs;
304
+ // 2. the user withdraws it (the cancel route, handled by the caller).
305
+ // The old design reported "the hard deadline has passed", which read like "the
306
+ // request is void" — it never was, and it only confused everybody reading it.
298
307
 
299
308
  // Backoff after a failed scheduling attempt: wait without re-planning.
300
309
  if (notBefore > 0 && now < notBefore) return { action: 'wait', reason: 'restart-backoff' };
@@ -312,50 +321,24 @@ export function restartDecision(
312
321
  return { action: 'restart', reason: 'no-agent-running' };
313
322
  }
314
323
 
315
- if (now < softDeadline) return { action: 'wait', reason: 'agents-busy-within-soft-window' };
316
-
317
- if (now < hardDeadline) {
318
- if (restart.softWarned === true) {
319
- return { action: 'wait', reason: 'agents-busy-awaiting-hard-deadline' };
320
- }
324
+ // Somebody is still running. The ONLY thing that may act on that is the explicit
325
+ // opt-in `forceRestartWhenBusy` — it restarts OVER a running conversation, and
326
+ // it says so in its own name. It is not a deadline and it is off by default.
327
+ if (forceRestartWhenBusy === true && policy === 'auto' && state.phase === 'deferred') {
328
+ if (attempt > 0) return { action: 'give-up', reason: 'forced-restart-already-attempted' };
321
329
  return {
322
- action: 'warn',
323
- reason: 'agents-busy-past-soft-deadline',
324
- log: 'dsh-autorestart: agents are still running after the soft deadline; waiting for the hard deadline, where the restart is only forced if forceRestartWhenBusy is enabled',
325
- markSoftWarned: true,
330
+ action: 'force-restart',
331
+ reason: 'force-restart-while-busy',
332
+ log: 'dsh-autorestart: forceRestartWhenBusy is enabled, so the restart interrupts the running conversations',
326
333
  };
327
334
  }
328
335
 
329
- // Hard deadline reached while a conversation is still running.
330
- //
331
- // From here on the plugin does NOT pretend that "everything is fine, we are
332
- // just waiting a little longer": the wait has become a LONG wait, and the
333
- // caller (index.js) has to make it VISIBLE — in the log and in the state
334
- // payload — while KEEPING the user's restart request alive. The request is
335
- // never silently dropped.
336
- if (policy === 'wait') {
337
- return {
338
- action: 'long-wait',
339
- reason: 'hard-deadline-policy-wait',
340
- log: 'defer policy wait: still busy after hard deadline',
341
- blockedBy: busy,
342
- waitingSince: requestedAt,
343
- };
344
- }
345
- // The hard deadline only forces a restart when the operator explicitly opted in
346
- // (forceRestartWhenBusy) AND the restart is actually deferred. Every other
347
- // combination keeps waiting: a running conversation is never killed.
348
- if (forceRestartWhenBusy === true && policy === 'auto' && state.phase === 'deferred') {
349
- if (attempt > 0) return { action: 'give-up', reason: 'forced-restart-already-attempted' };
350
- return { action: 'force-restart', reason: 'hard-deadline-force-restart' };
351
- }
352
- // forceRestartWhenBusy is disabled (the default): the restart keeps waiting
353
- // until the idle gate is quiet again, and it never gives up. The result says
354
- // so explicitly — a long wait, with the sessions that hold it.
336
+ // Everything else keeps waiting, for as long as it takes. The answer says so
337
+ // explicitly — a long wait, with the sessions that hold it, and no end time.
355
338
  return {
356
339
  action: 'long-wait',
357
- reason: 'hard-deadline-waiting-for-conversations',
358
- log: 'dsh-autorestart: hard deadline reached but conversations are still running; forceRestartWhenBusy is disabled, so the restart keeps waiting',
340
+ reason: 'waiting-for-conversations',
341
+ log: 'dsh-autorestart: conversations are still running; the restart keeps waiting until they are done',
359
342
  blockedBy: busy,
360
343
  waitingSince: requestedAt,
361
344
  };