dsh-cmdk 0.0.0-stage → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # dsh-cmdk: hosts the ⌘K palette (client) and two small host routes
2
+ # (profile directory lookup + native folder open).
3
+ - insert:
4
+ - id: dsh-cmdk
5
+ name: dsh-cmdk
package/icon.svg ADDED
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#5B6CF9"/>
5
+ <stop offset="1" stop-color="#8E5BF6"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="0" y="0" width="64" height="64" rx="15" fill="url(#g)"/>
9
+ <rect x="13" y="15" width="38" height="26" rx="7" fill="#FFFFFF" fill-opacity="0.96"/>
10
+ <rect x="19" y="23" width="14" height="2.6" rx="1.3" fill="#5B6CF9"/>
11
+ <rect x="19" y="29" width="22" height="2.6" rx="1.3" fill="#C9CEF8"/>
12
+ <rect x="19" y="35" width="9" height="2.6" rx="1.3" fill="#C9CEF8"/>
13
+ <circle cx="43" cy="43" r="8.5" fill="#FFFFFF" fill-opacity="0.96"/>
14
+ <circle cx="43" cy="43" r="5" stroke="#5B6CF9" stroke-width="2.4"/>
15
+ <rect x="46.6" y="47.4" width="7" height="2.6" rx="1.3" transform="rotate(45 46.6 47.4)" fill="#5B6CF9"/>
16
+ </svg>
package/index.js ADDED
@@ -0,0 +1,443 @@
1
+ /**
2
+ * dsh-cmdk — host half.
3
+ *
4
+ * The palette itself is client-only (navigation, theme and workspace state all
5
+ * live on the Client side). The Host half exists for the things a browser
6
+ * sandbox cannot reach:
7
+ *
8
+ * GET /cmdk/info → where this profile lives (home + profile dir)
9
+ * GET /cmdk/sessions?q= → full session catalogue + message-content hits
10
+ * POST /cmdk/open-profile → reveal the profile directory in Finder/Explorer
11
+ * GET /cmdk/plugins → installed bundles (name, version, enabled, dir)
12
+ * POST /cmdk/plugin → enable/disable one bundle via pluginManager
13
+ *
14
+ * Every route goes through the connection trust fence first, so a random page
15
+ * cannot probe the host filesystem through the loopback port.
16
+ */
17
+ import { execFile } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ export const name = 'dsh-cmdk';
23
+ export const inject = ['webServer'];
24
+
25
+ const ROUTE = '/cmdk';
26
+
27
+ /** How long the session title catalogue stays warm between keystrokes. */
28
+ const CATALOG_TTL = 20_000;
29
+ const CATALOG_SIZE = 120;
30
+ /** How many of the newest sessions the content scan looks at per query. */
31
+ const CONTENT_SCAN_SIZE = 12;
32
+
33
+ let catalogCache = { at: 0, items: [] };
34
+ /** Query → content hits, so held-down keys do not rescan the logs every 180 ms. */
35
+ const contentCache = new Map();
36
+ const CONTENT_TTL = 60_000;
37
+
38
+ function dshHome() {
39
+ const fromEnv = process.env.DSH_HOME;
40
+ return fromEnv && fromEnv.trim() ? fromEnv : path.join(os.homedir(), '.dsh');
41
+ }
42
+
43
+ /** Best-effort running profile directory (same order as the rest of the UI): env dir → env name → desktop. */
44
+ function profileDir() {
45
+ const dir = process.env.DSH_PROFILE_DIR;
46
+ if (dir && dir.trim()) return dir;
47
+ const profile = process.env.DSH_PROFILE;
48
+ if (profile && profile.trim()) return path.join(dshHome(), 'profiles', profile);
49
+ return path.join(dshHome(), 'profiles', 'desktop');
50
+ }
51
+
52
+ function sendJson(res, code, payload) {
53
+ const body = Buffer.from(JSON.stringify(payload), 'utf8');
54
+ res.writeHead(code, {
55
+ 'Content-Type': 'application/json; charset=utf-8',
56
+ 'Cache-Control': 'no-store',
57
+ 'Content-Length': String(body.length),
58
+ });
59
+ res.end(body);
60
+ }
61
+
62
+ function readBody(req, limit = 16 * 1024) {
63
+ return new Promise((resolve) => {
64
+ let size = 0;
65
+ const chunks = [];
66
+ req.on('data', (chunk) => {
67
+ size += chunk.length;
68
+ if (size > limit) {
69
+ req.destroy();
70
+ resolve('');
71
+ return;
72
+ }
73
+ chunks.push(chunk);
74
+ });
75
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
76
+ req.on('error', () => resolve(''));
77
+ });
78
+ }
79
+
80
+ /** Returns true when the request was already rejected (and answered). */
81
+ function refused(ctx, req, res) {
82
+ const conn = ctx.get('connection') || ctx.connection;
83
+ if (!conn || typeof conn.requestRejection !== 'function') return false;
84
+ const code = conn.requestRejection({ headers: req.headers });
85
+ if (code === undefined || code === null || code === false) return false;
86
+ res.statusCode = typeof code === 'number' ? code : 403;
87
+ res.end();
88
+ return true;
89
+ }
90
+
91
+ function openInFileManager(dir) {
92
+ const [command, args] =
93
+ process.platform === 'darwin'
94
+ ? ['open', [dir]]
95
+ : process.platform === 'win32'
96
+ ? ['explorer', [dir]]
97
+ : ['xdg-open', [dir]];
98
+ return new Promise((resolve, reject) => {
99
+ execFile(command, args, (error) => (error ? reject(error) : resolve()));
100
+ });
101
+ }
102
+
103
+ /** Session ids are brand types at the type level but plain strings at runtime. */
104
+ function idOf(record) {
105
+ return record && record.header && record.header.id ? String(record.header.id) : '';
106
+ }
107
+
108
+ /** Fold titles for a batch of ids; missing titles come back as ''. */
109
+ async function titlesFor(query, ids) {
110
+ const out = new Map();
111
+ if (!ids.length || typeof query.readTitleSnapshots !== 'function') return out;
112
+ let results = [];
113
+ try {
114
+ results = await query.readTitleSnapshots(ids);
115
+ } catch {
116
+ return out;
117
+ }
118
+ for (const result of results || []) {
119
+ if (!result || result.status !== 'fulfilled' || !result.value || !result.value.title) continue;
120
+ const title = result.value.title.title;
121
+ if (typeof title === 'string' && title.trim()) out.set(String(result.sessionId), title.trim());
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Human-facing sessions: no subagent branches, no seeded placeholders, and
128
+ * never more than CATALOG_SIZE entries. Cached briefly so typing in the
129
+ * palette does not re-read every log on each keystroke.
130
+ */
131
+ async function sessionCatalog(ctx) {
132
+ const now = Date.now();
133
+ if (now - catalogCache.at < CATALOG_TTL && catalogCache.items.length) return catalogCache.items;
134
+
135
+ const query = ctx.get('sessionQuery');
136
+ if (!query || typeof query.listSessions !== 'function') return [];
137
+
138
+ let records = [];
139
+ try {
140
+ records = await query.listSessions();
141
+ } catch {
142
+ return catalogCache.items;
143
+ }
144
+
145
+ const picked = (records || [])
146
+ .filter((record) => {
147
+ const header = record && record.header;
148
+ if (!header || !header.id) return false;
149
+ if (header.origin === 'subagent' || header.parentSession) return false;
150
+ if (header.isSeeded) return false;
151
+ return true;
152
+ })
153
+ .slice(0, CATALOG_SIZE);
154
+
155
+ const titles = await titlesFor(query, picked.map(idOf).filter(Boolean));
156
+ const items = picked
157
+ .map((record) => {
158
+ const sessionId = idOf(record);
159
+ return {
160
+ sessionId,
161
+ title: titles.get(sessionId) || '',
162
+ cwd: record.header && record.header.cwd ? record.header.cwd : '',
163
+ at: record.header && record.header.createdAt ? record.header.createdAt : 0,
164
+ snippet: '',
165
+ };
166
+ })
167
+ .filter((item) => item.sessionId);
168
+
169
+ catalogCache = { at: now, items };
170
+ return items;
171
+ }
172
+
173
+ /**
174
+ * Content hits. The SQLite full-text index can be empty in the wild (the
175
+ * native session search reports "no matches" on such machines), so this walks
176
+ * the newest sessions and uses the backend-independent `filterEvents` text
177
+ * predicate instead: no index required, just the session logs.
178
+ */
179
+ async function matchContent(ctx, text, limit) {
180
+ const query = ctx.get('sessionQuery');
181
+ if (!query || typeof query.filterEvents !== 'function') return [];
182
+ if (text.length < 2) return [];
183
+
184
+ const cached = contentCache.get(text);
185
+ if (cached && Date.now() - cached.at < CONTENT_TTL) return cached.items.slice(0, limit);
186
+ const catalog = await sessionCatalog(ctx);
187
+ const scan = catalog.slice(0, CONTENT_SCAN_SIZE);
188
+
189
+ const hits = [];
190
+ for (const item of scan) {
191
+ let documents = [];
192
+ try {
193
+ documents = await query.filterEvents(item.sessionId, [{ kind: 'text', text }]);
194
+ } catch {
195
+ continue;
196
+ }
197
+ if (!Array.isArray(documents) || !documents.length) continue;
198
+ const doc = documents.find((entry) => entry && typeof entry.text === 'string');
199
+ if (!doc) continue;
200
+ hits.push({
201
+ sessionId: item.sessionId,
202
+ title: item.title,
203
+ cwd: item.cwd,
204
+ at: item.at,
205
+ snippet: snippetAround(doc.text, text),
206
+ });
207
+ if (hits.length >= limit) break;
208
+ }
209
+ if (contentCache.size > 40) contentCache.clear();
210
+ contentCache.set(text, { at: Date.now(), items: hits });
211
+ return hits;
212
+ }
213
+
214
+ /** 40 chars either side of the match, whitespace collapsed. */
215
+ function snippetAround(text, needle) {
216
+ const flat = String(text || '').replace(/\s+/g, ' ');
217
+ const at = flat.toLowerCase().indexOf(String(needle).toLowerCase());
218
+ if (at < 0) return flat.slice(0, 120);
219
+ const from = Math.max(0, at - 40);
220
+ const to = Math.min(flat.length, at + needle.length + 60);
221
+ return (from > 0 ? '…' : '') + flat.slice(from, to) + (to < flat.length ? '…' : '');
222
+ }
223
+
224
+
225
+ function workspaceStorePath() {
226
+ return path.join(dshHome(), 'storages', 'workspace.json');
227
+ }
228
+
229
+ /** Workspaces live in the Client store; read the same file without touching it. */
230
+ function readWorkspaces() {
231
+ let raw;
232
+ try {
233
+ raw = JSON.parse(fs.readFileSync(workspaceStorePath(), 'utf8'));
234
+ } catch {
235
+ return { items: [], archived: [] };
236
+ }
237
+ if (!raw || typeof raw !== 'object') return { items: [], archived: [] };
238
+ const global = raw.global && typeof raw.global === 'object' ? raw.global : {};
239
+ const table = (raw.tables && raw.tables.workspaces) || {};
240
+ const items = [];
241
+ for (const id of Array.isArray(global.workspaceIds) ? global.workspaceIds : Object.keys(table)) {
242
+ const row = table[id];
243
+ if (!row) continue;
244
+ items.push({
245
+ workspaceId: id,
246
+ title: typeof row.title === 'string' ? row.title : '',
247
+ path: typeof row.path === 'string' ? row.path : '',
248
+ sessionIds: Array.isArray(row.sessionIds) ? row.sessionIds : [],
249
+ });
250
+ }
251
+ return {
252
+ items,
253
+ archived: Array.isArray(global.archivedSessionIds) ? global.archivedSessionIds.slice() : [],
254
+ };
255
+ }
256
+
257
+ export function apply(ctx, config) {
258
+ const server = ctx.get('webServer') || ctx.webServer;
259
+ if (!server || typeof server.register !== 'function') {
260
+ ctx.logger?.warn?.('dsh-cmdk: no webServer carrier, palette routes unavailable');
261
+ return;
262
+ }
263
+
264
+ const disposers = [];
265
+
266
+ disposers.push(
267
+ server.register({
268
+ kind: 'exact',
269
+ path: ROUTE + '/info',
270
+ handler: async (req, res) => {
271
+ if (refused(ctx, req, res)) return;
272
+ sendJson(res, 200, {
273
+ ok: true,
274
+ home: dshHome(),
275
+ profile: profileDir(),
276
+ platform: process.platform,
277
+ arch: process.arch,
278
+ });
279
+ },
280
+ }),
281
+ );
282
+
283
+ disposers.push(
284
+ server.register({
285
+ kind: 'exact',
286
+ path: ROUTE + '/sessions',
287
+ handler: async (req, res) => {
288
+ if (refused(ctx, req, res)) return;
289
+ let url;
290
+ try {
291
+ url = new URL(req.url || ROUTE, 'http://localhost');
292
+ } catch {
293
+ sendJson(res, 400, { ok: false, error: 'bad url' });
294
+ return;
295
+ }
296
+ const text = (url.searchParams.get('q') || '').trim();
297
+ const asked = Number(url.searchParams.get('limit'));
298
+ const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, 50) : 20;
299
+
300
+ try {
301
+ const catalog = await sessionCatalog(ctx);
302
+ if (!text) {
303
+ sendJson(res, 200, { ok: true, items: catalog, hits: [], total: catalog.length });
304
+ return;
305
+ }
306
+ // The client filters titles locally (fuzzy + pinyin); the Host only
307
+ // contributes the expensive part: message-content hits.
308
+ const hits = await matchContent(ctx, text, limit);
309
+ sendJson(res, 200, { ok: true, items: catalog, hits, total: catalog.length });
310
+ } catch (error) {
311
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) });
312
+ }
313
+ },
314
+ }),
315
+ );
316
+
317
+ disposers.push(
318
+ server.register({
319
+ kind: 'exact',
320
+ path: ROUTE + '/workspaces',
321
+ handler: (req, res) => {
322
+ if (refused(ctx, req, res)) return;
323
+ try {
324
+ const data = readWorkspaces();
325
+ sendJson(res, 200, { ok: true, items: data.items, archived: data.archived });
326
+ } catch (error) {
327
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) });
328
+ }
329
+ },
330
+ }),
331
+ );
332
+
333
+ disposers.push(
334
+ server.register({
335
+ kind: 'exact',
336
+ path: ROUTE + '/open-profile',
337
+ handler: async (req, res) => {
338
+ if (refused(ctx, req, res)) return;
339
+ if (req.method !== 'POST') {
340
+ sendJson(res, 405, { ok: false, error: 'POST only' });
341
+ return;
342
+ }
343
+ const raw = await readBody(req);
344
+ let target = profileDir();
345
+ if (raw) {
346
+ try {
347
+ const parsed = JSON.parse(raw);
348
+ if (parsed && typeof parsed.path === 'string' && parsed.path.trim()) {
349
+ target = parsed.path.trim();
350
+ }
351
+ } catch {
352
+ /* fall back to the profile directory */
353
+ }
354
+ }
355
+ try {
356
+ await openInFileManager(target);
357
+ sendJson(res, 200, { ok: true, path: target });
358
+ } catch (error) {
359
+ sendJson(res, 500, { ok: false, error: String(error && error.message ? error.message : error) });
360
+ }
361
+ },
362
+ }),
363
+ );
364
+
365
+ disposers.push(
366
+ server.register({
367
+ kind: 'exact',
368
+ path: ROUTE + '/plugins',
369
+ handler: async (req, res) => {
370
+ if (refused(ctx, req, res)) return;
371
+ try {
372
+ const manager = ctx.get('pluginManager') || ctx.pluginManager;
373
+ const bundles = manager && typeof manager.listBundles === 'function' ? await manager.listBundles() : [];
374
+ const items = bundles
375
+ .filter((bundle) => bundle && bundle.installed && bundle.availability !== 'installation')
376
+ .map((bundle) => ({
377
+ name: bundle.name,
378
+ version: bundle.version || '',
379
+ enabled: !!bundle.enabled,
380
+ dir: path.join(profileDir(), 'node_modules', ...bundle.name.split('/')),
381
+ }))
382
+ .sort((a, b) => a.name.localeCompare(b.name));
383
+ sendJson(res, 200, { ok: true, items });
384
+ } catch (error) {
385
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) });
386
+ }
387
+ },
388
+ }),
389
+ );
390
+
391
+ disposers.push(
392
+ server.register({
393
+ kind: 'exact',
394
+ path: ROUTE + '/plugin',
395
+ handler: async (req, res) => {
396
+ if (refused(ctx, req, res)) return;
397
+ if (req.method !== 'POST') {
398
+ sendJson(res, 405, { ok: false, error: 'POST only' });
399
+ return;
400
+ }
401
+ const raw = await readBody(req);
402
+ let body = null;
403
+ try {
404
+ body = raw ? JSON.parse(raw) : null;
405
+ } catch {
406
+ sendJson(res, 400, { ok: false, error: 'invalid json' });
407
+ return;
408
+ }
409
+ const name = body && typeof body.name === 'string' ? body.name.trim() : '';
410
+ if (!name) {
411
+ sendJson(res, 400, { ok: false, error: 'missing name' });
412
+ return;
413
+ }
414
+ const manager = ctx.get('pluginManager') || ctx.pluginManager;
415
+ if (!manager || typeof manager.setBundleEnabled !== 'function') {
416
+ sendJson(res, 503, { ok: false, error: 'plugin manager unavailable' });
417
+ return;
418
+ }
419
+ try {
420
+ const result = await manager.setBundleEnabled(name, !!body.enabled);
421
+ sendJson(res, 200, {
422
+ ok: !result.error,
423
+ changed: result.changed,
424
+ application: result.application,
425
+ error: result.error ? result.error.code || 'failed' : undefined,
426
+ });
427
+ } catch (error) {
428
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) });
429
+ }
430
+ },
431
+ }),
432
+ );
433
+
434
+ ctx.effect(() => () => {
435
+ for (const dispose of disposers) {
436
+ try {
437
+ dispose();
438
+ } catch {
439
+ /* already gone */
440
+ }
441
+ }
442
+ });
443
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "⌘K Command Palette",
4
+ "description": "One shortcut to any session: search titles and message content, switch workspaces, start a new session or theme — without touching the mouse."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "⌘K 命令面板",
4
+ "description": "一个快捷键跳到任何会话:搜标题、搜消息内容、切工作区、开新会话、换外观,都不用离开键盘。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "dsh-cmdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "type": "module",
5
+ "description": "⌘K command palette for DeepSeek Harness: jump to sessions, search message content, switch workspaces, toggle plugins.",
6
+ "icon": "icon.svg",
7
+ "keywords": [
8
+ "dsh",
9
+ "command-palette",
10
+ "search",
11
+ "productivity"
12
+ ],
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./client": "./client.js",
16
+ "./locale/*.json": "./locale/*.json",
17
+ "./icon": "./icon.svg",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "LICENSE",
24
+ "screenshot.png",
25
+ "screenshot-actions.png",
26
+ "screenshot-ext.png",
27
+ "index.js",
28
+ "client.js",
29
+ "cordis.patch.yml",
30
+ "locale/*.json",
31
+ "icon.svg"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web",
39
+ "immediately": true,
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-ui-layout"
42
+ ]
43
+ }
44
+ },
45
+ "license": "MIT",
46
+ "author": "mumuhmumu",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/hhse/dsh-cmdk.git"
50
+ },
51
+ "homepage": "https://github.com/hhse/dsh-cmdk#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/hhse/dsh-cmdk/issues"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
Binary file
Binary file
package/screenshot.png ADDED
Binary file