dsh-composer-plus 1.7.1 → 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/README.md +37 -11
- package/assets/features/contentSearch.webp +0 -0
- package/assets/features/deleteChat.webp +0 -0
- package/assets/features/featureIdeas.webp +0 -0
- package/assets/features/turnRollback.webp +0 -0
- package/client.js +478 -40
- package/cordis.patch.yml +12 -0
- package/index.js +576 -28
- package/locale/de.json +1 -1
- package/locale/en.json +1 -1
- package/locale/es.json +1 -1
- package/locale/fr.json +1 -1
- package/locale/hi.json +1 -1
- package/locale/id.json +1 -1
- package/locale/ja.json +1 -1
- package/locale/ko.json +1 -1
- package/locale/pt-BR.json +1 -1
- package/locale/ru.json +1 -1
- package/locale/tr.json +1 -1
- package/locale/vi.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +10 -4
- package/tether/gateway/server.mjs +16 -6
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
|
-
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';
|
|
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,13 @@ 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';
|
|
37
|
+
export const ROLLBACK_PATH = '/api/composer-plus.rollback';
|
|
38
|
+
export const DELETE_CHAT_PATH = '/api/composer-plus.delete-chat';
|
|
34
39
|
/** Feature screenshots shipped in assets/features/<id>.webp; only these ids are served (no path input). */
|
|
35
40
|
export const SHOTS = ['changesPanel', 'featureIdeas', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'resumeAfterRestart', 'inputHistory', 'planToggle', 'keepFocus',
|
|
36
|
-
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate'];
|
|
41
|
+
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'language', 'autoUpdate', 'contentSearch', 'turnRollback', 'deleteChat'];
|
|
37
42
|
const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
38
43
|
const NPM_NAME = 'dsh-composer-plus';
|
|
39
44
|
const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
|
|
@@ -58,18 +63,19 @@ async function gitPath(subprocess) {
|
|
|
58
63
|
return gitPaths.get(subprocess);
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
/** Run read-only `git <args>` in cwd: argv only (never a shell), scrubbed config, no prompts, no optional locks.
|
|
62
|
-
|
|
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 } = {}) {
|
|
63
69
|
const git = await gitPath(subprocess);
|
|
64
70
|
if (git === null) return { code: 127, stdout: '', truncated: false, stderr: 'git is not installed' };
|
|
65
|
-
const timeout = AbortSignal.timeout(
|
|
71
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
66
72
|
const handle = subprocess.spawn({
|
|
67
73
|
argv: [git, ...args],
|
|
68
74
|
cwd,
|
|
69
|
-
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 } },
|
|
70
76
|
graceMs: 1000,
|
|
71
77
|
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
|
72
|
-
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 },
|
|
73
79
|
});
|
|
74
80
|
const outcome = await handle.done;
|
|
75
81
|
const out = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false };
|
|
@@ -204,10 +210,11 @@ export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
|
|
|
204
210
|
return { ...info, newer: npm.ok };
|
|
205
211
|
}
|
|
206
212
|
|
|
207
|
-
/** Mobile connect
|
|
208
|
-
|
|
213
|
+
/** A Host switch (Mobile connect, Content search): `{ on }` in an owner-only JSON file. A missing or unreadable file, or
|
|
214
|
+
* any value other than the opposite of `fallback`, reads as `fallback`. */
|
|
215
|
+
export function mobileSwitch(file, fallback = false) {
|
|
209
216
|
return {
|
|
210
|
-
read: async () => { try {
|
|
217
|
+
read: async () => { try { const on = JSON.parse(await readFile(file, 'utf8'))?.on; return fallback ? on !== false : on === true; } catch { return fallback; } },
|
|
211
218
|
write: async (on) => {
|
|
212
219
|
await mkdir(dirname(file), { recursive: true });
|
|
213
220
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -447,6 +454,15 @@ export function ideasLanguage(locale) {
|
|
|
447
454
|
}
|
|
448
455
|
|
|
449
456
|
const clip = (value, max) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '');
|
|
457
|
+
/** One idea, cleaned and bounded (from the model or the browser), or undefined without a title and a summary. */
|
|
458
|
+
const ideaFrom = (item) => {
|
|
459
|
+
const title = clip(item?.title, 120);
|
|
460
|
+
const summary = clip(item?.summary, 600);
|
|
461
|
+
if (title === '' || summary === '') return undefined;
|
|
462
|
+
const why = clip(item?.why, 300);
|
|
463
|
+
const effort = ['small', 'medium', 'large'].includes(item?.effort) ? item.effort : undefined;
|
|
464
|
+
return { title, summary, ...(why === '' ? {} : { why }), ...(effort === undefined ? {} : { effort }) };
|
|
465
|
+
};
|
|
450
466
|
/** Ideas from the model's answer: the first JSON array in it (a code fence is fine), each idea cleaned and bounded. Throws
|
|
451
467
|
* when the answer holds no usable idea. */
|
|
452
468
|
export function parseIdeas(text) {
|
|
@@ -458,11 +474,9 @@ export function parseIdeas(text) {
|
|
|
458
474
|
if (!Array.isArray(list)) throw new Error('the model did not answer with a list of ideas');
|
|
459
475
|
const ideas = [];
|
|
460
476
|
for (const item of list) {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
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 }) });
|
|
477
|
+
const idea = ideaFrom(item);
|
|
478
|
+
if (idea === undefined) continue;
|
|
479
|
+
ideas.push({ id: randomUUID(), ...idea });
|
|
466
480
|
if (ideas.length === IDEAS_MAX) break;
|
|
467
481
|
}
|
|
468
482
|
if (ideas.length === 0) throw new Error('the model answered without a usable idea');
|
|
@@ -514,14 +528,20 @@ export function ideaPrompt(idea) {
|
|
|
514
528
|
'First read the related code. Then plan the smallest change that delivers this feature, with the checks that prove it works.'].join('\n');
|
|
515
529
|
}
|
|
516
530
|
|
|
531
|
+
/** The chat's own project folder, from its session header (the browser never sends a path). */
|
|
532
|
+
async function chatFolder(sessionQuery, sessionId, signal) {
|
|
533
|
+
const observation = await sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
|
|
534
|
+
let cwd;
|
|
535
|
+
try { cwd = observation.header?.cwd; } finally { observation[Symbol.dispose]?.(); }
|
|
536
|
+
if (!isText(cwd, 4096) || !isAbsolute(cwd)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 });
|
|
537
|
+
return cwd;
|
|
538
|
+
}
|
|
539
|
+
|
|
517
540
|
/** Start a new chat in the same project (the same sidebar group as the source chat) for one accepted idea, and enter plan
|
|
518
541
|
* mode with the idea as the request. When the profile has no /plan command, or /plan fails, the idea goes as an ordinary
|
|
519
542
|
* message. */
|
|
520
543
|
export async function startIdeaChat({ sessionController, sessionQuery, commands, workspaces }, sessionId, idea, signal = new AbortController().signal) {
|
|
521
|
-
const
|
|
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 });
|
|
544
|
+
const cwd = await chatFolder(sessionQuery, sessionId, signal);
|
|
525
545
|
let workspace;
|
|
526
546
|
try { workspace = workspaces?.list?.().find((w) => w.sessionIds.includes(sessionId)); } catch { workspace = undefined; }
|
|
527
547
|
const { sessionId: created } = await sessionController.create(workspace === undefined ? { cwd } : { workspaceId: workspace.id });
|
|
@@ -535,8 +555,325 @@ export async function startIdeaChat({ sessionController, sessionQuery, commands,
|
|
|
535
555
|
return { sessionId: created, plan };
|
|
536
556
|
}
|
|
537
557
|
|
|
558
|
+
/** Ideas saved for later: at most this many per project. A full list refuses a new idea; it never drops an old one. */
|
|
559
|
+
export const LATER_MAX = 50;
|
|
560
|
+
/** Ideas saved for later, per project folder: `{ [cwd]: [{ id, title, summary, why?, effort?, at }] }` (newest first) in
|
|
561
|
+
* one owner-only JSON file. A missing file is an empty list. A broken file throws, so no write replaces it (no data loss).
|
|
562
|
+
* Reads and writes run in order; each write replaces the file atomically. */
|
|
563
|
+
export function laterStore(file) {
|
|
564
|
+
let queue = Promise.resolve();
|
|
565
|
+
const run = (fn) => { const done = queue.then(fn); queue = done.catch(() => {}); return done; };
|
|
566
|
+
const read = async () => {
|
|
567
|
+
let text;
|
|
568
|
+
try { text = await readFile(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return {}; throw error; }
|
|
569
|
+
const data = JSON.parse(text);
|
|
570
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) throw new Error('the saved ideas file is not a JSON object');
|
|
571
|
+
return data;
|
|
572
|
+
};
|
|
573
|
+
const clean = (list) => (Array.isArray(list) ? list.filter((i) => isText(i?.id) && isText(i?.title, 120) && isText(i?.summary, 600)) : []);
|
|
574
|
+
// Keys are absolute folder paths only, so a key such as `__proto__` never reaches the object.
|
|
575
|
+
const folder = (cwd) => { if (!isText(cwd, 4096) || !isAbsolute(cwd)) throw Object.assign(new Error('this chat has no project folder'), { status: 409 }); return cwd; };
|
|
576
|
+
const update = (cwd, fn) => run(async () => {
|
|
577
|
+
const data = await read();
|
|
578
|
+
const list = fn(clean(data[folder(cwd)]));
|
|
579
|
+
if (list.length === 0) delete data[cwd]; else data[cwd] = list;
|
|
580
|
+
await mkdir(dirname(file), { recursive: true });
|
|
581
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
582
|
+
await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
583
|
+
await rename(tmp, file);
|
|
584
|
+
return list;
|
|
585
|
+
});
|
|
586
|
+
return {
|
|
587
|
+
list: (cwd) => run(async () => clean((await read())[folder(cwd)])),
|
|
588
|
+
/** Add a cleaned idea at the top; the same title (any case) is not added twice. */
|
|
589
|
+
add: (cwd, idea) => update(cwd, (list) => {
|
|
590
|
+
if (list.some((i) => i.title.toLowerCase() === idea.title.toLowerCase())) return list;
|
|
591
|
+
if (list.length >= LATER_MAX) throw Object.assign(new Error(`a project keeps at most ${LATER_MAX} ideas for later`), { status: 409 });
|
|
592
|
+
return [{ id: randomUUID(), ...idea, at: Date.now() }, ...list];
|
|
593
|
+
}),
|
|
594
|
+
remove: (cwd, id) => update(cwd, (list) => list.filter((i) => i.id !== id)),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
538
598
|
const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
539
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
|
+
}
|
|
540
877
|
|
|
541
878
|
export function apply(ctx) {
|
|
542
879
|
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
@@ -687,14 +1024,198 @@ export function apply(ctx) {
|
|
|
687
1024
|
},
|
|
688
1025
|
});
|
|
689
1026
|
installResume(ctx, home);
|
|
690
|
-
installIdeas(ctx);
|
|
1027
|
+
installIdeas(ctx, home);
|
|
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
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** Content search: dsh's own sidebar search also looks in message text. The work is dsh's (`cordis.patch.yml` opens its
|
|
1189
|
+
* SQLite index when this switch is on); the Host only keeps the switch, on by default. dsh reads it at start, so
|
|
1190
|
+
* `GET/POST {on}` also tells whether the running index is open (`active`; undefined when dsh does not say). */
|
|
1191
|
+
function installContentSearch(ctx, home) {
|
|
1192
|
+
const saved = mobileSwitch(join(home, 'storages', 'dsh-composer-plus', 'content-search.json'), true);
|
|
1193
|
+
const active = () => { try { const openAt = ctx.sessionQuery?.config?.openAt; return typeof openAt === 'string' ? openAt !== 'never' : undefined; } catch { return undefined; } };
|
|
1194
|
+
let queue = Promise.resolve(); // one save at a time: they share a temp file name
|
|
1195
|
+
ctx.connection.fetch.register({
|
|
1196
|
+
path: CONTENT_SEARCH_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
1197
|
+
fetch: async (request) => {
|
|
1198
|
+
if (request.method === 'POST') {
|
|
1199
|
+
let body;
|
|
1200
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
1201
|
+
if (typeof body?.on !== 'boolean') return json({ error: 'bad request' }, 400);
|
|
1202
|
+
const done = queue.then(() => saved.write(body.on));
|
|
1203
|
+
queue = done.catch(() => {});
|
|
1204
|
+
try { await done; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
1205
|
+
}
|
|
1206
|
+
return json({ on: await saved.read(), active: active() });
|
|
1207
|
+
},
|
|
1208
|
+
});
|
|
691
1209
|
}
|
|
692
1210
|
|
|
693
1211
|
/** Feature ideas: `POST {sessionId, locale?}` asks the chat's model for ideas; `POST {sessionId, idea}` starts a chat for one idea.
|
|
694
|
-
*
|
|
695
|
-
|
|
1212
|
+
* Ideas saved for later: `GET ?sessionId` lists the project's ideas; `POST {sessionId, add | remove | start}` changes the list
|
|
1213
|
+
* (`start` starts a chat for the stored idea and removes it once the chat runs). All act only on the chat's own project
|
|
1214
|
+
* folder; the browser never sends a path. */
|
|
1215
|
+
function installIdeas(ctx, home) {
|
|
696
1216
|
const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
|
|
697
1217
|
const status = (error) => (Number.isInteger(error?.status) ? error.status : 502);
|
|
1218
|
+
const services = () => ({ sessionController: ctx.sessionController, sessionQuery: ctx.sessionQuery, commands: get('commands'), workspaces: get('workspaceRegistry') });
|
|
698
1219
|
ctx.connection.fetch.register({
|
|
699
1220
|
path: IDEAS_PATH, methods: ['POST'], requestBody: 'buffered',
|
|
700
1221
|
fetch: async (request) => {
|
|
@@ -702,11 +1223,10 @@ function installIdeas(ctx) {
|
|
|
702
1223
|
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
703
1224
|
if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
|
|
704
1225
|
if (body.idea !== undefined) {
|
|
705
|
-
const idea =
|
|
706
|
-
if (idea
|
|
1226
|
+
const idea = ideaFrom(body.idea);
|
|
1227
|
+
if (idea === undefined) return json({ error: 'bad request' }, 400);
|
|
707
1228
|
try {
|
|
708
|
-
return json(await startIdeaChat(
|
|
709
|
-
body.sessionId, idea, request.signal));
|
|
1229
|
+
return json(await startIdeaChat(services(), body.sessionId, { title: idea.title, summary: idea.summary, why: idea.why }, request.signal));
|
|
710
1230
|
} catch (error) { return json({ error: String(error?.message ?? error) }, status(error)); }
|
|
711
1231
|
}
|
|
712
1232
|
const llm = get('llm');
|
|
@@ -716,6 +1236,34 @@ function installIdeas(ctx) {
|
|
|
716
1236
|
} catch (error) { return json({ error: String(error?.message ?? error) }, status(error)); }
|
|
717
1237
|
},
|
|
718
1238
|
});
|
|
1239
|
+
const later = laterStore(join(home, 'storages', 'dsh-composer-plus', 'later.json'));
|
|
1240
|
+
const starting = new Set(); // `${cwd}\0${id}` of the stored ideas whose chat is starting now
|
|
1241
|
+
ctx.connection.fetch.register({
|
|
1242
|
+
path: LATER_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
1243
|
+
fetch: async (request) => {
|
|
1244
|
+
let body;
|
|
1245
|
+
if (request.method === 'GET') body = { sessionId: new URL(request.url).searchParams.get('sessionId') };
|
|
1246
|
+
else try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
1247
|
+
if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
|
|
1248
|
+
const add = body.add === undefined ? undefined : ideaFrom(body.add);
|
|
1249
|
+
const action = request.method === 'GET' ? 'list' : body.add !== undefined ? 'add' : isText(body.remove) ? 'remove' : isText(body.start) ? 'start' : undefined;
|
|
1250
|
+
if (action === undefined || (action === 'add' && add === undefined)) return json({ error: 'bad request' }, 400);
|
|
1251
|
+
try {
|
|
1252
|
+
const cwd = await chatFolder(ctx.sessionQuery, body.sessionId, request.signal);
|
|
1253
|
+
if (action === 'list') return json({ ideas: await later.list(cwd) });
|
|
1254
|
+
if (action === 'add') return json({ ideas: await later.add(cwd, add) });
|
|
1255
|
+
if (action === 'remove') return json({ ideas: await later.remove(cwd, body.remove) });
|
|
1256
|
+
const idea = (await later.list(cwd)).find((i) => i.id === body.start);
|
|
1257
|
+
const key = `${cwd}\0${body.start}`;
|
|
1258
|
+
if (idea === undefined || starting.has(key)) return json({ error: 'this idea is no longer in the list' }, 409);
|
|
1259
|
+
starting.add(key);
|
|
1260
|
+
try {
|
|
1261
|
+
const started = await startIdeaChat(services(), body.sessionId, { title: idea.title, summary: idea.summary, why: idea.why }, request.signal);
|
|
1262
|
+
return json({ ...started, ideas: await later.remove(cwd, idea.id) });
|
|
1263
|
+
} finally { starting.delete(key); }
|
|
1264
|
+
} catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
719
1267
|
}
|
|
720
1268
|
|
|
721
1269
|
/** Resume after restart: follow the top-level chats, and once dsh is ready, continue the ones its last shutdown stopped.
|