dsh-keeper-plugin 0.0.0-stage → 0.1.1

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/index.js ADDED
@@ -0,0 +1,2672 @@
1
+ /**
2
+ * DSH Keeper — host half.
3
+ *
4
+ * Serves the panel's data API and performs the destructive Work (moving a
5
+ * session log into the plugin's own trash, restoring it, purging it) against
6
+ * the DSH host services instead of touching the registry behind its back.
7
+ *
8
+ * The client half renders three slots: the sidebar panel launcher, the main
9
+ * management panel, and a Settings→Plugins page.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import zlib from 'node:zlib';
16
+
17
+ export const name = 'dsh-keeper';
18
+
19
+ /** Host services this plugin composes. */
20
+ export const inject = [
21
+ 'webServer',
22
+ 'connection',
23
+ 'sessionPersistence',
24
+ 'workspaceRegistry',
25
+ 'sessionController',
26
+ ];
27
+
28
+ const NS = 'dsh-keeper';
29
+ const PANEL_ID = 'dsh-keeper';
30
+ const API_PATH = '/dsh-keeper/api';
31
+ const PLUGIN_VERSION = '0.1.1';
32
+ const META_NAME = '.dsh-keeper-trash.json';
33
+ /** Pre-rename identifiers. Kept only so existing installs migrate in place. */
34
+ const LEGACY_NS = 'sidebar-studio';
35
+ const LEGACY_META_NAME = '.sidebar-studio-trash.json';
36
+ const LEGACY_BUNDLE = 'dsh-sidebar-studio';
37
+
38
+ function isSelfEntryId(value) {
39
+ return typeof value === 'string' && (value.indexOf('dsh-keeper') !== -1 || value.indexOf(LEGACY_BUNDLE) !== -1);
40
+ }
41
+
42
+ const SOUND_KINDS = ['normal', 'interrupt'];
43
+ const SOUND_MIME = { mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg' };
44
+ const SOUND_MAX_BYTES = 2 * 1024 * 1024;
45
+
46
+ const NOTIFY_DEFAULTS = {
47
+ enabled: true,
48
+ onDone: true,
49
+ onError: true,
50
+ onAsk: true,
51
+ system: true,
52
+ inApp: true,
53
+ unfocusedOnly: true,
54
+ // `sound` is the on/off audio channel: 'none' mutes the tones, everything
55
+ // else keeps them on. `sounds` carries the per-group file names.
56
+ sound: 'system',
57
+ // The master switch is derived from the two channels; this remembers the last
58
+ // non-empty combination so turning it back on restores what the user had.
59
+ lastChannels: { banner: true, sound: true },
60
+ volume: 100,
61
+ sounds: { normal: { name: '' }, interrupt: { name: '' } },
62
+ openOnClick: true,
63
+ };
64
+
65
+ const DEFAULT_SETTINGS = {
66
+ defaultDeleteMode: 'trash',
67
+ confirmPurge: true,
68
+ defaultExportFormat: 'md',
69
+ notifications: { ...NOTIFY_DEFAULTS },
70
+ };
71
+
72
+ function normalizeSoundEntry(value) {
73
+ const raw = value && typeof value === 'object' ? value : {};
74
+ return { name: typeof raw.name === 'string' ? raw.name.slice(0, 120) : '' };
75
+ }
76
+
77
+ function normalizeNotifyLastChannels(value, fallback) {
78
+ const raw = value && typeof value === 'object' ? value : null;
79
+ const banner = raw ? raw.banner !== false : fallback.banner;
80
+ const sound = raw ? raw.sound !== false : fallback.sound;
81
+ if (banner || sound) return { banner, sound };
82
+ if (fallback.banner || fallback.sound) return { banner: fallback.banner, sound: fallback.sound };
83
+ return { banner: true, sound: true };
84
+ }
85
+
86
+ function normalizeNotificationSettings(value) {
87
+ const raw = value && typeof value === 'object' ? value : {};
88
+ // Older builds stored 'custom'; that synthetic tone is now the built-in
89
+ // default, so only 'none' still means silent.
90
+ const sound = raw.sound === 'none' ? 'none' : 'system';
91
+ const rawBanner = raw.system !== false;
92
+ const rawSoundOn = sound !== 'none';
93
+ const lastChannels = normalizeNotifyLastChannels(raw.lastChannels, {
94
+ banner: rawBanner,
95
+ sound: rawSoundOn,
96
+ });
97
+ // The master switch aggregates the two channels: muting it clears both, and
98
+ // turning the last channel off flips it off, so the persisted state can never
99
+ // claim "enabled" while every channel is silent.
100
+ const enabled = raw.enabled !== false;
101
+ const banner = enabled && rawBanner;
102
+ const soundOn = enabled && rawSoundOn;
103
+ const volume = Number(raw.volume);
104
+ const rawSounds = raw.sounds && typeof raw.sounds === 'object' ? raw.sounds : {};
105
+ const sounds = {};
106
+ for (const kind of SOUND_KINDS) sounds[kind] = normalizeSoundEntry(rawSounds[kind]);
107
+ return {
108
+ enabled: banner || soundOn,
109
+ onDone: raw.onDone !== false,
110
+ onError: raw.onError !== false,
111
+ onAsk: raw.onAsk !== false,
112
+ system: banner,
113
+ // The "提醒方式" card was folded into the banner switch: an in-app toast
114
+ // is only a fallback and banners stay background-only, so both are fixed on.
115
+ inApp: true,
116
+ unfocusedOnly: true,
117
+ sound: soundOn ? 'system' : 'none',
118
+ lastChannels,
119
+ volume: Number.isFinite(volume) ? Math.max(0, Math.min(100, Math.round(volume))) : 100,
120
+ sounds,
121
+ openOnClick: raw.openOnClick !== false,
122
+ };
123
+ }
124
+
125
+ const DEFAULT_STATE = {
126
+ version: 1,
127
+ hiddenPanels: [],
128
+ settings: { ...DEFAULT_SETTINGS },
129
+ updatedAt: 0,
130
+ };
131
+
132
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
133
+ const infoCache = new Map();
134
+
135
+ /* ------------------------------------------------------------------ paths */
136
+
137
+ function dshHome() {
138
+ const fromEnv = process.env.DSH_HOME;
139
+ if (typeof fromEnv === 'string' && fromEnv.trim().length > 0) return fromEnv;
140
+ return path.join(os.homedir(), '.dsh');
141
+ }
142
+
143
+ function storagesDir() {
144
+ return path.join(dshHome(), 'storages');
145
+ }
146
+
147
+ function dataDir() {
148
+ return path.join(storagesDir(), NS);
149
+ }
150
+
151
+ function legacyDataDir() {
152
+ return path.join(storagesDir(), LEGACY_NS);
153
+ }
154
+
155
+ function copyMissingTree(from, to) {
156
+ const stat = fs.statSync(from);
157
+ if (stat.isDirectory()) {
158
+ fs.mkdirSync(to, { recursive: true });
159
+ for (const name of fs.readdirSync(from)) copyMissingTree(path.join(from, name), path.join(to, name));
160
+ return;
161
+ }
162
+ if (!fs.existsSync(to)) fs.copyFileSync(from, to);
163
+ }
164
+
165
+ /**
166
+ * Move an old `storages/sidebar-studio` tree into the new data directory without
167
+ * overwriting anything the user already created under the new name. The old
168
+ * directory is left in place as a one-version rollback.
169
+ */
170
+ const MIGRATION_MARKER = '.dsh-keeper-migrated';
171
+
172
+ function migrateLegacyData() {
173
+ const target = dataDir();
174
+ const legacy = legacyDataDir();
175
+ if (path.resolve(target) === path.resolve(legacy)) return false;
176
+ try {
177
+ if (!fs.existsSync(legacy)) return false;
178
+ if (fs.existsSync(path.join(target, MIGRATION_MARKER))) return false;
179
+ fs.mkdirSync(target, { recursive: true });
180
+ for (const name of fs.readdirSync(legacy)) {
181
+ copyMissingTree(path.join(legacy, name), path.join(target, name));
182
+ }
183
+ fs.writeFileSync(path.join(target, MIGRATION_MARKER), `${new Date().toISOString()}\n`, 'utf8');
184
+ return true;
185
+ } catch (error) {
186
+ console.warn('[dsh-keeper] legacy data migration failed:', error?.message ?? error);
187
+ return false;
188
+ }
189
+ }
190
+
191
+ function stateFile() {
192
+ return path.join(dataDir(), 'state.json');
193
+ }
194
+
195
+ function trashRoot() {
196
+ return path.join(dataDir(), 'trash');
197
+ }
198
+
199
+ function soundsDir() {
200
+ return path.join(dataDir(), 'sounds');
201
+ }
202
+
203
+ function workspaceFile() {
204
+ return path.join(storagesDir(), 'workspace.json');
205
+ }
206
+
207
+ function sessionsRoot() {
208
+ return path.join(dshHome(), 'sessions');
209
+ }
210
+
211
+ /* ------------------------------------------------------ profile patch layer */
212
+
213
+ function profileDir() {
214
+ const fromEnv = process.env.DSH_PROFILE_DIR;
215
+ if (typeof fromEnv === 'string' && fromEnv.trim().length > 0) return fromEnv;
216
+ return path.join(dshHome(), 'profiles', 'desktop');
217
+ }
218
+
219
+ function profilePatchFile() {
220
+ return path.join(profileDir(), 'cordis.patch.yml');
221
+ }
222
+
223
+ const PATCH_BLOCK_START = /^\s*-\s/;
224
+ const PATCH_BLOCK_ID = /^\s*-?\s*id:\s*["']?([^"'\s#]+)/;
225
+ const PATCH_BLOCK_NAME = /^\s*name:\s*["']?([^"'\s#]+)/;
226
+ const PATCH_BLOCK_DISABLED = /^\s*disabled:\s*true\s*$/m;
227
+
228
+ /**
229
+ * Rows this profile disables through a patch override (`- id: <x>` plus only
230
+ * `disabled: true`). The Loader drops those rows from its live entry list, so
231
+ * `pluginManager.listPlugins()` can neither show nor re-enable them any more;
232
+ * the profile patch layer is the only surviving record.
233
+ */
234
+ function readDisabledPatches() {
235
+ let text = '';
236
+ try {
237
+ text = fs.readFileSync(profilePatchFile(), 'utf8');
238
+ } catch {
239
+ return [];
240
+ }
241
+ const lines = text.split(/\r?\n/);
242
+ const blocks = [];
243
+ let current = null;
244
+ for (const line of lines) {
245
+ if (PATCH_BLOCK_START.test(line) || line.trim() === '-') {
246
+ if (current) blocks.push(current);
247
+ current = { lines: [line] };
248
+ } else if (current) {
249
+ current.lines.push(line);
250
+ }
251
+ }
252
+ if (current) blocks.push(current);
253
+ const rows = [];
254
+ for (const block of blocks) {
255
+ const body = block.lines.join('\n');
256
+ if (!PATCH_BLOCK_DISABLED.test(body)) continue;
257
+ const idMatch = block.lines.map((line) => line.match(PATCH_BLOCK_ID)).find(Boolean);
258
+ if (!idMatch) continue;
259
+ const keys = block.lines
260
+ .map((line) => line.match(/^\s{2,}([A-Za-z_][\w-]*):/))
261
+ .filter(Boolean)
262
+ .map((match) => match[1]);
263
+ if (keys.some((key) => key !== 'id' && key !== 'name' && key !== 'disabled')) continue;
264
+ const nameMatch = block.lines.map((line) => line.match(PATCH_BLOCK_NAME)).find(Boolean);
265
+ rows.push({ id: idMatch[1], name: nameMatch ? nameMatch[1] : null });
266
+ }
267
+ return rows;
268
+ }
269
+
270
+ /** Remove one pure `- id / disabled: true` override so the Loader mounts it again. */
271
+ function enableDisabledPatch(patchId) {
272
+ let text = '';
273
+ try {
274
+ text = fs.readFileSync(profilePatchFile(), 'utf8');
275
+ } catch {
276
+ return false;
277
+ }
278
+ const lines = text.split(/\r?\n/);
279
+ let start = -1;
280
+ for (let index = 0; index < lines.length; index += 1) {
281
+ const match = lines[index].match(PATCH_BLOCK_ID);
282
+ if (!match || match[1] !== patchId) continue;
283
+ if (!(PATCH_BLOCK_START.test(lines[index]) || lines[index].trim() === '-')) continue;
284
+ start = index;
285
+ break;
286
+ }
287
+ if (start === -1) return false;
288
+ let end = lines.length;
289
+ for (let index = start + 1; index < lines.length; index += 1) {
290
+ if (PATCH_BLOCK_START.test(lines[index]) || lines[index].trim() === '-') {
291
+ end = index;
292
+ break;
293
+ }
294
+ }
295
+ if (end >= lines.length) end = lines.length;
296
+ while (end < lines.length && lines[end].trim() === '') end += 1;
297
+ const blockText = lines.slice(start, end).join('\n');
298
+ if (!PATCH_BLOCK_DISABLED.test(blockText)) return false;
299
+ lines.splice(start, end - start);
300
+ const next = `${lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '')}\n`;
301
+ try {
302
+ fs.writeFileSync(profilePatchFile(), next, 'utf8');
303
+ } catch {
304
+ return false;
305
+ }
306
+ return true;
307
+ }
308
+
309
+ /* ------------------------------------------------------------------- json */
310
+
311
+ function readJson(file, fallback) {
312
+ try {
313
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
314
+ } catch {
315
+ return fallback;
316
+ }
317
+ }
318
+
319
+ function writeJson(file, value) {
320
+ fs.mkdirSync(path.dirname(file), { recursive: true });
321
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
322
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
323
+ fs.renameSync(tmp, file);
324
+ }
325
+
326
+ function fail(code, message) {
327
+ const error = new Error(message);
328
+ error.code = code;
329
+ return error;
330
+ }
331
+
332
+ /* ------------------------------------------------------------------ state */
333
+
334
+ function readState() {
335
+ const raw = readJson(stateFile(), null);
336
+ const state = raw && typeof raw === 'object' ? raw : {};
337
+ const settings = state.settings && typeof state.settings === 'object' ? state.settings : {};
338
+ const hidden = Array.isArray(state.hiddenPanels)
339
+ ? state.hiddenPanels.filter((id) => typeof id === 'string' && id.length > 0 && id.length < 200)
340
+ : [];
341
+ return {
342
+ version: 1,
343
+ hiddenPanels: [...new Set(hidden)],
344
+ settings: {
345
+ defaultDeleteMode: settings.defaultDeleteMode === 'archive' ? 'archive' : 'trash',
346
+ confirmPurge: settings.confirmPurge !== false,
347
+ defaultExportFormat: normalizeExportFormat(settings.defaultExportFormat),
348
+ notifications: normalizeNotificationSettings(settings.notifications),
349
+ },
350
+ updatedAt: typeof state.updatedAt === 'number' ? state.updatedAt : 0,
351
+ };
352
+ }
353
+
354
+ function writeState(state) {
355
+ const next = {
356
+ version: 1,
357
+ hiddenPanels: [...new Set(state.hiddenPanels ?? [])],
358
+ settings: {
359
+ defaultDeleteMode: state.settings?.defaultDeleteMode === 'archive' ? 'archive' : 'trash',
360
+ confirmPurge: state.settings?.confirmPurge !== false,
361
+ defaultExportFormat: normalizeExportFormat(state.settings?.defaultExportFormat),
362
+ notifications: normalizeNotificationSettings(state.settings?.notifications),
363
+ },
364
+ updatedAt: Date.now(),
365
+ };
366
+ writeJson(stateFile(), next);
367
+ return next;
368
+ }
369
+
370
+ function readRegistryState() {
371
+ const raw = readJson(workspaceFile(), null);
372
+ const global = raw && raw.global && typeof raw.global === 'object' ? raw.global : {};
373
+ const toSet = (value) =>
374
+ new Set(Array.isArray(value) ? value.filter((id) => typeof id === 'string') : []);
375
+ return {
376
+ archived: toSet(global.archivedSessionIds),
377
+ pinned: toSet(global.pinnedSessionIds),
378
+ };
379
+ }
380
+
381
+ /* --------------------------------------------------------------- sessions */
382
+
383
+ function assertSessionId(id) {
384
+ if (typeof id !== 'string' || !/^session-[A-Za-z0-9_-]{1,120}$/.test(id)) {
385
+ throw fail('BAD_SESSION_ID', `Invalid session id: ${String(id)}`);
386
+ }
387
+ return id;
388
+ }
389
+
390
+ function listSessionDirs() {
391
+ const root = sessionsRoot();
392
+ const out = [];
393
+ let groups = [];
394
+ try {
395
+ groups = fs.readdirSync(root);
396
+ } catch {
397
+ return out;
398
+ }
399
+ for (const group of groups) {
400
+ const groupPath = path.join(root, group);
401
+ let groupStat;
402
+ try {
403
+ groupStat = fs.statSync(groupPath);
404
+ } catch {
405
+ continue;
406
+ }
407
+ if (!groupStat.isDirectory()) continue;
408
+ let names = [];
409
+ try {
410
+ names = fs.readdirSync(groupPath);
411
+ } catch {
412
+ continue;
413
+ }
414
+ for (const entry of names) {
415
+ if (!entry.startsWith('session-')) continue;
416
+ const dir = path.join(groupPath, entry);
417
+ let stat;
418
+ try {
419
+ stat = fs.statSync(dir);
420
+ } catch {
421
+ continue;
422
+ }
423
+ if (!stat.isDirectory()) continue;
424
+ out.push({ id: entry, dir, group, mtimeMs: stat.mtimeMs });
425
+ }
426
+ }
427
+ return out;
428
+ }
429
+
430
+ function sessionLogFile(dir) {
431
+ let names = [];
432
+ try {
433
+ names = fs.readdirSync(dir);
434
+ } catch {
435
+ return null;
436
+ }
437
+ const log = names.find((n) => n.endsWith('.jsonl.zstd')) ?? names.find((n) => n.endsWith('.jsonl'));
438
+ return log ? path.join(dir, log) : null;
439
+ }
440
+
441
+ function dirSize(dir) {
442
+ let total = 0;
443
+ let names = [];
444
+ try {
445
+ names = fs.readdirSync(dir, { withFileTypes: true });
446
+ } catch {
447
+ return 0;
448
+ }
449
+ for (const entry of names) {
450
+ const full = path.join(dir, entry.name);
451
+ try {
452
+ if (entry.isDirectory()) total += dirSize(full);
453
+ else total += fs.statSync(full).size;
454
+ } catch {
455
+ /* skip unreadable entries */
456
+ }
457
+ }
458
+ return total;
459
+ }
460
+
461
+ function hasZstd() {
462
+ return typeof zlib.zstdDecompressSync === 'function';
463
+ }
464
+
465
+ function decodeZstdLog(file) {
466
+ const buffer = fs.readFileSync(file);
467
+ const chunks = [];
468
+ let index = buffer.indexOf(ZSTD_MAGIC);
469
+ while (index !== -1) {
470
+ try {
471
+ chunks.push(zlib.zstdDecompressSync(buffer.subarray(index)));
472
+ } catch {
473
+ /* not a frame start; ignore */
474
+ }
475
+ index = buffer.indexOf(ZSTD_MAGIC, index + 4);
476
+ }
477
+ const text = Buffer.concat(chunks).toString('utf8');
478
+ const events = [];
479
+ for (const line of text.split('\n')) {
480
+ const trimmed = line.trim();
481
+ if (!trimmed) continue;
482
+ try {
483
+ const parsed = JSON.parse(trimmed);
484
+ if (parsed && typeof parsed === 'object') events.push(parsed);
485
+ } catch {
486
+ /* partial line in a frame boundary; ignore */
487
+ }
488
+ }
489
+ return { text, events };
490
+ }
491
+
492
+ async function readEventsViaPersistence(ctx, id) {
493
+ let handle;
494
+ try {
495
+ handle = await ctx.sessionPersistence.open(id, 'read');
496
+ } catch {
497
+ return [];
498
+ }
499
+ try {
500
+ const result = await handle.read();
501
+ return Array.isArray(result?.events) ? result.events : [];
502
+ } catch {
503
+ return [];
504
+ } finally {
505
+ try {
506
+ await handle.close();
507
+ } catch {
508
+ /* ignore */
509
+ }
510
+ }
511
+ }
512
+
513
+ function messageText(data) {
514
+ const direct = contentTextOf(data?.content);
515
+ if (direct) return direct;
516
+ // Assistant events keep the model message under `data.message`.
517
+ const nested = contentTextOf(data?.message?.content);
518
+ if (nested) return nested;
519
+ if (typeof data?.text === 'string' && data.text.trim()) return data.text.trim();
520
+ return '';
521
+ }
522
+
523
+ function truncate(text, max) {
524
+ const clean = text.replace(/\s+/g, ' ').trim();
525
+ if (!clean) return '';
526
+ return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
527
+ }
528
+
529
+ /**
530
+ * Toast bodies are plain text: strip the Markdown the model writes so that
531
+ * `**bold**` / `` `code` `` never leak into a Windows notification.
532
+ */
533
+ function stripMarkdown(text) {
534
+ return String(text ?? '')
535
+ .replace(/```[^\n]*\n?([\s\S]*?)```/g, ' $1 ')
536
+ .replace(/`([^`]+)`/g, '$1')
537
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
538
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
539
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
540
+ .replace(/^\s{0,3}>\s?/gm, '')
541
+ .replace(/^\s{0,3}(?:[-*+]|\d{1,3}[.)])\s+/gm, '')
542
+ .replace(/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/gm, ' ')
543
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
544
+ .replace(/__([^_]+)__/g, '$1')
545
+ .replace(/~~([^~]+)~~/g, '$1')
546
+ .replace(/\*([^*\n]+)\*/g, '$1')
547
+ .replace(/(^|[^\w])_([^_\n]+)_(?![\w])/g, '$1$2')
548
+ .replace(/<\/?[a-z][^>]*>/gi, ' ')
549
+ .replace(/\|/g, ' ')
550
+ .replace(/[ \t]+/g, ' ');
551
+ }
552
+
553
+ function deriveFromEvents(events, fallbackTitle) {
554
+ let title = '';
555
+ let firstUser = '';
556
+ let messages = 0;
557
+ let lastTime = 0;
558
+ for (const event of events) {
559
+ if (!event || typeof event !== 'object') continue;
560
+ if (typeof event.time === 'number' && event.time > lastTime) lastTime = event.time;
561
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
562
+ const candidate = event.data.title.trim();
563
+ if (candidate) title = candidate;
564
+ }
565
+ if (event.type === 'user/message') {
566
+ messages += 1;
567
+ if (!firstUser) firstUser = messageText(event.data);
568
+ } else if (event.type === 'assistant/message') {
569
+ messages += 1;
570
+ }
571
+ }
572
+ return {
573
+ title: title || truncate(firstUser, 80) || truncate(fallbackTitle ?? '', 80) || '',
574
+ messageCount: messages,
575
+ lastTime,
576
+ };
577
+ }
578
+
579
+ async function sessionInfo(ctx, id, snapshot, dirMap, runningSet, registry) {
580
+ const found = dirMap.get(id);
581
+ const dir = found?.dir ?? null;
582
+ const logFile = dir ? sessionLogFile(dir) : null;
583
+ let sizeBytes = snapshot?.sizeBytes;
584
+ if (typeof sizeBytes !== 'number') {
585
+ try {
586
+ sizeBytes = logFile ? fs.statSync(logFile).size : dirSize(dir ?? '');
587
+ } catch {
588
+ sizeBytes = 0;
589
+ }
590
+ }
591
+ const revision = snapshot?.revision;
592
+ const cached = infoCache.get(id);
593
+ let base;
594
+ if (cached && cached.revision === revision && cached.sizeBytes === sizeBytes && cached.dir === dir) {
595
+ base = cached.info;
596
+ } else {
597
+ let events = [];
598
+ let text = '';
599
+ if (logFile && hasZstd()) {
600
+ try {
601
+ const decoded = decodeZstdLog(logFile);
602
+ events = decoded.events;
603
+ text = decoded.text;
604
+ } catch {
605
+ events = [];
606
+ }
607
+ }
608
+ if (events.length === 0) {
609
+ events = await readEventsViaPersistence(ctx, id);
610
+ }
611
+ const derived = deriveFromEvents(events, '');
612
+ const createdAt = snapshot?.header?.createdAt;
613
+ base = {
614
+ id,
615
+ title: derived.title || snapshot?.header?.id || id,
616
+ sizeBytes,
617
+ eventCount: events.length > 0 ? events.length : snapshot?.eventCount ?? 0,
618
+ messageCount: derived.messageCount,
619
+ blank: derived.messageCount === 0,
620
+ createdAt: typeof createdAt === 'number' ? createdAt : (dir ? safeMtime(logFile) : Date.now()),
621
+ updatedAt: derived.lastTime || safeMtime(logFile) || Date.now(),
622
+ formatStatus: snapshot?.formatStatus ?? 'current',
623
+ missingLog: !logFile,
624
+ hasTitle: Boolean(derived.title),
625
+ _textLength: text.length,
626
+ };
627
+ infoCache.set(id, { revision, sizeBytes, dir, info: base });
628
+ }
629
+ return {
630
+ ...base,
631
+ running: runningSet.has(id),
632
+ archived: registry.archived.has(id),
633
+ pinned: registry.pinned.has(id),
634
+ };
635
+ }
636
+
637
+ function safeMtime(file) {
638
+ try {
639
+ return file ? Math.round(fs.statSync(file).mtimeMs) : 0;
640
+ } catch {
641
+ return 0;
642
+ }
643
+ }
644
+
645
+ /* ------------------------------------------------------------------ trash */
646
+
647
+ function sanitizeTrashId(id) {
648
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(id)) {
649
+ throw fail('BAD_TRASH_ID', `Invalid trash id: ${String(id)}`);
650
+ }
651
+ return id;
652
+ }
653
+
654
+ function trashDirOf(trashId) {
655
+ return path.join(trashRoot(), sanitizeTrashId(trashId));
656
+ }
657
+
658
+ function readTrash() {
659
+ const root = trashRoot();
660
+ let names = [];
661
+ try {
662
+ names = fs.readdirSync(root);
663
+ } catch {
664
+ return [];
665
+ }
666
+ const entries = [];
667
+ for (const name of names) {
668
+ const dir = path.join(root, name);
669
+ let stat;
670
+ try {
671
+ stat = fs.statSync(dir);
672
+ } catch {
673
+ continue;
674
+ }
675
+ if (!stat.isDirectory()) continue;
676
+ const meta = readJson(path.join(dir, META_NAME), null) ?? readJson(path.join(dir, LEGACY_META_NAME), null);
677
+ if (!meta || typeof meta !== 'object' || typeof meta.sessionId !== 'string') continue;
678
+ entries.push({
679
+ trashId: name,
680
+ sessionId: meta.sessionId,
681
+ title: typeof meta.title === 'string' && meta.title ? meta.title : meta.sessionId,
682
+ workspaceId: typeof meta.workspaceId === 'string' ? meta.workspaceId : null,
683
+ workspaceTitle: typeof meta.workspaceTitle === 'string' ? meta.workspaceTitle : '',
684
+ workspacePath: typeof meta.workspacePath === 'string' ? meta.workspacePath : '',
685
+ deletedAt: typeof meta.deletedAt === 'number' ? meta.deletedAt : Math.round(stat.mtimeMs),
686
+ sizeBytes: dirSize(dir),
687
+ wasArchived: meta.wasArchived === true,
688
+ });
689
+ }
690
+ entries.sort((a, b) => b.deletedAt - a.deletedAt);
691
+ return entries;
692
+ }
693
+
694
+ /* --------------------------------------------------------------- registry */
695
+
696
+ function workspaceContaining(ctx, sessionId) {
697
+ try {
698
+ return ctx.workspaceRegistry.list().find((workspace) => workspace.sessionIds.includes(sessionId)) ?? null;
699
+ } catch {
700
+ return null;
701
+ }
702
+ }
703
+
704
+ async function detachSession(ctx, sessionId) {
705
+ const workspace = workspaceContaining(ctx, sessionId);
706
+ if (!workspace) return null;
707
+ try {
708
+ await workspace.detachSession(sessionId);
709
+ return workspace.id;
710
+ } catch {
711
+ return null;
712
+ }
713
+ }
714
+
715
+ async function attachSession(ctx, sessionId, workspaceId, workspacePath, workspaceTitle) {
716
+ let workspace = null;
717
+ try {
718
+ if (workspaceId) workspace = ctx.workspaceRegistry.get(workspaceId) ?? null;
719
+ } catch {
720
+ workspace = null;
721
+ }
722
+ if (!workspace && workspacePath) {
723
+ try {
724
+ workspace = ctx.workspaceRegistry.resolveByPath(workspacePath) ?? null;
725
+ } catch {
726
+ workspace = null;
727
+ }
728
+ }
729
+ if (!workspace && workspacePath) {
730
+ try {
731
+ workspace = ctx.workspaceRegistry.create(workspacePath, workspaceTitle || undefined);
732
+ } catch {
733
+ workspace = null;
734
+ }
735
+ }
736
+ if (!workspace) throw fail('WORKSPACE_MISSING', '会话所属工作区已不存在,无法恢复。');
737
+ await workspace.attachSession(sessionId);
738
+ return workspace.id;
739
+ }
740
+
741
+ /* -------------------------------------------------------------- API types */
742
+
743
+ async function runningSet(ctx) {
744
+ const set = new Set();
745
+ try {
746
+ const result = await ctx.sessionController.list({}, new AbortController().signal);
747
+ const items = Array.isArray(result?.items) ? result.items : [];
748
+ for (const item of items) {
749
+ if (item && item.running === true && typeof item.sessionId === 'string') set.add(item.sessionId);
750
+ }
751
+ } catch {
752
+ /* running detection is best effort */
753
+ }
754
+ return set;
755
+ }
756
+
757
+ async function buildBootstrap(ctx) {
758
+ const state = readState();
759
+ const registry = readRegistryState();
760
+ const running = await runningSet(ctx);
761
+
762
+ let snapshots = [];
763
+ try {
764
+ snapshots = await ctx.sessionPersistence.list();
765
+ } catch {
766
+ snapshots = [];
767
+ }
768
+ const snapshotMap = new Map();
769
+ for (const snapshot of snapshots) {
770
+ if (snapshot?.header?.id) snapshotMap.set(snapshot.header.id, snapshot);
771
+ }
772
+
773
+ const dirMap = new Map(listSessionDirs().map((entry) => [entry.id, entry]));
774
+
775
+ let registryWorkspaces = [];
776
+ try {
777
+ registryWorkspaces = ctx.workspaceRegistry.list();
778
+ } catch {
779
+ registryWorkspaces = [];
780
+ }
781
+
782
+ const cache = new Map();
783
+ const workspaces = [];
784
+ for (const workspace of registryWorkspaces) {
785
+ const sessions = [];
786
+ for (const id of workspace.sessionIds) {
787
+ let info = cache.get(id);
788
+ if (!info) {
789
+ info = await sessionInfo(ctx, id, snapshotMap.get(id), dirMap, running, registry);
790
+ cache.set(id, info);
791
+ }
792
+ sessions.push(info);
793
+ }
794
+ sessions.sort((a, b) => b.updatedAt - a.updatedAt);
795
+ workspaces.push({
796
+ id: workspace.id,
797
+ title: workspace.title,
798
+ path: workspace.path,
799
+ createdAt: workspace.createdAt,
800
+ updatedAt: workspace.updatedAt,
801
+ sessions,
802
+ });
803
+ }
804
+
805
+ return {
806
+ version: PLUGIN_VERSION,
807
+ panelId: PANEL_ID,
808
+ state,
809
+ workspaces,
810
+ trash: readTrash(),
811
+ canOpenPath: canOpenWorkspacePath(ctx),
812
+ exportFormats: EXPORT_FORMATS,
813
+ paths: { sessionsRoot: sessionsRoot(), dataDir: dataDir(), trashDir: trashRoot() },
814
+ };
815
+ }
816
+
817
+ /** Whether this deployment can hand a workspace folder to the native desktop. */
818
+ function canOpenWorkspacePath(ctx) {
819
+ const controller = ctx.sessionController;
820
+ if (!controller || typeof controller.openWorkspacePath !== 'function') return false;
821
+ if (typeof controller.canOpenWorkspacePath !== 'function') return true;
822
+ try {
823
+ return controller.canOpenWorkspacePath() === true;
824
+ } catch {
825
+ return false;
826
+ }
827
+ }
828
+
829
+ /** Open one workspace folder in the Host desktop file manager. */
830
+ async function openWorkspacePath(ctx, payload) {
831
+ const raw = typeof payload?.path === 'string' ? payload.path.trim() : '';
832
+ if (!raw) throw fail('BAD_PATH', '缺少工作区路径。');
833
+ const controller = ctx.sessionController;
834
+ if (!controller || typeof controller.openWorkspacePath !== 'function') {
835
+ throw fail('UNSUPPORTED', '当前环境不支持打开本地路径。');
836
+ }
837
+ const value = await controller.openWorkspacePath({ path: raw }, AbortSignal.timeout(15000));
838
+ return { opened: value?.opened === true, path: raw };
839
+ }
840
+
841
+ /** Enable or disable one Loader entry through the profile plugin manager. */
842
+ async function setPluginEnabled(ctx, payload) {
843
+ const manager = typeof ctx.get === 'function' ? ctx.get('pluginManager') : undefined;
844
+ if (!manager || typeof manager.setPluginEnabled !== 'function') {
845
+ throw fail('UNSUPPORTED', '当前环境不支持启用/禁用插件。');
846
+ }
847
+ const entryId = typeof payload?.entryId === 'string' ? payload.entryId : '';
848
+ if (!entryId) throw fail('BAD_ENTRY_ID', '缺少插件入口 id。');
849
+ if (isSelfEntryId(entryId)) throw fail('PROTECTED', 'DSH 管家自身不能被禁用。');
850
+ const enabled = payload?.enabled === true;
851
+ const result = await manager.setPluginEnabled(entryId, enabled);
852
+ inventoryCache.at = 0;
853
+ scopeCache.at = 0;
854
+ return {
855
+ changed: result?.changed === true,
856
+ enabled,
857
+ application: typeof result?.application === 'string' ? result.application : null,
858
+ warnings: Array.isArray(result?.warnings) ? result.warnings : [],
859
+ error: result?.error ?? null,
860
+ };
861
+ }
862
+
863
+ /** Uninstall one profile-owned bundle through the profile plugin manager. */
864
+ async function uninstallPlugin(ctx, payload) {
865
+ const manager = typeof ctx.get === 'function' ? ctx.get('pluginManager') : undefined;
866
+ if (!manager || typeof manager.removeBundle !== 'function') {
867
+ throw fail('UNSUPPORTED', '当前环境不支持卸载插件。');
868
+ }
869
+ const name = typeof payload?.bundle === 'string' ? payload.bundle.trim() : '';
870
+ if (!name) throw fail('BAD_BUNDLE', '缺少插件包名。');
871
+ if (payload?.confirm !== true) throw fail('CONFIRM_REQUIRED', '卸载操作需要二次确认。');
872
+ if (name === 'dsh-keeper' || name === LEGACY_BUNDLE) throw fail('PROTECTED', '不能卸载 DSH 管家自身。');
873
+ let bundle = null;
874
+ try {
875
+ const bundles = await manager.listBundles();
876
+ bundle = (Array.isArray(bundles) ? bundles : []).find((entry) => entry && entry.name === name) ?? null;
877
+ } catch {
878
+ bundle = null;
879
+ }
880
+ if (!bundle) throw fail('NOT_FOUND', '找不到该插件包。');
881
+ if (bundle.removable !== true) throw fail('NOT_REMOVABLE', '该插件不能被卸载。');
882
+ const result = await manager.removeBundle(name);
883
+ inventoryCache.at = 0;
884
+ scopeCache.at = 0;
885
+ return {
886
+ changed: result?.changed === true,
887
+ application: typeof result?.application === 'string' ? result.application : null,
888
+ bundle: name,
889
+ version: typeof bundle.version === 'string' ? bundle.version : null,
890
+ warnings: Array.isArray(result?.warnings) ? result.warnings : [],
891
+ error: result?.error ?? null,
892
+ };
893
+ }
894
+
895
+ /**
896
+ * Re-enable a row this profile disabled through a pure patch override. The
897
+ * Loader drops disabled rows from listPlugins(), so pluginManager has no row to
898
+ * address any more; removing our own `disabled: true` override is the only
899
+ * recovery path, and the Loader only reads the patch at startup.
900
+ */
901
+ async function enablePluginOverride(ctx, payload) {
902
+ const patchId = typeof payload?.patchId === 'string' ? payload.patchId.trim() : '';
903
+ if (!patchId) throw fail('BAD_PATCH_ID', '缺少需要恢复的插件行 id。');
904
+ if (isSelfEntryId(patchId)) throw fail('PROTECTED', 'DSH 管家自身不能被修改。');
905
+ const known = readDisabledPatches().some((row) => row.id === patchId);
906
+ if (!known) throw fail('NOT_FOUND', '找不到该插件的禁用记录,请在「设置 → 插件」中手动启用。');
907
+ if (!enableDisabledPatch(patchId)) throw fail('WRITE_FAILED', '无法更新 profile 补丁文件,请手动启用该插件。');
908
+ inventoryCache.at = 0;
909
+ scopeCache.at = 0;
910
+ return { changed: true, patchId, application: 'restart-required' };
911
+ }
912
+
913
+ /* ------------------------------------------------------------ translation */
914
+
915
+ const TRANSLATIONS_FILE = 'translations.json';
916
+ const TRANSLATION_TTL_MS = 1000 * 60 * 60 * 24 * 90;
917
+ const TRANSLATION_MAX_ENTRIES = 400;
918
+
919
+ function translationsFile() {
920
+ return path.join(dataDir(), TRANSLATIONS_FILE);
921
+ }
922
+
923
+ let translationCache = null;
924
+
925
+ function readTranslations() {
926
+ if (translationCache) return translationCache;
927
+ const raw = readJson(translationsFile(), null);
928
+ const entries = raw && typeof raw === 'object' && raw.entries && typeof raw.entries === 'object' ? raw.entries : {};
929
+ translationCache = entries;
930
+ return translationCache;
931
+ }
932
+
933
+ function writeTranslations(map) {
934
+ try {
935
+ const keys = Object.keys(map);
936
+ let next = map;
937
+ if (keys.length > TRANSLATION_MAX_ENTRIES) {
938
+ keys.sort((a, b) => (map[b]?.at ?? 0) - (map[a]?.at ?? 0));
939
+ next = {};
940
+ for (const key of keys.slice(0, TRANSLATION_MAX_ENTRIES)) next[key] = map[key];
941
+ }
942
+ translationCache = next;
943
+ writeJson(translationsFile(), { version: 1, entries: next });
944
+ } catch {
945
+ /* the translation cache is best effort */
946
+ }
947
+ }
948
+
949
+ /** Translate one short English text into Simplified Chinese with the Host model. */
950
+ async function translateWithLlm(llm, text) {
951
+ const providers = typeof llm.listProviders === 'function' ? llm.listProviders() : [];
952
+ const order = [];
953
+ for (const provider of Array.isArray(providers) ? providers : []) {
954
+ const id = typeof provider === 'string' ? provider : provider && provider.id;
955
+ if (typeof id === 'string' && id && !order.includes(id)) order.push(id);
956
+ }
957
+ order.sort((a, b) => Number(b.includes('deepseek')) - Number(a.includes('deepseek')));
958
+ for (const providerId of order) {
959
+ let model = null;
960
+ try {
961
+ const models = await llm.listModels(providerId);
962
+ const list = (Array.isArray(models) ? models : []).filter((entry) => entry && typeof entry.id === 'string' && entry.id);
963
+ model = list.find((entry) => /chat|flash|pro|v\d/i.test(entry.id)) || list[0] || null;
964
+ } catch {
965
+ model = null;
966
+ }
967
+ if (!model) continue;
968
+ const controller = new AbortController();
969
+ const timer = setTimeout(() => controller.abort(), 25000);
970
+ let out = '';
971
+ try {
972
+ const stream = llm.stream({
973
+ provider: providerId,
974
+ model: model.id,
975
+ system:
976
+ '你是一名专业的技术翻译。把用户给出的英文软件或插件描述翻译成简体中文,只输出译文本身,保留产品名与专有名词,不添加解释。',
977
+ messages: [{ role: 'user', content: [{ type: 'text', text }] }],
978
+ temperature: 0.2,
979
+ maxTokens: 600,
980
+ signal: controller.signal,
981
+ });
982
+ for await (const chunk of stream) {
983
+ if (!chunk || typeof chunk !== 'object') continue;
984
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
985
+ out += chunk.text;
986
+ if (out.length > 4000) break;
987
+ }
988
+ if (chunk.type === 'finish' && chunk.reason && (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted')) break;
989
+ }
990
+ } catch {
991
+ /* try the next provider route */
992
+ } finally {
993
+ clearTimeout(timer);
994
+ }
995
+ const trimmed = out.trim().replace(/\s+/g, ' ');
996
+ if (trimmed) return trimmed;
997
+ }
998
+ throw fail('TRANSLATE_FAILED', '翻译失败,请稍后重试。');
999
+ }
1000
+
1001
+ /** Translate plugin description text to Simplified Chinese, with a local cache. */
1002
+ async function translateText(ctx, payload) {
1003
+ const raw = typeof payload?.text === 'string' ? payload.text.trim() : '';
1004
+ if (!raw) throw fail('BAD_TEXT', '缺少需要翻译的文本。');
1005
+ const text = raw.slice(0, 2000);
1006
+ const key = crypto.createHash('sha1').update(text).digest('hex');
1007
+ const cache = readTranslations();
1008
+ const cached = cache[key];
1009
+ if (cached && typeof cached.text === 'string' && cached.text && Date.now() - (cached.at ?? 0) < TRANSLATION_TTL_MS) {
1010
+ return { text: cached.text, cached: true };
1011
+ }
1012
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : undefined;
1013
+ if (!llm || typeof llm.stream !== 'function' || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') {
1014
+ throw fail('UNSUPPORTED', '当前环境不支持模型翻译。');
1015
+ }
1016
+ const translated = await translateWithLlm(llm, text);
1017
+ cache[key] = { text: translated, at: Date.now() };
1018
+ writeTranslations(cache);
1019
+ return { text: translated, cached: false };
1020
+ }
1021
+
1022
+ /** Drop every cached plugin-description translation. */
1023
+ function clearTranslationCache() {
1024
+ translationCache = {};
1025
+ try {
1026
+ fs.rmSync(translationsFile(), { force: true });
1027
+ } catch {
1028
+ /* removing the file is best effort: the in-memory cache is already empty */
1029
+ }
1030
+ return { cleared: true };
1031
+ }
1032
+
1033
+ /* --------------------------------------------------------------- mutations */
1034
+
1035
+ function moveToTrash(sessionId, dir, meta) {
1036
+ const trashId = `${Date.now().toString(36)}-${sessionId.replace(/^session-/, '')}`;
1037
+ const target = path.join(trashRoot(), trashId);
1038
+ fs.mkdirSync(trashRoot(), { recursive: true });
1039
+ if (fs.existsSync(target)) throw fail('TRASH_CONFLICT', '回收站条目重名,请重试。');
1040
+ let moved = false;
1041
+ let lastError = null;
1042
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1043
+ try {
1044
+ fs.renameSync(dir, target);
1045
+ moved = true;
1046
+ break;
1047
+ } catch (error) {
1048
+ lastError = error;
1049
+ const transient = error?.code === 'EBUSY' || error?.code === 'EPERM' || error?.code === 'EACCES';
1050
+ if (!transient) break;
1051
+ const until = Date.now() + 200 * (attempt + 1);
1052
+ while (Date.now() < until) {
1053
+ /* brief synchronous backoff; the API is a short request */
1054
+ }
1055
+ }
1056
+ }
1057
+ if (!moved) {
1058
+ throw fail(
1059
+ 'SESSION_BUSY',
1060
+ `无法移动会话日志(${lastError?.code ?? 'UNKNOWN'}),它可能仍被当前进程占用,请稍后重试。`,
1061
+ );
1062
+ }
1063
+ try {
1064
+ writeJson(path.join(target, META_NAME), { ...meta, sessionId, deletedAt: Date.now(), trashId });
1065
+ } catch (error) {
1066
+ try {
1067
+ fs.renameSync(target, dir);
1068
+ } catch {
1069
+ /* best effort rollback */
1070
+ }
1071
+ throw fail('TRASH_WRITE_FAILED', `无法写入回收站元数据:${error?.message ?? error}`);
1072
+ }
1073
+ return { trashId, target };
1074
+ }
1075
+
1076
+ async function deleteSessions(ctx, payload) {
1077
+ const mode = payload?.mode === 'archive' ? 'archive' : 'trash';
1078
+ const sessionIds = Array.isArray(payload?.sessionIds) ? payload.sessionIds : [];
1079
+ if (sessionIds.length === 0) throw fail('BAD_REQUEST', '没有选择会话。');
1080
+ if (sessionIds.length > 100) throw fail('BAD_REQUEST', '一次最多处理 100 个会话。');
1081
+ const running = await runningSet(ctx);
1082
+ const registry = readRegistryState();
1083
+ const dirMap = new Map(listSessionDirs().map((entry) => [entry.id, entry]));
1084
+
1085
+ const done = [];
1086
+ const errors = [];
1087
+ for (const rawId of sessionIds) {
1088
+ let id;
1089
+ try {
1090
+ id = assertSessionId(rawId);
1091
+ } catch (error) {
1092
+ errors.push({ sessionId: String(rawId), code: error.code ?? 'BAD_SESSION_ID', message: error.message });
1093
+ continue;
1094
+ }
1095
+ if (running.has(id)) {
1096
+ errors.push({ sessionId: id, code: 'SESSION_RUNNING', message: '会话正在运行,无法删除。' });
1097
+ continue;
1098
+ }
1099
+ if (mode === 'archive') {
1100
+ try {
1101
+ await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true });
1102
+ done.push({ sessionId: id, mode: 'archive' });
1103
+ } catch (error) {
1104
+ errors.push({ sessionId: id, code: 'ARCHIVE_FAILED', message: error?.message ?? '归档失败' });
1105
+ }
1106
+ continue;
1107
+ }
1108
+
1109
+ const found = dirMap.get(id);
1110
+ if (!found) {
1111
+ /* No log on disk: just detach the dangling reference. */
1112
+ const workspaceId = await detachSession(ctx, id);
1113
+ done.push({ sessionId: id, mode: 'trash', detachedOnly: true, workspaceId });
1114
+ continue;
1115
+ }
1116
+ let info;
1117
+ try {
1118
+ info = await sessionInfo(
1119
+ ctx,
1120
+ id,
1121
+ undefined,
1122
+ dirMap,
1123
+ running,
1124
+ registry,
1125
+ );
1126
+ } catch {
1127
+ info = { title: id, sizeBytes: dirSize(found.dir) };
1128
+ }
1129
+ const workspace = workspaceContaining(ctx, id);
1130
+ try {
1131
+ const moved = moveToTrash(id, found.dir, {
1132
+ title: info.title,
1133
+ workspaceId: workspace?.id ?? null,
1134
+ workspaceTitle: workspace?.title ?? '',
1135
+ workspacePath: workspace?.path ?? '',
1136
+ originalDir: found.dir,
1137
+ wasArchived: registry.archived.has(id),
1138
+ });
1139
+ await detachSession(ctx, id);
1140
+ done.push({ sessionId: id, mode: 'trash', trashId: moved.trashId });
1141
+ } catch (error) {
1142
+ errors.push({ sessionId: id, code: error.code ?? 'DELETE_FAILED', message: error.message });
1143
+ }
1144
+ }
1145
+ return { done, errors };
1146
+ }
1147
+
1148
+ async function restoreSessions(ctx, payload) {
1149
+ const sessionIds = Array.isArray(payload?.sessionIds) ? payload.sessionIds : [];
1150
+ if (sessionIds.length === 0) throw fail('BAD_REQUEST', '没有选择回收站条目。');
1151
+ const trash = readTrash();
1152
+ const bySession = new Map(trash.map((entry) => [entry.sessionId, entry]));
1153
+ const done = [];
1154
+ const errors = [];
1155
+ for (const rawId of sessionIds) {
1156
+ let id;
1157
+ try {
1158
+ id = assertSessionId(rawId);
1159
+ } catch (error) {
1160
+ errors.push({ sessionId: String(rawId), code: error.code ?? 'BAD_SESSION_ID', message: error.message });
1161
+ continue;
1162
+ }
1163
+ const entry = bySession.get(id);
1164
+ if (!entry) {
1165
+ errors.push({ sessionId: id, code: 'NOT_IN_TRASH', message: '回收站中没有这个会话。' });
1166
+ continue;
1167
+ }
1168
+ const trashDir = path.join(trashRoot(), entry.trashId);
1169
+ const meta = readJson(path.join(trashDir, META_NAME), null) ?? readJson(path.join(trashDir, LEGACY_META_NAME), null);
1170
+ const originalDir = typeof meta?.originalDir === 'string' ? meta.originalDir : '';
1171
+ const root = sessionsRoot();
1172
+ if (!originalDir || !path.resolve(originalDir).startsWith(path.resolve(root) + path.sep)) {
1173
+ errors.push({ sessionId: id, code: 'BAD_ORIGINAL_DIR', message: '回收站元数据的原始路径无效。' });
1174
+ continue;
1175
+ }
1176
+ try {
1177
+ fs.mkdirSync(path.dirname(originalDir), { recursive: true });
1178
+ if (fs.existsSync(originalDir)) {
1179
+ errors.push({ sessionId: id, code: 'TARGET_EXISTS', message: '原路径已存在同名会话目录。' });
1180
+ continue;
1181
+ }
1182
+ fs.renameSync(trashDir, originalDir);
1183
+ } catch (error) {
1184
+ errors.push({ sessionId: id, code: 'RESTORE_FAILED', message: error?.message ?? '恢复失败' });
1185
+ continue;
1186
+ }
1187
+ try {
1188
+ await attachSession(ctx, id, entry.workspaceId, entry.workspacePath, entry.workspaceTitle);
1189
+ done.push({ sessionId: id });
1190
+ } catch (error) {
1191
+ errors.push({ sessionId: id, code: 'ATTACH_FAILED', message: error?.message ?? '恢复后无法挂回工作区' });
1192
+ }
1193
+ }
1194
+ return { done, errors };
1195
+ }
1196
+
1197
+ async function purgeSessions(ctx, payload) {
1198
+ const sessionIds = Array.isArray(payload?.sessionIds) ? payload.sessionIds : [];
1199
+ if (sessionIds.length === 0) throw fail('BAD_REQUEST', '没有选择回收站条目。');
1200
+ const trash = readTrash();
1201
+ const bySession = new Map(trash.map((entry) => [entry.sessionId, entry]));
1202
+ const done = [];
1203
+ const errors = [];
1204
+ for (const rawId of sessionIds) {
1205
+ let id;
1206
+ try {
1207
+ id = assertSessionId(rawId);
1208
+ } catch (error) {
1209
+ errors.push({ sessionId: String(rawId), code: error.code ?? 'BAD_SESSION_ID', message: error.message });
1210
+ continue;
1211
+ }
1212
+ const entry = bySession.get(id);
1213
+ if (!entry) {
1214
+ errors.push({ sessionId: id, code: 'NOT_IN_TRASH', message: '回收站中没有这个会话。' });
1215
+ continue;
1216
+ }
1217
+ const trashDir = path.join(trashRoot(), entry.trashId);
1218
+ if (!path.resolve(trashDir).startsWith(path.resolve(trashRoot()) + path.sep)) {
1219
+ errors.push({ sessionId: id, code: 'BAD_TRASH_PATH', message: '回收站路径无效。' });
1220
+ continue;
1221
+ }
1222
+ try {
1223
+ fs.rmSync(trashDir, { recursive: true, force: true });
1224
+ } catch (error) {
1225
+ errors.push({ sessionId: id, code: 'PURGE_FAILED', message: error?.message ?? '彻底删除失败' });
1226
+ continue;
1227
+ }
1228
+ try {
1229
+ await ctx.workspaceRegistry.unarchiveSession(id);
1230
+ } catch {
1231
+ /* stale archive flag is harmless */
1232
+ }
1233
+ try {
1234
+ await ctx.workspaceRegistry.unpinSession(id);
1235
+ } catch {
1236
+ /* ignore */
1237
+ }
1238
+ done.push({ sessionId: id });
1239
+ }
1240
+ return { done, errors };
1241
+ }
1242
+
1243
+ /* --------------------------------------------------------------- export */
1244
+
1245
+ const EXPORT_FORMATS = ['jsonl', 'md', 'txt', 'html', 'zstd'];
1246
+ const EXPORT_MIME = {
1247
+ jsonl: 'application/jsonl;charset=utf-8',
1248
+ md: 'text/markdown;charset=utf-8',
1249
+ txt: 'text/plain;charset=utf-8',
1250
+ html: 'text/html;charset=utf-8',
1251
+ zstd: 'application/octet-stream',
1252
+ };
1253
+
1254
+ function normalizeExportFormat(value) {
1255
+ return EXPORT_FORMATS.includes(value) ? value : 'md';
1256
+ }
1257
+
1258
+ function contentTextOf(content) {
1259
+ if (!Array.isArray(content)) return '';
1260
+ const parts = [];
1261
+ for (const part of content) {
1262
+ if (part && part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
1263
+ parts.push(part.text);
1264
+ }
1265
+ }
1266
+ return parts.join('\n\n').trim();
1267
+ }
1268
+
1269
+ function escapeHtml(value) {
1270
+ return String(value ?? '')
1271
+ .replace(/&/g, '&amp;')
1272
+ .replace(/</g, '&lt;')
1273
+ .replace(/>/g, '&gt;')
1274
+ .replace(/"/g, '&quot;')
1275
+ .replace(/'/g, '&#39;');
1276
+ }
1277
+
1278
+ function formatExportTime(value) {
1279
+ const ms = typeof value === 'number' && Number.isFinite(value) ? value : Date.now();
1280
+ return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
1281
+ }
1282
+
1283
+ function transcriptBlocks(events) {
1284
+ const blocks = [];
1285
+ for (const event of events) {
1286
+ if (!event || typeof event !== 'object') continue;
1287
+ const data = event.data && typeof event.data === 'object' ? event.data : {};
1288
+ if (event.type === 'user/message') {
1289
+ const text = contentTextOf(data.content);
1290
+ if (text) blocks.push({ kind: 'user', text, tools: [] });
1291
+ continue;
1292
+ }
1293
+ if (event.type !== 'assistant/message') continue;
1294
+ const content = data.message && Array.isArray(data.message.content) ? data.message.content : [];
1295
+ const tools = [];
1296
+ for (const part of content) {
1297
+ if (!part || part.type !== 'tool-call') continue;
1298
+ const toolName = typeof part.name === 'string'
1299
+ ? part.name
1300
+ : typeof part.toolName === 'string' ? part.toolName : 'tool';
1301
+ tools.push(toolName);
1302
+ }
1303
+ const text = contentTextOf(content);
1304
+ if (text || tools.length > 0) blocks.push({ kind: 'assistant', text, tools });
1305
+ }
1306
+ return blocks;
1307
+ }
1308
+
1309
+ function exportMetaLines(meta, count) {
1310
+ return [
1311
+ `会话 ID:${meta.sessionId}`,
1312
+ `工作区:${meta.cwd || '—'}`,
1313
+ `最后活动:${formatExportTime(meta.updatedAt)}`,
1314
+ `消息数:${count}`,
1315
+ ];
1316
+ }
1317
+
1318
+ function buildMarkdownExport(meta, blocks) {
1319
+ const lines = [
1320
+ `# ${meta.title}`,
1321
+ '',
1322
+ ...exportMetaLines(meta, blocks.length).map((line) => `> ${line}`),
1323
+ '',
1324
+ '---',
1325
+ '',
1326
+ ];
1327
+ for (const block of blocks) {
1328
+ if (block.kind === 'user') {
1329
+ lines.push('## 用户', '', block.text, '');
1330
+ continue;
1331
+ }
1332
+ lines.push('## 助手', '');
1333
+ if (block.text) lines.push(block.text, '');
1334
+ if (block.tools.length > 0) lines.push(`> 工具调用:${block.tools.join('、')}`, '');
1335
+ }
1336
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
1337
+ }
1338
+
1339
+ function buildTextExport(meta, blocks) {
1340
+ const lines = [meta.title, '', ...exportMetaLines(meta, blocks.length), '', '----------', ''];
1341
+ for (const block of blocks) {
1342
+ lines.push(`[${block.kind === 'user' ? '用户' : '助手'}]`, '');
1343
+ if (block.text) lines.push(block.text, '');
1344
+ if (block.tools.length > 0) lines.push(`(工具调用:${block.tools.join('、')})`, '');
1345
+ }
1346
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
1347
+ }
1348
+
1349
+ function buildHtmlExport(meta, blocks) {
1350
+ const sections = blocks.map((block) => {
1351
+ const tools = block.tools.length > 0
1352
+ ? `\n <div class="tools">工具调用:${escapeHtml(block.tools.join('、'))}</div>`
1353
+ : '';
1354
+ return ` <section class="msg ${block.kind}">
1355
+ <div class="role">${block.kind === 'user' ? '用户' : '助手'}</div>
1356
+ <div class="text">${escapeHtml(block.text)}</div>${tools}
1357
+ </section>`;
1358
+ }).join('\n');
1359
+ return `<!doctype html>
1360
+ <html lang="zh-CN">
1361
+ <head>
1362
+ <meta charset="utf-8">
1363
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1364
+ <title>${escapeHtml(meta.title)}</title>
1365
+ <style>
1366
+ :root{color-scheme:light dark;--bg:#f7f7f8;--card:#ffffff;--text:#1c1c1e;--muted:#6b6b70;--border:#e3e3e6;--user:#eef2ff;--assistant:#f3f7f3}
1367
+ @media (prefers-color-scheme:dark){:root{--bg:#141416;--card:#1c1c1f;--text:#e6e6ea;--muted:#9a9aa2;--border:#33333a;--user:#23283a;--assistant:#1f2a22}}
1368
+ *{box-sizing:border-box}
1369
+ body{margin:0;padding:32px 18px 56px;background:var(--bg);color:var(--text);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
1370
+ main{max-width:860px;margin:0 auto}
1371
+ h1{font-size:22px;line-height:1.35;margin:0 0 10px}
1372
+ .meta{color:var(--muted);font-size:12px;line-height:1.7;margin:0 0 26px;white-space:pre-line}
1373
+ .msg{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin:0 0 14px}
1374
+ .msg.user{background:var(--user)}
1375
+ .msg.assistant{background:var(--assistant)}
1376
+ .role{font-size:12px;font-weight:600;color:var(--muted);margin-bottom:6px}
1377
+ .text{white-space:pre-wrap;word-break:break-word}
1378
+ .tools{margin-top:8px;font-size:12px;color:var(--muted)}
1379
+ </style>
1380
+ </head>
1381
+ <body>
1382
+ <main>
1383
+ <h1>${escapeHtml(meta.title)}</h1>
1384
+ <p class="meta">${escapeHtml(exportMetaLines(meta, blocks.length).join('\n'))}</p>
1385
+ ${sections}
1386
+ </main>
1387
+ </body>
1388
+ </html>
1389
+ `;
1390
+ }
1391
+
1392
+ async function exportSession(ctx, payload) {
1393
+ const id = assertSessionId(payload?.sessionId);
1394
+ const format = normalizeExportFormat(payload?.format);
1395
+ const dir = listSessionDirs().find((entry) => entry.id === id);
1396
+ const logFile = dir ? sessionLogFile(dir.dir) : null;
1397
+
1398
+ if (format === 'zstd') {
1399
+ if (!logFile) throw fail('EXPORT_UNAVAILABLE', '找不到这个会话的原始日志文件。');
1400
+ const buffer = fs.readFileSync(logFile);
1401
+ return {
1402
+ sessionId: id,
1403
+ format,
1404
+ mime: EXPORT_MIME[format],
1405
+ encoding: 'base64',
1406
+ fileName: `${id}.jsonl.zstd`,
1407
+ content: buffer.toString('base64'),
1408
+ bytes: buffer.length,
1409
+ };
1410
+ }
1411
+
1412
+ let text = '';
1413
+ if (logFile && hasZstd()) {
1414
+ try {
1415
+ text = decodeZstdLog(logFile).text;
1416
+ } catch {
1417
+ text = '';
1418
+ }
1419
+ }
1420
+ if (!text) {
1421
+ const events = await readEventsViaPersistence(ctx, id);
1422
+ text = events.map((event) => JSON.stringify(event)).join('\n');
1423
+ }
1424
+ if (!text) throw fail('EXPORT_EMPTY', '这个会话没有可导出的日志。');
1425
+
1426
+ if (format === 'jsonl') {
1427
+ return {
1428
+ sessionId: id,
1429
+ format,
1430
+ mime: EXPORT_MIME[format],
1431
+ encoding: 'utf8',
1432
+ fileName: `${id}.jsonl`,
1433
+ content: text,
1434
+ bytes: Buffer.byteLength(text, 'utf8'),
1435
+ };
1436
+ }
1437
+
1438
+ const events = [];
1439
+ for (const line of text.split('\n')) {
1440
+ if (!line.trim()) continue;
1441
+ try {
1442
+ events.push(JSON.parse(line));
1443
+ } catch {
1444
+ /* skip a malformed line instead of failing the whole export */
1445
+ }
1446
+ }
1447
+ const header = events.find((event) => event && event.type === 'session') ?? {};
1448
+ let derivedTitle = '';
1449
+ for (const event of events) {
1450
+ if (event && event.type === 'session/title' && event.data && typeof event.data.title === 'string') {
1451
+ derivedTitle = event.data.title;
1452
+ }
1453
+ }
1454
+ const blocks = transcriptBlocks(events);
1455
+ const meta = {
1456
+ title: String(payload?.title ?? '').trim() || derivedTitle || `会话 ${id}`,
1457
+ sessionId: id,
1458
+ cwd: typeof header.cwd === 'string' ? header.cwd : '',
1459
+ updatedAt: typeof payload?.updatedAt === 'number' ? payload.updatedAt : Date.now(),
1460
+ };
1461
+ const body = format === 'md'
1462
+ ? buildMarkdownExport(meta, blocks)
1463
+ : format === 'txt'
1464
+ ? buildTextExport(meta, blocks)
1465
+ : buildHtmlExport(meta, blocks);
1466
+ return {
1467
+ sessionId: id,
1468
+ format,
1469
+ mime: EXPORT_MIME[format],
1470
+ encoding: 'utf8',
1471
+ fileName: `${id}.${format}`,
1472
+ content: body,
1473
+ bytes: Buffer.byteLength(body, 'utf8'),
1474
+ };
1475
+ }
1476
+
1477
+ async function cleanBlankSessions(ctx) {
1478
+ const running = await runningSet(ctx);
1479
+ const registry = readRegistryState();
1480
+ const dirMap = new Map(listSessionDirs().map((entry) => [entry.id, entry]));
1481
+ const snapshotMap = new Map();
1482
+ try {
1483
+ for (const snapshot of await ctx.sessionPersistence.list()) {
1484
+ if (snapshot?.header?.id) snapshotMap.set(snapshot.header.id, snapshot);
1485
+ }
1486
+ } catch {
1487
+ /* ignore */
1488
+ }
1489
+ const blank = [];
1490
+ for (const workspace of ctx.workspaceRegistry.list()) {
1491
+ for (const id of workspace.sessionIds) {
1492
+ if (running.has(id)) continue;
1493
+ try {
1494
+ const info = await sessionInfo(ctx, id, snapshotMap.get(id), dirMap, running, registry);
1495
+ if (info.blank && !info.missingLog) blank.push(id);
1496
+ } catch {
1497
+ /* skip */
1498
+ }
1499
+ }
1500
+ }
1501
+ if (blank.length === 0) return { done: [], errors: [] };
1502
+ return deleteSessions(ctx, { sessionIds: blank, mode: 'trash' });
1503
+ }
1504
+
1505
+ /* ------------------------------------------------------------------- http */
1506
+
1507
+ function writeJsonResponse(res, status, payload) {
1508
+ const body = JSON.stringify(payload);
1509
+ res.writeHead(status, {
1510
+ 'content-type': 'application/json; charset=utf-8',
1511
+ 'content-length': Buffer.byteLength(body),
1512
+ 'cache-control': 'no-store',
1513
+ });
1514
+ res.end(body);
1515
+ }
1516
+
1517
+ function writeOk(res, value) {
1518
+ writeJsonResponse(res, 200, { ok: true, value });
1519
+ }
1520
+
1521
+ function writeError(res, code, message) {
1522
+ writeJsonResponse(res, 200, { ok: false, error: { code, message } });
1523
+ }
1524
+
1525
+ async function readBody(req) {
1526
+ const chunks = [];
1527
+ let size = 0;
1528
+ for await (const chunk of req) {
1529
+ size += chunk.length;
1530
+ if (size > 2 * 1024 * 1024) throw fail('BODY_TOO_LARGE', '请求体过大。');
1531
+ chunks.push(chunk);
1532
+ }
1533
+ if (chunks.length === 0) return {};
1534
+ try {
1535
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
1536
+ } catch {
1537
+ throw fail('BAD_JSON', '请求体不是合法 JSON。');
1538
+ }
1539
+ }
1540
+
1541
+ /* --------------------------------------------------------- plugin inventory */
1542
+
1543
+ const inventoryCache = { at: 0, value: null };
1544
+ const INVENTORY_TTL = 30_000;
1545
+
1546
+ function normalizeModuleName(value) {
1547
+ return String(value ?? '')
1548
+ .toLowerCase()
1549
+ .replace(/[^a-z0-9]/g, '');
1550
+ }
1551
+
1552
+ /** `@deepseek-ai/cordis-plugin-timer` → `timer`;`@deepseek-ai/dsh-llm` → `llm`。 */
1553
+ function shortModuleName(value) {
1554
+ let name = String(value ?? '').trim();
1555
+ if (!name) return '';
1556
+ if (name.startsWith('@')) {
1557
+ const slash = name.indexOf('/');
1558
+ if (slash !== -1) name = name.slice(slash + 1);
1559
+ }
1560
+ return name.replace(/^cordis-plugin-/, '').replace(/^dsh-/, '');
1561
+ }
1562
+
1563
+ function pickLocalized(value, language) {
1564
+ if (!value) return null;
1565
+ if (typeof value === 'string') return value;
1566
+ if (typeof value !== 'object') return null;
1567
+ const pick = (key) =>
1568
+ key && typeof value[key] === 'string' && value[key] !== '' ? value[key] : null;
1569
+ if (language) {
1570
+ const base = String(language).split('-')[0];
1571
+ const direct = pick(language) ?? pick(base) ?? pick(base.toLowerCase());
1572
+ if (direct) return direct;
1573
+ }
1574
+ return (
1575
+ pick('en') ??
1576
+ Object.values(value).find((entry) => typeof entry === 'string' && entry !== '') ??
1577
+ null
1578
+ );
1579
+ }
1580
+
1581
+ const CJK_TEXT = /[\u3400-\u9fff]/;
1582
+
1583
+ function pickChinese(value) {
1584
+ if (!value) return null;
1585
+ if (typeof value === 'string') return CJK_TEXT.test(value) ? value : null;
1586
+ if (typeof value !== 'object') return null;
1587
+ for (const key of ['zh', 'zh-CN', 'zh-Hans', 'zh-Hant', 'zh-TW']) {
1588
+ const text = value[key];
1589
+ if (typeof text === 'string' && text !== '') return text;
1590
+ }
1591
+ const first = Object.values(value).find(
1592
+ (entry) => typeof entry === 'string' && entry !== '' && CJK_TEXT.test(entry),
1593
+ );
1594
+ return typeof first === 'string' ? first : null;
1595
+ }
1596
+
1597
+ /**
1598
+ * DSH 内置模块与结构节点的中文名。模块名或官方中文名有变动时同步更新这里,
1599
+ * 未命中时Inventory 会回退到模块名本身。
1600
+ */
1601
+ const BUILTIN_TITLES_ZH = {
1602
+ 'cordis:include': '插件包含节点',
1603
+ 'cordis:group': '插件分组',
1604
+ 'timer': '计时服务',
1605
+ 'hmr': 'Web 客户端热更新',
1606
+ 'plugin-manager': '插件管理',
1607
+ 'plugin-manager/tools': '插件管理·工具',
1608
+ };
1609
+
1610
+ function builtinTitleZh(moduleName) {
1611
+ if (typeof moduleName !== 'string' || moduleName === '') return null;
1612
+ return (
1613
+ BUILTIN_TITLES_ZH[moduleName] ??
1614
+ BUILTIN_TITLES_ZH[moduleName.toLowerCase()] ??
1615
+ (moduleName.startsWith('cordis:')
1616
+ ? BUILTIN_TITLES_ZH['cordis:' + moduleName.slice('cordis:'.length)]
1617
+ : null) ??
1618
+ null
1619
+ );
1620
+ }
1621
+
1622
+ function uniqueSuffixTitle(index, key) {
1623
+ if (!key || key.length < 3) return null;
1624
+ const matches = new Map();
1625
+ for (const row of index) {
1626
+ if (!row.zh || row.norm === key) continue;
1627
+ if (!row.norm.endsWith(key) && !key.endsWith(row.norm)) continue;
1628
+ if (!matches.has(row.zh)) matches.set(row.zh, row.en ?? null);
1629
+ }
1630
+ if (matches.size !== 1) return null;
1631
+ const [zh, en] = [...matches.entries()][0];
1632
+ return { zh, en };
1633
+ }
1634
+
1635
+ /**
1636
+ * Read the host plugin inventory and join it with the profile's bundle rows so
1637
+ * the panel page can show versions, origins and load state next to each entry.
1638
+ * Read-only: nothing here changes enablement.
1639
+ */
1640
+ async function readPluginInventory(ctx) {
1641
+ const now = Date.now();
1642
+ if (inventoryCache.value && now - inventoryCache.at < INVENTORY_TTL) return inventoryCache.value;
1643
+ const manager = typeof ctx.get === 'function' ? ctx.get('pluginManager') : undefined;
1644
+ if (!manager || typeof manager.listPlugins !== 'function') {
1645
+ const unsupported = { supported: false, reason: 'manager-missing', items: [] };
1646
+ inventoryCache.at = now;
1647
+ inventoryCache.value = unsupported;
1648
+ return unsupported;
1649
+ }
1650
+ let plugins = [];
1651
+ let bundles = [];
1652
+ let failure = null;
1653
+ try {
1654
+ plugins = await manager.listPlugins();
1655
+ } catch (error) {
1656
+ plugins = [];
1657
+ failure = error instanceof Error ? error.message : String(error);
1658
+ }
1659
+ try {
1660
+ if (typeof manager.listBundles === 'function') bundles = await manager.listBundles();
1661
+ } catch (error) {
1662
+ bundles = [];
1663
+ if (!failure) failure = error instanceof Error ? error.message : String(error);
1664
+ }
1665
+ const byModule = new Map();
1666
+ const rowsByRowId = new Map();
1667
+ const suffixIndex = [];
1668
+ for (const bundle of Array.isArray(bundles) ? bundles : []) {
1669
+ const rows = Array.isArray(bundle?.rows) ? bundle.rows : [];
1670
+ const bundleZh = pickChinese(bundle?.meta?.title);
1671
+ const bundleNorm = normalizeModuleName(bundle?.name);
1672
+ if (bundleZh && bundleNorm) suffixIndex.push({ norm: bundleNorm, zh: bundleZh, en: pickLocalized(bundle?.meta?.title) });
1673
+ for (const row of rows) {
1674
+ const key = normalizeModuleName(row?.moduleName);
1675
+ if (!key) continue;
1676
+ const shortKey = normalizeModuleName(shortModuleName(row?.moduleName));
1677
+ const titleValue = row?.meta?.title ?? bundle?.meta?.title ?? null;
1678
+ const descriptionValue = row?.meta?.description ?? bundle?.meta?.description ?? null;
1679
+ const zhTitle = pickChinese(titleValue) ?? bundleZh;
1680
+ const info = {
1681
+ version: typeof bundle.version === 'string' ? bundle.version : null,
1682
+ official: bundle.official === true,
1683
+ availability: typeof bundle.availability === 'string' ? bundle.availability : null,
1684
+ removable: bundle.removable === true,
1685
+ source: typeof bundle.source === 'string' ? bundle.source : null,
1686
+ bundle: typeof bundle.name === 'string' ? bundle.name : null,
1687
+ title: titleValue,
1688
+ titleEn: pickLocalized(titleValue),
1689
+ titleZh: zhTitle,
1690
+ description: descriptionValue,
1691
+ icon: typeof row.meta?.icon === 'string' ? row.meta.icon : typeof bundle.meta?.icon === 'string' ? bundle.meta.icon : null,
1692
+ };
1693
+ byModule.set(key, info);
1694
+ if (shortKey && shortKey !== key && !byModule.has(shortKey)) byModule.set(shortKey, info);
1695
+ if (typeof row?.rowId === 'string' && row.rowId) rowsByRowId.set(row.rowId, { info, moduleName: row.moduleName });
1696
+ if (zhTitle) {
1697
+ suffixIndex.push({ norm: key, zh: zhTitle, en: pickLocalized(titleValue) });
1698
+ if (shortKey && shortKey !== key) {
1699
+ suffixIndex.push({ norm: shortKey, zh: zhTitle, en: pickLocalized(titleValue) });
1700
+ }
1701
+ }
1702
+ }
1703
+ }
1704
+ const items = (Array.isArray(plugins) ? plugins : []).map((entry) => {
1705
+ const key = normalizeModuleName(entry?.moduleName);
1706
+ const shortName = shortModuleName(entry?.moduleName) || String(entry?.moduleName ?? '');
1707
+ const shortKey = normalizeModuleName(shortName);
1708
+ const info = byModule.get(key) ?? (shortKey ? byModule.get(shortKey) : null) ?? null;
1709
+ const suffixTitle = uniqueSuffixTitle(suffixIndex, key) ?? uniqueSuffixTitle(suffixIndex, shortKey);
1710
+ const zhTitle =
1711
+ info?.titleZh ??
1712
+ builtinTitleZh(shortName) ??
1713
+ builtinTitleZh(entry?.moduleName) ??
1714
+ suffixTitle?.zh ??
1715
+ null;
1716
+ return {
1717
+ entryId: typeof entry?.entryId === 'string' ? entry.entryId : null,
1718
+ moduleName: typeof entry?.moduleName === 'string' ? entry.moduleName : null,
1719
+ shortName,
1720
+ key,
1721
+ enabled: entry?.enabled === true,
1722
+ fiberPhase: typeof entry?.fiberPhase === 'string' ? entry.fiberPhase : null,
1723
+ readOnlyReason: typeof entry?.readOnlyReason === 'string' ? entry.readOnlyReason : null,
1724
+ patchId: typeof entry?.patchId === 'string' ? entry.patchId : null,
1725
+ version: info?.version ?? null,
1726
+ official: info?.official ?? null,
1727
+ availability: info?.availability ?? null,
1728
+ removable: info?.removable ?? null,
1729
+ source: info?.source ?? null,
1730
+ bundle: info?.bundle ?? null,
1731
+ title: info?.title ?? suffixTitle?.en ?? zhTitle ?? null,
1732
+ titleZh: zhTitle,
1733
+ description: info?.description ?? null,
1734
+ icon: info?.icon ?? null,
1735
+ };
1736
+ });
1737
+ const presentIds = new Set(items.map((item) => normalizeModuleName(item.entryId)).filter(Boolean));
1738
+ const presentModules = new Set(items.map((item) => normalizeModuleName(item.moduleName)).filter(Boolean));
1739
+ const ghosts = [];
1740
+ for (const patch of readDisabledPatches()) {
1741
+ const idKey = normalizeModuleName(patch.id);
1742
+ if (!idKey || presentIds.has(idKey)) continue;
1743
+ const matched = rowsByRowId.get(patch.id);
1744
+ const moduleName = patch.name || matched?.moduleName || patch.id;
1745
+ const key = normalizeModuleName(moduleName);
1746
+ if (!key || presentModules.has(key)) continue;
1747
+ const shortName = shortModuleName(moduleName) || String(moduleName);
1748
+ const shortKey = normalizeModuleName(shortName);
1749
+ const info = matched?.info ?? byModule.get(key) ?? (shortKey ? byModule.get(shortKey) : null) ?? null;
1750
+ const suffixTitle = uniqueSuffixTitle(suffixIndex, key) ?? uniqueSuffixTitle(suffixIndex, shortKey);
1751
+ const zhTitle =
1752
+ info?.titleZh ??
1753
+ builtinTitleZh(shortName) ??
1754
+ builtinTitleZh(moduleName) ??
1755
+ suffixTitle?.zh ??
1756
+ null;
1757
+ ghosts.push({
1758
+ entryId: patch.id,
1759
+ moduleName,
1760
+ shortName,
1761
+ key,
1762
+ enabled: false,
1763
+ fiberPhase: null,
1764
+ readOnlyReason: null,
1765
+ patchId: patch.id,
1766
+ disabledByProfile: true,
1767
+ version: info?.version ?? null,
1768
+ official: info?.official ?? null,
1769
+ availability: info?.availability ?? null,
1770
+ removable: info?.removable ?? null,
1771
+ source: info?.source ?? null,
1772
+ bundle: info?.bundle ?? null,
1773
+ title: info?.title ?? suffixTitle?.en ?? zhTitle ?? null,
1774
+ titleZh: zhTitle,
1775
+ description: info?.description ?? null,
1776
+ icon: info?.icon ?? null,
1777
+ });
1778
+ }
1779
+ const allItems = items.concat(ghosts);
1780
+ const value = failure
1781
+ ? { supported: true, items: allItems, warning: failure }
1782
+ : { supported: true, items: allItems };
1783
+ inventoryCache.at = now;
1784
+ inventoryCache.value = value;
1785
+ return value;
1786
+ }
1787
+
1788
+ /**
1789
+ * Split every inventory row into its composition plane(s): rows mounted by the
1790
+ * host Loader are global, rows declared by an Agent preset are session-scoped,
1791
+ * and the same module can be both (the base composition mounts many tools that
1792
+ * presets also declare). Read-only: preset rows are managed by the preset, not
1793
+ * by this plugin.
1794
+ */
1795
+ const scopeCache = { at: 0, value: null };
1796
+ const SCOPE_TTL = 30_000;
1797
+
1798
+ async function readPluginScopes(ctx) {
1799
+ const now = Date.now();
1800
+ if (scopeCache.value && now - scopeCache.at < SCOPE_TTL) return scopeCache.value;
1801
+ const inventory = await readPluginInventory(ctx);
1802
+ const items = (Array.isArray(inventory.items) ? inventory.items : []).map((item) => ({
1803
+ ...item,
1804
+ scopes: ['global'],
1805
+ presets: [],
1806
+ }));
1807
+ const presetsService = typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined;
1808
+ const supported = Boolean(
1809
+ presetsService && typeof presetsService.compositionInventory === 'function',
1810
+ );
1811
+ let warning = typeof inventory.warning === 'string' ? inventory.warning : null;
1812
+ const presets = [];
1813
+ const presetRows = [];
1814
+ if (supported) {
1815
+ let compositions = [];
1816
+ let registry = [];
1817
+ try {
1818
+ compositions = await presetsService.compositionInventory();
1819
+ } catch (error) {
1820
+ warning = warning ?? (error instanceof Error ? error.message : String(error));
1821
+ compositions = [];
1822
+ }
1823
+ try {
1824
+ if (typeof presetsService.list === 'function') registry = await presetsService.list();
1825
+ } catch {
1826
+ registry = [];
1827
+ }
1828
+ const registryNames = new Map();
1829
+ for (const row of Array.isArray(registry) ? registry : []) {
1830
+ if (!row || typeof row.id !== 'string') continue;
1831
+ registryNames.set(row.id, typeof row.name === 'string' && row.name ? row.name : row.id);
1832
+ }
1833
+ for (const composition of Array.isArray(compositions) ? compositions : []) {
1834
+ const id = typeof composition?.id === 'string' ? composition.id : null;
1835
+ if (!id) continue;
1836
+ const name =
1837
+ typeof composition?.name === 'string' && composition.name
1838
+ ? composition.name
1839
+ : (registryNames.get(id) ?? id);
1840
+ const preset = {
1841
+ id,
1842
+ name,
1843
+ default: composition?.isDefault === true,
1844
+ broken: composition?.broken === true,
1845
+ };
1846
+ presets.push(preset);
1847
+ for (const row of Array.isArray(composition?.rows) ? composition.rows : []) {
1848
+ const moduleName = typeof row?.moduleName === 'string' ? row.moduleName : '';
1849
+ if (!moduleName) continue;
1850
+ presetRows.push({
1851
+ preset,
1852
+ entryId: typeof row?.entryId === 'string' ? row.entryId : null,
1853
+ moduleName,
1854
+ enabled: row?.enabled === true ? true : row?.enabled === 'conditional' ? 'conditional' : false,
1855
+ condition: typeof row?.condition === 'string' ? row.condition : null,
1856
+ fiberState: typeof row?.fiberState === 'string' ? row.fiberState : null,
1857
+ });
1858
+ }
1859
+ }
1860
+ }
1861
+ const byKey = new Map();
1862
+ for (const item of items) {
1863
+ if (item.key) byKey.set(item.key, item);
1864
+ const shortKey = normalizeModuleName(item.shortName);
1865
+ if (shortKey && !byKey.has(shortKey)) byKey.set(shortKey, item);
1866
+ }
1867
+ const presetInfo = (row) => ({
1868
+ id: row.preset.id,
1869
+ name: row.preset.name,
1870
+ default: row.preset.default === true,
1871
+ enabled: row.enabled,
1872
+ condition: row.condition,
1873
+ });
1874
+ const seenModules = new Set();
1875
+ for (const row of presetRows) {
1876
+ const key = normalizeModuleName(row.moduleName);
1877
+ if (!key) continue;
1878
+ const target = byKey.get(key) ?? byKey.get(normalizeModuleName(shortModuleName(row.moduleName)));
1879
+ if (!target) continue;
1880
+ if (!target.presets.some((preset) => preset.id === row.preset.id)) {
1881
+ target.presets.push(presetInfo(row));
1882
+ }
1883
+ if (target.scopes.indexOf('session') === -1) target.scopes.push('session');
1884
+ seenModules.add(key);
1885
+ }
1886
+ // Preset-only rows (e.g. `persona`): synthesize inventory entries so the
1887
+ // list can label and filter them, marked read-only and unremovable.
1888
+ const synthetic = new Map();
1889
+ for (const row of presetRows) {
1890
+ const key = normalizeModuleName(row.moduleName);
1891
+ if (!key || seenModules.has(key)) continue;
1892
+ if (byKey.has(key) || byKey.has(normalizeModuleName(shortModuleName(row.moduleName)))) continue;
1893
+ let item = synthetic.get(key);
1894
+ if (!item) {
1895
+ item = {
1896
+ entryId: row.entryId,
1897
+ moduleName: row.moduleName,
1898
+ shortName: shortModuleName(row.moduleName) || row.moduleName,
1899
+ key,
1900
+ enabled: true,
1901
+ fiberPhase: row.fiberState ?? null,
1902
+ readOnlyReason: 'preset-managed',
1903
+ patchId: null,
1904
+ version: null,
1905
+ official: null,
1906
+ availability: null,
1907
+ removable: false,
1908
+ source: null,
1909
+ bundle: null,
1910
+ title: row.moduleName,
1911
+ titleZh: null,
1912
+ description: null,
1913
+ icon: null,
1914
+ scopes: ['session'],
1915
+ presets: [],
1916
+ };
1917
+ synthetic.set(key, item);
1918
+ }
1919
+ if (!item.presets.some((preset) => preset.id === row.preset.id)) {
1920
+ item.presets.push(presetInfo(row));
1921
+ }
1922
+ if (typeof row.entryId === 'string' && row.entryId && !item.entryId) item.entryId = row.entryId;
1923
+ seenModules.add(key);
1924
+ }
1925
+ const value = {
1926
+ supported,
1927
+ presets,
1928
+ items: [...items, ...synthetic.values()],
1929
+ ...(warning ? { warning } : {}),
1930
+ };
1931
+ scopeCache.at = now;
1932
+ scopeCache.value = value;
1933
+ return value;
1934
+ }
1935
+
1936
+ /* ------------------------------------------------------------ notifications */
1937
+
1938
+ const NOTIFY_LIMIT = 100;
1939
+ const notificationQueue = [];
1940
+ const recentNotificationKeys = new Map();
1941
+ let notificationSeq = 0;
1942
+ // Identifies this host process: the client keeps its cursor across page reloads
1943
+ // but the in-memory sequence restarts with the process, so a changed epoch
1944
+ // tells the client to discard a stale cursor instead of missing new items.
1945
+ const notificationEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1946
+
1947
+ const NOTIFY_DEBUG_PATH = () => path.join(dataDir(), 'notify-debug.log');
1948
+ const NOTIFY_DEBUG_CAP = 512 * 1024;
1949
+
1950
+ /**
1951
+ * Append one diagnostic line for the notification pipeline. The desktop app
1952
+ * hides the host console, so both host events and client-side steps are
1953
+ * written to a small capped file the user can hand back when a reminder does
1954
+ * not appear.
1955
+ */
1956
+ function debugLine(ctx, tag, data) {
1957
+ try {
1958
+ const file = NOTIFY_DEBUG_PATH();
1959
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1960
+ const stamp = new Date().toISOString();
1961
+ const payload = data === undefined ? '' : ` ${JSON.stringify(data)}`;
1962
+ fs.appendFileSync(file, `${stamp} [${tag}]${payload}\n`, 'utf8');
1963
+ const stat = fs.statSync(file);
1964
+ if (stat.size > NOTIFY_DEBUG_CAP) {
1965
+ fs.writeFileSync(file, `${stamp} [debug] log rotated\n`, 'utf8');
1966
+ }
1967
+ } catch (error) {
1968
+ try {
1969
+ ctx?.logger?.warn?.('[dsh-keeper] debug log failed', error);
1970
+ } catch {
1971
+ /* ignore */
1972
+ }
1973
+ }
1974
+ }
1975
+
1976
+ const notificationMetaCache = new Map();
1977
+
1978
+ /** The last real user prompt, skipping the injected time-context snapshot. */
1979
+ function lastUserExcerpt(events) {
1980
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1981
+ const event = events[i];
1982
+ if (!event || event.type !== 'user/message') continue;
1983
+ const source = event.data && event.data.source;
1984
+ if (source && source.kind === 'time-context') continue;
1985
+ const text = messageText(event.data);
1986
+ if (text) return truncate(text, 20);
1987
+ }
1988
+ return '';
1989
+ }
1990
+
1991
+ /** The last assistant answer, used as the task summary in a notification. */
1992
+ function lastAssistantExcerpt(events, max) {
1993
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1994
+ const event = events[i];
1995
+ if (!event || event.type !== 'assistant/message') continue;
1996
+ const text = messageText(event.data);
1997
+ if (text) return truncate(stripMarkdown(text), max);
1998
+ }
1999
+ return '';
2000
+ }
2001
+
2002
+ function notificationMetaFromEvents(events) {
2003
+ let title = '';
2004
+ let model = '';
2005
+ let cwd = '';
2006
+ for (const event of events) {
2007
+ if (!event || typeof event !== 'object') continue;
2008
+ if (event.type === 'session/title' && event.data && typeof event.data.title === 'string') {
2009
+ const candidate = event.data.title.trim();
2010
+ if (candidate) title = candidate;
2011
+ } else if (event.type === 'model/selection' && event.data && typeof event.data.model === 'string') {
2012
+ model = event.data.model.trim();
2013
+ } else if (event.type === 'session' && typeof event.cwd === 'string' && !cwd) {
2014
+ cwd = event.cwd;
2015
+ }
2016
+ }
2017
+ return {
2018
+ title: title || lastUserExcerpt(events),
2019
+ model,
2020
+ cwd,
2021
+ summary: lastAssistantExcerpt(events, 100),
2022
+ };
2023
+ }
2024
+
2025
+ function workspaceLabelFor(ctx, cwd) {
2026
+ if (!cwd) return '';
2027
+ try {
2028
+ const registry = ctx.workspaceRegistry;
2029
+ if (registry && typeof registry.resolveByPath === 'function') {
2030
+ const workspace = registry.resolveByPath(cwd);
2031
+ if (workspace && typeof workspace.title === 'string' && workspace.title) return workspace.title;
2032
+ }
2033
+ } catch {
2034
+ /* fall back to the folder name */
2035
+ }
2036
+ try {
2037
+ return path.basename(cwd);
2038
+ } catch {
2039
+ return '';
2040
+ }
2041
+ }
2042
+
2043
+ /**
2044
+ * Resolve the friendly bits of a notification: the session title with a
2045
+ * message-excerpt fallback (never the raw session id), the workspace label and
2046
+ * the selected model. Prefers live events, then the persisted log.
2047
+ */
2048
+ function notificationMeta(ctx, session, sessionId) {
2049
+ const headerCwd =
2050
+ session && session.header && typeof session.header.cwd === 'string' ? session.header.cwd : '';
2051
+ let events = [];
2052
+ if (session && typeof session.snapshotEvents === 'function') {
2053
+ try {
2054
+ events = session.snapshotEvents() || [];
2055
+ } catch {
2056
+ events = [];
2057
+ }
2058
+ }
2059
+ const cached = sessionId ? notificationMetaCache.get(sessionId) : undefined;
2060
+ if (!events.length && cached && Date.now() - cached.at < 15000) return cached.value;
2061
+ if (!events.length && sessionId) {
2062
+ try {
2063
+ const live = ctx.sessions && typeof ctx.sessions.get === 'function' ? ctx.sessions.get(sessionId) : undefined;
2064
+ if (live && typeof live.snapshotEvents === 'function') events = live.snapshotEvents() || [];
2065
+ } catch {
2066
+ events = [];
2067
+ }
2068
+ }
2069
+ if (!events.length && sessionId) {
2070
+ // The live session may already be gone when the notification is read; fall
2071
+ // back to the persisted log so the toast never shows a raw session id.
2072
+ try {
2073
+ const found = listSessionDirs().find((entry) => entry.id === sessionId);
2074
+ const logFile = found ? sessionLogFile(found.dir) : null;
2075
+ if (logFile && hasZstd()) events = decodeZstdLog(logFile).events;
2076
+ } catch {
2077
+ events = [];
2078
+ }
2079
+ }
2080
+ const derived = events.length
2081
+ ? notificationMetaFromEvents(events)
2082
+ : { title: '', model: '', cwd: '', summary: '' };
2083
+ const value = {
2084
+ title: derived.title,
2085
+ model: derived.model,
2086
+ workspace: workspaceLabelFor(ctx, derived.cwd || headerCwd),
2087
+ summary: derived.summary || '',
2088
+ };
2089
+ if (sessionId) {
2090
+ notificationMetaCache.set(sessionId, { at: Date.now(), value });
2091
+ if (notificationMetaCache.size > 200) notificationMetaCache.clear();
2092
+ }
2093
+ return value;
2094
+ }
2095
+
2096
+ function pushNotification(ctx, entry) {
2097
+ const now = Date.now();
2098
+ const dedupeKey = `${entry.kind}|${entry.sessionId ?? ''}|${entry.detail ?? ''}`;
2099
+ const previous = recentNotificationKeys.get(dedupeKey);
2100
+ if (previous !== undefined && now - previous < 3000) {
2101
+ debugLine(ctx, 'push-skip', { kind: entry.kind, sessionId: entry.sessionId ?? '', detail: entry.detail ?? '' });
2102
+ return null;
2103
+ }
2104
+ recentNotificationKeys.set(dedupeKey, now);
2105
+ if (recentNotificationKeys.size > 200) recentNotificationKeys.clear();
2106
+
2107
+ notificationSeq += 1;
2108
+ const sessionId = entry.sessionId ?? '';
2109
+ const meta = sessionId
2110
+ ? notificationMeta(ctx, entry.session, sessionId)
2111
+ : { title: '', model: '', workspace: '', summary: '' };
2112
+ const rawSummary =
2113
+ entry.summary || (entry.kind === 'error' ? entry.detail : '') || meta.summary || '';
2114
+ const summary = truncate(stripMarkdown(String(rawSummary)), 100);
2115
+ const notification = {
2116
+ id: String(notificationSeq),
2117
+ kind: entry.kind,
2118
+ sessionId,
2119
+ title: meta.title,
2120
+ workspace: meta.workspace,
2121
+ model: meta.model,
2122
+ summary,
2123
+ detail: entry.detail ?? '',
2124
+ at: now,
2125
+ ...(entry.approval ? { approval: true } : {}),
2126
+ ...(Number.isFinite(entry.questionCount) ? { questionCount: entry.questionCount } : {}),
2127
+ };
2128
+ notificationQueue.push(notification);
2129
+ if (notificationQueue.length > NOTIFY_LIMIT) {
2130
+ notificationQueue.splice(0, notificationQueue.length - NOTIFY_LIMIT);
2131
+ }
2132
+ debugLine(ctx, 'push', {
2133
+ id: notification.id,
2134
+ kind: notification.kind,
2135
+ sessionId: notification.sessionId,
2136
+ title: notification.title,
2137
+ detail: notification.detail,
2138
+ });
2139
+ return notification;
2140
+ }
2141
+
2142
+ function readNotifications(ctx, payload) {
2143
+ const after = Number(payload && payload.after);
2144
+ const items =
2145
+ Number.isFinite(after) && after > 0
2146
+ ? notificationQueue.filter((item) => Number(item.id) > after)
2147
+ : notificationQueue.slice();
2148
+ // Late metadata resolution: a notification may be queued before the title or
2149
+ // model is available, so fill the gaps the first time it is read.
2150
+ for (const item of items) {
2151
+ if (!item.sessionId) continue;
2152
+ if (item.title && item.workspace && item.model && item.summary) continue;
2153
+ const meta = notificationMeta(ctx, null, item.sessionId);
2154
+ if (!item.title && meta.title) item.title = meta.title;
2155
+ if (!item.workspace && meta.workspace) item.workspace = meta.workspace;
2156
+ if (!item.model && meta.model) item.model = meta.model;
2157
+ if (!item.summary && meta.summary) item.summary = meta.summary;
2158
+ }
2159
+ const state = readState();
2160
+ if (items.length > 0) {
2161
+ debugLine(ctx, 'read', { after, count: items.length, latest: String(notificationSeq) });
2162
+ }
2163
+ return {
2164
+ items,
2165
+ latest: String(notificationSeq),
2166
+ epoch: notificationEpoch,
2167
+ // The notifier runs outside the plugin panel, so it needs the live settings
2168
+ // with every poll instead of a bootstrap snapshot that may be stale.
2169
+ settings: normalizeNotificationSettings(state.settings && state.settings.notifications),
2170
+ };
2171
+ }
2172
+
2173
+ const callSessionCache = new Map();
2174
+
2175
+ function liveSessions(ctx) {
2176
+ try {
2177
+ const sessions = ctx && ctx.sessions;
2178
+ if (!sessions) return [];
2179
+ if (typeof sessions.list === 'function') {
2180
+ const listed = sessions.list();
2181
+ if (Array.isArray(listed)) return listed;
2182
+ }
2183
+ if (typeof sessions.values === 'function') {
2184
+ const values = sessions.values();
2185
+ if (Array.isArray(values)) return values;
2186
+ }
2187
+ } catch {
2188
+ /* ignore */
2189
+ }
2190
+ return [];
2191
+ }
2192
+
2193
+ // Legacy (non-timed) questions carry no agent, only the tool call id from the
2194
+ // session stream. Find the owning session so the reminder still names it.
2195
+ function sessionIdForCallId(ctx, callId) {
2196
+ if (!callId) return '';
2197
+ const cached = callSessionCache.get(callId);
2198
+ if (cached && Date.now() - cached.at < 60000) return cached.sessionId;
2199
+ let result = '';
2200
+ for (const session of liveSessions(ctx)) {
2201
+ try {
2202
+ const events = session && typeof session.snapshotEvents === 'function' ? session.snapshotEvents() || [] : [];
2203
+ const match = events.some(
2204
+ (event) => event && event.type === 'tool/call' && event.data && String(event.data.callId) === String(callId),
2205
+ );
2206
+ if (match) {
2207
+ result = String((session && session.id) || '');
2208
+ break;
2209
+ }
2210
+ } catch {
2211
+ /* keep scanning */
2212
+ }
2213
+ }
2214
+ if (result) {
2215
+ if (callSessionCache.size > 200) callSessionCache.clear();
2216
+ callSessionCache.set(callId, { sessionId: result, at: Date.now() });
2217
+ } else {
2218
+ debugLine(ctx, 'callid-miss', { callId, live: liveSessions(ctx).length });
2219
+ }
2220
+ return result;
2221
+ }
2222
+
2223
+ // Short, human-readable summaries for the "waiting for you" reminders.
2224
+ function questionSummary(questions) {
2225
+ if (!Array.isArray(questions) || questions.length === 0) return '';
2226
+ const first = questions[0];
2227
+ const fields = ['question', 'text', 'prompt', 'header', 'title'];
2228
+ for (const field of fields) {
2229
+ const value = first && first[field];
2230
+ if (typeof value === 'string' && value.trim()) return truncate(value, 100);
2231
+ }
2232
+ return '';
2233
+ }
2234
+
2235
+ function approvalSummary(request) {
2236
+ const fields = ['prompt', 'title', 'description', 'message', 'reason'];
2237
+ for (const field of fields) {
2238
+ const value = request && request[field];
2239
+ if (typeof value === 'string' && value.trim()) return truncate(value, 100);
2240
+ }
2241
+ return '';
2242
+ }
2243
+
2244
+ function installNotificationWatchers(ctx) {
2245
+ const disposers = [];
2246
+
2247
+ if (typeof ctx.on === 'function') {
2248
+ disposers.push(
2249
+ ctx.on('session/event', (session, event) => {
2250
+ try {
2251
+ const header = session && session.header;
2252
+ if (header && header.origin === 'subagent') return;
2253
+ if (event && event.type === 'tool/call') {
2254
+ // The user-question waterfall does not always reach an unscoped
2255
+ // listener (legacy asks carry no agent), so the tool call itself is
2256
+ // the reliable signal that the model is waiting for an answer.
2257
+ const toolName = event.data && event.data.name ? String(event.data.name) : '';
2258
+ if (/ask_user/i.test(toolName)) {
2259
+ debugLine(ctx, 'ask-toolcall', { toolName, sessionId: session && session.id });
2260
+ pushNotification(ctx, {
2261
+ kind: 'ask',
2262
+ sessionId: session && session.id,
2263
+ detail: 'question',
2264
+ questionCount: 0,
2265
+ session,
2266
+ });
2267
+ }
2268
+ return;
2269
+ }
2270
+ if (event && event.type === 'approval/asked') {
2271
+ // `approval/request` is a scoped waterfall that `dsh-api-remotes`
2272
+ // forwards to the Web client first. When the user answers, that
2273
+ // listener resolves the waterfall with a result and never calls
2274
+ // `next()`, so a later Host listener is never invoked. The durable
2275
+ // `approval/asked` audit event is the reliable "user is waiting" signal.
2276
+ const data = event.data || {};
2277
+ debugLine(ctx, 'approval-asked', {
2278
+ sessionId: session && session.id,
2279
+ approvalId: data.id ? String(data.id) : '',
2280
+ callId: data.callId ? String(data.callId) : '',
2281
+ toolName: data.toolName ? String(data.toolName) : '',
2282
+ });
2283
+ pushNotification(ctx, {
2284
+ kind: 'ask',
2285
+ sessionId: session && session.id,
2286
+ detail: 'approval',
2287
+ approval: true,
2288
+ summary: approvalSummary({ prompt: data.reason, title: data.toolName }),
2289
+ session,
2290
+ });
2291
+ return;
2292
+ }
2293
+ if (!event || event.type !== 'turn/end') return;
2294
+ const reason = event.data && event.data.reason ? event.data.reason : { kind: 'completed' };
2295
+ if (reason.kind === 'forked') return;
2296
+ let kind = 'done';
2297
+ let detail = '';
2298
+ if (reason.kind === 'error') {
2299
+ kind = 'error';
2300
+ detail = reason.error && reason.error.message ? reason.error.message : '模型请求失败';
2301
+ } else if (reason.kind !== 'completed') {
2302
+ kind = 'stopped';
2303
+ detail = reason.kind;
2304
+ }
2305
+ pushNotification(ctx, { kind, sessionId: session.id, detail, session });
2306
+ } catch (error) {
2307
+ try {
2308
+ ctx.logger?.warn?.('[dsh-keeper] notification watcher failed', error);
2309
+ } catch {
2310
+ /* ignore logging failures */
2311
+ }
2312
+ }
2313
+ }),
2314
+ );
2315
+
2316
+ disposers.push(
2317
+ ctx.on('user-questions/request', function (request, next) {
2318
+ try {
2319
+ // The scoped agent is passed as the listener receiver; the request
2320
+ // payload only carries `agent` on some paths, so accept both.
2321
+ const scoped = this && typeof this.id === 'string' ? this : null;
2322
+ const agent = (request && request.agent) || scoped;
2323
+ const callId = request && request.wait && request.wait.callId ? String(request.wait.callId) : '';
2324
+ debugLine(ctx, 'ask-event', {
2325
+ agentId: agent && agent.id ? String(agent.id) : '',
2326
+ thisId: this && typeof this.id === 'string' ? String(this.id) : '',
2327
+ callId,
2328
+ questionCount: Array.isArray(request && request.questions) ? request.questions.length : 0,
2329
+ });
2330
+ if (agent && agent.id) {
2331
+ const questions = Array.isArray(request.questions) ? request.questions : [];
2332
+ const first = questions[0];
2333
+ const intent = first && first.intent ? first.intent.kind : 'question';
2334
+ pushNotification(ctx, {
2335
+ kind: 'ask',
2336
+ sessionId: agent.id,
2337
+ detail: intent,
2338
+ approval: intent === 'approval',
2339
+ questionCount: questions.length,
2340
+ summary: questionSummary(questions),
2341
+ });
2342
+ } else {
2343
+ // Legacy asks reach listeners without an agent; map the tool call
2344
+ // back to its session instead of dropping the reminder.
2345
+ const sessionId = callId ? sessionIdForCallId(ctx, callId) : '';
2346
+ if (sessionId) {
2347
+ const questions = Array.isArray(request.questions) ? request.questions : [];
2348
+ const first = questions[0];
2349
+ const intent = first && first.intent ? first.intent.kind : 'question';
2350
+ pushNotification(ctx, {
2351
+ kind: 'ask',
2352
+ sessionId,
2353
+ detail: intent,
2354
+ approval: intent === 'approval',
2355
+ questionCount: questions.length,
2356
+ summary: questionSummary(questions),
2357
+ });
2358
+ }
2359
+ }
2360
+ } catch {
2361
+ /* ignore notification failures; the question must still be asked */
2362
+ }
2363
+ return next();
2364
+ }),
2365
+ );
2366
+
2367
+ disposers.push(
2368
+ ctx.on('approval/request', function (request, next) {
2369
+ try {
2370
+ const scoped = this && typeof this.id === 'string' ? this : null;
2371
+ const callId = request && request.callId ? String(request.callId) : '';
2372
+ debugLine(ctx, 'approval-event', {
2373
+ thisId: this && typeof this.id === 'string' ? String(this.id) : '',
2374
+ callId,
2375
+ sessionId: request && typeof request.sessionId === 'string' ? request.sessionId : '',
2376
+ });
2377
+ const sessionId =
2378
+ request && typeof request.sessionId === 'string'
2379
+ ? request.sessionId
2380
+ : request && request.agent && request.agent.id
2381
+ ? request.agent.id
2382
+ : scoped
2383
+ ? scoped.id
2384
+ : callId
2385
+ ? sessionIdForCallId(ctx, callId)
2386
+ : '';
2387
+ if (sessionId) {
2388
+ pushNotification(ctx, {
2389
+ kind: 'ask',
2390
+ sessionId,
2391
+ detail: 'approval',
2392
+ approval: true,
2393
+ summary: approvalSummary(request),
2394
+ });
2395
+ }
2396
+ } catch {
2397
+ /* ignore notification failures; approval must still be asked */
2398
+ }
2399
+ return next();
2400
+ }),
2401
+ );
2402
+ }
2403
+
2404
+ return () => {
2405
+ for (const dispose of disposers) {
2406
+ try {
2407
+ dispose();
2408
+ } catch {
2409
+ /* ignore disposal failures */
2410
+ }
2411
+ }
2412
+ };
2413
+ }
2414
+
2415
+ /* ------------------------------------------------------ notification sounds */
2416
+
2417
+ function soundExtFor(buffer) {
2418
+ if (buffer.length >= 4 && buffer.toString('ascii', 0, 4) === 'RIFF') return 'wav';
2419
+ if (buffer.length >= 4 && buffer.toString('ascii', 0, 4) === 'OggS') return 'ogg';
2420
+ return 'mp3';
2421
+ }
2422
+
2423
+ function findSoundFile(kind) {
2424
+ for (const ext of Object.keys(SOUND_MIME)) {
2425
+ const file = path.join(soundsDir(), `${kind}.${ext}`);
2426
+ try {
2427
+ if (fs.existsSync(file)) return { file, ext };
2428
+ } catch {
2429
+ /* keep looking */
2430
+ }
2431
+ }
2432
+ return null;
2433
+ }
2434
+
2435
+ function saveNotificationSound(ctx, payload) {
2436
+ const kind = payload && payload.kind;
2437
+ if (!SOUND_KINDS.includes(kind)) throw fail('BAD_REQUEST', '不支持的提示音类型。');
2438
+ const rawName = typeof payload?.name === 'string' ? path.basename(payload.name).slice(0, 120) : '';
2439
+ let base64 = typeof payload?.data === 'string' ? payload.data : '';
2440
+ if (base64.startsWith('data:')) {
2441
+ const comma = base64.indexOf(',');
2442
+ if (comma !== -1) base64 = base64.slice(comma + 1);
2443
+ }
2444
+ let buffer;
2445
+ try {
2446
+ buffer = Buffer.from(base64, 'base64');
2447
+ } catch {
2448
+ throw fail('BAD_REQUEST', '音频内容不是有效的 base64。');
2449
+ }
2450
+ if (!buffer || buffer.length === 0) throw fail('BAD_REQUEST', '音频文件为空。');
2451
+ if (buffer.length > SOUND_MAX_BYTES) throw fail('TOO_LARGE', '音频文件不能超过 2 MB。');
2452
+ const ext = soundExtFor(buffer);
2453
+ const dir = soundsDir();
2454
+ try {
2455
+ fs.mkdirSync(dir, { recursive: true });
2456
+ for (const other of Object.keys(SOUND_MIME)) {
2457
+ if (other === ext) continue;
2458
+ fs.rmSync(path.join(dir, `${kind}.${other}`), { force: true });
2459
+ }
2460
+ fs.writeFileSync(path.join(dir, `${kind}.${ext}`), buffer);
2461
+ } catch (error) {
2462
+ throw fail('IO_ERROR', `写入提示音失败:${error && error.message ? error.message : String(error)}`);
2463
+ }
2464
+ const state = readState();
2465
+ const sounds = { ...state.settings.notifications.sounds, [kind]: { name: rawName || `${kind}.${ext}` } };
2466
+ state.settings.notifications = normalizeNotificationSettings({ ...state.settings.notifications, sounds });
2467
+ const saved = writeState(state);
2468
+ return { kind, name: state.settings.notifications.sounds[kind].name, state: saved };
2469
+ }
2470
+
2471
+ function readNotificationSound(payload) {
2472
+ const kind = payload && payload.kind;
2473
+ if (!SOUND_KINDS.includes(kind)) throw fail('BAD_REQUEST', '不支持的提示音类型。');
2474
+ const found = findSoundFile(kind);
2475
+ if (!found) return { kind, name: '', mime: '', data: '' };
2476
+ let buffer;
2477
+ try {
2478
+ buffer = fs.readFileSync(found.file);
2479
+ } catch {
2480
+ return { kind, name: '', mime: '', data: '' };
2481
+ }
2482
+ const state = readState();
2483
+ const stored = state.settings.notifications.sounds[kind];
2484
+ return {
2485
+ kind,
2486
+ name: stored && stored.name ? stored.name : path.basename(found.file),
2487
+ mime: SOUND_MIME[found.ext] || 'audio/mpeg',
2488
+ data: buffer.toString('base64'),
2489
+ };
2490
+ }
2491
+
2492
+ function clearNotificationSound(payload) {
2493
+ const kind = payload && payload.kind;
2494
+ if (!SOUND_KINDS.includes(kind)) throw fail('BAD_REQUEST', '不支持的提示音类型。');
2495
+ for (const ext of Object.keys(SOUND_MIME)) {
2496
+ try {
2497
+ fs.rmSync(path.join(soundsDir(), `${kind}.${ext}`), { force: true });
2498
+ } catch {
2499
+ /* ignore cleanup failures */
2500
+ }
2501
+ }
2502
+ const state = readState();
2503
+ const sounds = { ...state.settings.notifications.sounds, [kind]: { name: '' } };
2504
+ state.settings.notifications = normalizeNotificationSettings({ ...state.settings.notifications, sounds });
2505
+ const saved = writeState(state);
2506
+ return { kind, name: '', state: saved };
2507
+ }
2508
+
2509
+ function createApi(ctx) {
2510
+ return {
2511
+ bootstrap: () => buildBootstrap(ctx),
2512
+ deleteSessions: (payload) => deleteSessions(ctx, payload),
2513
+ restoreSessions: (payload) => restoreSessions(ctx, payload),
2514
+ purgeSessions: (payload) => purgeSessions(ctx, payload),
2515
+ archiveSessions: (payload) => deleteSessions(ctx, { ...payload, mode: 'archive' }),
2516
+ unarchiveSessions: async (payload) => {
2517
+ const ids = Array.isArray(payload?.sessionIds) ? payload.sessionIds : [];
2518
+ const done = [];
2519
+ const errors = [];
2520
+ for (const rawId of ids) {
2521
+ let id;
2522
+ try {
2523
+ id = assertSessionId(rawId);
2524
+ } catch (error) {
2525
+ errors.push({ sessionId: String(rawId), code: error.code, message: error.message });
2526
+ continue;
2527
+ }
2528
+ try {
2529
+ await ctx.workspaceRegistry.unarchiveSession(id);
2530
+ done.push({ sessionId: id });
2531
+ } catch (error) {
2532
+ errors.push({ sessionId: id, code: 'UNARCHIVE_FAILED', message: error?.message ?? '取消归档失败' });
2533
+ }
2534
+ }
2535
+ return { done, errors };
2536
+ },
2537
+ cleanBlankSessions: () => cleanBlankSessions(ctx),
2538
+ openWorkspacePath: (payload) => openWorkspacePath(ctx, payload),
2539
+ pluginInventory: async (payload) => {
2540
+ if (payload && payload.refresh === true) inventoryCache.at = 0;
2541
+ return readPluginInventory(ctx);
2542
+ },
2543
+ pluginScopes: async (payload) => {
2544
+ if (payload && payload.refresh === true) {
2545
+ inventoryCache.at = 0;
2546
+ scopeCache.at = 0;
2547
+ }
2548
+ return readPluginScopes(ctx);
2549
+ },
2550
+ notifications: async (payload) => readNotifications(ctx, payload),
2551
+ debugLog: async (payload) => {
2552
+ if (payload && typeof payload.message === 'string') {
2553
+ debugLine(ctx, `client:${payload.message}`, payload.data);
2554
+ }
2555
+ return true;
2556
+ },
2557
+ saveNotificationSound: (payload) => saveNotificationSound(ctx, payload),
2558
+ readNotificationSound: (payload) => readNotificationSound(payload),
2559
+ clearNotificationSound: (payload) => clearNotificationSound(payload),
2560
+ setPluginEnabled: (payload) => setPluginEnabled(ctx, payload),
2561
+ uninstallPlugin: (payload) => uninstallPlugin(ctx, payload),
2562
+ enablePluginOverride: (payload) => enablePluginOverride(ctx, payload),
2563
+ translateText: (payload) => translateText(ctx, payload),
2564
+ clearTranslationCache: () => clearTranslationCache(),
2565
+ exportSession: (payload) => exportSession(ctx, payload),
2566
+ setHiddenPanels: async (payload) => {
2567
+ const ids = Array.isArray(payload?.hiddenPanels)
2568
+ ? payload.hiddenPanels.filter((id) => typeof id === 'string' && id.length > 0 && id.length < 200)
2569
+ : [];
2570
+ const state = readState();
2571
+ state.hiddenPanels = [...new Set(ids)];
2572
+ const saved = writeState(state);
2573
+ return { state: saved };
2574
+ },
2575
+ saveSettings: async (payload) => {
2576
+ const patch = payload?.settings;
2577
+ if (!patch || typeof patch !== 'object') throw fail('BAD_REQUEST', '缺少设置内容。');
2578
+ const state = readState();
2579
+ if (patch.defaultDeleteMode === 'trash' || patch.defaultDeleteMode === 'archive') {
2580
+ state.settings.defaultDeleteMode = patch.defaultDeleteMode;
2581
+ }
2582
+ if (typeof patch.confirmPurge === 'boolean') state.settings.confirmPurge = patch.confirmPurge;
2583
+ if (EXPORT_FORMATS.includes(patch.defaultExportFormat)) {
2584
+ state.settings.defaultExportFormat = patch.defaultExportFormat;
2585
+ }
2586
+ if (patch.notifications && typeof patch.notifications === 'object') {
2587
+ state.settings.notifications = normalizeNotificationSettings({
2588
+ ...state.settings.notifications,
2589
+ ...patch.notifications,
2590
+ });
2591
+ }
2592
+ const saved = writeState(state);
2593
+ return { state: saved };
2594
+ },
2595
+ resetSettings: async () => {
2596
+ const state = readState();
2597
+ state.settings = { ...DEFAULT_SETTINGS };
2598
+ const saved = writeState(state);
2599
+ return { state: saved };
2600
+ },
2601
+ resetPanels: async () => {
2602
+ const state = readState();
2603
+ state.hiddenPanels = [];
2604
+ const saved = writeState(state);
2605
+ return { state: saved };
2606
+ },
2607
+ emptyTrash: async () => {
2608
+ const entries = readTrash();
2609
+ return purgeSessions(ctx, { sessionIds: entries.map((entry) => entry.sessionId) });
2610
+ },
2611
+ ping: async () => ({ pong: true, version: PLUGIN_VERSION, root: dshHome() }),
2612
+ };
2613
+ }
2614
+
2615
+ /* ------------------------------------------------------------------- apply */
2616
+
2617
+ /**
2618
+ * Mount the plugin against the host context.
2619
+ * @param ctx - Host context carrying the web server, connection fence and the
2620
+ * session persistence/workspace services.
2621
+ */
2622
+ export function apply(ctx) {
2623
+ const api = createApi(ctx);
2624
+
2625
+ ctx.effect(
2626
+ () =>
2627
+ ctx.webServer.register({
2628
+ kind: 'prefix',
2629
+ path: API_PATH,
2630
+ handler: async (req, res) => {
2631
+ if (ctx.connection.requestRejection(req) !== undefined) {
2632
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
2633
+ res.end('forbidden');
2634
+ return;
2635
+ }
2636
+ if (req.method !== 'POST') {
2637
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'POST' });
2638
+ res.end('method not allowed');
2639
+ return;
2640
+ }
2641
+ const method = new URL(req.url ?? '/', 'http://dsh.internal').pathname
2642
+ .slice(API_PATH.length)
2643
+ .replace(/^\/+/, '');
2644
+ const fn = Object.hasOwn(api, method) ? api[method] : undefined;
2645
+ if (method.length === 0 || method.includes('/') || typeof fn !== 'function') {
2646
+ writeError(res, 'NOT_FOUND', `Unknown method: ${method}`);
2647
+ return;
2648
+ }
2649
+ try {
2650
+ const payload = await readBody(req);
2651
+ const value = await fn(payload);
2652
+ writeOk(res, value);
2653
+ } catch (error) {
2654
+ writeError(res, error?.code ?? 'INTERNAL', error?.message ?? String(error));
2655
+ }
2656
+ },
2657
+ }),
2658
+ 'dsh-keeper: data API',
2659
+ );
2660
+
2661
+ ctx.effect(() => {
2662
+ migrateLegacyData();
2663
+ fs.mkdirSync(trashRoot(), { recursive: true });
2664
+ return () => {};
2665
+ }, 'dsh-keeper: storage directories');
2666
+
2667
+ ctx.effect(() => installNotificationWatchers(ctx), 'dsh-keeper: notification watchers');
2668
+ }
2669
+
2670
+ /** Exported for tests and for the host package metadata. */
2671
+ export const plugin = { name, NS, PANEL_ID, version: PLUGIN_VERSION };
2672
+ export { sessionInfo as _sessionInfo };