dsh-sidebar-suite 0.1.3 → 0.2.4

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,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
+ }
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
+ }