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.
- package/CHANGELOG.md +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- 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
|
-
/**
|
|
96
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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: '
|
|
323
|
-
reason: '
|
|
324
|
-
log: 'dsh-autorestart:
|
|
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
|
-
//
|
|
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: '
|
|
358
|
-
log: 'dsh-autorestart:
|
|
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
|
};
|