dsh-composer-plus 1.8.0 → 1.9.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/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * per project folder under the dsh home.
15
15
  */
16
16
  import { constants, readFileSync } from 'node:fs';
17
- import { mkdir, open, readdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises';
17
+ import { lstat, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
18
18
  import { randomUUID } from 'node:crypto';
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, isAbsolute, join, sep } from 'node:path';
@@ -34,9 +34,11 @@ export const RESUME_PATH = '/api/composer-plus.resume';
34
34
  export const IDEAS_PATH = '/api/composer-plus.ideas';
35
35
  export const LATER_PATH = '/api/composer-plus.later';
36
36
  export const CONTENT_SEARCH_PATH = '/api/composer-plus.content-search';
37
+ export const ROLLBACK_PATH = '/api/composer-plus.rollback';
38
+ export const DELETE_CHAT_PATH = '/api/composer-plus.delete-chat';
37
39
  /** Feature screenshots shipped in assets/features/<id>.webp; only these ids are served (no path input). */
38
40
  export const SHOTS = ['changesPanel', 'featureIdeas', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'resumeAfterRestart', 'inputHistory', 'planToggle', 'keepFocus',
39
- 'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate', 'contentSearch'];
41
+ 'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate', 'contentSearch', 'turnRollback', 'deleteChat'];
40
42
  const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
41
43
  const NPM_NAME = 'dsh-composer-plus';
42
44
  const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
@@ -61,18 +63,19 @@ async function gitPath(subprocess) {
61
63
  return gitPaths.get(subprocess);
62
64
  }
63
65
 
64
- /** Run read-only `git <args>` in cwd: argv only (never a shell), scrubbed config, no prompts, no optional locks. */
65
- export async function runGit(subprocess, cwd, args, signal) {
66
+ /** Run read-only `git <args>` in cwd: argv only (never a shell), scrubbed config, no prompts, no optional locks.
67
+ * `env` adds variables (Turn rollback points git at its own store); `stdin` is sent as the child's input. */
68
+ export async function runGit(subprocess, cwd, args, signal, { env = {}, stdin, timeoutMs = TIMEOUT_MS } = {}) {
66
69
  const git = await gitPath(subprocess);
67
70
  if (git === null) return { code: 127, stdout: '', truncated: false, stderr: 'git is not installed' };
68
- const timeout = AbortSignal.timeout(TIMEOUT_MS);
71
+ const timeout = AbortSignal.timeout(timeoutMs);
69
72
  const handle = subprocess.spawn({
70
73
  argv: [git, ...args],
71
74
  cwd,
72
- stdio: { stdin: 'ignore', stdout: { maxBytes: MAX_OUTPUT }, stderr: { maxBytes: 16384 } },
75
+ stdio: { stdin: stdin === undefined ? 'ignore' : { data: stdin }, stdout: { maxBytes: MAX_OUTPUT }, stderr: { maxBytes: 16384 } },
73
76
  graceMs: 1000,
74
77
  signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
75
- env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
78
+ env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...env },
76
79
  });
77
80
  const outcome = await handle.done;
78
81
  const out = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false };
@@ -594,6 +597,283 @@ export function laterStore(file) {
594
597
 
595
598
  const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
596
599
  const isText = (v, max = 512) => typeof v === 'string' && v.length > 0 && v.length <= max;
600
+ const refused = (message, status = 409) => Object.assign(new Error(message), { status });
601
+
602
+ /* Turn rollback. The idea of one appended marker message that hides a span of the chat from the model (dsh's own
603
+ * `surfaceOp: replace`, as `/compact` uses) comes from dsh-rewind-plugin (MIT, SiriLee); the code here is new. */
604
+ export const ROLLBACK_SOURCE = 'composer-plus-rollback';
605
+ const ROLLBACK_TEXT = '(empty message)';
606
+ /** Snapshots kept per chat: the newest user messages that a rollback can still bring the files back to. */
607
+ export const SNAPSHOT_MAX = 50;
608
+ const SNAPSHOT_TIMEOUT_MS = 30000;
609
+ /** A tool call waits at most this long for its chat's snapshot (a FIFO named .gitignore makes git wait until its time-out). */
610
+ const TOOL_WAIT_MS = 10000;
611
+ /** Every store command: the system and global git config are never read and these keys are fixed, so no filter, fsmonitor
612
+ * or line-ending rule runs; with the store's info/attributes ("-text -filter") files go in and come out byte for byte. */
613
+ const STORE_CONFIG = ['-c', 'core.autocrlf=false', '-c', 'core.safecrlf=false', '-c', 'core.fsmonitor=false', '-c', 'core.symlinks=true', '-c', 'gc.auto=0', '-c', 'core.hooksPath=/dev/null'];
614
+ const storeEnv = (store, workTree, index) => ({ GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_DIR: store, GIT_WORK_TREE: workTree, GIT_INDEX_FILE: index });
615
+ const safeSegment = (id) => encodeURIComponent(id).replace(/[.%*]/g, (c) => `_${c.charCodeAt(0).toString(16)}`);
616
+
617
+ /** The marker append that withdraws `surface` from `seq` on: dsh checks that `sourceEventSeqs` holds every shadowed node. */
618
+ export function rollbackPlan(surface, seq) {
619
+ const at = surface.indexOf(seq);
620
+ if (at < 0) throw refused('this message is no longer in the model context');
621
+ const shadowed = surface.slice(at);
622
+ return { shadowed, options: { surfaceOp: { op: 'replace', startSeq: shadowed[0], endSeq: shadowed.at(-1) }, sourceEventSeqs: shadowed } };
623
+ }
624
+ export const rollbackMarker = () => Object.freeze({ id: randomUUID(), role: 'user', content: Object.freeze([Object.freeze({ type: 'text', text: ROLLBACK_TEXT })]),
625
+ source: Object.freeze({ kind: ROLLBACK_SOURCE }) });
626
+ /** The spans a chat's markers hide in the view, as `[first withdrawn seq, marker seq]`. */
627
+ export const rollbackSpans = (events) => events.filter((e) => e.type === 'user/message' && e.data?.source?.kind === ROLLBACK_SOURCE && Array.isArray(e.sourceEventSeqs) && e.sourceEventSeqs.length > 0)
628
+ .map((e) => [Math.min(...e.sourceEventSeqs), e.seq]);
629
+
630
+ /** `path` relative to `root` ('' for root itself, '/' separators), or undefined when it is outside. Both are real paths. */
631
+ function relativeInside(root, path) {
632
+ if (path === root) return '';
633
+ const prefix = root.endsWith(sep) ? root : root + sep;
634
+ return path.startsWith(prefix) ? path.slice(prefix.length).split(sep).join('/') : undefined;
635
+ }
636
+ async function nearestExisting(path) {
637
+ for (let p = path; ; p = dirname(p)) {
638
+ const real = await realpath(p).catch(() => undefined);
639
+ if (real !== undefined || dirname(p) === p) return real;
640
+ }
641
+ }
642
+
643
+ /** The bytes of one regular file (at most `max`), or an empty buffer when it is missing, a symlink, a FIFO or a device:
644
+ * O_NOFOLLOW | O_NONBLOCK, so nothing put in its place can block the read or point elsewhere. */
645
+ async function readRegular(path, max = 1024 * 1024) {
646
+ let file;
647
+ try {
648
+ file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
649
+ const info = await file.stat();
650
+ if (!info.isFile()) return Buffer.alloc(0);
651
+ const buffer = Buffer.alloc(Math.min(info.size, max));
652
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
653
+ return buffer.subarray(0, bytesRead);
654
+ } catch { return Buffer.alloc(0); } finally { await file?.close().catch(() => {}); }
655
+ }
656
+
657
+ /** Git snapshots of one chat's folder, in a private store `<dir>/<chat>.git` whose objects/info/alternates names the
658
+ * repository's objects: blobs that git already has are not copied. A snapshot is `git add --all -- <chat folder>` into the
659
+ * store's own index plus `write-tree`, kept as ref `refs/s/<seq>` (the user message it was taken before), with the list
660
+ * of paths that were ignored at that time (`ignored/<seq>.json`): a file that is not in the snapshot is deleted by a
661
+ * restore only when it was neither in the snapshot nor ignored then, so a later change of an ignore rule never deletes a
662
+ * file that was already there. The user's repository is only read: its index, HEAD, refs, stash and config are never
663
+ * written (git does refresh the time stamp of an object file it reuses). One task at a time per chat. */
664
+ export function snapshotStore({ subprocess, dir, timeoutMs = SNAPSHOT_TIMEOUT_MS }) {
665
+ const queues = new Map(); // chat id → promise of its last queued task
666
+ const enqueue = (id, task) => {
667
+ const next = (queues.get(id) ?? Promise.resolve()).then(task);
668
+ const settled = next.then(() => {}, () => {});
669
+ queues.set(id, settled);
670
+ settled.then(() => { if (queues.get(id) === settled) queues.delete(id); });
671
+ return next;
672
+ };
673
+ const storeOf = (id) => join(dir, `${safeSegment(id)}.git`);
674
+ const ok = (r, what) => { if (r.code !== 0) throw new Error(`${what} failed: ${r.stderr.trim() || `exit ${r.code}`}`); return r; };
675
+ /** The repository around cwd: its top folder, objects, info/exclude, the user's global excludes file and the chat
676
+ * folder relative to the top (`inside`); undefined outside a repository. */
677
+ const repository = async (cwd, signal) => {
678
+ const r = await runGit(subprocess, cwd, ['rev-parse', '--show-toplevel', '--git-path', 'objects', '--git-path', 'info/exclude'], signal);
679
+ if (r.code !== 0) return undefined;
680
+ const [top, objects, exclude] = r.stdout.split('\n').map((line) => (isAbsolute(line) ? line : join(cwd, line)));
681
+ const inside = relativeInside(await realpath(top), await realpath(cwd));
682
+ if (inside === undefined) return undefined;
683
+ const global = (await runGit(subprocess, cwd, ['config', '--path', '--get', 'core.excludesFile'], signal)).stdout.trim()
684
+ || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'git', 'ignore');
685
+ return { top, objects, exclude, global, inside, pathspec: inside === '' ? '.' : `:(literal)${inside}` };
686
+ };
687
+ /** The store for this chat, created on first use. Each time: the alternates, the attributes, and copies of the two
688
+ * exclude files (read as regular files only), so git itself never opens a file of the user's for its rules. */
689
+ const prepare = async (id, repo, signal) => {
690
+ const store = storeOf(id);
691
+ if (!(await stat(join(store, 'HEAD')).then(() => true, () => false))) {
692
+ await mkdir(dir, { recursive: true, mode: 0o700 });
693
+ ok(await runGit(subprocess, dir, ['init', '-q', '--bare', '--template=', store], signal, { env: { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } }), 'git init');
694
+ }
695
+ await mkdir(join(store, 'info'), { recursive: true });
696
+ await mkdir(join(store, 'objects', 'info'), { recursive: true });
697
+ await mkdir(join(store, 'ignored'), { recursive: true });
698
+ await writeFile(join(store, 'info', 'attributes'), '* -text -filter -ident -diff\n');
699
+ await writeFile(join(store, 'objects', 'info', 'alternates'), `${repo.objects}\n`);
700
+ await writeFile(join(store, 'info', 'exclude'), await readRegular(repo.exclude));
701
+ await writeFile(join(store, 'info', 'global-exclude'), await readRegular(repo.global));
702
+ const config = ['-c', `core.excludesFile=${join(store, 'info', 'global-exclude')}`];
703
+ const run = (args, extra = {}) => runGit(subprocess, extra.cwd ?? repo.top, [...STORE_CONFIG, ...config, ...args], signal,
704
+ { env: storeEnv(store, extra.workTree ?? repo.top, extra.index ?? join(store, 'index')), stdin: extra.stdin, timeoutMs });
705
+ return { store, run };
706
+ };
707
+ /** The chat folder now as a tree id (the store's index follows it). An index entry whose object the repository lost
708
+ * (gc after a rebase) makes write-tree fail once: the index is then made again from the files. */
709
+ const treeNow = async ({ store, run }, repo) => {
710
+ const add = async () => {
711
+ const added = await run(['add', '--all', '--ignore-errors', '--', repo.pathspec]);
712
+ if (added.code !== 0 && added.code !== 1) ok(added, 'git add');
713
+ return added.code === 0; // 1: a file could not be read and is not in the snapshot
714
+ };
715
+ let complete = await add();
716
+ let tree = await run(['write-tree']);
717
+ if (tree.code !== 0) { await rm(join(store, 'index'), { force: true }); complete = await add(); tree = ok(await run(['write-tree']), 'git write-tree'); }
718
+ return { tree: tree.stdout.trim(), complete };
719
+ };
720
+ const take = async (id, cwd, seq, signal) => {
721
+ const repo = await repository(cwd, signal);
722
+ if (repo === undefined) return undefined;
723
+ const prepared = await prepare(id, repo, signal);
724
+ const { store, run } = prepared;
725
+ const { tree, complete } = await treeNow(prepared, repo);
726
+ // ponytail: a list that git cuts at the output limit counts as unknown: that snapshot then deletes no file.
727
+ const ignored = await run(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z', '--', repo.pathspec]);
728
+ const known = complete && ignored.code === 0 && !ignored.truncated;
729
+ await writeFile(join(store, 'ignored', `${seq}.json`), JSON.stringify({ known, paths: known ? ignored.stdout.split('\0').filter(Boolean) : [] }));
730
+ ok(await run(['update-ref', `refs/s/${seq}`, tree]), 'git update-ref');
731
+ const refs = ok(await run(['for-each-ref', '--format=%(refname)', 'refs/s/']), 'git for-each-ref').stdout.split('\n').filter(Boolean)
732
+ .sort((a, b) => Number(a.split('/').at(-1)) - Number(b.split('/').at(-1)));
733
+ if (refs.length > SNAPSHOT_MAX) {
734
+ for (const old of refs.slice(0, refs.length - SNAPSHOT_MAX)) {
735
+ await run(['update-ref', '-d', old]);
736
+ await rm(join(store, 'ignored', `${old.split('/').at(-1)}.json`), { force: true });
737
+ }
738
+ await run(['prune', '--expire=now']);
739
+ }
740
+ return tree;
741
+ };
742
+ /** What a restore to the snapshot before message `seq` changes inside `cwd`: `files: [{ path (from cwd), top (from the
743
+ * repository top), action: restore | create | delete }]`. */
744
+ const plan = async (id, cwd, seq, signal) => {
745
+ const repo = await repository(cwd, signal);
746
+ if (repo === undefined) return { git: false };
747
+ const prepared = await prepare(id, repo, signal);
748
+ const old = await prepared.run(['rev-parse', '-q', '--verify', `refs/s/${seq}^{tree}`]);
749
+ if (old.code !== 0) return { git: true, snapshot: false };
750
+ const tree = old.stdout.trim();
751
+ const then = await readFile(join(prepared.store, 'ignored', `${seq}.json`), 'utf8').then(JSON.parse, () => ({ known: false, paths: [] }));
752
+ const wasIgnored = (path) => !then.known || then.paths.some((p) => (p.endsWith('/') ? path.startsWith(p) : path === p));
753
+ const { tree: now } = await treeNow(prepared, repo);
754
+ const out = ok(await prepared.run(['diff-tree', '-r', '-z', '--no-renames', tree, now]), 'git diff-tree').stdout.split('\0');
755
+ const files = [];
756
+ for (let i = 0; i + 1 < out.length; i += 2) {
757
+ const status = out[i].split(' ').at(-1); const top = out[i + 1];
758
+ if (repo.inside !== '' && !top.startsWith(`${repo.inside}/`)) continue;
759
+ if (status === 'A' && wasIgnored(top)) continue; // it may have been there, ignored: keep it
760
+ files.push({ path: repo.inside === '' ? top : top.slice(repo.inside.length + 1), top, action: status === 'A' ? 'delete' : status === 'D' ? 'create' : 'restore' });
761
+ }
762
+ return { git: true, snapshot: true, prepared, tree, files };
763
+ };
764
+ return {
765
+ /** Queue a snapshot before the user message `seq`; resolves to the tree id, or undefined outside a repository. */
766
+ snapshot: (id, cwd, seq) => enqueue(id, () => take(id, cwd, seq)),
767
+ /** Resolves once every queued task of this chat has settled. */
768
+ settled: (id) => queues.get(id) ?? Promise.resolve(),
769
+ preview: (id, cwd, seq, signal) => enqueue(id, async () => {
770
+ const { prepared, tree, ...rest } = await plan(id, cwd, seq, signal);
771
+ return { ...rest, ...(rest.files === undefined ? {} : { files: rest.files.map(({ path, action }) => ({ path, action })) }) };
772
+ }),
773
+ /** Put the files back. Every git step runs first (into a private folder); then `beforeWrite` runs (the caller withdraws
774
+ * the messages there), then the files are written. A failure before `beforeWrite` changes nothing. */
775
+ restore: (id, cwd, seq, signal, beforeWrite = () => {}) => enqueue(id, async () => {
776
+ const p = await plan(id, cwd, seq, signal);
777
+ if (!p.git || !p.snapshot) { await beforeWrite(); return { git: p.git, snapshot: p.snapshot ?? false, restored: [], deleted: [], skipped: [] }; }
778
+ const scratch = await mkdtemp(join(dir, 'restore-'));
779
+ try {
780
+ const index = join(scratch, 'index');
781
+ const local = { cwd: scratch, workTree: scratch, index };
782
+ ok(await p.prepared.run(['read-tree', p.tree], local), 'git read-tree');
783
+ const wanted = p.files.filter((f) => f.action !== 'delete');
784
+ const modes = new Map();
785
+ if (wanted.length > 0) {
786
+ // A blob that the repository lost (gc) fails only its own file: writeBack lists it as skipped.
787
+ await p.prepared.run(['checkout-index', `--prefix=${join(scratch, 'files')}${sep}`, '-z', '--stdin'], { ...local, stdin: `${wanted.map((f) => f.top).join('\0')}\0` });
788
+ const listed = await p.prepared.run(['ls-files', '-s', '-z', '--', ...wanted.map((f) => `:(literal)${f.top}`)], local);
789
+ for (const entry of listed.stdout.split('\0')) { const m = /^(\d+) \w+ \d\t([\s\S]*)$/.exec(entry); if (m) modes.set(m[2], m[1]); }
790
+ }
791
+ await beforeWrite();
792
+ return { git: true, snapshot: true, ...(await writeBack(cwd, p.files, (f) => ({ source: join(scratch, 'files', ...f.top.split('/')), mode: modes.get(f.top) }))) };
793
+ } finally { await rm(scratch, { recursive: true, force: true }); }
794
+ }),
795
+ forget: (id) => enqueue(id, () => rm(storeOf(id), { recursive: true, force: true })),
796
+ };
797
+ }
798
+
799
+ /** Write snapshot bytes back into the chat folder. A target counts only when its real parent folder is inside the folder's
800
+ * real path and the target is a regular file or missing (lstat); the bytes go to a new file in that real folder (O_EXCL |
801
+ * O_NOFOLLOW) that then replaces the target by rename, so a symlink or FIFO swapped in meanwhile is replaced, never
802
+ * followed or opened. Symlinks and submodules in the snapshot, and every failure, are listed as skipped; one failure never
803
+ * stops the others. */
804
+ export async function writeBack(cwd, files, sourceOf) {
805
+ const root = await realpath(cwd);
806
+ const restored = []; const deleted = []; const skipped = [];
807
+ for (const file of files) {
808
+ const skip = (reason) => { skipped.push({ path: file.path, reason }); };
809
+ let temp;
810
+ try {
811
+ const parts = file.path.split('/');
812
+ if (parts.some((part) => part === '' || part === '.' || part === '..')) { skip('unsafe path'); continue; }
813
+ const name = parts.at(-1);
814
+ let folder = await realpath(join(root, ...parts.slice(0, -1))).catch(() => undefined);
815
+ const inside = folder !== undefined && relativeInside(root, folder) !== undefined;
816
+ const info = inside ? await lstat(join(folder, name)).catch(() => undefined) : undefined;
817
+ if (info !== undefined && !info.isFile()) { skip(info.isSymbolicLink() ? 'symlink' : 'not a regular file'); continue; }
818
+ if (file.action === 'delete') {
819
+ if (folder !== undefined && !inside) { skip('outside the project folder'); continue; }
820
+ if (info !== undefined) await unlink(join(folder, name));
821
+ deleted.push(file.path);
822
+ continue;
823
+ }
824
+ const { source, mode } = sourceOf(file);
825
+ if (mode !== '100644' && mode !== '100755') { skip(mode === '120000' ? 'symlink' : mode === '160000' ? 'submodule' : 'not in the snapshot'); continue; }
826
+ if (folder === undefined) {
827
+ const nearest = await nearestExisting(join(root, ...parts.slice(0, -1)));
828
+ if (nearest === undefined || relativeInside(root, nearest) === undefined) { skip('outside the project folder'); continue; }
829
+ await mkdir(join(root, ...parts.slice(0, -1)), { recursive: true });
830
+ folder = await realpath(join(root, ...parts.slice(0, -1)));
831
+ }
832
+ if (relativeInside(root, folder) === undefined) { skip('outside the project folder'); continue; }
833
+ const bytes = await readFile(source);
834
+ temp = join(folder, `.${name}.cp-rollback-${randomUUID().slice(0, 8)}`);
835
+ const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), mode === '100755' ? 0o755 : 0o644);
836
+ try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
837
+ await rename(temp, join(folder, name));
838
+ temp = undefined;
839
+ restored.push(file.path);
840
+ } catch (error) {
841
+ skip(error?.code === 'ELOOP' ? 'symlink' : error?.code === 'ENOENT' ? 'not in the snapshot' : String(error?.code ?? error?.message ?? error));
842
+ } finally { if (temp !== undefined) await unlink(temp).catch(() => {}); }
843
+ }
844
+ return { restored, deleted, skipped };
845
+ }
846
+
847
+ /** Whether `rename` can move the folders into `<home>/trash` (same disk); the macOS Trash moves across disks itself. */
848
+ async function canMove(folders, home, platform) {
849
+ if (platform === 'darwin') return true;
850
+ const target = await stat(home).catch(() => undefined);
851
+ for (const f of folders) { const s = await stat(f).catch(() => undefined); if (s !== undefined && target !== undefined && s.dev !== target.dev) return false; }
852
+ return true;
853
+ }
854
+
855
+ /** Delete chat: move the chat's log folders to a trash: the macOS Trash (`/usr/bin/trash`, macOS 15 and later), else
856
+ * `<home>/trash/<project folder name>/<chat folder name>-<time>`. */
857
+ export async function moveToTrash(folders, { home, subprocess, platform = process.platform, signal } = {}) {
858
+ const existing = [];
859
+ for (const f of folders) if (await lstat(f).then((s) => s.isDirectory(), () => false)) existing.push(f);
860
+ if (existing.length === 0) return { to: 'none', moved: [] };
861
+ if (platform === 'darwin' && subprocess !== undefined && (await stat('/usr/bin/trash').then((s) => s.isFile(), () => false))) {
862
+ try {
863
+ const handle = subprocess.spawn({ argv: ['/usr/bin/trash', ...existing], cwd: dirname(existing[0]), stdio: { stdin: 'ignore', stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } }, graceMs: 1000, signal });
864
+ if ((await handle.done).exitCode === 0) return { to: 'trash', moved: existing };
865
+ } catch { /* fall back to the folder below */ }
866
+ }
867
+ const moved = [];
868
+ for (const f of existing) {
869
+ if (!(await lstat(f).then((s) => s.isDirectory(), () => false))) continue; // the Trash took it before failing
870
+ const target = join(home, 'trash', f.split(sep).at(-2), `${f.split(sep).at(-1)}-${Date.now()}`);
871
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
872
+ await rename(f, target);
873
+ moved.push(target);
874
+ }
875
+ return { to: 'folder', moved };
876
+ }
597
877
 
598
878
  export function apply(ctx) {
599
879
  const home = process.env.DSH_HOME || join(homedir(), '.dsh');
@@ -746,6 +1026,163 @@ export function apply(ctx) {
746
1026
  installResume(ctx, home);
747
1027
  installIdeas(ctx, home);
748
1028
  installContentSearch(ctx, home);
1029
+ const snapshots = snapshotStore({ subprocess: ctx.subprocess, dir: join(home, 'cache', 'composer-plus', 'rollback') });
1030
+ installRollback(ctx, home, snapshots);
1031
+ installDeleteChat(ctx, home, snapshots);
1032
+ }
1033
+
1034
+ /** Wait until the agent is idle, at most `ms`; false on time-out. */
1035
+ async function idleWithin(agent, ms) {
1036
+ if (agent.status === 'idle') return true;
1037
+ let timer;
1038
+ try { return await Promise.race([agent.whenIdle().then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })]); } finally { clearTimeout(timer); }
1039
+ }
1040
+ const STOP_WAIT_MS = 15000;
1041
+ const isRootChat = (session) => session?.header?.origin !== 'subagent' && (session?.header?.delegationDepth ?? 0) === 0;
1042
+
1043
+ /** Turn rollback (Host half): a git snapshot of the chat's work tree before each user message, and `/api/composer-plus.rollback`:
1044
+ * `GET ?sessionId` → `{ on, spans, git }`; `POST {on}` moves the switch; `POST {sessionId, seq, preview: true}` → the files a
1045
+ * rollback with files would change; `POST {sessionId, seq, files}` stops a running turn, withdraws the message and everything
1046
+ * after it (one marker), and with `files` puts the project files back. When dsh-rewind-plugin is loaded it owns rollback:
1047
+ * no snapshots, and the route answers `rewind: true`. */
1048
+ function installRollback(ctx, home, store) {
1049
+ const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
1050
+ const saved = mobileSwitch(join(home, 'storages', 'dsh-composer-plus', 'rollback.json'), true);
1051
+ let on = true;
1052
+ const read = saved.read().then((value) => { on = value; });
1053
+ let switchQueue = Promise.resolve();
1054
+ const rewindLoaded = (agent) => { try { return get('commands')?.find?.(agent, 'rewind-plugin') !== undefined; } catch { return false; } };
1055
+ const busy = new Set(); // chats with a rollback running now
1056
+ const log = (text) => ctx.logger?.warn?.(`dsh-composer-plus: ${text}`);
1057
+ ctx.on('session/event', (session, event) => {
1058
+ if (!on || event.type !== 'user/message' || event.data?.source?.kind !== 'user' || !isRootChat(session)) return;
1059
+ const cwd = session.header?.cwd;
1060
+ if (!isText(cwd, 4096) || busy.has(session.id)) return;
1061
+ const agent = get('agents')?.get?.(session.id);
1062
+ if (agent !== undefined && rewindLoaded(agent)) return;
1063
+ store.snapshot(session.id, cwd, event.seq).catch((error) => log(`turn rollback snapshot failed: ${error?.message ?? error}`));
1064
+ });
1065
+ // A tool of the chat waits for its queued snapshot, so the snapshot shows the files before this message's work; at most
1066
+ // TOOL_WAIT_MS, so a stuck git (a FIFO named .gitignore in the tree) never stops the chat.
1067
+ ctx.on('tools/pre-execute', async (exec, next) => {
1068
+ const id = exec.agent?.session?.id;
1069
+ if (id !== undefined) {
1070
+ let timer;
1071
+ await Promise.race([store.settled(id), new Promise((resolve) => { timer = setTimeout(resolve, TOOL_WAIT_MS); })]);
1072
+ clearTimeout(timer);
1073
+ }
1074
+ return next();
1075
+ });
1076
+ ctx.connection.fetch.register({
1077
+ path: ROLLBACK_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
1078
+ fetch: async (request) => {
1079
+ let body;
1080
+ if (request.method === 'GET') body = { sessionId: new URL(request.url).searchParams.get('sessionId') ?? undefined };
1081
+ else try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
1082
+ if (typeof body?.on === 'boolean') {
1083
+ const done = switchQueue.then(async () => { await read; await saved.write(body.on); on = body.on; });
1084
+ switchQueue = done.catch(() => {});
1085
+ try { await done; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
1086
+ return json({ on });
1087
+ }
1088
+ await read;
1089
+ if (body?.sessionId === undefined && request.method === 'GET') return json({ on });
1090
+ if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
1091
+ try {
1092
+ const found = await ctx.sessionController.resolveAgent(body.sessionId);
1093
+ if ('error' in found) return json({ error: 'this chat is not available' }, 409);
1094
+ const { agent } = found;
1095
+ const session = agent.session;
1096
+ const rewind = rewindLoaded(agent);
1097
+ if (request.method === 'GET') return json({ on, rewind, spans: rollbackSpans(session.snapshotEvents()) });
1098
+ if (!on) return json({ error: 'Turn rollback is switched off' }, 409);
1099
+ if (!isRootChat(session)) return json({ error: 'a subagent chat cannot be rolled back' }, 409);
1100
+ if (!Number.isSafeInteger(body.seq)) return json({ error: 'bad request' }, 400);
1101
+ const target = session.snapshotEvents().find((e) => e.seq === body.seq);
1102
+ if (target?.type !== 'user/message' || target.data?.source?.kind !== 'user') return json({ error: 'not a message of yours' }, 400);
1103
+ const cwd = session.header?.cwd;
1104
+ if (body.preview === true) {
1105
+ rollbackPlan([...session.surface.nodes], body.seq);
1106
+ return json(isText(cwd, 4096) ? await store.preview(session.id, cwd, body.seq, request.signal) : { git: false });
1107
+ }
1108
+ if (busy.has(session.id)) return json({ error: 'a rollback of this chat is already running' }, 409);
1109
+ busy.add(session.id);
1110
+ try {
1111
+ if (agent.status !== 'idle') {
1112
+ agent.cancel({ kind: 'user' }, { keepInbox: true });
1113
+ if (!(await idleWithin(agent, STOP_WAIT_MS))) return json({ error: 'the running turn did not stop' }, 409);
1114
+ }
1115
+ for (const message of [...(agent.inbox?.nextStep ?? [])]) agent.inbox.remove(message.id); // unread steering goes with the turn
1116
+ let plan; let marker;
1117
+ const withdraw = () => { plan = rollbackPlan([...session.surface.nodes], body.seq); marker = session.append('user/message', rollbackMarker(), plan.options); };
1118
+ const text = (target.data.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('');
1119
+ const attachments = (target.data.content ?? []).filter((b) => b.type !== 'text').length;
1120
+ let files;
1121
+ // Every git step runs before the marker; the files are written after it. A git failure leaves the chat as it was.
1122
+ // ponytail: dsh's read-before-edit guard is not told about the restored files; the model reads them again.
1123
+ if (body.files === true && isText(cwd, 4096)) files = await store.restore(session.id, cwd, body.seq, request.signal, withdraw);
1124
+ else withdraw();
1125
+ return json({ markerSeq: marker.seq, span: [plan.shadowed[0], marker.seq], text, attachments, ...(files === undefined ? {} : { files }) });
1126
+ } finally { busy.delete(session.id); }
1127
+ } catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
1128
+ },
1129
+ });
1130
+ }
1131
+
1132
+ /** Delete chat (Host half): `POST /api/composer-plus.delete-chat {sessionId}` archives the chat with dsh's own stop (turn,
1133
+ * jobs, subagents, schedules), waits for it to be idle, flushes its log, takes the log's write lock when no agent of this
1134
+ * dsh holds it, and moves its folder and its subagents' folders to the Trash. The id stays archived, so a folder that is
1135
+ * put back comes back as an archived chat at the next start. */
1136
+ function installDeleteChat(ctx, home, snapshots) {
1137
+ const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
1138
+ const busy = new Set();
1139
+ ctx.connection.fetch.register({
1140
+ path: DELETE_CHAT_PATH, methods: ['POST'], requestBody: 'buffered',
1141
+ fetch: async (request) => {
1142
+ let body;
1143
+ try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
1144
+ if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
1145
+ const id = body.sessionId;
1146
+ const registry = get('workspaceRegistry'); const persistence = get('sessionPersistence');
1147
+ if (registry?.archiveSession === undefined || persistence?.locate === undefined || persistence?.list === undefined) return json({ error: 'this dsh cannot delete chats' }, 409);
1148
+ if (busy.has(id)) return json({ error: 'this chat is already being deleted' }, 409);
1149
+ busy.add(id);
1150
+ const locks = [];
1151
+ try {
1152
+ const headers = (await persistence.list({ signal: request.signal })).map((s) => s.header);
1153
+ const header = headers.find((h) => h.id === id);
1154
+ if (header === undefined) return json({ error: 'this chat has no saved log' }, 404);
1155
+ if (!isRootChat({ header })) return json({ error: 'a subagent chat goes with its parent chat' }, 409);
1156
+ // The chat's subagents (any depth) go with it.
1157
+ const family = new Set([id]);
1158
+ for (let grew = true; grew;) { grew = false; for (const h of headers) if (!family.has(h.id) && h.origin === 'subagent' && family.has(h.parentSession)) { family.add(h.id); grew = true; } }
1159
+ const members = headers.filter((h) => family.has(h.id));
1160
+ const folders = members.map((h) => dirname(persistence.locate(h).path));
1161
+ // ponytail: COMPOSER_PLUS_TRASH=folder keeps test runs out of the user's macOS Trash; there is no setting for it.
1162
+ const platform = process.env.COMPOSER_PLUS_TRASH === 'folder' ? 'folder' : process.platform;
1163
+ if (!(await canMove(folders, home, platform))) return json({ error: 'the chat history is on another disk than the dsh home; nothing was changed' }, 409);
1164
+ await registry.archiveSession(id, { stopActivity: true });
1165
+ // Every log of the family must be quiet before it moves: an agent of this dsh is idle and flushed; any other log is
1166
+ // locked (another dsh that writes it makes this fail). One failure moves nothing; the chat stays archived.
1167
+ for (const h of members) {
1168
+ const agent = get('agents')?.get?.(h.id);
1169
+ if (agent !== undefined) {
1170
+ if (!(await idleWithin(agent, STOP_WAIT_MS))) return json({ error: 'the chat did not stop; it stays archived' }, 409);
1171
+ const live = get('sessions')?.get?.(h.id);
1172
+ if (live !== undefined) await get('sessions').flush(live);
1173
+ } else {
1174
+ try { locks.push(await persistence.open(h.id, 'write', { signal: request.signal })); }
1175
+ catch (error) { return json({ error: error?.name === 'SessionAlreadyOwnedError' ? 'another dsh has this chat open; it stays archived' : String(error?.message ?? error) }, 409); }
1176
+ }
1177
+ }
1178
+ // The lock descriptors stay open while their folders move (same disk) and close in `finally`.
1179
+ const result = await moveToTrash(folders, { home, subprocess: ctx.subprocess, signal: request.signal, platform });
1180
+ for (const h of members) snapshots?.forget(h.id).catch(() => {});
1181
+ return json({ deleted: true, to: result.to, chats: family.size });
1182
+ } catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
1183
+ finally { for (const lock of locks) await lock.close().catch(() => {}); busy.delete(id); }
1184
+ },
1185
+ });
749
1186
  }
750
1187
 
751
1188
  /** Content search: dsh's own sidebar search also looks in message text. The work is dsh's (`cordis.patch.yml` opens its
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
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",
3
+ "version": "1.9.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, turn rollback with git file restore, delete chats to the Trash, 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",
@@ -19,6 +19,7 @@
19
19
  "deepseek",
20
20
  "deepseek-harness",
21
21
  "deepseek-harness-plugin",
22
+ "delete-chat",
22
23
  "drag-and-drop",
23
24
  "dsh",
24
25
  "dsh-composer-plus",
@@ -42,11 +43,14 @@
42
43
  "remote-control",
43
44
  "restart",
44
45
  "resume",
46
+ "rewind",
47
+ "rollback",
45
48
  "search",
46
49
  "sort-by-status",
47
50
  "tailscale",
48
51
  "tether",
49
- "translation"
52
+ "translation",
53
+ "undo"
50
54
  ],
51
55
  "exports": {
52
56
  ".": "./index.js",
@@ -69,7 +73,7 @@
69
73
  "assets/features/*.webp"
70
74
  ],
71
75
  "scripts": {
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",
76
+ "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/turn-rollback.test.mjs && node test/turn-rollback-client.test.mjs && node test/delete-chat.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",
73
77
  "build:locales": "node scripts/build-locales.mjs",
74
78
  "prepublishOnly": "npm test"
75
79
  },