dsh-autorestart 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * dsh-autorestart — ONE task count for every surface.
3
+ *
4
+ * A TASK is one conversation of the left sidebar: the session itself. A
5
+ * subagent belongs to the session that spawned it, so a session and its
6
+ * subagents are ONE task. The sidebar counts them once — so the bubble has
7
+ * to count them the same way.
8
+ *
9
+ * THE DEFECT THIS FIXES
10
+ *
11
+ * The real machine showed this:
12
+ *
13
+ * blockedBy: [1] session (父会话 A)
14
+ * [2] subagent (父 = A)
15
+ * [3] subagent (父 = B)
16
+ *
17
+ * The sidebar showed TWO busy conversations, and the bubble said "waiting for
18
+ * 3 tasks to finish". It counted every BLOCKER, so a parent session and its
19
+ * own subagent were counted twice.
20
+ *
21
+ * THE RULE (one rule, four surfaces)
22
+ *
23
+ * - a session is a task of its own: its key is its own session id;
24
+ * - a subagent belongs to the session that created it: its key is the
25
+ * PARENT's session id, so a parent and its subagents are ONE task;
26
+ * - a subagent whose parent is only known by its TITLE joins the session
27
+ * with that title (the shutdown card reports the title but no id);
28
+ * - a subagent with no known parent at all is a task of its own;
29
+ * - a pending restart is not a task: it is what the shutdown waits for.
30
+ *
31
+ * THE TWO HALVES
32
+ *
33
+ * The browser half of this rule lives in `client.js`: the client is a CLASSIC
34
+ * script and cannot import an ES module. The two implementations MUST agree,
35
+ * and `test/task-count.test.mjs` runs the same cases through both — so a
36
+ * change on one side that is not made on the other fails the suite.
37
+ *
38
+ * ESM only, Node builtins only.
39
+ */
40
+
41
+ /** The prefix of the key of a family that is known by its session id. */
42
+ const SESSION_KEY = 'session:'
43
+
44
+ /** The prefix of the key of a family that is only known by its title. */
45
+ const TITLE_KEY = 'title:'
46
+
47
+ /**
48
+ * One trimmed string field of a blocker.
49
+ *
50
+ * @param {any} entry
51
+ * @param {string} key
52
+ * @returns {string}
53
+ */
54
+ function field(entry, key) {
55
+ if (!entry || typeof entry !== 'object') return ''
56
+ const value = entry[key]
57
+ return typeof value === 'string' ? value.trim() : ''
58
+ }
59
+
60
+ /**
61
+ * WHAT a blocker is: a session, a subagent or a pending restart.
62
+ *
63
+ * The kind is NOT the only evidence: an entry that names a parent session
64
+ * (`parentSessionId` / `parentTitle`) IS a subagent, whatever the Host
65
+ * called it.
66
+ *
67
+ * @param {any} entry
68
+ * @returns {'session'|'subagent'|'restart-pending'}
69
+ */
70
+ export function blockerKind(entry) {
71
+ if (!entry || typeof entry !== 'object') return 'session'
72
+ if (entry.kind === 'restart-pending') return 'restart-pending'
73
+ if (entry.kind === 'subagent') return 'subagent'
74
+ if (entry.kind === 'session') return 'session'
75
+ if (field(entry, 'parentSessionId') !== '' || field(entry, 'parentTitle') !== '') return 'subagent'
76
+ return 'session'
77
+ }
78
+
79
+ /**
80
+ * The session id of a blocker.
81
+ *
82
+ * @param {any} entry
83
+ * @returns {string}
84
+ */
85
+ function sessionIdOf(entry) {
86
+ if (!entry || typeof entry !== 'object') return ''
87
+ const value = entry.sessionId
88
+ return value == null ? '' : String(value).trim()
89
+ }
90
+
91
+ /**
92
+ * The title of the session a subagent belongs to.
93
+ *
94
+ * @param {any} entry
95
+ * @returns {string}
96
+ */
97
+ function parentTitleOf(entry) {
98
+ return field(entry, 'parentTitle') || field(entry, 'label')
99
+ }
100
+
101
+ /**
102
+ * The title of a blocker (for a session: its own title).
103
+ *
104
+ * @param {any} entry
105
+ * @returns {string}
106
+ */
107
+ function titleOf(entry) {
108
+ return field(entry, 'label') || field(entry, 'title')
109
+ }
110
+
111
+ /**
112
+ * The map "title of a session" → "session id" of the sessions in the list.
113
+ *
114
+ * It is the fallback for the surfaces that report the parent by its title
115
+ * only (the shutdown card): without it, the members of a team would become
116
+ * tasks of their own.
117
+ *
118
+ * @param {any[]} entries
119
+ * @returns {Map<string, string>}
120
+ */
121
+ function sessionTitles(entries) {
122
+ const map = new Map()
123
+ for (const entry of entries) {
124
+ if (!entry || typeof entry !== 'object') continue
125
+ if (blockerKind(entry) === 'subagent') continue
126
+ const title = titleOf(entry)
127
+ if (title === '') continue
128
+ const id = sessionIdOf(entry)
129
+ if (id === '') continue
130
+ if (!map.has(title)) map.set(title, id)
131
+ }
132
+ return map
133
+ }
134
+
135
+ /**
136
+ * The key of the family every entry belongs to.
137
+ *
138
+ * One key per entry, in the order of the list ('' for an entry that is not a
139
+ * task at all).
140
+ *
141
+ * @param {any[]} entries the blockers the Host reported
142
+ * @returns {string[]}
143
+ */
144
+ export function blockerTaskKeys(entries) {
145
+ const list = Array.isArray(entries) ? entries : []
146
+ const titles = sessionTitles(list)
147
+ const keys = []
148
+ for (let index = 0; index < list.length; index += 1) {
149
+ const entry = list[index]
150
+ if (!entry || typeof entry !== 'object') {
151
+ keys.push('')
152
+ continue
153
+ }
154
+ const kind = blockerKind(entry)
155
+ if (kind === 'restart-pending') {
156
+ // A pending restart is not a task: it is what the shutdown waits for.
157
+ keys.push('')
158
+ continue
159
+ }
160
+ if (kind === 'subagent') {
161
+ const parentId = field(entry, 'parentSessionId')
162
+ if (parentId !== '') {
163
+ keys.push(SESSION_KEY + parentId)
164
+ continue
165
+ }
166
+ const parentTitle = parentTitleOf(entry)
167
+ if (parentTitle !== '') {
168
+ const known = titles.get(parentTitle)
169
+ keys.push(known ? SESSION_KEY + known : TITLE_KEY + parentTitle)
170
+ continue
171
+ }
172
+ const own = sessionIdOf(entry)
173
+ keys.push(own !== '' ? SESSION_KEY + own : 'entry:' + index)
174
+ continue
175
+ }
176
+ const own = sessionIdOf(entry)
177
+ keys.push(own !== '' ? SESSION_KEY + own : 'entry:' + index)
178
+ }
179
+ return keys
180
+ }
181
+
182
+ /**
183
+ * How many TASKS the blockers are.
184
+ *
185
+ * A parent session and its own subagents are ONE task: the same number the
186
+ * left sidebar shows.
187
+ *
188
+ * @param {any[]} entries the blockers the Host reported
189
+ * @returns {number}
190
+ */
191
+ export function countBlockerTasks(entries) {
192
+ const keys = blockerTaskKeys(entries)
193
+ const families = new Set()
194
+ for (const key of keys) {
195
+ if (key !== '') families.add(key)
196
+ }
197
+ return families.size
198
+ }
199
+
200
+ /**
201
+ * How many of those tasks are carried by SUBAGENTS ONLY.
202
+ *
203
+ * A task whose session is idle and whose subagent works is a task of the
204
+ * subagent — that is what the card tells the user ("2 of them are
205
+ * subagents"). A task whose session works itself is not one of them, even
206
+ * when it has subagents of its own.
207
+ *
208
+ * @param {any[]} entries the blockers the Host reported
209
+ * @returns {number}
210
+ */
211
+ export function countSubagentTasks(entries) {
212
+ const list = Array.isArray(entries) ? entries : []
213
+ const keys = blockerTaskKeys(list)
214
+ const families = new Map()
215
+ list.forEach((entry, index) => {
216
+ const key = keys[index]
217
+ if (key === '') return
218
+ const family = families.get(key) || { session: false, subagent: false }
219
+ if (blockerKind(entry) === 'subagent') family.subagent = true
220
+ else family.session = true
221
+ families.set(key, family)
222
+ })
223
+ let count = 0
224
+ for (const family of families.values()) {
225
+ if (family.subagent && !family.session) count += 1
226
+ }
227
+ return count
228
+ }
package/lib/checkpoint.js CHANGED
@@ -106,6 +106,7 @@ const TOP_LEVEL_KEYS = new Set([
106
106
  'parkedGoals',
107
107
  'disarmedGoals',
108
108
  'seenFeatureVersions',
109
+ 'onboarding',
109
110
  'sleep',
110
111
  'updatedAt',
111
112
  ]);
@@ -175,11 +176,84 @@ export function createEmptyState(now = Date.now()) {
175
176
  restart: null,
176
177
  sessions: {},
177
178
  seenFeatureVersions: {},
179
+ onboarding: null,
178
180
  sleep: null,
179
181
  updatedAt: now,
180
182
  };
181
183
  }
182
184
 
185
+ /**
186
+ * Normalize the record of the one-time questions.
187
+ *
188
+ * The record is `{ feature, version, answeredAt, choice }`: WHICH question was
189
+ * answered (the id of the feature), the version of the question, when it was
190
+ * answered, and WHAT the user chose.
191
+ *
192
+ * A broken record is DROPPED: a question whose answer cannot be read is
193
+ * asked again — that is much better than a card that never comes back
194
+ * because of a half-written file.
195
+ *
196
+ * @param {unknown} raw
197
+ * @returns {{ onboarding: object|null, changed: boolean }}
198
+ */
199
+ function normalizeOnboarding(raw) {
200
+ if (raw == null) return { onboarding: null, changed: false };
201
+ if (!isObject(raw)) return { onboarding: null, changed: true };
202
+ const feature = nullableString(raw.feature);
203
+ const version = nullableString(raw.version);
204
+ const choice = nullableString(raw.choice);
205
+ const answeredAt = nullableNumber(raw.answeredAt);
206
+ if (feature === null || version === null || choice === null || answeredAt === null) {
207
+ return { onboarding: null, changed: true };
208
+ }
209
+ // The values that were in effect BEFORE the answer: they are what the undo
210
+ // puts back.
211
+ const previous = isObject(raw.previous)
212
+ ? {
213
+ restartMode: nullableString(raw.previous.restartMode),
214
+ restartCommand: nullableString(raw.previous.restartCommand),
215
+ }
216
+ : null;
217
+ return { onboarding: { feature, version, choice, answeredAt, previous }, changed: false };
218
+ }
219
+
220
+ /**
221
+ * Record the answer of a one-time question.
222
+ *
223
+ * @param {object} state the checkpoint state (mutated in place)
224
+ * @param {{
225
+ * feature?: string,
226
+ * version?: string,
227
+ * choice?: string,
228
+ * previous?: { restartMode?: string, restartCommand?: string }|null,
229
+ * now?: number,
230
+ * }} answer
231
+ * @returns {object} the recorded answer
232
+ */
233
+ export function recordOnboarding(state, {
234
+ feature = '',
235
+ version = '',
236
+ choice = '',
237
+ previous = null,
238
+ now = Date.now(),
239
+ } = {}) {
240
+ const record = {
241
+ feature: String(feature ?? ''),
242
+ version: String(version ?? ''),
243
+ choice: String(choice ?? ''),
244
+ answeredAt: Number.isFinite(Number(now)) ? Number(now) : Date.now(),
245
+ previous: previous && typeof previous === 'object'
246
+ ? {
247
+ restartMode: typeof previous.restartMode === 'string' ? previous.restartMode : null,
248
+ restartCommand: typeof previous.restartCommand === 'string' ? previous.restartCommand : null,
249
+ }
250
+ : null,
251
+ };
252
+ if (!state || typeof state !== 'object') return record;
253
+ state.onboarding = record;
254
+ return record;
255
+ }
256
+
183
257
  /**
184
258
  * Normalize one persisted goal record. Unknown or wrong-typed fields are
185
259
  * dropped; `{ goal: null }` means "no goal recorded".
@@ -610,6 +684,14 @@ export function normalizeState(raw, now = Date.now()) {
610
684
  if (sleep.changed) repaired = true;
611
685
  }
612
686
 
687
+ // The answer of the one-time questions: it has to survive a restart, so a
688
+ // question is never asked twice.
689
+ if (raw.onboarding != null) {
690
+ const onboarding = normalizeOnboarding(raw.onboarding);
691
+ state.onboarding = onboarding.onboarding;
692
+ if (onboarding.changed) repaired = true;
693
+ }
694
+
613
695
  return { state, repaired };
614
696
  }
615
697
 
@@ -1271,6 +1353,71 @@ export function cancelRestart(state, { now = Date.now() } = {}) {
1271
1353
  return state;
1272
1354
  }
1273
1355
 
1356
+ /**
1357
+ * Withdraw ONE requester from a pending restart — "cancel MY restart".
1358
+ *
1359
+ * A restart request is COALESCED: several sessions may ask for the same
1360
+ * restart cycle, and every one of them is kept in `restart.requesters`. So
1361
+ * "withdraw the restart I asked for" must NOT take the other requesters
1362
+ * with it — that is how one session could cancel everybody's restart.
1363
+ *
1364
+ * The rules:
1365
+ *
1366
+ * - the session must be a requester; if it is not, NOTHING is changed
1367
+ * (`reason: 'not-a-requester'`) — never a silent "cancel everything";
1368
+ * - the session id is required (`reason: 'no-session-id'` when it is
1369
+ * missing) — it is never guessed;
1370
+ * - the last requester leaving really cancels the restart (`cancelled:
1371
+ * true`), with the same cleanup as {@link cancelRestart};
1372
+ * - while other requesters remain, the restart stays and
1373
+ * `restart.bySessionId` is re-pointed at the first of them when it
1374
+ * pointed at the session that just left.
1375
+ *
1376
+ * Mutates `state` in place and returns the outcome.
1377
+ *
1378
+ * @param {object} state
1379
+ * @param {{ sessionId?: string|null, now?: number }} options
1380
+ * @returns {{ removed: boolean, cancelled: boolean, remaining: number, requesters: string[], reason: string }}
1381
+ */
1382
+ export function cancelOwnRestart(state, { sessionId = null, now = Date.now() } = {}) {
1383
+ const restart = isObject(state.restart) ? state.restart : null;
1384
+ if (!restart) {
1385
+ return { removed: false, cancelled: false, remaining: 0, requesters: [], reason: 'no-restart-pending' };
1386
+ }
1387
+ const requesters = normalizeRequesters(restart.requesters);
1388
+ const ids = requesters.map((entry) => entry.sessionId);
1389
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
1390
+ if (id === '') {
1391
+ return { removed: false, cancelled: false, remaining: ids.length, requesters: ids, reason: 'no-session-id' };
1392
+ }
1393
+ // The requester list is the truth. The legacy fallback covers a state file
1394
+ // that knows only `bySessionId` (no requesters at all).
1395
+ const inList = ids.includes(id);
1396
+ const legacyOnly = !inList && requesters.length === 0 && restart.bySessionId === id;
1397
+ if (!inList && !legacyOnly) {
1398
+ return { removed: false, cancelled: false, remaining: ids.length, requesters: ids, reason: 'not-a-requester' };
1399
+ }
1400
+ const kept = requesters.filter((entry) => entry.sessionId !== id);
1401
+ if (kept.length === 0) {
1402
+ // The LAST requester left: the restart really goes away.
1403
+ cancelRestart(state, { now });
1404
+ return { removed: true, cancelled: true, remaining: 0, requesters: [], reason: 'cancelled' };
1405
+ }
1406
+ // Other sessions are still waiting for this restart: it stays.
1407
+ restart.requesters = kept;
1408
+ if (restart.bySessionId === id) {
1409
+ restart.bySessionId = kept[0].sessionId;
1410
+ }
1411
+ state.updatedAt = now;
1412
+ return {
1413
+ removed: true,
1414
+ cancelled: false,
1415
+ remaining: kept.length,
1416
+ requesters: kept.map((entry) => entry.sessionId),
1417
+ reason: 'withdrawn',
1418
+ };
1419
+ }
1420
+
1274
1421
  /**
1275
1422
  * Record the shutdown intent: "switch the machine off when the work is done".
1276
1423
  *
@@ -0,0 +1,180 @@
1
+ /**
2
+ * dsh-autorestart — THE DIAGNOSTICS BEHIND "REPORT A PROBLEM".
3
+ *
4
+ * When something goes wrong, the user should be able to hand us the facts in
5
+ * one click — without writing anything down by hand and without leaking
6
+ * anything private.
7
+ *
8
+ * There is exactly ONE generator (`buildDiagnostics`): the host builds the
9
+ * text, the browser copies it. Two generators would drift, and a drifted
10
+ * diagnostic is worse than none.
11
+ *
12
+ * WHAT IS IN IT: the plugin version, the platform, whether this is the
13
+ * desktop client, how the host was started (the mode AND the evidence the
14
+ * verdict rests on), who supervises it, the restart mode, and the verdict of
15
+ * the last restart (with the checks that failed).
16
+ *
17
+ * WHAT IS NEVER IN IT: the access token, a URL that carries a token, or any
18
+ * secret. The generator only copies the fields it knows, so a token cannot
19
+ * leak through a field nobody thought about — and everything that IS copied
20
+ * goes through the redaction again.
21
+ *
22
+ * ESM only, Node builtins only.
23
+ */
24
+
25
+ /** The mailbox that works when GitHub does not. */
26
+ export const SUPPORT_EMAIL = '252170229@qq.com'
27
+
28
+ /** Where the "new issue" page lives. */
29
+ export const ISSUE_URL_BASE = 'https://github.com/Eason-22/dsh-autorestart/issues/new'
30
+
31
+ /** The body of a pre-filled issue must stay short: URLs have a length limit. */
32
+ export const DIAGNOSTIC_BODY_LIMIT = 800
33
+
34
+ /**
35
+ * The patterns that must never survive into a diagnostic.
36
+ *
37
+ * They cover the access token of the instance, the query parameters that
38
+ * carry it, and the usual "key: value" secrets.
39
+ */
40
+ const SECRET_PATTERNS = Object.freeze([
41
+ /([?&](?:token|access[_-]?token|api[_-]?key|apikey|secret|password|auth|authorization)=)[^&\s"'<>]+/gi,
42
+ /("|')?(?:token|access[_-]?token|api[_-]?key|apikey|secret|password|authorization)("|')?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi,
43
+ /\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi,
44
+ ])
45
+
46
+ /**
47
+ * Remove anything that looks like a secret from a piece of text.
48
+ *
49
+ * @param {unknown} value
50
+ * @returns {string}
51
+ */
52
+ export function redactSecrets(value) {
53
+ let text = typeof value === 'string' ? value : String(value ?? '')
54
+ for (const pattern of SECRET_PATTERNS) {
55
+ text = text.replace(pattern, '[redacted]')
56
+ }
57
+ return text
58
+ }
59
+
60
+ /**
61
+ * Shorten a path: the last four segments are kept (the user name may stay),
62
+ * everything before them is cut.
63
+ *
64
+ * @param {unknown} value
65
+ * @returns {string}
66
+ */
67
+ export function truncatePath(value) {
68
+ const text = typeof value === 'string' ? value.trim() : ''
69
+ if (text === '') return ''
70
+ const parts = text.split(/[\\/]+/).filter((part) => part !== '')
71
+ if (parts.length <= 4) return text
72
+ return '…/' + parts.slice(-4).join('/')
73
+ }
74
+
75
+ /**
76
+ * Cut a text to a length limit (the issue body has to stay short).
77
+ *
78
+ * @param {string} text
79
+ * @param {number} [limit]
80
+ * @returns {string}
81
+ */
82
+ export function truncateBody(text, limit = DIAGNOSTIC_BODY_LIMIT) {
83
+ const source = typeof text === 'string' ? text : ''
84
+ const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.trunc(Number(limit)) : DIAGNOSTIC_BODY_LIMIT
85
+ if (source.length <= max) return source
86
+ return source.slice(0, Math.max(0, max - 1)) + '…'
87
+ }
88
+
89
+ /**
90
+ * The pre-filled "new issue" URL.
91
+ *
92
+ * @param {string} base
93
+ * @param {string} title
94
+ * @param {string} body
95
+ * @returns {string}
96
+ */
97
+ export function buildIssueUrl(base, title, body) {
98
+ const root = typeof base === 'string' && base.trim() !== '' ? base.trim() : ISSUE_URL_BASE
99
+ const safeTitle = truncateBody(redactSecrets(title), 200)
100
+ const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
101
+ return root + '?title=' + encodeURIComponent(safeTitle) + '&body=' + encodeURIComponent(safeBody)
102
+ }
103
+
104
+ /**
105
+ * The `mailto:` link (the fallback when there is no GitHub account).
106
+ *
107
+ * @param {string} email
108
+ * @param {string} subject
109
+ * @param {string} body
110
+ * @returns {string}
111
+ */
112
+ export function buildMailtoUrl(email, subject, body) {
113
+ const to = typeof email === 'string' && email.trim() !== '' ? email.trim() : SUPPORT_EMAIL
114
+ const safeSubject = truncateBody(redactSecrets(subject), 200)
115
+ const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
116
+ return 'mailto:' + to + '?subject=' + encodeURIComponent(safeSubject) + '&body=' + encodeURIComponent(safeBody)
117
+ }
118
+
119
+ /**
120
+ * Read one field out of a payload, without ever throwing.
121
+ *
122
+ * @param {any} source
123
+ * @param {string} key
124
+ * @param {any} [fallback]
125
+ * @returns {any}
126
+ */
127
+ function field(source, key, fallback = '') {
128
+ if (!source || typeof source !== 'object') return fallback
129
+ const value = source[key]
130
+ return value === undefined || value === null ? fallback : value
131
+ }
132
+
133
+ /**
134
+ * The diagnostic text of this host.
135
+ *
136
+ * It is a PURE function of the payload: the same facts always produce the
137
+ * same text, and nothing outside the fields listed below can ever end up in
138
+ * it.
139
+ *
140
+ * @param {{
141
+ * version?: string,
142
+ * platform?: string,
143
+ * hostKind?: string,
144
+ * isDesktopApp?: boolean,
145
+ * startup?: { mode?: string, kind?: string, evidence?: string },
146
+ * supervisor?: { mode?: string, kind?: string },
147
+ * restartMode?: string,
148
+ * lastRestart?: { result?: string, failedChecks?: string[] },
149
+ * stateDir?: string,
150
+ * }} [payload]
151
+ * @returns {string}
152
+ */
153
+ export function buildDiagnostics(payload = {}) {
154
+ const source = payload && typeof payload === 'object' ? payload : {}
155
+ const startup = field(source, 'startup', {})
156
+ const supervisor = field(source, 'supervisor', {})
157
+ const lastRestart = field(source, 'lastRestart', {})
158
+ const failedChecks = Array.isArray(field(lastRestart, 'failedChecks', []))
159
+ ? field(lastRestart, 'failedChecks', []).map((check) => redactSecrets(String(check)))
160
+ : []
161
+
162
+ const lines = [
163
+ '--- dsh-autorestart diagnostics ---',
164
+ 'plugin version: ' + redactSecrets(field(source, 'version', 'unknown')),
165
+ 'platform: ' + redactSecrets(field(source, 'platform', 'unknown')),
166
+ 'host kind: ' + redactSecrets(field(source, 'hostKind', 'unknown')),
167
+ 'desktop client: ' + (field(source, 'isDesktopApp', false) === true ? 'yes' : 'no'),
168
+ 'startup mode: ' + redactSecrets(field(startup, 'mode', 'unknown')),
169
+ 'startup evidence: ' + redactSecrets(field(startup, 'evidence', 'unknown')),
170
+ 'supervisor mode: ' + redactSecrets(field(supervisor, 'mode', 'unknown')),
171
+ 'supervisor kind: ' + redactSecrets(field(supervisor, 'kind', 'unknown')),
172
+ 'restart mode: ' + redactSecrets(field(source, 'restartMode', 'unknown')),
173
+ 'last restart result: ' + redactSecrets(field(lastRestart, 'result', 'unknown')),
174
+ 'last restart failed checks: ' + (failedChecks.length > 0 ? failedChecks.join(', ') : 'none'),
175
+ ]
176
+ const stateDir = truncatePath(field(source, 'stateDir', ''))
177
+ if (stateDir !== '') lines.push('dsh home: ' + redactSecrets(stateDir))
178
+ lines.push('--- end ---')
179
+ return lines.join('\n')
180
+ }