dsh-sidebar-suite 0.2.7 → 0.2.8

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.
@@ -1,842 +1,918 @@
1
- // dsh-sidebar-suite — temporary session host half (0.2.0).
2
- //
3
- // What changed from 0.1.x, and why
4
- // --------------------------------
5
- // 0.1.x kept the conversation "in memory" and drew its own window. That could
6
- // never satisfy two requirements at once: a temporary chat must show up in the
7
- // left sidebar next to normal sessions (so the user can switch tasks), and it
8
- // must use the host's own conversation window.
9
- //
10
- // So from 0.2.0 the Client half creates a REAL DSH session through the host's
11
- // own navigation service (`uiWorkspace.createNamed` + `openSession`) — exactly
12
- // what the native "New Session" button does — and this Host half only remembers
13
- // which sessions are temporary and removes them afterwards.
14
- //
15
- // Removal facts verified in app.asar (DSH 0.2.2-rc.1):
16
- // - `@deepseek-ai/dsh-api-remotes` exposes session/create, session/rename,
17
- // workspace/archiveSession ... but NO session delete. There is no official
18
- // "delete a conversation" API: the UI's own action is archive (hide).
19
- // - `@deepseek-ai/dsh-session-query-sqlite` reconciles its index against the
20
- // files actually present (`persistentDeletes`), so removing a session
21
- // directory is safe: the search index drops the row on the next reconcile.
22
- // - There is no plugin-facing quit hook (`before-quit` / `will-quit` are not
23
- // reachable from a bundle), so deletion is attempted twice: synchronously
24
- // from a `process.on('exit')` handler, and again on the next launch before
25
- // the window lists sessions. The second pass is the guarantee.
26
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
- import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
28
- import { homedir, release as osRelease } from 'node:os'
29
- import { dirname, join } from 'node:path'
30
- import { fileURLToPath } from 'node:url'
31
-
32
- const HERE = dirname(fileURLToPath(import.meta.url))
33
- const PLUGIN_ROOT = dirname(HERE)
34
-
35
- /**
36
- * The version an issue report should quote.
37
- *
38
- * Read from this package's own manifest rather than hard-coded, so a feedback
39
- * report can never claim a version the running code does not have. A missing or
40
- * unreadable manifest is not an error: the report just omits the field.
41
- */
42
- let pluginVersionCache = ''
43
- function pluginVersion() {
44
- if (pluginVersionCache) return pluginVersionCache
45
- try {
46
- const pkg = JSON.parse(readFileSync(join(PLUGIN_ROOT, 'package.json'), 'utf8'))
47
- if (pkg && typeof pkg.version === 'string') pluginVersionCache = pkg.version
48
- } catch (error) {
49
- void error
50
- }
51
- return pluginVersionCache
52
- }
53
-
54
- const DEFAULT_CONFIG = {
55
- showButton: true,
56
- inChatMode: 'hide', // hide | show
57
- buttonStyle: 'pair', // pair = beside New Session | own = its own full-width row
58
- // 0.2.4: the host's workspace list folds after five sessions and paints a
59
- // "展开其余 N 个会话" button. This plugin expands every group instead.
60
- expandAllSessions: true,
61
- deleteOnExit: true,
62
- // Daily cleanup (0.2.2). Independent of deleteOnExit: a user who keeps DSH
63
- // open for days wants "delete at 03:00 every night", and a user who closes it
64
- // every evening wants "delete on quit". Both can be on at the same time.
65
- scheduleDelete: false,
66
- scheduleTime: '03:00', // HH:MM, local time
67
- maxKept: 20,
68
- found: false,
69
- error: '',
70
- }
71
-
72
- /** A session touched this recently is probably open — never delete it now. */
73
- const ACTIVE_WINDOW_MS = 3 * 60 * 1000
74
- /** After skipping an active session, look again this much later. */
75
- const RETRY_DELAY_MS = 5 * 60 * 1000
76
- /** The scheduler wakes up this often; a minute is plenty for a HH:MM target. */
77
- const TICK_MS = 30 * 1000
78
-
79
- /** Every tracked id is a session this plugin created and must delete later. */
80
- const state = {
81
- config: { ...DEFAULT_CONFIG },
82
- tracked: [],
83
- routes: { registered: 0, errors: [] },
84
- contexts: [],
85
- storePath: '',
86
- lastPurge: null,
87
- lastError: '',
88
- bootPurged: false,
89
- hooks: false,
90
- scheduler: { timer: null, nextRunAt: null, lastRunAt: null, retryAt: null, busy: false },
91
- }
92
-
93
- // ---------------------------------------------------------------------------
94
- // locations
95
- // ---------------------------------------------------------------------------
96
-
97
- /** DSH home: `$DSH_HOME` or `~/.dsh` (@deepseek-ai/dsh-home-paths rule). */
98
- function dshHome() {
99
- const configured = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
100
- if (configured.length > 0) return configured
101
- return join(homedir(), '.dsh')
102
- }
103
-
104
- /** Where session directories live: `<home>/sessions/<encoded-cwd>/<sessionId>/`. */
105
- function sessionsRoot() {
106
- return join(dshHome(), 'sessions')
107
- }
108
-
109
- /** Our own tracking file, inside DSH home so it travels with the profile. */
110
- function storePath() {
111
- return join(dshHome(), 'dsh-sidebar-suite', 'temp-sessions.json')
112
- }
113
-
114
- function projectionCacheFile(sessionId) {
115
- return join(dshHome(), 'storages', 'session_projcache', 'sessions', `session-${sessionId}.json`)
116
- }
117
-
118
- // ---------------------------------------------------------------------------
119
- // config file (Notepad-editable, no source edits)
120
- // ---------------------------------------------------------------------------
121
-
122
- function configPath() {
123
- return join(PLUGIN_ROOT, 'temp-session-config.txt')
124
- }
125
-
126
- /** `3:5` / `03:05` / `25:99` → `03:05` / `03:05` / `''` (invalid). */
127
- function normalizeClock(value) {
128
- const text = String(value === undefined || value === null ? '' : value).trim()
129
- const match = /^(\d{1,2}):(\d{1,2})$/.exec(text)
130
- if (!match) return ''
131
- const hour = Number(match[1])
132
- const minute = Number(match[2])
133
- if (!Number.isFinite(hour) || !Number.isFinite(minute)) return ''
134
- if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return ''
135
- return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
136
- }
137
-
138
- /** One entry per editable field: its key in the text file and how to write it. */
139
- const CONFIG_KEYS = {
140
- showButton: { key: 'SHOW_TEMP_BUTTON', text: (value) => (value ? 'on' : 'off') },
141
- inChatMode: { key: 'TEMP_IN_CHAT_MODE', text: (value) => String(value) },
142
- buttonStyle: { key: 'TEMP_BUTTON_STYLE', text: (value) => String(value) },
143
- expandAllSessions: { key: 'EXPAND_ALL_SESSIONS', text: (value) => (value ? 'on' : 'off') },
144
- deleteOnExit: { key: 'DELETE_ON_EXIT', text: (value) => (value ? 'on' : 'off') },
145
- scheduleDelete: { key: 'SCHEDULE_DELETE', text: (value) => (value ? 'on' : 'off') },
146
- scheduleTime: { key: 'SCHEDULE_TIME', text: (value) => String(value) },
147
- maxKept: { key: 'MAX_KEPT', text: (value) => String(value) },
148
- }
149
-
150
- const FIELD_BY_KEY = Object.create(null)
151
- for (const field of Object.keys(CONFIG_KEYS)) FIELD_BY_KEY[CONFIG_KEYS[field].key] = field
152
-
153
- function parseConfig() {
154
- const next = { ...DEFAULT_CONFIG }
155
- let raw = ''
156
- try {
157
- raw = readFileSync(configPath(), 'utf8')
158
- next.found = true
159
- } catch (error) {
160
- next.error = `config not readable: ${error && error.message}`
161
- return next
162
- }
163
- const kv = Object.create(null)
164
- for (const line of raw.split(/\r?\n/)) {
165
- const text = line.trim()
166
- if (text.length === 0 || text.startsWith('#') || text.startsWith(';')) continue
167
- const eq = text.indexOf('=')
168
- if (eq <= 0) continue
169
- kv[text.slice(0, eq).trim().toUpperCase()] = text.slice(eq + 1).trim()
170
- }
171
- if (kv.SHOW_TEMP_BUTTON !== undefined) next.showButton = kv.SHOW_TEMP_BUTTON.toLowerCase() !== 'off'
172
- if (kv.TEMP_IN_CHAT_MODE !== undefined) {
173
- next.inChatMode = kv.TEMP_IN_CHAT_MODE.toLowerCase() === 'show' ? 'show' : 'hide'
174
- }
175
- if (kv.TEMP_BUTTON_STYLE !== undefined) {
176
- next.buttonStyle = kv.TEMP_BUTTON_STYLE.toLowerCase() === 'own' ? 'own' : 'pair'
177
- }
178
- if (kv.EXPAND_ALL_SESSIONS !== undefined) {
179
- next.expandAllSessions = kv.EXPAND_ALL_SESSIONS.toLowerCase() !== 'off'
180
- }
181
- if (kv.DELETE_ON_EXIT !== undefined) next.deleteOnExit = kv.DELETE_ON_EXIT.toLowerCase() !== 'off'
182
- if (kv.SCHEDULE_DELETE !== undefined) next.scheduleDelete = kv.SCHEDULE_DELETE.toLowerCase() !== 'off'
183
- if (kv.SCHEDULE_TIME !== undefined) {
184
- const clock = normalizeClock(kv.SCHEDULE_TIME)
185
- if (clock) next.scheduleTime = clock
186
- }
187
- if (kv.MAX_KEPT !== undefined) {
188
- const parsed = Number.parseInt(kv.MAX_KEPT, 10)
189
- if (Number.isFinite(parsed) && parsed > 0) next.maxKept = Math.min(parsed, 200)
190
- }
191
- return next
192
- }
193
-
194
- /**
195
- * Persist the settings the detail page changed.
196
- *
197
- * Line-based on purpose: comments, blank lines and key order survive, so the
198
- * file stays readable in Notepad and nothing the user wrote by hand is lost.
199
- * Only values after `=` are replaced; keys that are missing get appended.
200
- */
201
- function saveConfig(patch) {
202
- const next = { ...state.config }
203
- const input = patch && typeof patch === 'object' ? patch : {}
204
- if (typeof input.showButton === 'boolean') next.showButton = input.showButton
205
- if (input.inChatMode === 'show' || input.inChatMode === 'hide') next.inChatMode = input.inChatMode
206
- if (input.buttonStyle === 'own' || input.buttonStyle === 'pair') next.buttonStyle = input.buttonStyle
207
- if (typeof input.expandAllSessions === 'boolean') next.expandAllSessions = input.expandAllSessions
208
- if (typeof input.deleteOnExit === 'boolean') next.deleteOnExit = input.deleteOnExit
209
- if (typeof input.scheduleDelete === 'boolean') next.scheduleDelete = input.scheduleDelete
210
- const clock = normalizeClock(input.scheduleTime)
211
- if (clock) next.scheduleTime = clock
212
- const kept = Number.parseInt(input.maxKept, 10)
213
- if (Number.isFinite(kept) && kept > 0) next.maxKept = Math.min(kept, 200)
214
-
215
- let raw = ''
216
- let existing = false
217
- try {
218
- raw = readFileSync(configPath(), 'utf8')
219
- existing = true
220
- } catch (error) {
221
- void error
222
- }
223
- if (!existing) {
224
- raw = [
225
- '# 临时会话配置 · 用记事本改等号右边,保存后「完全退出并重开 DSH」才生效。',
226
- '# Temporary session settings. Edit values after =, then fully quit and reopen DSH.',
227
- '',
228
- ].join('\n')
229
- }
230
- const eol = raw.includes('\r\n') ? '\r\n' : '\n'
231
- const written = Object.create(null)
232
-
233
- const lines = raw.split(/\r?\n/).map((line) => {
234
- const trimmed = line.trim()
235
- if (trimmed.length === 0 || trimmed.startsWith('#') || trimmed.startsWith(';')) return line
236
- const eq = line.indexOf('=')
237
- if (eq <= 0) return line
238
- const name = line.slice(0, eq).trim().toUpperCase()
239
- const field = FIELD_BY_KEY[name]
240
- if (!field) return line
241
- written[name] = true
242
- return `${line.slice(0, eq)}=${CONFIG_KEYS[field].text(next[field])}`
243
- })
244
-
245
- const missing = Object.keys(CONFIG_KEYS).filter((field) => !written[CONFIG_KEYS[field].key])
246
- if (missing.length > 0) {
247
- lines.push('')
248
- for (const field of missing) lines.push(`${CONFIG_KEYS[field].key}=${CONFIG_KEYS[field].text(next[field])}`)
249
- }
250
-
251
- try {
252
- writeFileSync(configPath(), lines.join(eol), 'utf8')
253
- } catch (error) {
254
- return { ok: false, error: `config write failed: ${error && error.message}` }
255
- }
256
-
257
- next.found = true
258
- next.error = ''
259
- state.config = next
260
- restartScheduler()
261
- return { ok: true, config: publicConfig() }
262
- }
263
-
264
- /** The subset the detail page reads back. */
265
- function publicConfig() {
266
- return {
267
- showButton: state.config.showButton,
268
- inChatMode: state.config.inChatMode,
269
- buttonStyle: state.config.buttonStyle,
270
- expandAllSessions: state.config.expandAllSessions,
271
- deleteOnExit: state.config.deleteOnExit,
272
- scheduleDelete: state.config.scheduleDelete,
273
- scheduleTime: state.config.scheduleTime,
274
- maxKept: state.config.maxKept,
275
- configFile: configPath(),
276
- found: state.config.found,
277
- }
278
- }
279
-
280
- // ---------------------------------------------------------------------------
281
- // tracking store
282
- // ---------------------------------------------------------------------------
283
-
284
- function normalizeId(value) {
285
- if (typeof value !== 'string') return ''
286
- const trimmed = value.trim()
287
- if (trimmed.length === 0 || trimmed.length > 128) return ''
288
- return /^[A-Za-z0-9._:-]+$/.test(trimmed) ? trimmed : ''
289
- }
290
-
291
- async function loadStore() {
292
- state.storePath = storePath()
293
- try {
294
- const raw = await readFile(state.storePath, 'utf8')
295
- const parsed = JSON.parse(raw)
296
- const list = Array.isArray(parsed) ? parsed : parsed && Array.isArray(parsed.sessions) ? parsed.sessions : []
297
- state.tracked = list.map(normalizeId).filter(Boolean)
298
- } catch (error) {
299
- if (error && error.code !== 'ENOENT') state.lastError = `store read failed: ${error.message}`
300
- state.tracked = []
301
- }
302
- }
303
-
304
- async function saveStore() {
305
- const path = state.storePath || storePath()
306
- state.storePath = path
307
- try {
308
- await mkdir(dirname(path), { recursive: true })
309
- await writeFile(path, JSON.stringify({ version: 1, sessions: state.tracked }, null, 2), 'utf8')
310
- } catch (error) {
311
- state.lastError = `store write failed: ${error && error.message}`
312
- }
313
- }
314
-
315
- // ---------------------------------------------------------------------------
316
- // deletion
317
- // ---------------------------------------------------------------------------
318
-
319
- /** Every `<sessions>/<workspace>/<id>` directory that exists right now. */
320
- function sessionDirsSync(sessionId) {
321
- const root = sessionsRoot()
322
- const found = []
323
- try {
324
- for (const entry of readdirSync(root, { withFileTypes: true })) {
325
- if (!entry.isDirectory()) continue
326
- const candidate = join(root, entry.name, sessionId)
327
- if (existsSync(candidate)) found.push(candidate)
328
- }
329
- } catch (error) {
330
- void error
331
- }
332
- return found
333
- }
334
-
335
- /**
336
- * True when the session wrote anything within `ms`.
337
- *
338
- * There is no reliable "is this conversation open" signal reachable from a
339
- * bundle, so the newest file mtime stands in for it: an open conversation keeps
340
- * touching its transcript, an idle one goes quiet. Deleting an open session
341
- * would make the host's window point at a directory that no longer exists, so
342
- * the scheduled pass skips those and tries again a few minutes later.
343
- */
344
- function sessionActiveWithin(sessionId, ms) {
345
- const cutoff = Date.now() - ms
346
- for (const dir of sessionDirsSync(sessionId)) {
347
- let entries = []
348
- try {
349
- entries = readdirSync(dir, { withFileTypes: true })
350
- } catch (error) {
351
- void error
352
- continue
353
- }
354
- for (const entry of entries) {
355
- if (!entry.isFile()) continue
356
- try {
357
- if (statSync(join(dir, entry.name)).mtimeMs >= cutoff) return true
358
- } catch (error) {
359
- void error
360
- }
361
- }
362
- }
363
- return false
364
- }
365
-
366
- function removeSessionSync(sessionId) {
367
- let removed = 0
368
- for (const dir of sessionDirsSync(sessionId)) {
369
- try {
370
- rmSync(dir, { recursive: true, force: true, maxRetries: 2 })
371
- removed += 1
372
- } catch (error) {
373
- state.lastError = `delete ${sessionId} failed: ${error && error.message}`
374
- }
375
- }
376
- try {
377
- rmSync(projectionCacheFile(sessionId), { force: true })
378
- } catch (error) {
379
- void error
380
- }
381
- return removed
382
- }
383
-
384
- async function removeSession(sessionId) {
385
- let removed = 0
386
- const root = sessionsRoot()
387
- let entries = []
388
- try {
389
- entries = await readdir(root, { withFileTypes: true })
390
- } catch (error) {
391
- void error
392
- }
393
- for (const entry of entries) {
394
- if (!entry.isDirectory()) continue
395
- const target = join(root, entry.name, sessionId)
396
- if (!existsSync(target)) continue
397
- try {
398
- await rm(target, { recursive: true, force: true, maxRetries: 2 })
399
- removed += 1
400
- } catch (error) {
401
- state.lastError = `delete ${sessionId} failed: ${error && error.message}`
402
- }
403
- }
404
- try {
405
- await rm(projectionCacheFile(sessionId), { force: true })
406
- } catch (error) {
407
- void error
408
- }
409
- return removed
410
- }
411
-
412
- /**
413
- * Hide a session we could not delete from disk.
414
- *
415
- * `workspaceRegistry.archiveSession` is the host's own "remove from the list"
416
- * primitive (the sidebar's archive action). It is a fallback only: when the
417
- * files are gone the session disappears by itself once the search index
418
- * reconciles.
419
- */
420
- async function hideSession(sessionId) {
421
- for (const candidate of state.contexts) {
422
- try {
423
- const registry = candidate && typeof candidate.get === 'function' ? candidate.get('workspaceRegistry') : null
424
- if (registry && typeof registry.archiveSession === 'function') {
425
- await registry.archiveSession(sessionId, { stopActivity: true })
426
- return true
427
- }
428
- } catch (error) {
429
- void error
430
- }
431
- }
432
- return false
433
- }
434
-
435
- /**
436
- * Delete every tracked session (async path: startup, manual, scheduled, route).
437
- *
438
- * `skipActiveMs` protects a conversation the user is looking at right now: the
439
- * scheduled cleanup passes 3 minutes, the quit path passes nothing (a quit is
440
- * the user asking for the cleanup, and the process is going away anyway).
441
- */
442
- async function purgeAll(reason, options) {
443
- const skipActiveMs = options && Number.isFinite(options.skipActiveMs) ? options.skipActiveMs : 0
444
- const ids = state.tracked.slice()
445
- const report = {
446
- reason, at: new Date().toISOString(),
447
- tried: ids.length, removed: 0, hidden: 0, skipped: 0, failed: [],
448
- }
449
- const kept = []
450
- for (const id of ids) {
451
- if (skipActiveMs > 0 && sessionActiveWithin(id, skipActiveMs)) {
452
- report.skipped += 1
453
- kept.push(id)
454
- continue
455
- }
456
- let removed = 0
457
- try {
458
- removed = await removeSession(id)
459
- } catch (error) {
460
- void error
461
- }
462
- if (removed > 0 && sessionDirsSync(id).length === 0) {
463
- report.removed += 1
464
- continue
465
- }
466
- // The directory survived (locked file, permission). Hide it so the sidebar
467
- // does not show a "temporary" chat that outlives its promise, and retry the
468
- // delete on the next launch.
469
- const hidden = await hideSession(id)
470
- if (hidden) report.hidden += 1
471
- report.failed.push(id)
472
- kept.push(id)
473
- }
474
- state.tracked = kept
475
- await saveStore()
476
- state.lastPurge = report
477
- return report
478
- }
479
-
480
- /** Delete every tracked session synchronously — the quit path has no `await`. */
481
- function purgeSync(reason) {
482
- if (state.tracked.length === 0) return { reason, tried: 0, removed: 0, kept: [] }
483
- const ids = state.tracked.slice()
484
- const kept = []
485
- let removed = 0
486
- for (const id of ids) {
487
- const count = removeSessionSync(id)
488
- if (count > 0 && sessionDirsSync(id).length === 0) removed += 1
489
- else kept.push(id)
490
- }
491
- state.tracked = kept
492
- // Best effort: the process is going away, so this write often loses the race.
493
- try {
494
- const path = state.storePath || storePath()
495
- mkdirSync(dirname(path), { recursive: true })
496
- writeFileSync(path, JSON.stringify({ version: 1, sessions: kept }, null, 2))
497
- } catch (error) {
498
- void error
499
- }
500
- state.lastPurge = { reason, at: new Date().toISOString(), tried: ids.length, removed, kept }
501
- return state.lastPurge
502
- }
503
-
504
- /**
505
- * One readiness gate for the whole process: load the tracking file, then run
506
- * the startup purge exactly once. Every route awaits it, otherwise a request
507
- * arriving first would be overwritten by the load (`state.tracked = []`).
508
- */
509
- let readyTask = null
510
- function ensureReady() {
511
- if (readyTask) return readyTask
512
- readyTask = (async () => {
513
- await loadStore()
514
- if (state.config.deleteOnExit && !state.bootPurged) {
515
- state.bootPurged = true
516
- if (state.tracked.length > 0) await purgeAll('startup')
517
- }
518
- })().catch((error) => {
519
- state.lastError = `startup failed: ${error && error.message}`
520
- })
521
- return readyTask
522
- }
523
-
524
- /** Register the quit hook once per process. */
525
- function installExitHook() {
526
- if (state.hooks) return
527
- state.hooks = true
528
- try {
529
- process.once('exit', () => {
530
- try {
531
- purgeSync('process-exit')
532
- } catch (error) {
533
- void error
534
- }
535
- })
536
- } catch (error) {
537
- void error
538
- }
539
- }
540
-
541
- // ---------------------------------------------------------------------------
542
- // daily cleanup scheduler (0.2.2)
543
- // ---------------------------------------------------------------------------
544
-
545
- /** Next occurrence of `HH:MM`, local time. Always strictly in the future. */
546
- function computeNextRun(clock) {
547
- const normalized = normalizeClock(clock)
548
- if (!normalized) return null
549
- const hour = Number(normalized.slice(0, 2))
550
- const minute = Number(normalized.slice(3, 5))
551
- const now = new Date()
552
- const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0)
553
- if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1)
554
- return next
555
- }
556
-
557
- function stopScheduler() {
558
- if (state.scheduler.timer) {
559
- try { clearInterval(state.scheduler.timer) } catch (error) { void error }
560
- state.scheduler.timer = null
561
- }
562
- state.scheduler.nextRunAt = null
563
- state.scheduler.retryAt = null
564
- }
565
-
566
- /**
567
- * One wake-up. Two ways to be due: the daily target arrived, or a previous run
568
- * skipped a session that was in use and asked to look again in a few minutes.
569
- */
570
- async function schedulerTick() {
571
- const scheduler = state.scheduler
572
- if (scheduler.busy || !state.config.scheduleDelete) return
573
- const now = Date.now()
574
- const dueNext = !!(scheduler.nextRunAt && now >= scheduler.nextRunAt.getTime())
575
- const dueRetry = !!(scheduler.retryAt && now >= scheduler.retryAt.getTime())
576
- if (!dueNext && !dueRetry) return
577
-
578
- scheduler.busy = true
579
- try {
580
- await ensureReady()
581
- const report = await purgeAll(dueNext ? 'scheduled' : 'scheduled-retry', { skipActiveMs: ACTIVE_WINDOW_MS })
582
- scheduler.lastRunAt = report.at
583
- scheduler.retryAt = report.skipped > 0 || report.failed.length > 0
584
- ? new Date(Date.now() + RETRY_DELAY_MS)
585
- : null
586
- if (dueNext) scheduler.nextRunAt = computeNextRun(state.config.scheduleTime)
587
- } catch (error) {
588
- state.lastError = `scheduled cleanup failed: ${error && error.message}`
589
- } finally {
590
- scheduler.busy = false
591
- }
592
- }
593
-
594
- function startScheduler() {
595
- stopScheduler()
596
- if (!state.config.scheduleDelete) return
597
- state.scheduler.nextRunAt = computeNextRun(state.config.scheduleTime)
598
- try {
599
- const timer = setInterval(schedulerTick, TICK_MS)
600
- // A cleanup timer must never be the reason the host process stays alive.
601
- if (timer && typeof timer.unref === 'function') timer.unref()
602
- state.scheduler.timer = timer
603
- } catch (error) {
604
- state.lastError = `scheduler start failed: ${error && error.message}`
605
- }
606
- }
607
-
608
- /** Re-read the target after a settings change; also used at boot. */
609
- function restartScheduler() {
610
- startScheduler()
611
- }
612
-
613
- function schedulerStatus() {
614
- return {
615
- enabled: state.config.scheduleDelete,
616
- at: state.config.scheduleTime,
617
- nextRunAt: state.scheduler.nextRunAt ? state.scheduler.nextRunAt.toISOString() : null,
618
- lastRunAt: state.scheduler.lastRunAt,
619
- retryAt: state.scheduler.retryAt ? state.scheduler.retryAt.toISOString() : null,
620
- }
621
- }
622
-
623
- // ---------------------------------------------------------------------------
624
- // routes
625
- // ---------------------------------------------------------------------------
626
-
627
- /** Add one session id to the temporary set (called right after creation). */
628
- async function track(sessionId) {
629
- await ensureReady()
630
- const id = normalizeId(sessionId)
631
- if (id.length === 0) return { ok: false, error: 'invalid session id' }
632
- if (!state.tracked.includes(id)) state.tracked.push(id)
633
- // Oldest first: trimming keeps the newest MAX_KEPT entries.
634
- if (state.tracked.length > state.config.maxKept) {
635
- const overflow = state.tracked.length - state.config.maxKept
636
- const dropped = state.tracked.splice(0, overflow)
637
- for (const old of dropped) {
638
- try {
639
- await removeSession(old)
640
- } catch (error) {
641
- void error
642
- }
643
- }
644
- }
645
- await saveStore()
646
- return { ok: true, ids: state.tracked.slice(), count: state.tracked.length }
647
- }
648
-
649
- async function untrack(sessionId) {
650
- await ensureReady()
651
- const id = normalizeId(sessionId)
652
- if (id.length === 0) return { ok: false, error: 'invalid session id' }
653
- state.tracked = state.tracked.filter((item) => item !== id)
654
- await saveStore()
655
- return { ok: true, ids: state.tracked.slice(), count: state.tracked.length }
656
- }
657
-
658
- function status() {
659
- return {
660
- ok: true,
661
- showButton: state.config.showButton,
662
- chatModePolicy: state.config.inChatMode,
663
- buttonStyle: state.config.buttonStyle,
664
- expandAllSessions: state.config.expandAllSessions,
665
- deleteOnExit: state.config.deleteOnExit,
666
- scheduleDelete: state.config.scheduleDelete,
667
- scheduleTime: state.config.scheduleTime,
668
- schedule: schedulerStatus(),
669
- tempCount: state.tracked.length,
670
- config: { found: state.config.found, error: state.config.error || undefined },
671
- routes: { registered: state.routes.registered, errors: state.routes.errors.slice(0, 4) },
672
- lastPurge: state.lastPurge,
673
- lastError: state.lastError || undefined,
674
- // Everything a feedback report needs to be diagnosable without a second
675
- // round-trip. `DSH_CLIENT_VERSION` is set by the desktop main process for
676
- // the host child (verified in app.asar lib/main.js: DesktopBackendController
677
- // spawns the host with `DSH_CLIENT_VERSION: desktopClientVersion()`).
678
- env: {
679
- plugin: pluginVersion(),
680
- dshClient: process.env.DSH_CLIENT_VERSION || '',
681
- platform: process.platform,
682
- arch: process.arch,
683
- os: (() => {
684
- try {
685
- return osRelease()
686
- } catch (error) {
687
- void error
688
- return ''
689
- }
690
- })(),
691
- node: process.versions.node || '',
692
- electron: process.versions.electron || '',
693
- },
694
- }
695
- }
696
-
697
- function probe() {
698
- return {
699
- ok: true,
700
- mode: 'native-session',
701
- dshHome: dshHome(),
702
- sessionsRoot: sessionsRoot(),
703
- storePath: state.storePath || storePath(),
704
- configFile: configPath(),
705
- tracked: state.tracked.slice(),
706
- config: state.config,
707
- schedule: schedulerStatus(),
708
- routes: state.routes,
709
- lastPurge: state.lastPurge,
710
- lastError: state.lastError || undefined,
711
- deletesOnExit: state.config.deleteOnExit,
712
- scheduleEnabled: state.config.scheduleDelete,
713
- processExitHook: state.hooks,
714
- createsOwnWindow: false,
715
- usesNativeWindow: true,
716
- }
717
- }
718
-
719
- function json(body, code = 200) {
720
- return new Response(JSON.stringify(body), {
721
- status: code,
722
- headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
723
- })
724
- }
725
-
726
- function queryOf(request) {
727
- try {
728
- const url = new URL(request.url, 'http://localhost')
729
- return { sessionId: normalizeId(url.searchParams.get('sessionId') || '') }
730
- } catch (error) {
731
- void error
732
- return { sessionId: '' }
733
- }
734
- }
735
-
736
- export function createTempSessionHost(initialCtx) {
737
- if (initialCtx) state.contexts.push(initialCtx)
738
-
739
- /** Keep every context the plugin is applied to: services differ per scope. */
740
- function setContext(next) {
741
- if (!next) return
742
- if (!state.contexts.includes(next)) state.contexts.push(next)
743
- }
744
-
745
- function register(hostCtx) {
746
- setContext(hostCtx)
747
- state.config = parseConfig()
748
- installExitHook()
749
- // Boot the daily cleanup from whatever the file says; a stale timer from a
750
- // previous apply is replaced, never duplicated.
751
- restartScheduler()
752
-
753
- // Boot pass: anything still tracked was created in a previous run and must
754
- // not survive it. `ensureReady()` runs it once and every route waits on it.
755
- ensureReady()
756
-
757
- const routes = [
758
- ['/api/dsh-temp-session.status', ['GET'], async () => { await ensureReady(); return json(status()) }],
759
- ['/api/dsh-temp-session.list', ['GET'], async () => {
760
- await ensureReady()
761
- return json({ ok: true, ids: state.tracked.slice(), count: state.tracked.length })
762
- }],
763
- ['/api/dsh-temp-session.config', ['GET', 'POST'], async (request) => {
764
- await ensureReady()
765
- const method = request && request.method ? String(request.method).toUpperCase() : 'GET'
766
- if (method !== 'POST') return json({ ok: true, ...publicConfig() })
767
- let patch = null
768
- try {
769
- const raw = await request.text()
770
- patch = raw && raw.trim().length > 0 ? JSON.parse(raw) : {}
771
- } catch (error) {
772
- void error
773
- return json({ ok: false, error: 'invalid JSON body' }, 400)
774
- }
775
- const result = saveConfig(patch)
776
- if (!result.ok) return json(result, 500)
777
- return json({ ok: true, ...result.config, schedule: schedulerStatus() })
778
- }],
779
- ['/api/dsh-temp-session.track', ['POST', 'GET'], async (request) => {
780
- const { sessionId } = queryOf(request)
781
- if (sessionId.length === 0) return json({ ok: false, error: 'sessionId required' }, 400)
782
- return json(await track(sessionId))
783
- }],
784
- ['/api/dsh-temp-session.untrack', ['POST', 'GET'], async (request) => {
785
- const { sessionId } = queryOf(request)
786
- if (sessionId.length === 0) return json({ ok: false, error: 'sessionId required' }, 400)
787
- return json(await untrack(sessionId))
788
- }],
789
- ['/api/dsh-temp-session.purge', ['POST', 'GET'], async () => {
790
- await ensureReady()
791
- return json({ ok: true, ...(await purgeAll('manual')) })
792
- }],
793
- ['/api/dsh-temp-session.probe', ['GET'], () => json(probe())],
794
- ]
795
-
796
- let registered = 0
797
- for (const [path, methods, handler] of routes) {
798
- try {
799
- const connection = typeof hostCtx?.get === 'function' ? hostCtx.get('connection') : null
800
- const registry = connection && connection.fetch
801
- if (!registry || typeof registry.register !== 'function') {
802
- state.routes.errors.push(`${path}: connection.fetch unavailable`)
803
- continue
804
- }
805
- registry.register({
806
- path,
807
- methods,
808
- requestBody: 'buffered',
809
- fetch: (request) => handler(request),
810
- })
811
- registered += 1
812
- } catch (error) {
813
- const message = String((error && error.message) || error)
814
- // Re-applying the plugin must not fail the batch: an already mounted
815
- // route is a success, not an error.
816
- if (/already registered/i.test(message)) registered += 1
817
- else state.routes.errors.push(`${path}: ${message}`)
818
- }
819
- }
820
- state.routes.registered = registered
821
- state.routes.errors = state.routes.errors.slice(-6)
822
- return registered
823
- }
824
-
825
- return {
826
- register,
827
- setContext,
828
- state,
829
- status,
830
- probe,
831
- purgeAll,
832
- purgeSync,
833
- track,
834
- untrack,
835
- saveConfig,
836
- publicConfig,
837
- computeNextRun,
838
- schedulerTick,
839
- startScheduler,
840
- restartScheduler,
841
- }
842
- }
1
+ // dsh-sidebar-suite — temporary session host half (0.2.0).
2
+ //
3
+ // What changed from 0.1.x, and why
4
+ // --------------------------------
5
+ // 0.1.x kept the conversation "in memory" and drew its own window. That could
6
+ // never satisfy two requirements at once: a temporary chat must show up in the
7
+ // left sidebar next to normal sessions (so the user can switch tasks), and it
8
+ // must use the host's own conversation window.
9
+ //
10
+ // So from 0.2.0 the Client half creates a REAL DSH session through the host's
11
+ // own navigation service (`uiWorkspace.createNamed` + `openSession`) — exactly
12
+ // what the native "New Session" button does — and this Host half only remembers
13
+ // which sessions are temporary and removes them afterwards.
14
+ //
15
+ // Removal facts verified in app.asar (DSH 0.2.2-rc.1):
16
+ // - `@deepseek-ai/dsh-api-remotes` exposes session/create, session/rename,
17
+ // workspace/archiveSession ... but NO session delete. There is no official
18
+ // "delete a conversation" API: the UI's own action is archive (hide).
19
+ // - `@deepseek-ai/dsh-session-query-sqlite` reconciles its index against the
20
+ // files actually present (`persistentDeletes`), so removing a session
21
+ // directory is safe: the search index drops the row on the next reconcile.
22
+ // - There is no plugin-facing quit hook (`before-quit` / `will-quit` are not
23
+ // reachable from a bundle), so deletion is attempted twice: synchronously
24
+ // from a `process.on('exit')` handler, and again on the next launch before
25
+ // the window lists sessions. The second pass is the guarantee.
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
27
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
28
+ import { homedir, release as osRelease } from 'node:os'
29
+ import { dirname, join } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url))
33
+ const PLUGIN_ROOT = dirname(HERE)
34
+
35
+ /**
36
+ * The version an issue report should quote.
37
+ *
38
+ * Read from this package's own manifest rather than hard-coded, so a feedback
39
+ * report can never claim a version the running code does not have. A missing or
40
+ * unreadable manifest is not an error: the report just omits the field.
41
+ */
42
+ let pluginVersionCache = ''
43
+ function pluginVersion() {
44
+ if (pluginVersionCache) return pluginVersionCache
45
+ try {
46
+ const pkg = JSON.parse(readFileSync(join(PLUGIN_ROOT, 'package.json'), 'utf8'))
47
+ if (pkg && typeof pkg.version === 'string') pluginVersionCache = pkg.version
48
+ } catch (error) {
49
+ void error
50
+ }
51
+ return pluginVersionCache
52
+ }
53
+
54
+ const DEFAULT_CONFIG = {
55
+ showButton: true,
56
+ inChatMode: 'hide', // hide | show
57
+ buttonStyle: 'pair', // pair = beside New Session | own = its own full-width row
58
+ // 0.2.4: the host's workspace list folds after five sessions and paints a
59
+ // "展开其余 N 个会话" button. This plugin expands every group instead.
60
+ expandAllSessions: true,
61
+ deleteOnExit: true,
62
+ // Daily cleanup (0.2.2). Independent of deleteOnExit: a user who keeps DSH
63
+ // open for days wants "delete at 03:00 every night", and a user who closes it
64
+ // every evening wants "delete on quit". Both can be on at the same time.
65
+ scheduleDelete: false,
66
+ scheduleTime: '03:00', // HH:MM, local time
67
+ maxKept: 20,
68
+ found: false,
69
+ error: '',
70
+ }
71
+
72
+ /** A session touched this recently is probably open — never delete it now. */
73
+ const ACTIVE_WINDOW_MS = 3 * 60 * 1000
74
+ /** After skipping an active session, look again this much later. */
75
+ const RETRY_DELAY_MS = 5 * 60 * 1000
76
+ /** The scheduler wakes up this often; a minute is plenty for a HH:MM target. */
77
+ const TICK_MS = 30 * 1000
78
+
79
+ /** Every tracked id is a session this plugin created and must delete later. */
80
+ const state = {
81
+ config: { ...DEFAULT_CONFIG },
82
+ tracked: [],
83
+ routes: { registered: 0, errors: [] },
84
+ contexts: [],
85
+ storePath: '',
86
+ lastPurge: null,
87
+ lastError: '',
88
+ bootPurged: false,
89
+ hooks: false,
90
+ scheduler: { timer: null, nextRunAt: null, lastRunAt: null, retryAt: null, busy: false },
91
+ // 0.2.8 hot reload. The config file is user-editable in Notepad; a mtime+size
92
+ // stamp lets us notice a save without hashing the file, and the monotone
93
+ // revision is what the client polls so it can re-apply settings in place.
94
+ configStamp: '',
95
+ configRevision: 0,
96
+ configReloads: 0,
97
+ configReloadSource: 'boot',
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // locations
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** DSH home: `$DSH_HOME` or `~/.dsh` (@deepseek-ai/dsh-home-paths rule). */
105
+ function dshHome() {
106
+ const configured = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
107
+ if (configured.length > 0) return configured
108
+ return join(homedir(), '.dsh')
109
+ }
110
+
111
+ /** Where session directories live: `<home>/sessions/<encoded-cwd>/<sessionId>/`. */
112
+ function sessionsRoot() {
113
+ return join(dshHome(), 'sessions')
114
+ }
115
+
116
+ /** Our own tracking file, inside DSH home so it travels with the profile. */
117
+ function storePath() {
118
+ return join(dshHome(), 'dsh-sidebar-suite', 'temp-sessions.json')
119
+ }
120
+
121
+ function projectionCacheFile(sessionId) {
122
+ return join(dshHome(), 'storages', 'session_projcache', 'sessions', `session-${sessionId}.json`)
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // config file (Notepad-editable, no source edits)
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function configPath() {
130
+ return join(PLUGIN_ROOT, 'temp-session-config.txt')
131
+ }
132
+
133
+ /** `3:5` / `03:05` / `25:99` → `03:05` / `03:05` / `''` (invalid). */
134
+ function normalizeClock(value) {
135
+ const text = String(value === undefined || value === null ? '' : value).trim()
136
+ const match = /^(\d{1,2}):(\d{1,2})$/.exec(text)
137
+ if (!match) return ''
138
+ const hour = Number(match[1])
139
+ const minute = Number(match[2])
140
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return ''
141
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return ''
142
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
143
+ }
144
+
145
+ /** One entry per editable field: its key in the text file and how to write it. */
146
+ const CONFIG_KEYS = {
147
+ showButton: { key: 'SHOW_TEMP_BUTTON', text: (value) => (value ? 'on' : 'off') },
148
+ inChatMode: { key: 'TEMP_IN_CHAT_MODE', text: (value) => String(value) },
149
+ buttonStyle: { key: 'TEMP_BUTTON_STYLE', text: (value) => String(value) },
150
+ expandAllSessions: { key: 'EXPAND_ALL_SESSIONS', text: (value) => (value ? 'on' : 'off') },
151
+ deleteOnExit: { key: 'DELETE_ON_EXIT', text: (value) => (value ? 'on' : 'off') },
152
+ scheduleDelete: { key: 'SCHEDULE_DELETE', text: (value) => (value ? 'on' : 'off') },
153
+ scheduleTime: { key: 'SCHEDULE_TIME', text: (value) => String(value) },
154
+ maxKept: { key: 'MAX_KEPT', text: (value) => String(value) },
155
+ }
156
+
157
+ const FIELD_BY_KEY = Object.create(null)
158
+ for (const field of Object.keys(CONFIG_KEYS)) FIELD_BY_KEY[CONFIG_KEYS[field].key] = field
159
+
160
+ function parseConfig() {
161
+ const next = { ...DEFAULT_CONFIG }
162
+ let raw = ''
163
+ try {
164
+ raw = readFileSync(configPath(), 'utf8')
165
+ next.found = true
166
+ } catch (error) {
167
+ next.error = `config not readable: ${error && error.message}`
168
+ return next
169
+ }
170
+ const kv = Object.create(null)
171
+ for (const line of raw.split(/\r?\n/)) {
172
+ const text = line.trim()
173
+ if (text.length === 0 || text.startsWith('#') || text.startsWith(';')) continue
174
+ const eq = text.indexOf('=')
175
+ if (eq <= 0) continue
176
+ kv[text.slice(0, eq).trim().toUpperCase()] = text.slice(eq + 1).trim()
177
+ }
178
+ if (kv.SHOW_TEMP_BUTTON !== undefined) next.showButton = kv.SHOW_TEMP_BUTTON.toLowerCase() !== 'off'
179
+ if (kv.TEMP_IN_CHAT_MODE !== undefined) {
180
+ next.inChatMode = kv.TEMP_IN_CHAT_MODE.toLowerCase() === 'show' ? 'show' : 'hide'
181
+ }
182
+ if (kv.TEMP_BUTTON_STYLE !== undefined) {
183
+ next.buttonStyle = kv.TEMP_BUTTON_STYLE.toLowerCase() === 'own' ? 'own' : 'pair'
184
+ }
185
+ if (kv.EXPAND_ALL_SESSIONS !== undefined) {
186
+ next.expandAllSessions = kv.EXPAND_ALL_SESSIONS.toLowerCase() !== 'off'
187
+ }
188
+ if (kv.DELETE_ON_EXIT !== undefined) next.deleteOnExit = kv.DELETE_ON_EXIT.toLowerCase() !== 'off'
189
+ if (kv.SCHEDULE_DELETE !== undefined) next.scheduleDelete = kv.SCHEDULE_DELETE.toLowerCase() !== 'off'
190
+ if (kv.SCHEDULE_TIME !== undefined) {
191
+ const clock = normalizeClock(kv.SCHEDULE_TIME)
192
+ if (clock) next.scheduleTime = clock
193
+ }
194
+ if (kv.MAX_KEPT !== undefined) {
195
+ const parsed = Number.parseInt(kv.MAX_KEPT, 10)
196
+ if (Number.isFinite(parsed) && parsed > 0) next.maxKept = Math.min(parsed, 200)
197
+ }
198
+ return next
199
+ }
200
+
201
+ /** Cheap fingerprint of the on-disk config file. */
202
+ function configStamp() {
203
+ try {
204
+ const st = statSync(configPath())
205
+ return `${st.mtimeMs}:${st.size}`
206
+ } catch (error) {
207
+ void error
208
+ return 'missing'
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 0.2.8 hot reload — pick up a hand edit without restarting DSH.
214
+ *
215
+ * There is deliberately no fs.watch here: editors save by renaming a temp file,
216
+ * watches get lost on some network/OneDrive folders, and a watcher can keep the
217
+ * host process alive. Every status/config/probe request checks the stamp
218
+ * instead, and the client polls every few seconds, so behaviour is live within
219
+ * one poll even on filesystems with coarse mtime resolution.
220
+ */
221
+ function refreshConfigIfChanged() {
222
+ const stamp = configStamp()
223
+ if (stamp === state.configStamp) return false
224
+ state.configStamp = stamp
225
+ const next = parseConfig()
226
+ const fields = [
227
+ 'showButton', 'inChatMode', 'buttonStyle', 'expandAllSessions',
228
+ 'deleteOnExit', 'scheduleDelete', 'scheduleTime', 'maxKept',
229
+ ]
230
+ const changed = fields.some((field) => next[field] !== state.config[field])
231
+ state.config = next
232
+ if (changed) {
233
+ state.configRevision += 1
234
+ state.configReloads += 1
235
+ state.configReloadSource = 'file'
236
+ // The daily target may have moved (or been switched off entirely).
237
+ restartScheduler()
238
+ }
239
+ return changed
240
+ }
241
+
242
+ /**
243
+ * Persist the settings the detail page changed.
244
+ *
245
+ * Line-based on purpose: comments, blank lines and key order survive, so the
246
+ * file stays readable in Notepad and nothing the user wrote by hand is lost.
247
+ * Only values after `=` are replaced; keys that are missing get appended.
248
+ */
249
+ function saveConfig(patch) {
250
+ // A hand edit made after boot must not be silently overwritten by a UI save.
251
+ refreshConfigIfChanged()
252
+ const next = { ...state.config }
253
+ const input = patch && typeof patch === 'object' ? patch : {}
254
+ if (typeof input.showButton === 'boolean') next.showButton = input.showButton
255
+ if (input.inChatMode === 'show' || input.inChatMode === 'hide') next.inChatMode = input.inChatMode
256
+ if (input.buttonStyle === 'own' || input.buttonStyle === 'pair') next.buttonStyle = input.buttonStyle
257
+ if (typeof input.expandAllSessions === 'boolean') next.expandAllSessions = input.expandAllSessions
258
+ if (typeof input.deleteOnExit === 'boolean') next.deleteOnExit = input.deleteOnExit
259
+ if (typeof input.scheduleDelete === 'boolean') next.scheduleDelete = input.scheduleDelete
260
+ const clock = normalizeClock(input.scheduleTime)
261
+ if (clock) next.scheduleTime = clock
262
+ const kept = Number.parseInt(input.maxKept, 10)
263
+ if (Number.isFinite(kept) && kept > 0) next.maxKept = Math.min(kept, 200)
264
+
265
+ let raw = ''
266
+ let existing = false
267
+ try {
268
+ raw = readFileSync(configPath(), 'utf8')
269
+ existing = true
270
+ } catch (error) {
271
+ void error
272
+ }
273
+ if (!existing) {
274
+ raw = [
275
+ '# 临时会话设置(0.2.8)· 记事本改等号右边,保存后约 3 秒自动生效,不用重启 DSH。',
276
+ '# Temporary session settings (0.2.8). Edit values after =; saves apply in ~3s, no restart.',
277
+ '',
278
+ ].join('\n')
279
+ }
280
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n'
281
+ const written = Object.create(null)
282
+
283
+ const lines = raw.split(/\r?\n/).map((line) => {
284
+ const trimmed = line.trim()
285
+ if (trimmed.length === 0 || trimmed.startsWith('#') || trimmed.startsWith(';')) return line
286
+ const eq = line.indexOf('=')
287
+ if (eq <= 0) return line
288
+ const name = line.slice(0, eq).trim().toUpperCase()
289
+ const field = FIELD_BY_KEY[name]
290
+ if (!field) return line
291
+ written[name] = true
292
+ return `${line.slice(0, eq)}=${CONFIG_KEYS[field].text(next[field])}`
293
+ })
294
+
295
+ const missing = Object.keys(CONFIG_KEYS).filter((field) => !written[CONFIG_KEYS[field].key])
296
+ if (missing.length > 0) {
297
+ lines.push('')
298
+ for (const field of missing) lines.push(`${CONFIG_KEYS[field].key}=${CONFIG_KEYS[field].text(next[field])}`)
299
+ }
300
+
301
+ try {
302
+ writeFileSync(configPath(), lines.join(eol), 'utf8')
303
+ } catch (error) {
304
+ return { ok: false, error: `config write failed: ${error && error.message}` }
305
+ }
306
+
307
+ next.found = true
308
+ next.error = ''
309
+ state.config = next
310
+ // We just changed the file's mtime ourselves. Record the new stamp so the
311
+ // next poll does not report our own write as an external edit.
312
+ state.configStamp = configStamp()
313
+ state.configRevision += 1
314
+ state.configReloads += 1
315
+ state.configReloadSource = 'ui'
316
+ restartScheduler()
317
+ return { ok: true, config: publicConfig() }
318
+ }
319
+
320
+ /** The subset the detail page reads back. */
321
+ function publicConfig() {
322
+ return {
323
+ showButton: state.config.showButton,
324
+ inChatMode: state.config.inChatMode,
325
+ buttonStyle: state.config.buttonStyle,
326
+ expandAllSessions: state.config.expandAllSessions,
327
+ deleteOnExit: state.config.deleteOnExit,
328
+ scheduleDelete: state.config.scheduleDelete,
329
+ scheduleTime: state.config.scheduleTime,
330
+ maxKept: state.config.maxKept,
331
+ configFile: configPath(),
332
+ found: state.config.found,
333
+ configRevision: state.configRevision,
334
+ configReloads: state.configReloads,
335
+ configReloadSource: state.configReloadSource,
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // tracking store
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function normalizeId(value) {
344
+ if (typeof value !== 'string') return ''
345
+ const trimmed = value.trim()
346
+ if (trimmed.length === 0 || trimmed.length > 128) return ''
347
+ return /^[A-Za-z0-9._:-]+$/.test(trimmed) ? trimmed : ''
348
+ }
349
+
350
+ async function loadStore() {
351
+ state.storePath = storePath()
352
+ try {
353
+ const raw = await readFile(state.storePath, 'utf8')
354
+ const parsed = JSON.parse(raw)
355
+ const list = Array.isArray(parsed) ? parsed : parsed && Array.isArray(parsed.sessions) ? parsed.sessions : []
356
+ state.tracked = list.map(normalizeId).filter(Boolean)
357
+ } catch (error) {
358
+ if (error && error.code !== 'ENOENT') state.lastError = `store read failed: ${error.message}`
359
+ state.tracked = []
360
+ }
361
+ }
362
+
363
+ async function saveStore() {
364
+ const path = state.storePath || storePath()
365
+ state.storePath = path
366
+ try {
367
+ await mkdir(dirname(path), { recursive: true })
368
+ await writeFile(path, JSON.stringify({ version: 1, sessions: state.tracked }, null, 2), 'utf8')
369
+ } catch (error) {
370
+ state.lastError = `store write failed: ${error && error.message}`
371
+ }
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // deletion
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /** Every `<sessions>/<workspace>/<id>` directory that exists right now. */
379
+ function sessionDirsSync(sessionId) {
380
+ const root = sessionsRoot()
381
+ const found = []
382
+ try {
383
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
384
+ if (!entry.isDirectory()) continue
385
+ const candidate = join(root, entry.name, sessionId)
386
+ if (existsSync(candidate)) found.push(candidate)
387
+ }
388
+ } catch (error) {
389
+ void error
390
+ }
391
+ return found
392
+ }
393
+
394
+ /**
395
+ * True when the session wrote anything within `ms`.
396
+ *
397
+ * There is no reliable "is this conversation open" signal reachable from a
398
+ * bundle, so the newest file mtime stands in for it: an open conversation keeps
399
+ * touching its transcript, an idle one goes quiet. Deleting an open session
400
+ * would make the host's window point at a directory that no longer exists, so
401
+ * the scheduled pass skips those and tries again a few minutes later.
402
+ */
403
+ function sessionActiveWithin(sessionId, ms) {
404
+ const cutoff = Date.now() - ms
405
+ for (const dir of sessionDirsSync(sessionId)) {
406
+ let entries = []
407
+ try {
408
+ entries = readdirSync(dir, { withFileTypes: true })
409
+ } catch (error) {
410
+ void error
411
+ continue
412
+ }
413
+ for (const entry of entries) {
414
+ if (!entry.isFile()) continue
415
+ try {
416
+ if (statSync(join(dir, entry.name)).mtimeMs >= cutoff) return true
417
+ } catch (error) {
418
+ void error
419
+ }
420
+ }
421
+ }
422
+ return false
423
+ }
424
+
425
+ function removeSessionSync(sessionId) {
426
+ let removed = 0
427
+ for (const dir of sessionDirsSync(sessionId)) {
428
+ try {
429
+ rmSync(dir, { recursive: true, force: true, maxRetries: 2 })
430
+ removed += 1
431
+ } catch (error) {
432
+ state.lastError = `delete ${sessionId} failed: ${error && error.message}`
433
+ }
434
+ }
435
+ try {
436
+ rmSync(projectionCacheFile(sessionId), { force: true })
437
+ } catch (error) {
438
+ void error
439
+ }
440
+ return removed
441
+ }
442
+
443
+ async function removeSession(sessionId) {
444
+ let removed = 0
445
+ const root = sessionsRoot()
446
+ let entries = []
447
+ try {
448
+ entries = await readdir(root, { withFileTypes: true })
449
+ } catch (error) {
450
+ void error
451
+ }
452
+ for (const entry of entries) {
453
+ if (!entry.isDirectory()) continue
454
+ const target = join(root, entry.name, sessionId)
455
+ if (!existsSync(target)) continue
456
+ try {
457
+ await rm(target, { recursive: true, force: true, maxRetries: 2 })
458
+ removed += 1
459
+ } catch (error) {
460
+ state.lastError = `delete ${sessionId} failed: ${error && error.message}`
461
+ }
462
+ }
463
+ try {
464
+ await rm(projectionCacheFile(sessionId), { force: true })
465
+ } catch (error) {
466
+ void error
467
+ }
468
+ return removed
469
+ }
470
+
471
+ /**
472
+ * Hide a session we could not delete from disk.
473
+ *
474
+ * `workspaceRegistry.archiveSession` is the host's own "remove from the list"
475
+ * primitive (the sidebar's archive action). It is a fallback only: when the
476
+ * files are gone the session disappears by itself once the search index
477
+ * reconciles.
478
+ */
479
+ async function hideSession(sessionId) {
480
+ for (const candidate of state.contexts) {
481
+ try {
482
+ const registry = candidate && typeof candidate.get === 'function' ? candidate.get('workspaceRegistry') : null
483
+ if (registry && typeof registry.archiveSession === 'function') {
484
+ await registry.archiveSession(sessionId, { stopActivity: true })
485
+ return true
486
+ }
487
+ } catch (error) {
488
+ void error
489
+ }
490
+ }
491
+ return false
492
+ }
493
+
494
+ /**
495
+ * Delete every tracked session (async path: startup, manual, scheduled, route).
496
+ *
497
+ * `skipActiveMs` protects a conversation the user is looking at right now: the
498
+ * scheduled cleanup passes 3 minutes, the quit path passes nothing (a quit is
499
+ * the user asking for the cleanup, and the process is going away anyway).
500
+ */
501
+ async function purgeAll(reason, options) {
502
+ const skipActiveMs = options && Number.isFinite(options.skipActiveMs) ? options.skipActiveMs : 0
503
+ const ids = state.tracked.slice()
504
+ const report = {
505
+ reason, at: new Date().toISOString(),
506
+ tried: ids.length, removed: 0, hidden: 0, skipped: 0, failed: [],
507
+ }
508
+ const kept = []
509
+ for (const id of ids) {
510
+ if (skipActiveMs > 0 && sessionActiveWithin(id, skipActiveMs)) {
511
+ report.skipped += 1
512
+ kept.push(id)
513
+ continue
514
+ }
515
+ let removed = 0
516
+ try {
517
+ removed = await removeSession(id)
518
+ } catch (error) {
519
+ void error
520
+ }
521
+ if (removed > 0 && sessionDirsSync(id).length === 0) {
522
+ report.removed += 1
523
+ continue
524
+ }
525
+ // The directory survived (locked file, permission). Hide it so the sidebar
526
+ // does not show a "temporary" chat that outlives its promise, and retry the
527
+ // delete on the next launch.
528
+ const hidden = await hideSession(id)
529
+ if (hidden) report.hidden += 1
530
+ report.failed.push(id)
531
+ kept.push(id)
532
+ }
533
+ state.tracked = kept
534
+ await saveStore()
535
+ state.lastPurge = report
536
+ return report
537
+ }
538
+
539
+ /** Delete every tracked session synchronously — the quit path has no `await`. */
540
+ function purgeSync(reason) {
541
+ if (state.tracked.length === 0) return { reason, tried: 0, removed: 0, kept: [] }
542
+ const ids = state.tracked.slice()
543
+ const kept = []
544
+ let removed = 0
545
+ for (const id of ids) {
546
+ const count = removeSessionSync(id)
547
+ if (count > 0 && sessionDirsSync(id).length === 0) removed += 1
548
+ else kept.push(id)
549
+ }
550
+ state.tracked = kept
551
+ // Best effort: the process is going away, so this write often loses the race.
552
+ try {
553
+ const path = state.storePath || storePath()
554
+ mkdirSync(dirname(path), { recursive: true })
555
+ writeFileSync(path, JSON.stringify({ version: 1, sessions: kept }, null, 2))
556
+ } catch (error) {
557
+ void error
558
+ }
559
+ state.lastPurge = { reason, at: new Date().toISOString(), tried: ids.length, removed, kept }
560
+ return state.lastPurge
561
+ }
562
+
563
+ /**
564
+ * One readiness gate for the whole process: load the tracking file, then run
565
+ * the startup purge exactly once. Every route awaits it, otherwise a request
566
+ * arriving first would be overwritten by the load (`state.tracked = []`).
567
+ */
568
+ let readyTask = null
569
+ function ensureReady() {
570
+ if (readyTask) return readyTask
571
+ readyTask = (async () => {
572
+ await loadStore()
573
+ if (state.config.deleteOnExit && !state.bootPurged) {
574
+ state.bootPurged = true
575
+ if (state.tracked.length > 0) await purgeAll('startup')
576
+ }
577
+ })().catch((error) => {
578
+ state.lastError = `startup failed: ${error && error.message}`
579
+ })
580
+ return readyTask
581
+ }
582
+
583
+ /** Register the quit hook once per process. */
584
+ function installExitHook() {
585
+ if (state.hooks) return
586
+ state.hooks = true
587
+ try {
588
+ process.once('exit', () => {
589
+ try {
590
+ purgeSync('process-exit')
591
+ } catch (error) {
592
+ void error
593
+ }
594
+ })
595
+ } catch (error) {
596
+ void error
597
+ }
598
+ }
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // daily cleanup scheduler (0.2.2)
602
+ // ---------------------------------------------------------------------------
603
+
604
+ /** Next occurrence of `HH:MM`, local time. Always strictly in the future. */
605
+ function computeNextRun(clock) {
606
+ const normalized = normalizeClock(clock)
607
+ if (!normalized) return null
608
+ const hour = Number(normalized.slice(0, 2))
609
+ const minute = Number(normalized.slice(3, 5))
610
+ const now = new Date()
611
+ const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0)
612
+ if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1)
613
+ return next
614
+ }
615
+
616
+ function stopScheduler() {
617
+ if (state.scheduler.timer) {
618
+ try { clearInterval(state.scheduler.timer) } catch (error) { void error }
619
+ state.scheduler.timer = null
620
+ }
621
+ state.scheduler.nextRunAt = null
622
+ state.scheduler.retryAt = null
623
+ }
624
+
625
+ /**
626
+ * One wake-up. Two ways to be due: the daily target arrived, or a previous run
627
+ * skipped a session that was in use and asked to look again in a few minutes.
628
+ */
629
+ async function schedulerTick() {
630
+ const scheduler = state.scheduler
631
+ if (scheduler.busy || !state.config.scheduleDelete) return
632
+ const now = Date.now()
633
+ const dueNext = !!(scheduler.nextRunAt && now >= scheduler.nextRunAt.getTime())
634
+ const dueRetry = !!(scheduler.retryAt && now >= scheduler.retryAt.getTime())
635
+ if (!dueNext && !dueRetry) return
636
+
637
+ scheduler.busy = true
638
+ try {
639
+ await ensureReady()
640
+ const report = await purgeAll(dueNext ? 'scheduled' : 'scheduled-retry', { skipActiveMs: ACTIVE_WINDOW_MS })
641
+ scheduler.lastRunAt = report.at
642
+ scheduler.retryAt = report.skipped > 0 || report.failed.length > 0
643
+ ? new Date(Date.now() + RETRY_DELAY_MS)
644
+ : null
645
+ if (dueNext) scheduler.nextRunAt = computeNextRun(state.config.scheduleTime)
646
+ } catch (error) {
647
+ state.lastError = `scheduled cleanup failed: ${error && error.message}`
648
+ } finally {
649
+ scheduler.busy = false
650
+ }
651
+ }
652
+
653
+ function startScheduler() {
654
+ stopScheduler()
655
+ if (!state.config.scheduleDelete) return
656
+ state.scheduler.nextRunAt = computeNextRun(state.config.scheduleTime)
657
+ try {
658
+ const timer = setInterval(schedulerTick, TICK_MS)
659
+ // A cleanup timer must never be the reason the host process stays alive.
660
+ if (timer && typeof timer.unref === 'function') timer.unref()
661
+ state.scheduler.timer = timer
662
+ } catch (error) {
663
+ state.lastError = `scheduler start failed: ${error && error.message}`
664
+ }
665
+ }
666
+
667
+ /** Re-read the target after a settings change; also used at boot. */
668
+ function restartScheduler() {
669
+ startScheduler()
670
+ }
671
+
672
+ function schedulerStatus() {
673
+ return {
674
+ enabled: state.config.scheduleDelete,
675
+ at: state.config.scheduleTime,
676
+ nextRunAt: state.scheduler.nextRunAt ? state.scheduler.nextRunAt.toISOString() : null,
677
+ lastRunAt: state.scheduler.lastRunAt,
678
+ retryAt: state.scheduler.retryAt ? state.scheduler.retryAt.toISOString() : null,
679
+ }
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // routes
684
+ // ---------------------------------------------------------------------------
685
+
686
+ /** Add one session id to the temporary set (called right after creation). */
687
+ async function track(sessionId) {
688
+ await ensureReady()
689
+ const id = normalizeId(sessionId)
690
+ if (id.length === 0) return { ok: false, error: 'invalid session id' }
691
+ if (!state.tracked.includes(id)) state.tracked.push(id)
692
+ // Oldest first: trimming keeps the newest MAX_KEPT entries.
693
+ if (state.tracked.length > state.config.maxKept) {
694
+ const overflow = state.tracked.length - state.config.maxKept
695
+ const dropped = state.tracked.splice(0, overflow)
696
+ for (const old of dropped) {
697
+ try {
698
+ await removeSession(old)
699
+ } catch (error) {
700
+ void error
701
+ }
702
+ }
703
+ }
704
+ await saveStore()
705
+ return { ok: true, ids: state.tracked.slice(), count: state.tracked.length }
706
+ }
707
+
708
+ async function untrack(sessionId) {
709
+ await ensureReady()
710
+ const id = normalizeId(sessionId)
711
+ if (id.length === 0) return { ok: false, error: 'invalid session id' }
712
+ state.tracked = state.tracked.filter((item) => item !== id)
713
+ await saveStore()
714
+ return { ok: true, ids: state.tracked.slice(), count: state.tracked.length }
715
+ }
716
+
717
+ function status() {
718
+ // Every poll doubles as a hot-reload check.
719
+ refreshConfigIfChanged()
720
+ return {
721
+ ok: true,
722
+ showButton: state.config.showButton,
723
+ chatModePolicy: state.config.inChatMode,
724
+ buttonStyle: state.config.buttonStyle,
725
+ expandAllSessions: state.config.expandAllSessions,
726
+ deleteOnExit: state.config.deleteOnExit,
727
+ scheduleDelete: state.config.scheduleDelete,
728
+ scheduleTime: state.config.scheduleTime,
729
+ schedule: schedulerStatus(),
730
+ maxKept: state.config.maxKept,
731
+ configFile: configPath(),
732
+ configRevision: state.configRevision,
733
+ configReloads: state.configReloads,
734
+ configReloadSource: state.configReloadSource,
735
+ tempCount: state.tracked.length,
736
+ config: { found: state.config.found, error: state.config.error || undefined },
737
+ routes: { registered: state.routes.registered, errors: state.routes.errors.slice(0, 4) },
738
+ lastPurge: state.lastPurge,
739
+ lastError: state.lastError || undefined,
740
+ // Everything a feedback report needs to be diagnosable without a second
741
+ // round-trip. `DSH_CLIENT_VERSION` is set by the desktop main process for
742
+ // the host child (verified in app.asar lib/main.js: DesktopBackendController
743
+ // spawns the host with `DSH_CLIENT_VERSION: desktopClientVersion()`).
744
+ env: {
745
+ plugin: pluginVersion(),
746
+ dshClient: process.env.DSH_CLIENT_VERSION || '',
747
+ platform: process.platform,
748
+ arch: process.arch,
749
+ os: (() => {
750
+ try {
751
+ return osRelease()
752
+ } catch (error) {
753
+ void error
754
+ return ''
755
+ }
756
+ })(),
757
+ node: process.versions.node || '',
758
+ electron: process.versions.electron || '',
759
+ },
760
+ }
761
+ }
762
+
763
+ function probe() {
764
+ refreshConfigIfChanged()
765
+ return {
766
+ ok: true,
767
+ mode: 'native-session',
768
+ dshHome: dshHome(),
769
+ sessionsRoot: sessionsRoot(),
770
+ storePath: state.storePath || storePath(),
771
+ configFile: configPath(),
772
+ tracked: state.tracked.slice(),
773
+ config: state.config,
774
+ configRevision: state.configRevision,
775
+ configReloadSource: state.configReloadSource,
776
+ schedule: schedulerStatus(),
777
+ routes: state.routes,
778
+ lastPurge: state.lastPurge,
779
+ lastError: state.lastError || undefined,
780
+ deletesOnExit: state.config.deleteOnExit,
781
+ scheduleEnabled: state.config.scheduleDelete,
782
+ processExitHook: state.hooks,
783
+ createsOwnWindow: false,
784
+ usesNativeWindow: true,
785
+ }
786
+ }
787
+
788
+ function json(body, code = 200) {
789
+ return new Response(JSON.stringify(body), {
790
+ status: code,
791
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
792
+ })
793
+ }
794
+
795
+ function queryOf(request) {
796
+ try {
797
+ const url = new URL(request.url, 'http://localhost')
798
+ return { sessionId: normalizeId(url.searchParams.get('sessionId') || '') }
799
+ } catch (error) {
800
+ void error
801
+ return { sessionId: '' }
802
+ }
803
+ }
804
+
805
+ export function createTempSessionHost(initialCtx) {
806
+ if (initialCtx) state.contexts.push(initialCtx)
807
+
808
+ /** Keep every context the plugin is applied to: services differ per scope. */
809
+ function setContext(next) {
810
+ if (!next) return
811
+ if (!state.contexts.includes(next)) state.contexts.push(next)
812
+ }
813
+
814
+ function register(hostCtx) {
815
+ setContext(hostCtx)
816
+ state.config = parseConfig()
817
+ state.configStamp = configStamp()
818
+ state.configRevision += 1
819
+ installExitHook()
820
+ // Boot the daily cleanup from whatever the file says; a stale timer from a
821
+ // previous apply is replaced, never duplicated.
822
+ restartScheduler()
823
+
824
+ // Boot pass: anything still tracked was created in a previous run and must
825
+ // not survive it. `ensureReady()` runs it once and every route waits on it.
826
+ ensureReady()
827
+
828
+ const routes = [
829
+ ['/api/dsh-temp-session.status', ['GET'], async () => { await ensureReady(); return json(status()) }],
830
+ ['/api/dsh-temp-session.list', ['GET'], async () => {
831
+ await ensureReady()
832
+ return json({ ok: true, ids: state.tracked.slice(), count: state.tracked.length })
833
+ }],
834
+ ['/api/dsh-temp-session.config', ['GET', 'POST'], async (request) => {
835
+ await ensureReady()
836
+ const method = request && request.method ? String(request.method).toUpperCase() : 'GET'
837
+ if (method !== 'POST') {
838
+ refreshConfigIfChanged()
839
+ return json({ ok: true, ...publicConfig() })
840
+ }
841
+ let patch = null
842
+ try {
843
+ const raw = await request.text()
844
+ patch = raw && raw.trim().length > 0 ? JSON.parse(raw) : {}
845
+ } catch (error) {
846
+ void error
847
+ return json({ ok: false, error: 'invalid JSON body' }, 400)
848
+ }
849
+ const result = saveConfig(patch)
850
+ if (!result.ok) return json(result, 500)
851
+ return json({ ok: true, ...result.config, schedule: schedulerStatus() })
852
+ }],
853
+ ['/api/dsh-temp-session.track', ['POST', 'GET'], async (request) => {
854
+ const { sessionId } = queryOf(request)
855
+ if (sessionId.length === 0) return json({ ok: false, error: 'sessionId required' }, 400)
856
+ return json(await track(sessionId))
857
+ }],
858
+ ['/api/dsh-temp-session.untrack', ['POST', 'GET'], async (request) => {
859
+ const { sessionId } = queryOf(request)
860
+ if (sessionId.length === 0) return json({ ok: false, error: 'sessionId required' }, 400)
861
+ return json(await untrack(sessionId))
862
+ }],
863
+ ['/api/dsh-temp-session.purge', ['POST', 'GET'], async () => {
864
+ await ensureReady()
865
+ return json({ ok: true, ...(await purgeAll('manual')) })
866
+ }],
867
+ ['/api/dsh-temp-session.probe', ['GET'], () => json(probe())],
868
+ ]
869
+
870
+ let registered = 0
871
+ for (const [path, methods, handler] of routes) {
872
+ try {
873
+ const connection = typeof hostCtx?.get === 'function' ? hostCtx.get('connection') : null
874
+ const registry = connection && connection.fetch
875
+ if (!registry || typeof registry.register !== 'function') {
876
+ state.routes.errors.push(`${path}: connection.fetch unavailable`)
877
+ continue
878
+ }
879
+ registry.register({
880
+ path,
881
+ methods,
882
+ requestBody: 'buffered',
883
+ fetch: (request) => handler(request),
884
+ })
885
+ registered += 1
886
+ } catch (error) {
887
+ const message = String((error && error.message) || error)
888
+ // Re-applying the plugin must not fail the batch: an already mounted
889
+ // route is a success, not an error.
890
+ if (/already registered/i.test(message)) registered += 1
891
+ else state.routes.errors.push(`${path}: ${message}`)
892
+ }
893
+ }
894
+ state.routes.registered = registered
895
+ state.routes.errors = state.routes.errors.slice(-6)
896
+ return registered
897
+ }
898
+
899
+ return {
900
+ register,
901
+ setContext,
902
+ state,
903
+ status,
904
+ probe,
905
+ purgeAll,
906
+ purgeSync,
907
+ track,
908
+ untrack,
909
+ saveConfig,
910
+ publicConfig,
911
+ configStamp,
912
+ refreshConfigIfChanged,
913
+ computeNextRun,
914
+ schedulerTick,
915
+ startScheduler,
916
+ restartScheduler,
917
+ }
918
+ }