dsh-composer-plus 1.7.1 → 1.8.0

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/cordis.patch.yml CHANGED
@@ -1,3 +1,15 @@
1
1
  - insert:
2
2
  - id: composer-plus
3
3
  name: dsh-composer-plus
4
+
5
+ # Content search: dsh's own sidebar search (Cmd+K) also finds chats by the words in their messages. dsh ships the index
6
+ # off (`openAt: never`, `:memory:`); this later layer switches it on with a durable, owner-only file under the dsh home.
7
+ # The index opens at the first search, not at start. Settings → Composer Plus → Content search writes
8
+ # `storages/dsh-composer-plus/content-search.json`; `{ "on": false }` keeps dsh's default (titles only). Read at start only.
9
+ # `journalMode: delete`: a rebuildable index needs no write-ahead log; WAL kept a second file as large as the index
10
+ # (measured: 166 MB next to a 165 MB index) until dsh quit.
11
+ - id: session-query-sqlite
12
+ config:
13
+ path: !!js dshHomePath('cache', 'composer-plus', 'session-search.db')
14
+ openAt: !!js "(() => { try { return JSON.parse(process.getBuiltinModule('node:fs').readFileSync(dshHomePath('storages', 'dsh-composer-plus', 'content-search.json'), 'utf8'))?.on === false ? 'never' : 'first-search' } catch { return 'first-search' } })()"
15
+ journalMode: delete
package/index.js CHANGED
@@ -10,13 +10,14 @@
10
10
  * - Resume after restart: remembers the chats that were running when dsh quit and continues them at the next start; off by
11
11
  * default (it starts model turns without a click).
12
12
  * - Feature ideas: on a click, one model call (the chat's own model) reads a short brief of the chat's project and returns
13
- * feature ideas as cards. Read-only: no chat message, nothing written to the project.
13
+ * feature ideas as cards. Read-only: no chat message, nothing written to the project. Ideas saved for later are kept
14
+ * per project folder under the dsh home.
14
15
  */
15
16
  import { constants, readFileSync } from 'node:fs';
16
17
  import { mkdir, open, readdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises';
17
18
  import { randomUUID } from 'node:crypto';
18
19
  import { homedir } from 'node:os';
19
- import { dirname, join, sep } from 'node:path';
20
+ import { dirname, isAbsolute, join, sep } from 'node:path';
20
21
  import { createGateway } from './tether/gateway/server.mjs';
21
22
 
22
23
  export const name = 'dsh-composer-plus';
@@ -31,9 +32,11 @@ export const SHOT_PATH = '/api/composer-plus.shot';
31
32
  export const MOBILE_PATH = '/api/composer-plus.mobile';
32
33
  export const RESUME_PATH = '/api/composer-plus.resume';
33
34
  export const IDEAS_PATH = '/api/composer-plus.ideas';
35
+ export const LATER_PATH = '/api/composer-plus.later';
36
+ export const CONTENT_SEARCH_PATH = '/api/composer-plus.content-search';
34
37
  /** Feature screenshots shipped in assets/features/<id>.webp; only these ids are served (no path input). */
35
38
  export const SHOTS = ['changesPanel', 'featureIdeas', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'resumeAfterRestart', 'inputHistory', 'planToggle', 'keepFocus',
36
- 'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate'];
39
+ 'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate', 'contentSearch'];
37
40
  const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
38
41
  const NPM_NAME = 'dsh-composer-plus';
39
42
  const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
@@ -204,10 +207,11 @@ export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
204
207
  return { ...info, newer: npm.ok };
205
208
  }
206
209
 
207
- /** Mobile connect switch: `{ on: true }` in a JSON file, off when the file is missing or unreadable. */
208
- export function mobileSwitch(file) {
210
+ /** A Host switch (Mobile connect, Content search): `{ on }` in an owner-only JSON file. A missing or unreadable file, or
211
+ * any value other than the opposite of `fallback`, reads as `fallback`. */
212
+ export function mobileSwitch(file, fallback = false) {
209
213
  return {
210
- read: async () => { try { return JSON.parse(await readFile(file, 'utf8'))?.on === true; } catch { return false; } },
214
+ read: async () => { try { const on = JSON.parse(await readFile(file, 'utf8'))?.on; return fallback ? on !== false : on === true; } catch { return fallback; } },
211
215
  write: async (on) => {
212
216
  await mkdir(dirname(file), { recursive: true });
213
217
  const tmp = `${file}.${process.pid}.tmp`;
@@ -447,6 +451,15 @@ export function ideasLanguage(locale) {
447
451
  }
448
452
 
449
453
  const clip = (value, max) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '');
454
+ /** One idea, cleaned and bounded (from the model or the browser), or undefined without a title and a summary. */
455
+ const ideaFrom = (item) => {
456
+ const title = clip(item?.title, 120);
457
+ const summary = clip(item?.summary, 600);
458
+ if (title === '' || summary === '') return undefined;
459
+ const why = clip(item?.why, 300);
460
+ const effort = ['small', 'medium', 'large'].includes(item?.effort) ? item.effort : undefined;
461
+ return { title, summary, ...(why === '' ? {} : { why }), ...(effort === undefined ? {} : { effort }) };
462
+ };
450
463
  /** Ideas from the model's answer: the first JSON array in it (a code fence is fine), each idea cleaned and bounded. Throws
451
464
  * when the answer holds no usable idea. */
452
465
  export function parseIdeas(text) {
@@ -458,11 +471,9 @@ export function parseIdeas(text) {
458
471
  if (!Array.isArray(list)) throw new Error('the model did not answer with a list of ideas');
459
472
  const ideas = [];
460
473
  for (const item of list) {
461
- const title = clip(item?.title, 120);
462
- const summary = clip(item?.summary, 600);
463
- if (title === '' || summary === '') continue;
464
- const effort = ['small', 'medium', 'large'].includes(item?.effort) ? item.effort : undefined;
465
- ideas.push({ id: randomUUID(), title, summary, ...(clip(item?.why, 300) === '' ? {} : { why: clip(item.why, 300) }), ...(effort === undefined ? {} : { effort }) });
474
+ const idea = ideaFrom(item);
475
+ if (idea === undefined) continue;
476
+ ideas.push({ id: randomUUID(), ...idea });
466
477
  if (ideas.length === IDEAS_MAX) break;
467
478
  }
468
479
  if (ideas.length === 0) throw new Error('the model answered without a usable idea');
@@ -514,14 +525,20 @@ export function ideaPrompt(idea) {
514
525
  'First read the related code. Then plan the smallest change that delivers this feature, with the checks that prove it works.'].join('\n');
515
526
  }
516
527
 
528
+ /** The chat's own project folder, from its session header (the browser never sends a path). */
529
+ async function chatFolder(sessionQuery, sessionId, signal) {
530
+ const observation = await sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
531
+ let cwd;
532
+ try { cwd = observation.header?.cwd; } finally { observation[Symbol.dispose]?.(); }
533
+ if (!isText(cwd, 4096) || !isAbsolute(cwd)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 });
534
+ return cwd;
535
+ }
536
+
517
537
  /** Start a new chat in the same project (the same sidebar group as the source chat) for one accepted idea, and enter plan
518
538
  * mode with the idea as the request. When the profile has no /plan command, or /plan fails, the idea goes as an ordinary
519
539
  * message. */
520
540
  export async function startIdeaChat({ sessionController, sessionQuery, commands, workspaces }, sessionId, idea, signal = new AbortController().signal) {
521
- const observation = await sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
522
- let cwd;
523
- try { cwd = observation.header?.cwd; } finally { observation[Symbol.dispose]?.(); }
524
- if (!isText(cwd, 4096)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 });
541
+ const cwd = await chatFolder(sessionQuery, sessionId, signal);
525
542
  let workspace;
526
543
  try { workspace = workspaces?.list?.().find((w) => w.sessionIds.includes(sessionId)); } catch { workspace = undefined; }
527
544
  const { sessionId: created } = await sessionController.create(workspace === undefined ? { cwd } : { workspaceId: workspace.id });
@@ -535,6 +552,46 @@ export async function startIdeaChat({ sessionController, sessionQuery, commands,
535
552
  return { sessionId: created, plan };
536
553
  }
537
554
 
555
+ /** Ideas saved for later: at most this many per project. A full list refuses a new idea; it never drops an old one. */
556
+ export const LATER_MAX = 50;
557
+ /** Ideas saved for later, per project folder: `{ [cwd]: [{ id, title, summary, why?, effort?, at }] }` (newest first) in
558
+ * one owner-only JSON file. A missing file is an empty list. A broken file throws, so no write replaces it (no data loss).
559
+ * Reads and writes run in order; each write replaces the file atomically. */
560
+ export function laterStore(file) {
561
+ let queue = Promise.resolve();
562
+ const run = (fn) => { const done = queue.then(fn); queue = done.catch(() => {}); return done; };
563
+ const read = async () => {
564
+ let text;
565
+ try { text = await readFile(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return {}; throw error; }
566
+ const data = JSON.parse(text);
567
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) throw new Error('the saved ideas file is not a JSON object');
568
+ return data;
569
+ };
570
+ const clean = (list) => (Array.isArray(list) ? list.filter((i) => isText(i?.id) && isText(i?.title, 120) && isText(i?.summary, 600)) : []);
571
+ // Keys are absolute folder paths only, so a key such as `__proto__` never reaches the object.
572
+ const folder = (cwd) => { if (!isText(cwd, 4096) || !isAbsolute(cwd)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 }); return cwd; };
573
+ const update = (cwd, fn) => run(async () => {
574
+ const data = await read();
575
+ const list = fn(clean(data[folder(cwd)]));
576
+ if (list.length === 0) delete data[cwd]; else data[cwd] = list;
577
+ await mkdir(dirname(file), { recursive: true });
578
+ const tmp = `${file}.${process.pid}.tmp`;
579
+ await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
580
+ await rename(tmp, file);
581
+ return list;
582
+ });
583
+ return {
584
+ list: (cwd) => run(async () => clean((await read())[folder(cwd)])),
585
+ /** Add a cleaned idea at the top; the same title (any case) is not added twice. */
586
+ add: (cwd, idea) => update(cwd, (list) => {
587
+ if (list.some((i) => i.title.toLowerCase() === idea.title.toLowerCase())) return list;
588
+ if (list.length >= LATER_MAX) throw Object.assign(new Error(`a project keeps at most ${LATER_MAX} ideas for later`), { status: 409 });
589
+ return [{ id: randomUUID(), ...idea, at: Date.now() }, ...list];
590
+ }),
591
+ remove: (cwd, id) => update(cwd, (list) => list.filter((i) => i.id !== id)),
592
+ };
593
+ }
594
+
538
595
  const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
539
596
  const isText = (v, max = 512) => typeof v === 'string' && v.length > 0 && v.length <= max;
540
597
 
@@ -687,14 +744,41 @@ export function apply(ctx) {
687
744
  },
688
745
  });
689
746
  installResume(ctx, home);
690
- installIdeas(ctx);
747
+ installIdeas(ctx, home);
748
+ installContentSearch(ctx, home);
749
+ }
750
+
751
+ /** Content search: dsh's own sidebar search also looks in message text. The work is dsh's (`cordis.patch.yml` opens its
752
+ * SQLite index when this switch is on); the Host only keeps the switch, on by default. dsh reads it at start, so
753
+ * `GET/POST {on}` also tells whether the running index is open (`active`; undefined when dsh does not say). */
754
+ function installContentSearch(ctx, home) {
755
+ const saved = mobileSwitch(join(home, 'storages', 'dsh-composer-plus', 'content-search.json'), true);
756
+ const active = () => { try { const openAt = ctx.sessionQuery?.config?.openAt; return typeof openAt === 'string' ? openAt !== 'never' : undefined; } catch { return undefined; } };
757
+ let queue = Promise.resolve(); // one save at a time: they share a temp file name
758
+ ctx.connection.fetch.register({
759
+ path: CONTENT_SEARCH_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
760
+ fetch: async (request) => {
761
+ if (request.method === 'POST') {
762
+ let body;
763
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
764
+ if (typeof body?.on !== 'boolean') return json({ error: 'bad request' }, 400);
765
+ const done = queue.then(() => saved.write(body.on));
766
+ queue = done.catch(() => {});
767
+ try { await done; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
768
+ }
769
+ return json({ on: await saved.read(), active: active() });
770
+ },
771
+ });
691
772
  }
692
773
 
693
774
  /** Feature ideas: `POST {sessionId, locale?}` asks the chat's model for ideas; `POST {sessionId, idea}` starts a chat for one idea.
694
- * Both act only on the chat's own project folder; the browser never sends a path. */
695
- function installIdeas(ctx) {
775
+ * Ideas saved for later: `GET ?sessionId` lists the project's ideas; `POST {sessionId, add | remove | start}` changes the list
776
+ * (`start` starts a chat for the stored idea and removes it once the chat runs). All act only on the chat's own project
777
+ * folder; the browser never sends a path. */
778
+ function installIdeas(ctx, home) {
696
779
  const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
697
780
  const status = (error) => (Number.isInteger(error?.status) ? error.status : 502);
781
+ const services = () => ({ sessionController: ctx.sessionController, sessionQuery: ctx.sessionQuery, commands: get('commands'), workspaces: get('workspaceRegistry') });
698
782
  ctx.connection.fetch.register({
699
783
  path: IDEAS_PATH, methods: ['POST'], requestBody: 'buffered',
700
784
  fetch: async (request) => {
@@ -702,11 +786,10 @@ function installIdeas(ctx) {
702
786
  try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
703
787
  if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
704
788
  if (body.idea !== undefined) {
705
- const idea = { title: clip(body.idea?.title, 120), summary: clip(body.idea?.summary, 600), why: clip(body.idea?.why, 300) || undefined };
706
- if (idea.title === '' || idea.summary === '') return json({ error: 'bad request' }, 400);
789
+ const idea = ideaFrom(body.idea);
790
+ if (idea === undefined) return json({ error: 'bad request' }, 400);
707
791
  try {
708
- return json(await startIdeaChat({ sessionController: ctx.sessionController, sessionQuery: ctx.sessionQuery, commands: get('commands'), workspaces: get('workspaceRegistry') },
709
- body.sessionId, idea, request.signal));
792
+ return json(await startIdeaChat(services(), body.sessionId, { title: idea.title, summary: idea.summary, why: idea.why }, request.signal));
710
793
  } catch (error) { return json({ error: String(error?.message ?? error) }, status(error)); }
711
794
  }
712
795
  const llm = get('llm');
@@ -716,6 +799,34 @@ function installIdeas(ctx) {
716
799
  } catch (error) { return json({ error: String(error?.message ?? error) }, status(error)); }
717
800
  },
718
801
  });
802
+ const later = laterStore(join(home, 'storages', 'dsh-composer-plus', 'later.json'));
803
+ const starting = new Set(); // `${cwd}\0${id}` of the stored ideas whose chat is starting now
804
+ ctx.connection.fetch.register({
805
+ path: LATER_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
806
+ fetch: async (request) => {
807
+ let body;
808
+ if (request.method === 'GET') body = { sessionId: new URL(request.url).searchParams.get('sessionId') };
809
+ else try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
810
+ if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
811
+ const add = body.add === undefined ? undefined : ideaFrom(body.add);
812
+ const action = request.method === 'GET' ? 'list' : body.add !== undefined ? 'add' : isText(body.remove) ? 'remove' : isText(body.start) ? 'start' : undefined;
813
+ if (action === undefined || (action === 'add' && add === undefined)) return json({ error: 'bad request' }, 400);
814
+ try {
815
+ const cwd = await chatFolder(ctx.sessionQuery, body.sessionId, request.signal);
816
+ if (action === 'list') return json({ ideas: await later.list(cwd) });
817
+ if (action === 'add') return json({ ideas: await later.add(cwd, add) });
818
+ if (action === 'remove') return json({ ideas: await later.remove(cwd, body.remove) });
819
+ const idea = (await later.list(cwd)).find((i) => i.id === body.start);
820
+ const key = `${cwd}\0${body.start}`;
821
+ if (idea === undefined || starting.has(key)) return json({ error: 'this idea is no longer in the list' }, 409);
822
+ starting.add(key);
823
+ try {
824
+ const started = await startIdeaChat(services(), body.sessionId, { title: idea.title, summary: idea.summary, why: idea.why }, request.signal);
825
+ return json({ ...started, ideas: await later.remove(cwd, idea.id) });
826
+ } finally { starting.delete(key); }
827
+ } catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
828
+ },
829
+ });
719
830
  }
720
831
 
721
832
  /** Resume after restart: follow the top-level chats, and once dsh is ready, continue the ones its last shutdown stopped.
package/locale/de.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Änderungsansicht, Karten mit Funktionsideen, Eingabeverlauf, Shift+Tab /plan, Fortsetzen mit einem Klick, Fortsetzen nach Neustart, Warteschlange per Drag-and-drop, Links, Benachrichtigungen, iPhone-Zugriff (Tether), 11 Sprachen und automatische Updates."
4
+ "description": "Änderungsansicht, Karten mit Funktionsideen, Suche in Nachrichten, Eingabeverlauf, Shift+Tab /plan, Fortsetzen mit einem Klick, Fortsetzen nach Neustart, Warteschlange per Drag-and-drop, Links, Benachrichtigungen, iPhone-Zugriff (Tether), 11 Sprachen und automatische Updates."
5
5
  }
6
6
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Changes panel, feature idea cards, input history, Shift+Tab /plan, one-click Continue, resume after restart, queue drag-and-drop, deeplinks, notifications, iPhone access (Tether), 11 UI languages and automatic updates."
4
+ "description": "Changes panel, feature idea cards, search in messages, input history, Shift+Tab /plan, one-click Continue, resume after restart, queue drag-and-drop, deeplinks, notifications, iPhone access (Tether), 11 UI languages and automatic updates."
5
5
  }
6
6
  }
package/locale/es.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Panel de cambios, tarjetas de ideas de funciones, historial de entrada, Shift+Tab /plan, Continuar con un clic, reanudar tras reiniciar, reordenar la cola arrastrando, enlaces, notificaciones, acceso desde iPhone (Tether), 11 idiomas y actualizaciones automáticas."
4
+ "description": "Panel de cambios, tarjetas de ideas de funciones, búsqueda en los mensajes, historial de entrada, Shift+Tab /plan, Continuar con un clic, reanudar tras reiniciar, reordenar la cola arrastrando, enlaces, notificaciones, acceso desde iPhone (Tether), 11 idiomas y actualizaciones automáticas."
5
5
  }
6
6
  }
package/locale/fr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Panneau des modifications, cartes d’idées de fonctionnalités, historique de saisie, Shift+Tab /plan, Continuer en un clic, reprise après redémarrage, file réorganisable par glisser-déposer, liens, notifications, accès depuis l’iPhone (Tether), 11 langues et mises à jour automatiques."
4
+ "description": "Panneau des modifications, cartes d’idées de fonctionnalités, recherche dans les messages, historique de saisie, Shift+Tab /plan, Continuer en un clic, reprise après redémarrage, file réorganisable par glisser-déposer, liens, notifications, accès depuis l’iPhone (Tether), 11 langues et mises à jour automatiques."
5
5
  }
6
6
  }
package/locale/hi.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "बदलाव पैनल, फ़ीचर आइडिया कार्ड, इनपुट इतिहास, Shift+Tab /plan, एक क्लिक में जारी रखें, रीस्टार्ट के बाद जारी रखें, कतार ड्रैग-एंड-ड्रॉप, लिंक, सूचनाएं, iPhone से एक्सेस (Tether), 11 भाषाएं और अपने-आप अपडेट।"
4
+ "description": "बदलाव पैनल, फ़ीचर आइडिया कार्ड, संदेशों में खोज, इनपुट इतिहास, Shift+Tab /plan, एक क्लिक में जारी रखें, रीस्टार्ट के बाद जारी रखें, कतार ड्रैग-एंड-ड्रॉप, लिंक, सूचनाएं, iPhone से एक्सेस (Tether), 11 भाषाएं और अपने-आप अपडेट।"
5
5
  }
6
6
  }
package/locale/id.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Panel perubahan, kartu ide fitur, riwayat input, Shift+Tab /plan, Lanjutkan sekali klik, lanjutkan setelah mulai ulang, urutkan antrean dengan seret, tautan, notifikasi, akses dari iPhone (Tether), 11 bahasa, dan pembaruan otomatis."
4
+ "description": "Panel perubahan, kartu ide fitur, pencarian isi pesan, riwayat input, Shift+Tab /plan, Lanjutkan sekali klik, lanjutkan setelah mulai ulang, urutkan antrean dengan seret, tautan, notifikasi, akses dari iPhone (Tether), 11 bahasa, dan pembaruan otomatis."
5
5
  }
6
6
  }
package/locale/ja.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "変更パネル、機能アイデアのカード、入力履歴、Shift+Tab で /plan、ワンクリック続行、再起動後の再開、キューのドラッグ並べ替え、リンク、通知、iPhone からの操作(Tether)、11 言語、自動アップデート。"
4
+ "description": "変更パネル、機能アイデアのカード、メッセージ内容の検索、入力履歴、Shift+Tab で /plan、ワンクリック続行、再起動後の再開、キューのドラッグ並べ替え、リンク、通知、iPhone からの操作(Tether)、11 言語、自動アップデート。"
5
5
  }
6
6
  }
package/locale/ko.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "변경 사항 패널, 기능 아이디어 카드, 입력 기록, Shift+Tab /plan, 원클릭 계속, 재시작 후 이어서 실행, 대기열 끌어서 정렬, 링크, 알림, iPhone 연결(Tether), 11개 언어와 자동 업데이트."
4
+ "description": "변경 사항 패널, 기능 아이디어 카드, 메시지 내용 검색, 입력 기록, Shift+Tab /plan, 원클릭 계속, 재시작 후 이어서 실행, 대기열 끌어서 정렬, 링크, 알림, iPhone 연결(Tether), 11개 언어와 자동 업데이트."
5
5
  }
6
6
  }
package/locale/pt-BR.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Painel de alterações, cartões de ideias de recursos, histórico de entrada, Shift+Tab /plan, Continuar com um clique, retomar após reiniciar, reordenar a fila arrastando, links, notificações, acesso pelo iPhone (Tether), 11 idiomas e atualizações automáticas."
4
+ "description": "Painel de alterações, cartões de ideias de recursos, pesquisa nas mensagens, histórico de entrada, Shift+Tab /plan, Continuar com um clique, retomar após reiniciar, reordenar a fila arrastando, links, notificações, acesso pelo iPhone (Tether), 11 idiomas e atualizações automáticas."
5
5
  }
6
6
  }
package/locale/ru.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Панель изменений, карточки идей функций, история ввода, Shift+Tab /plan, продолжение в один клик, продолжение после перезапуска, перетаскивание очереди, ссылки, уведомления, доступ с iPhone (Tether), 11 языков и автообновления."
4
+ "description": "Панель изменений, карточки идей функций, поиск по сообщениям, история ввода, Shift+Tab /plan, продолжение в один клик, продолжение после перезапуска, перетаскивание очереди, ссылки, уведомления, доступ с iPhone (Tether), 11 языков и автообновления."
5
5
  }
6
6
  }
package/locale/tr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Değişiklikler paneli, özellik fikri kartları, giriş geçmişi, Shift+Tab /plan, tek tıkla devam, yeniden açılışta sürdürme, sıradaki mesajları sürükle-bırak, bağlantılar, bildirimler, iPhone’dan erişim (Tether), 11 arayüz dili ve otomatik güncellemeler."
4
+ "description": "Değişiklikler paneli, özellik fikri kartları, mesaj içeriğinde arama, giriş geçmişi, Shift+Tab /plan, tek tıkla devam, yeniden açılışta sürdürme, sıradaki mesajları sürükle-bırak, bağlantılar, bildirimler, iPhone’dan erişim (Tether), 11 arayüz dili ve otomatik güncellemeler."
5
5
  }
6
6
  }
package/locale/vi.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "Bảng thay đổi, thẻ ý tưởng tính năng, lịch sử nhập, Shift+Tab /plan, Tiếp tục một chạm, tiếp tục sau khi khởi động lại, kéo thả hàng đợi, liên kết, thông báo, truy cập từ iPhone (Tether), 11 ngôn ngữ và tự động cập nhật."
4
+ "description": "Bảng thay đổi, thẻ ý tưởng tính năng, tìm trong nội dung tin nhắn, lịch sử nhập, Shift+Tab /plan, Tiếp tục một chạm, tiếp tục sau khi khởi động lại, kéo thả hàng đợi, liên kết, thông báo, truy cập từ iPhone (Tether), 11 ngôn ngữ và tự động cập nhật."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Composer Plus",
4
- "description": "改动面板、功能创意卡片、输入历史、Shift+Tab /plan、一键继续、重启后继续、队列拖拽排序、会话链接、通知、iPhone 远程使用(Tether)、11 种界面语言和自动更新。"
4
+ "description": "改动面板、功能创意卡片、消息内容搜索、输入历史、Shift+Tab /plan、一键继续、重启后继续、队列拖拽排序、会话链接、通知、iPhone 远程使用(Tether)、11 种界面语言和自动更新。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
3
- "version": "1.7.1",
4
- "description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
3
+ "version": "1.8.0",
4
+ "description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, sidebar search in message text, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
5
5
  "icon": "./assets/icon.svg",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -25,6 +25,7 @@
25
25
  "dsh-plugin",
26
26
  "dsh-plugins",
27
27
  "feature-ideas",
28
+ "full-text-search",
28
29
  "git",
29
30
  "git-diff",
30
31
  "i18n",
@@ -41,6 +42,7 @@
41
42
  "remote-control",
42
43
  "restart",
43
44
  "resume",
45
+ "search",
44
46
  "sort-by-status",
45
47
  "tailscale",
46
48
  "tether",
@@ -67,7 +69,7 @@
67
69
  "assets/features/*.webp"
68
70
  ],
69
71
  "scripts": {
70
- "test": "node scripts/build-locales.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/queue-drag.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/tether-gateway.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs",
72
+ "test": "node scripts/build-locales.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/queue-drag.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/tether-gateway.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs",
71
73
  "build:locales": "node scripts/build-locales.mjs",
72
74
  "prepublishOnly": "npm test"
73
75
  },
@@ -6,7 +6,7 @@ import { createServer as createHttp, request as httpRequest } from 'node:http'
6
6
  import { createHash, randomBytes, randomUUID, timingSafeEqual, X509Certificate } from 'node:crypto'
7
7
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
8
  import { execFile, execFileSync, spawn } from 'node:child_process'
9
- import { homedir, networkInterfaces } from 'node:os'
9
+ import { homedir, hostname, networkInterfaces } from 'node:os'
10
10
  import { dirname, join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
 
@@ -14,6 +14,14 @@ const HERE = dirname(fileURLToPath(import.meta.url))
14
14
  const TOKEN_TTL = 30 * 864e5
15
15
  const MAX_BODY = 16 << 20
16
16
 
17
+ /** The name the phone shows for this Mac in its Mac picker: the Mac's own "Computer Name", else the host name. */
18
+ export function macName(read = () => execFileSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8', timeout: 2000 })) {
19
+ let n = ''
20
+ try { n = String(read()).trim() } catch {}
21
+ if (!n) n = hostname().replace(/\.local$/i, '')
22
+ return n.replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'Mac'
23
+ }
24
+
17
25
  // Allowlisted DSH remote methods. Everything else is 403.
18
26
  export const RPC = new Set([
19
27
  'session/list', 'session/page', 'session/create', 'session/rename', 'session/prompt', 'session/cancel',
@@ -220,6 +228,7 @@ export function createGateway(opts = {}) {
220
228
  const a = (networkInterfaces()[iface] ?? []).find(i => i.family === 'IPv4' && !i.internal)
221
229
  return a && { address: a.address, netmask: a.netmask }
222
230
  })
231
+ const mac = opts.macName ?? macName()
223
232
  const codeTtl = opts.codeTtlMs ?? 5 * 60e3
224
233
  const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
225
234
  mkdirSync(dir, { recursive: true, mode: 0o700 })
@@ -290,7 +299,7 @@ export function createGateway(opts = {}) {
290
299
  function newCode() {
291
300
  const net = bound ?? netInfo()
292
301
  code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
293
- code.payload = JSON.stringify({ v: 1, host: net?.address, port, fp, code: code.value, ...(ts.online && ts.ip ? { ts: ts.ip } : {}) })
302
+ code.payload = JSON.stringify({ v: 1, host: net?.address, port, fp, code: code.value, mac, ...(ts.online && ts.ip ? { ts: ts.ip } : {}) })
294
303
  qrReady = false
295
304
  if (opts.qr !== false) {
296
305
  const c = code
@@ -320,7 +329,7 @@ export function createGateway(opts = {}) {
320
329
  const token = randomBytes(32).toString('base64url') // 256-bit; only its hash is stored
321
330
  const d = { id: randomUUID(), name: p.name, tokenHash: sha(token), createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + TOKEN_TTL).toISOString() }
322
331
  devices.push(d); saveDevices()
323
- reply(p.res, 200, { token, deviceId: d.id, name: d.name, expiresAt: d.expiresAt })
332
+ reply(p.res, 200, { token, deviceId: d.id, name: d.name, expiresAt: d.expiresAt, mac })
324
333
  return true
325
334
  }
326
335
 
@@ -395,7 +404,7 @@ export function createGateway(opts = {}) {
395
404
  const dev = auth(req)
396
405
  if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
397
406
  track(dev.id, req.socket)
398
- if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null })
407
+ if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null, mac })
399
408
  if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
400
409
  const method = path.slice(5)
401
410
  let body
@@ -475,7 +484,8 @@ export function createGateway(opts = {}) {
475
484
  await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
476
485
  server = s; bound = net; port = s.address().port; running = true
477
486
  if (opts.bonjour !== false) {
478
- bonjour = spawn('dns-sd', ['-R', 'Tether', '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
487
+ // The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
488
+ bonjour = spawn('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
479
489
  bonjour.on('error', () => {})
480
490
  }
481
491
  connectEvents()
@@ -505,7 +515,7 @@ export function createGateway(opts = {}) {
505
515
  watch.unref()
506
516
 
507
517
  const state = () => ({
508
- running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
518
+ mac, running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
509
519
  fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError,
510
520
  code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
511
521
  pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at })),