dsh-sidebar-suite 0.1.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/DETAIL.md +137 -64
- package/README.md +213 -90
- package/assets/icon-180.png +0 -0
- package/assets/screenshots/screenshot-1.png +0 -0
- package/assets/screenshots/screenshot-2.png +0 -0
- package/assets/screenshots/screenshot-3.png +0 -0
- package/lib/client.js +2285 -1276
- package/lib/index.js +215 -158
- package/lib/temp-session-host.mjs +842 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +72 -60
- package/screenshots.json +5 -0
- package/temp-session-config.txt +53 -0
- package//344/275/277/347/224/250/350/257/264/346/230/216.md +418 -0
|
@@ -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
|
+
}
|