dsh-composer-plus 1.12.0 → 1.13.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.de.md +36 -13
- package/README.es.md +36 -13
- package/README.fr.md +36 -13
- package/README.hi.md +36 -13
- package/README.id.md +36 -13
- package/README.ja.md +36 -13
- package/README.ko.md +36 -13
- package/README.md +36 -13
- package/README.pt-BR.md +36 -13
- package/README.ru.md +36 -13
- package/README.tr.md +36 -13
- package/README.vi.md +36 -13
- package/README.zh.md +36 -13
- package/assets/features/changesPanel.webp +0 -0
- package/assets/features/copyMessageLink.webp +0 -0
- package/assets/features/markProjectRead.webp +0 -0
- package/assets/features/queueEdit.webp +0 -0
- package/client.js +917 -133
- package/index.js +439 -83
- package/package.json +7 -3
- package/tether/gateway/admin.html +47 -3
- package/tether/gateway/server.mjs +98 -28
package/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { constants, readFileSync } from 'node:fs';
|
|
|
18
18
|
import { lstat, mkdir, mkdtemp, open, readdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
19
19
|
import { randomUUID } from 'node:crypto';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
|
-
import { dirname, isAbsolute, join, sep } from 'node:path';
|
|
21
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
22
22
|
import { createGateway } from './tether/gateway/server.mjs';
|
|
23
23
|
|
|
24
24
|
export const name = 'dsh-composer-plus';
|
|
@@ -26,6 +26,7 @@ export const inject = ['connection', 'subprocess', 'sessionQuery', 'sessionContr
|
|
|
26
26
|
|
|
27
27
|
export const CHANGES_PATH = '/api/composer-plus.changes';
|
|
28
28
|
export const DIFF_PATH = '/api/composer-plus.diff';
|
|
29
|
+
export const GRAPH_PATH = '/api/composer-plus.graph';
|
|
29
30
|
export const GRANTS_PATH = '/api/composer-plus.grants';
|
|
30
31
|
export const CONTINUE_PATH = '/api/composer-plus.continue';
|
|
31
32
|
export const UPDATE_PATH = '/api/composer-plus.update';
|
|
@@ -48,7 +49,7 @@ export const UNREAD_PATH = '/api/composer-plus.unread';
|
|
|
48
49
|
export const UNREAD_MAX = 1000;
|
|
49
50
|
/** Feature screenshots shipped in assets/features/<id>.webp; only these ids are served (no path input). */
|
|
50
51
|
export const SHOTS = ['changesPanel', 'featureIdeas', 'projectStatus', 'recentProjects', 'collapseProjects', 'statusOrder', 'notifications', 'allowForChat', 'continueButton', 'resumeAfterRestart', 'inputHistory', 'planToggle', 'keepFocus',
|
|
51
|
-
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'projectPicker', 'nextPrediction', 'typingCompletion', 'promptImprove', 'exportMarkdown', 'language', 'autoUpdate', 'contentSearch', 'turnRollback', 'deleteChat', 'projectName'];
|
|
52
|
+
'queueReorder', 'mobileConnect', 'copyDeeplink', 'copyProjectPath', 'projectPicker', 'nextPrediction', 'typingCompletion', 'promptImprove', 'exportMarkdown', 'language', 'autoUpdate', 'contentSearch', 'turnRollback', 'deleteChat', 'projectName', 'markProjectRead', 'queueEdit', 'copyMessageLink'];
|
|
52
53
|
const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
53
54
|
const NPM_NAME = 'dsh-composer-plus';
|
|
54
55
|
const RELEASES_URL = 'https://api.github.com/repos/furkancak1r/dsh-composer-plus/releases/latest';
|
|
@@ -56,7 +57,11 @@ const CONTINUE_SOURCE = 'composer-plus-continue';
|
|
|
56
57
|
const CONTINUE_TEXT = 'The previous turn stopped before it finished (it was interrupted or failed). Continue the task from where it stopped. Do not repeat completed work.';
|
|
57
58
|
const continueMessage = () => Object.freeze({ id: randomUUID(), role: 'user', content: Object.freeze([Object.freeze({ type: 'text', text: CONTINUE_TEXT })]),
|
|
58
59
|
source: Object.freeze({ kind: 'composer-plus-resume', form: 'snapshot' }) });
|
|
59
|
-
export const MODES = ['uncommitted', 'unstaged', 'staged', 'branch'];
|
|
60
|
+
export const MODES = ['uncommitted', 'unstaged', 'staged', 'branch', 'commit', 'chat'];
|
|
61
|
+
/** The branch tree shows at most this many commits (newest first). */
|
|
62
|
+
export const GRAPH_MAX = 300;
|
|
63
|
+
/** A full commit id (SHA-1 or SHA-256): the only form of `commit` that reaches git, so it can never be an option. */
|
|
64
|
+
export const SHA_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
60
65
|
const MAX_OUTPUT = 4 * 1024 * 1024;
|
|
61
66
|
const MAX_FILES = 500;
|
|
62
67
|
const TIMEOUT_MS = 15000;
|
|
@@ -92,14 +97,58 @@ export async function runGit(subprocess, cwd, args, signal, { env = {}, stdin, t
|
|
|
92
97
|
return { code: outcome.exitCode, stdout: out.text, truncated: out.lossy, stderr: handle.collected.stderr?.readFrom(0).text ?? '' };
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
/** `git diff` arguments for a mode, relative to `base` for branch mode.
|
|
96
|
-
|
|
100
|
+
/** `git diff` arguments for a mode, relative to `base` for branch mode. Commit mode: what one commit changed, against
|
|
101
|
+
* its first parent (a merge shows what it brought in; a root commit shows its files). */
|
|
102
|
+
export function diffArgs(mode, base, commit) {
|
|
103
|
+
// `^{commit}`: a blob, tree or tag id must not print its content as a "diff".
|
|
104
|
+
if (mode === 'commit') return ['show', '--format=', '--root', '-m', '--first-parent', '--no-show-signature', `${commit}^{commit}`];
|
|
97
105
|
if (mode === 'staged') return ['diff', '--cached'];
|
|
98
106
|
if (mode === 'unstaged') return ['diff'];
|
|
99
107
|
if (mode === 'branch') return ['diff', base];
|
|
100
108
|
return ['diff', 'HEAD'];
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
/** `%D` of `git log --decorate=full` as tags for the tree: local and remote branches, tags and a detached HEAD.
|
|
112
|
+
* `<remote>/HEAD` only repeats the remote's default branch, and other refs (stash, notes) are left out. */
|
|
113
|
+
export function parseRefs(decoration) {
|
|
114
|
+
const refs = [];
|
|
115
|
+
for (let part of String(decoration ?? '').split(', ')) {
|
|
116
|
+
let current = false;
|
|
117
|
+
if (part.startsWith('HEAD -> ')) { current = true; part = part.slice(8); }
|
|
118
|
+
if (part === 'HEAD') refs.push({ name: 'HEAD', kind: 'head' });
|
|
119
|
+
else if (part.startsWith('tag: refs/tags/')) refs.push({ name: part.slice(15), kind: 'tag' });
|
|
120
|
+
else if (part.startsWith('refs/heads/')) refs.push({ name: part.slice(11), kind: 'local', ...(current ? { current } : {}) });
|
|
121
|
+
else if (part.startsWith('refs/remotes/') && !part.endsWith('/HEAD')) refs.push({ name: part.slice(13), kind: 'remote' });
|
|
122
|
+
}
|
|
123
|
+
return refs;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The branch tree: the newest `limit` commits of all local and remote branches and HEAD, children before parents
|
|
127
|
+
* (`--topo-order`), with their parents and refs. `more` says that older commits are not shown. */
|
|
128
|
+
export async function commitGraph(subprocess, cwd, signal, limit = GRAPH_MAX) {
|
|
129
|
+
const known = await repoFacts(subprocess, cwd, signal);
|
|
130
|
+
if (known === undefined) return { repo: false };
|
|
131
|
+
const { hasHead, ...rest } = known;
|
|
132
|
+
const facts = { repo: true, ...rest };
|
|
133
|
+
const root = facts.root;
|
|
134
|
+
// A repository without commits has no HEAD: naming it would fail, and the other refs are empty too.
|
|
135
|
+
const log = await runGit(subprocess, root, ['log', '--branches', '--remotes', ...(hasHead ? ['HEAD'] : []), '--topo-order', '-z',
|
|
136
|
+
`--max-count=${limit + 1}`, '--decorate=full', '--no-color', '--no-show-signature', '--format=%H%x1f%P%x1f%ct%x1f%an%x1f%D%x1f%s'], signal);
|
|
137
|
+
if (log.code !== 0) throw new Error(`git log failed: ${log.stderr.trim()}`);
|
|
138
|
+
const commits = [];
|
|
139
|
+
// With -z every record ends in \0: the last piece is '' or, in cut-off output, a broken record. Only whole ones count.
|
|
140
|
+
for (const record of log.stdout.split('\0').slice(0, -1)) {
|
|
141
|
+
// The subject is last, so a \x1f in it stays in it. ponytail: a \x1f in an author name shifts %D into the name
|
|
142
|
+
// (display only; git never puts one in a ref name).
|
|
143
|
+
const [sha, parents, time, author, decoration, ...subject] = record.split('\x1f');
|
|
144
|
+
if (!SHA_RE.test(sha ?? '')) continue;
|
|
145
|
+
commits.push({ sha, parents: parents ? parents.split(' ') : [], refs: parseRefs(decoration), author: String(author ?? '').slice(0, 100),
|
|
146
|
+
time: Number(time) || 0, subject: subject.join('\x1f').slice(0, 300) });
|
|
147
|
+
}
|
|
148
|
+
const more = commits.length > limit || log.truncated;
|
|
149
|
+
return { ...facts, commits: commits.slice(0, limit), more };
|
|
150
|
+
}
|
|
151
|
+
|
|
103
152
|
/** Parse `--numstat -z` (renames carry an empty path then old\0new). */
|
|
104
153
|
export function parseNumstat(text) {
|
|
105
154
|
const files = [];
|
|
@@ -117,15 +166,37 @@ export function parseNumstat(text) {
|
|
|
117
166
|
return files;
|
|
118
167
|
}
|
|
119
168
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
169
|
+
/** `-w` (ignore all whitespace) when the view hides whitespace-only changes: a file whose lines differ only in
|
|
170
|
+
* indentation or line endings leaves the list, and such lines leave the diff. */
|
|
171
|
+
const wsArgs = (ws) => (ws ? ['-w'] : []);
|
|
172
|
+
/** The repository around cwd: its top folder, branch, upstream, HEAD commit (`head`, none before the first commit) and
|
|
173
|
+
* `hasHead`; undefined outside one. */
|
|
174
|
+
async function repoFacts(subprocess, cwd, signal) {
|
|
122
175
|
const top = await runGit(subprocess, cwd, ['rev-parse', '--show-toplevel'], signal);
|
|
123
|
-
if (top.code !== 0) return
|
|
176
|
+
if (top.code !== 0) return undefined;
|
|
124
177
|
const root = top.stdout.trim();
|
|
125
178
|
const branch = (await runGit(subprocess, root, ['rev-parse', '--abbrev-ref', 'HEAD'], signal)).stdout.trim();
|
|
126
179
|
const up = await runGit(subprocess, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], signal);
|
|
127
|
-
const
|
|
128
|
-
|
|
180
|
+
const head = await runGit(subprocess, root, ['rev-parse', '--verify', '-q', 'HEAD'], signal);
|
|
181
|
+
return { root, branch, ...(up.code === 0 ? { upstream: up.stdout.trim() } : {}), ...(head.code === 0 ? { head: head.stdout.trim() } : {}), hasHead: head.code === 0 };
|
|
182
|
+
}
|
|
183
|
+
/** Sum and cap a file list as the views show it (sorted by path, at most MAX_FILES). */
|
|
184
|
+
const fileSummary = (files) => {
|
|
185
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
186
|
+
return { files: files.slice(0, MAX_FILES), total: files.length, added: files.reduce((n, f) => n + f.added, 0), deleted: files.reduce((n, f) => n + f.deleted, 0) };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Repository facts and the changed files for one mode. Untracked files count as additions outside the staged and
|
|
190
|
+
* commit views. `commit` (commit mode only) must match SHA_RE: the route checks it before it calls this.
|
|
191
|
+
* `opts.ws` hides whitespace-only changes; `opts.paths` (pathspecs from the repository top) limits the list. `known`:
|
|
192
|
+
* `repoFacts` that the caller read already. */
|
|
193
|
+
export async function changes(subprocess, cwd, mode, signal, commit, { ws = false, paths } = {}, known = undefined) {
|
|
194
|
+
if (mode === 'commit' && !SHA_RE.test(commit ?? '')) throw new Error('commit mode needs a full commit id');
|
|
195
|
+
const facts = known ?? await repoFacts(subprocess, cwd, signal);
|
|
196
|
+
if (facts === undefined) return { repo: false };
|
|
197
|
+
const { root, branch, upstream, hasHead } = facts;
|
|
198
|
+
if (paths !== undefined && paths.length === 0) return { repo: true, root, branch, ...(upstream === undefined ? {} : { upstream }), mode, ...fileSummary([]) };
|
|
199
|
+
const only = paths === undefined ? [] : ['--', ...paths];
|
|
129
200
|
let base;
|
|
130
201
|
if (mode === 'branch') {
|
|
131
202
|
if (upstream === undefined) return { repo: true, root, branch, mode, files: [], total: 0, noUpstream: true };
|
|
@@ -134,26 +205,22 @@ export async function changes(subprocess, cwd, mode, signal) {
|
|
|
134
205
|
base = mb.stdout.trim();
|
|
135
206
|
}
|
|
136
207
|
// A repository without commits has no HEAD: compare against the empty tree.
|
|
137
|
-
const args = mode === 'uncommitted' && !hasHead ? ['diff', '--cached'] : diffArgs(mode, base);
|
|
138
|
-
const stat = await runGit(subprocess, root, [...args, '--numstat', '-z', '-M', '--no-color', '--no-ext-diff'], signal);
|
|
208
|
+
const args = mode === 'uncommitted' && !hasHead ? ['diff', '--cached'] : diffArgs(mode, base, commit);
|
|
209
|
+
const stat = await runGit(subprocess, root, [...args, '--numstat', '-z', '-M', '--no-color', '--no-ext-diff', ...wsArgs(ws), ...only], signal);
|
|
139
210
|
if (stat.code !== 0) throw new Error(`git diff failed: ${stat.stderr.trim()}`);
|
|
140
211
|
const files = parseNumstat(stat.stdout);
|
|
141
|
-
if (mode !== 'staged') {
|
|
142
|
-
const untracked = await runGit(subprocess, root, ['ls-files', '--others', '--exclude-standard', '-z'], signal);
|
|
212
|
+
if (mode !== 'staged' && mode !== 'commit') {
|
|
213
|
+
const untracked = await runGit(subprocess, root, ['ls-files', '--others', '--exclude-standard', '-z', ...only], signal);
|
|
143
214
|
for (const path of untracked.stdout.split('\0')) if (path !== '' && !files.some((f) => f.path === path)) files.push({ path, added: 0, deleted: 0, binary: false, untracked: true });
|
|
144
215
|
}
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
repo: true, root, branch, ...(upstream === undefined ? {} : { upstream }), mode,
|
|
148
|
-
files: files.slice(0, MAX_FILES), total: files.length,
|
|
149
|
-
added: files.reduce((n, f) => n + f.added, 0), deleted: files.reduce((n, f) => n + f.deleted, 0),
|
|
150
|
-
};
|
|
216
|
+
return { repo: true, root, branch, ...(upstream === undefined ? {} : { upstream }), mode, ...(mode === 'commit' ? { commit } : {}), ...fileSummary(files) };
|
|
151
217
|
}
|
|
152
218
|
|
|
153
|
-
/** Unified diff of one listed file; untracked files diff against /dev/null. */
|
|
154
|
-
export async function fileDiff(subprocess, cwd, mode, path, signal) {
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
219
|
+
/** Unified diff of one listed file; untracked files diff against /dev/null. `opts` as in `changes`. */
|
|
220
|
+
export async function fileDiff(subprocess, cwd, mode, path, signal, commit, opts = {}, known = undefined) {
|
|
221
|
+
const facts = known ?? await repoFacts(subprocess, cwd, signal);
|
|
222
|
+
if (facts === undefined) return undefined;
|
|
223
|
+
const summary = await changes(subprocess, cwd, mode, signal, commit, opts, facts);
|
|
157
224
|
const file = summary.files.find((f) => f.path === path);
|
|
158
225
|
if (file === undefined) return undefined; // only paths git itself listed: no arbitrary file reads
|
|
159
226
|
if (file.untracked) {
|
|
@@ -162,36 +229,110 @@ export async function fileDiff(subprocess, cwd, mode, path, signal) {
|
|
|
162
229
|
}
|
|
163
230
|
let base;
|
|
164
231
|
if (mode === 'branch') base = (await runGit(subprocess, summary.root, ['merge-base', 'HEAD', summary.upstream], signal)).stdout.trim();
|
|
165
|
-
const
|
|
166
|
-
const args = mode === 'uncommitted' && !hasHead ? ['diff', '--cached'] : diffArgs(mode, base);
|
|
232
|
+
const args = mode === 'uncommitted' && !facts.hasHead ? ['diff', '--cached'] : diffArgs(mode, base, commit);
|
|
167
233
|
const paths = file.from === undefined ? [path] : [file.from, path];
|
|
168
|
-
const r = await runGit(subprocess, summary.root, [...args, '-M', '--no-color', '--no-ext-diff', '--', ...paths], signal);
|
|
234
|
+
const r = await runGit(subprocess, summary.root, [...args, '-M', '--no-color', '--no-ext-diff', ...wsArgs(opts.ws), '--', ...paths.map((p) => `:(literal)${p}`)], signal);
|
|
169
235
|
return { path, patch: r.stdout, truncated: r.truncated, binary: file.binary };
|
|
170
236
|
}
|
|
171
237
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
238
|
+
/** The file tools whose successful calls count as "written by this chat", and the argument that names the file. */
|
|
239
|
+
const WRITE_TOOLS = { write: 'file_path', edit: 'file_path', str_replace_editor: 'path' };
|
|
240
|
+
/** Changes → This chat: the absolute paths that dsh's file tools (write, edit, str_replace_editor) wrote in this chat, in
|
|
241
|
+
* every turn, from its log: a call counts when its result is not an error. Relative paths resolve against the chat's
|
|
242
|
+
* folder at that time (`working-directory/change`). ponytail: shell commands are not counted (their paths are unknown);
|
|
243
|
+
* the Uncommitted view shows those. At most MAX_FILES paths, the newest kept. */
|
|
244
|
+
export function chatWrites(events, cwd) {
|
|
245
|
+
let dir = cwd;
|
|
246
|
+
const calls = new Map(); // callId → absolute path
|
|
247
|
+
const paths = new Set();
|
|
248
|
+
for (const e of events) {
|
|
249
|
+
if (e.type === 'working-directory/change' && typeof e.data?.cwd === 'string' && isAbsolute(e.data.cwd)) dir = e.data.cwd;
|
|
250
|
+
else if (e.type === 'tool/call' && Object.hasOwn(WRITE_TOOLS, e.data?.name)) {
|
|
251
|
+
let args = e.data.arguments;
|
|
252
|
+
if (typeof args === 'string') { try { args = JSON.parse(args); } catch { args = undefined; } }
|
|
253
|
+
const file = args?.[WRITE_TOOLS[e.data.name]];
|
|
254
|
+
if (e.data.name === 'str_replace_editor' && args?.command === 'view') continue;
|
|
255
|
+
if (isText(file, 4096) && typeof dir === 'string') calls.set(e.data.callId, resolve(dir, file));
|
|
256
|
+
} else if (e.type === 'tool/result') {
|
|
257
|
+
const callId = e.data?.message?.source?.callId;
|
|
258
|
+
const file = calls.get(callId);
|
|
259
|
+
if (file === undefined) continue;
|
|
260
|
+
calls.delete(callId);
|
|
261
|
+
if (e.data.message.isError === true) continue;
|
|
262
|
+
paths.delete(file); paths.add(file); // newest last
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return [...paths].slice(-MAX_FILES);
|
|
266
|
+
}
|
|
267
|
+
/** Absolute paths as paths from the repository top (`/` separators); paths outside `top` (or outside `inside`, a folder
|
|
268
|
+
* from the top) are only counted. git gives `top` as a real path, but dsh keeps the chat folder `cwd` as it was opened:
|
|
269
|
+
* a path below `cwd` is read below its real path, so a folder opened through a symlink (macOS `/tmp`) still counts. */
|
|
270
|
+
async function topRelative(top, paths, inside = '', cwd) {
|
|
271
|
+
const real = typeof cwd === 'string' ? await realpath(cwd).catch(() => cwd) : cwd;
|
|
272
|
+
const from = cwd === real ? undefined : cwd.endsWith(sep) ? cwd : cwd + sep;
|
|
273
|
+
const head = top.endsWith(sep) ? top : top + sep;
|
|
274
|
+
const rel = []; let outside = 0;
|
|
275
|
+
for (const raw of paths) {
|
|
276
|
+
const p = from !== undefined && raw.startsWith(from) ? join(real, raw.slice(from.length)) : raw;
|
|
277
|
+
const r = p.startsWith(head) ? p.slice(head.length).split(sep).join('/') : undefined;
|
|
278
|
+
if (r === undefined || r === '' || r.split('/').some((x) => x === '' || x === '.' || x === '..') || (inside !== '' && !r.startsWith(`${inside}/`))) outside += 1;
|
|
279
|
+
else rel.push(r);
|
|
280
|
+
}
|
|
281
|
+
return { rel, outside };
|
|
282
|
+
}
|
|
283
|
+
/** Changes → This chat: the chat's written files (`chatWrites`) now, against the chat's oldest Turn rollback snapshot, so
|
|
284
|
+
* the work of every turn shows, also after a commit. `base: 'snapshot'` when that snapshot is from the chat's first
|
|
285
|
+
* message (`first`: its seq), `'partial'` when it is from a later one (Turn rollback keeps the newest SNAPSHOT_MAX of a
|
|
286
|
+
* chat; a chat older than the plugin). Without a snapshot (Turn rollback off, dsh-rewind-plugin loaded, or no message since
|
|
287
|
+
* the plugin came) it is against HEAD (`base: 'head'`). `file` gives that file's patch instead. */
|
|
288
|
+
export async function chatChanges(subprocess, snapshots, sessionId, cwd, paths, signal, { ws = false, file, first } = {}) {
|
|
289
|
+
const facts = await repoFacts(subprocess, cwd, signal);
|
|
290
|
+
if (facts === undefined) return { repo: false };
|
|
291
|
+
const { root, branch, upstream } = facts;
|
|
292
|
+
const head = { repo: true, root, branch, ...(upstream === undefined ? {} : { upstream }), mode: 'chat' };
|
|
293
|
+
const view = await snapshots?.chatView(sessionId, cwd, paths, signal, { ws, file });
|
|
294
|
+
if (view !== undefined && view.snapshot !== false) {
|
|
295
|
+
return file === undefined ? { ...head, ...view, base: first === undefined || view.since <= first ? 'snapshot' : 'partial' } : view.diff;
|
|
296
|
+
}
|
|
297
|
+
const { rel, outside } = await topRelative(root, paths, '', cwd);
|
|
298
|
+
const opts = { ws, paths: rel.map((r) => `:(literal)${r}`) };
|
|
299
|
+
if (file !== undefined) return fileDiff(subprocess, cwd, 'uncommitted', file, signal, undefined, opts, facts);
|
|
300
|
+
const v = await changes(subprocess, cwd, 'uncommitted', signal, undefined, opts, facts);
|
|
301
|
+
return { ...v, mode: 'chat', base: 'head', outside };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** "Allow for this chat" time limits (ms) that Settings offers; `null` = until revoked. */
|
|
305
|
+
export const GRANT_TTLS = [15 * 60e3, 60 * 60e3, 4 * 60 * 60e3, 8 * 60 * 60e3, null];
|
|
306
|
+
export const GRANT_TTL_DEFAULT = 60 * 60e3;
|
|
307
|
+
/** Grants file: `{ ttlMs, grants: { [sessionId]: { [tool]: grantedAtMs } } }`: exact tool names allowed without asking.
|
|
308
|
+
* A grant ends `ttlMs` after it was given (the current setting, so a shorter one also ends older grants at once); an
|
|
309
|
+
* ended grant is never answered and leaves the file at the next write. No file, a broken file or an unknown `ttlMs`
|
|
310
|
+
* reads as the default time. ponytail: `grants.json` of 1.12 and earlier (no times) is not read, so those grants ask
|
|
311
|
+
* once again; that file stays, so an older copy of the plugin still works with it. */
|
|
312
|
+
export function grantsStore(file, now = Date.now) {
|
|
313
|
+
// Object.fromEntries defines every key as an own property: a chat id or tool name "__proto__" stays plain data.
|
|
314
|
+
const store = settingsStore(file, (v) => ({
|
|
315
|
+
ttlMs: GRANT_TTLS.includes(v?.ttlMs) ? v.ttlMs : GRANT_TTL_DEFAULT,
|
|
316
|
+
grants: Object.fromEntries(Object.entries(v?.grants ?? {}).filter(([, tools]) => tools !== null && typeof tools === 'object')
|
|
317
|
+
.map(([sid, tools]) => [sid, Object.fromEntries(Object.entries(tools).filter(([, at]) => Number.isFinite(at)))])),
|
|
190
318
|
}));
|
|
319
|
+
/** Only the grants that still run, at time `t`. */
|
|
320
|
+
const live = ({ ttlMs, grants }, t = now()) => ({ ttlMs, grants: Object.fromEntries(Object.entries(grants)
|
|
321
|
+
.map(([sid, tools]) => [sid, Object.fromEntries(Object.entries(tools).filter(([, at]) => ttlMs === null || at + ttlMs > t))])
|
|
322
|
+
.filter(([, tools]) => Object.keys(tools).length > 0)) });
|
|
323
|
+
const change = (mutate) => store.change((d) => { const next = live(d); mutate(next); return next; });
|
|
324
|
+
const tools = (d, sid) => (Object.hasOwn(d.grants, sid) ? d.grants[sid] : {});
|
|
191
325
|
return {
|
|
192
|
-
read,
|
|
193
|
-
grant: (sessionId, tool) =>
|
|
194
|
-
revoke: (sessionId) =>
|
|
326
|
+
read: async () => live(await store.read()),
|
|
327
|
+
grant: (sessionId, tool) => change((d) => { d.grants = { ...d.grants, ...Object.fromEntries([[sessionId, Object.fromEntries([...Object.entries(tools(d, sessionId)), [tool, now()]])]]) }; }),
|
|
328
|
+
revoke: (sessionId) => change((d) => { d.grants = Object.fromEntries(Object.entries(d.grants).filter(([sid]) => sid !== sessionId)); }),
|
|
329
|
+
setTtl: (ttlMs) => change((d) => { d.ttlMs = ttlMs; }),
|
|
330
|
+
/** `{ [sessionId]: [tool] }`: the shape that the gateway and older clients read. */
|
|
331
|
+
names: ({ grants }) => Object.fromEntries(Object.entries(grants).map(([sid, tools]) => [sid, Object.keys(tools)])),
|
|
332
|
+
/** `{ ttlMs, grants: { [sessionId]: { [tool]: msLeft | null } } }`: the time left, so a browser clock that is off
|
|
333
|
+
* changes nothing. */
|
|
334
|
+
detail: ({ ttlMs, grants }, t = now()) => ({ ttlMs, grants: Object.fromEntries(Object.entries(grants).map(([sid, tools]) =>
|
|
335
|
+
[sid, Object.fromEntries(Object.entries(tools).map(([tool, at]) => [tool, ttlMs === null ? null : at + ttlMs - t]))])) }),
|
|
195
336
|
};
|
|
196
337
|
}
|
|
197
338
|
|
|
@@ -802,7 +943,7 @@ async function readRegular(path, max = 1024 * 1024) {
|
|
|
802
943
|
* restore only when it was neither in the snapshot nor ignored then, so a later change of an ignore rule never deletes a
|
|
803
944
|
* file that was already there. The user's repository is only read: its index, HEAD, refs, stash and config are never
|
|
804
945
|
* written (git does refresh the time stamp of an object file it reuses). One task at a time per chat. */
|
|
805
|
-
export function snapshotStore({ subprocess, dir, timeoutMs = SNAPSHOT_TIMEOUT_MS }) {
|
|
946
|
+
export function snapshotStore({ subprocess, dir, timeoutMs = SNAPSHOT_TIMEOUT_MS, log = () => {} }) {
|
|
806
947
|
const queues = new Map(); // chat id → promise of its last queued task
|
|
807
948
|
const enqueue = (id, task) => {
|
|
808
949
|
const next = (queues.get(id) ?? Promise.resolve()).then(task);
|
|
@@ -900,7 +1041,76 @@ export function snapshotStore({ subprocess, dir, timeoutMs = SNAPSHOT_TIMEOUT_MS
|
|
|
900
1041
|
if (status === 'A' && wasIgnored(top)) continue; // it may have been there, ignored: keep it
|
|
901
1042
|
files.push({ path: repo.inside === '' ? top : top.slice(repo.inside.length + 1), top, action: status === 'A' ? 'delete' : status === 'D' ? 'create' : 'restore' });
|
|
902
1043
|
}
|
|
903
|
-
return { git: true, snapshot: true, prepared, tree, files };
|
|
1044
|
+
return { git: true, snapshot: true, prepared, tree, now, files };
|
|
1045
|
+
};
|
|
1046
|
+
/** Write `files` back from `tree`: every git step into a private folder first, then `beforeWrite`, then `writeBack`. */
|
|
1047
|
+
const writeTree = async (prepared, cwd, tree, files, beforeWrite) => {
|
|
1048
|
+
const scratch = await mkdtemp(join(dir, 'restore-'));
|
|
1049
|
+
try {
|
|
1050
|
+
const index = join(scratch, 'index');
|
|
1051
|
+
const local = { cwd: scratch, workTree: scratch, index };
|
|
1052
|
+
ok(await prepared.run(['read-tree', tree], local), 'git read-tree');
|
|
1053
|
+
const wanted = files.filter((f) => f.action !== 'delete');
|
|
1054
|
+
const modes = new Map();
|
|
1055
|
+
if (wanted.length > 0) {
|
|
1056
|
+
// A blob that the repository lost (gc) fails only its own file: writeBack lists it as skipped.
|
|
1057
|
+
await prepared.run(['checkout-index', `--prefix=${join(scratch, 'files')}${sep}`, '-z', '--stdin'], { ...local, stdin: `${wanted.map((f) => f.top).join('\0')}\0` });
|
|
1058
|
+
const listed = await prepared.run(['ls-files', '-s', '-z', '--', ...wanted.map((f) => `:(literal)${f.top}`)], local);
|
|
1059
|
+
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]); }
|
|
1060
|
+
}
|
|
1061
|
+
await beforeWrite();
|
|
1062
|
+
return await writeBack(cwd, files, (f) => ({ source: join(scratch, 'files', ...f.top.split('/')), mode: modes.get(f.top) }));
|
|
1063
|
+
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
1064
|
+
};
|
|
1065
|
+
/** The blob id of each path (from the repository top) in `tree`; a path that is not there is missing. The paths go to
|
|
1066
|
+
* git in parts of about 64 kB, so a rollback of thousands of files stays below the argument size limit. */
|
|
1067
|
+
const blobsOf = async (prepared, tree, tops) => {
|
|
1068
|
+
const out = new Map();
|
|
1069
|
+
for (let i = 0; i < tops.length;) {
|
|
1070
|
+
const part = [];
|
|
1071
|
+
for (let size = 0; i < tops.length && size < 65536; i++) { part.push(`:(literal)${tops[i]}`); size += tops[i].length + 11; }
|
|
1072
|
+
const r = ok(await prepared.run(['ls-tree', '-r', '-z', tree, '--', ...part]), 'git ls-tree');
|
|
1073
|
+
for (const entry of r.stdout.split('\0')) { const m = /^\d+ \w+ ([0-9a-f]+)\t([\s\S]*)$/.exec(entry); if (m) out.set(m[2], m[1]); }
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
};
|
|
1077
|
+
/** Redo record of a rollback with files: `refs/r/<markerSeq>` = the tree just before it; `rolled/<markerSeq>.json` = each
|
|
1078
|
+
* file it wrote (path, path from the top, and `left`: the blob id it wrote, null for a delete). Newest SNAPSHOT_MAX kept. */
|
|
1079
|
+
const keepRolled = async (prepared, markerSeq, before, snapshot, files) => {
|
|
1080
|
+
const { store, run } = prepared;
|
|
1081
|
+
const left = await blobsOf(prepared, snapshot, files.map((f) => f.top));
|
|
1082
|
+
await mkdir(join(store, 'rolled'), { recursive: true });
|
|
1083
|
+
await writeFile(join(store, 'rolled', `${markerSeq}.json`), JSON.stringify({ files: files.map(({ path, top, action }) => ({ path, top, left: action === 'delete' ? null : left.get(top) ?? null })) }));
|
|
1084
|
+
ok(await run(['update-ref', `refs/r/${markerSeq}`, before]), 'git update-ref');
|
|
1085
|
+
const refs = ok(await run(['for-each-ref', '--format=%(refname)', 'refs/r/']), 'git for-each-ref').stdout.split('\n').filter(Boolean)
|
|
1086
|
+
.sort((a, b) => Number(a.split('/').at(-1)) - Number(b.split('/').at(-1)));
|
|
1087
|
+
for (const old of refs.slice(0, Math.max(0, refs.length - SNAPSHOT_MAX))) {
|
|
1088
|
+
await run(['update-ref', '-d', old]);
|
|
1089
|
+
await rm(join(store, 'rolled', `${old.split('/').at(-1)}.json`), { force: true });
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
/** Redo plan of rollback `markerSeq`: each file it wrote that is not as before the rollback, with the action that puts it
|
|
1093
|
+
* back, and `changed` when it is not as the rollback left it either (edited since). undefined: no record (Chat only,
|
|
1094
|
+
* pruned, or another folder). */
|
|
1095
|
+
const redoPlan = async (id, cwd, markerSeq, signal) => {
|
|
1096
|
+
const repo = await repository(cwd, signal);
|
|
1097
|
+
if (repo === undefined) return undefined;
|
|
1098
|
+
const prepared = await prepare(id, repo, signal);
|
|
1099
|
+
const before = await prepared.run(['rev-parse', '-q', '--verify', `refs/r/${markerSeq}^{tree}`]);
|
|
1100
|
+
const record = await readFile(join(prepared.store, 'rolled', `${markerSeq}.json`), 'utf8').then(JSON.parse, () => undefined);
|
|
1101
|
+
if (before.code !== 0 || !Array.isArray(record?.files)) return undefined;
|
|
1102
|
+
const tree = before.stdout.trim();
|
|
1103
|
+
const kept = record.files.filter((f) => isText(f?.top, 4096) && isText(f?.path, 4096) && (f.left === null || typeof f.left === 'string'));
|
|
1104
|
+
const tops = kept.map((f) => f.top);
|
|
1105
|
+
const { tree: now } = await treeNow(prepared, repo);
|
|
1106
|
+
const [then, current] = [await blobsOf(prepared, tree, tops), await blobsOf(prepared, now, tops)];
|
|
1107
|
+
const files = [];
|
|
1108
|
+
for (const f of kept) {
|
|
1109
|
+
const want = then.get(f.top); const has = current.get(f.top);
|
|
1110
|
+
if (want === has) continue; // already as it was before the rollback
|
|
1111
|
+
files.push({ path: f.path, top: f.top, action: want === undefined ? 'delete' : has === undefined ? 'create' : 'restore', changed: (has ?? null) !== f.left });
|
|
1112
|
+
}
|
|
1113
|
+
return { prepared, tree, files };
|
|
904
1114
|
};
|
|
905
1115
|
return {
|
|
906
1116
|
/** Queue a snapshot before the user message `seq`; resolves to the tree id, or undefined outside a repository. */
|
|
@@ -912,27 +1122,90 @@ export function snapshotStore({ subprocess, dir, timeoutMs = SNAPSHOT_TIMEOUT_MS
|
|
|
912
1122
|
return { ...rest, ...(rest.files === undefined ? {} : { files: rest.files.map(({ path, action }) => ({ path, action })) }) };
|
|
913
1123
|
}),
|
|
914
1124
|
/** Put the files back. Every git step runs first (into a private folder); then `beforeWrite` runs (the caller withdraws
|
|
915
|
-
* the messages there), then the files are written. A failure before `beforeWrite` changes
|
|
1125
|
+
* the messages there; it answers the marker seq), then the files are written. A failure before `beforeWrite` changes
|
|
1126
|
+
* nothing. The tree from just before (`refs/r/<marker seq>`) and the paths are kept for Redo. */
|
|
916
1127
|
restore: (id, cwd, seq, signal, beforeWrite = () => {}) => enqueue(id, async () => {
|
|
917
1128
|
const p = await plan(id, cwd, seq, signal);
|
|
918
1129
|
if (!p.git || !p.snapshot) { await beforeWrite(); return { git: p.git, snapshot: p.snapshot ?? false, restored: [], deleted: [], skipped: [] }; }
|
|
919
|
-
|
|
1130
|
+
return { git: true, snapshot: true, ...(await writeTree(p.prepared, cwd, p.tree, p.files, async () => {
|
|
1131
|
+
const markerSeq = await beforeWrite();
|
|
1132
|
+
// The messages are withdrawn now: a failed Redo record must not stop the files. Redo then offers Chat only.
|
|
1133
|
+
if (Number.isSafeInteger(markerSeq)) await keepRolled(p.prepared, markerSeq, p.now, p.tree, p.files).catch((error) => log(`redo record not saved: ${error?.message ?? error}`));
|
|
1134
|
+
})) };
|
|
1135
|
+
}),
|
|
1136
|
+
/** Redo: what putting the files back to just before rollback `markerSeq` changes now. `changed`: the file is not as
|
|
1137
|
+
* the rollback left it (edited meanwhile). `{ files: false }` when that rollback kept no files. */
|
|
1138
|
+
redoPreview: (id, cwd, markerSeq, signal) => enqueue(id, async () => {
|
|
1139
|
+
const r = await redoPlan(id, cwd, markerSeq, signal);
|
|
1140
|
+
return r === undefined ? { files: false } : { files: r.files.map(({ path, action, changed }) => ({ path, action, changed })) };
|
|
1141
|
+
}),
|
|
1142
|
+
/** Redo the files of rollback `markerSeq`: `beforeWrite` (the caller starts the new chat) runs after every git step;
|
|
1143
|
+
* a file that changed after the rollback is written only with `overwrite`, else it is listed in `changed`. */
|
|
1144
|
+
redo: (id, cwd, markerSeq, signal, { overwrite = false } = {}, beforeWrite = () => {}) => enqueue(id, async () => {
|
|
1145
|
+
const r = await redoPlan(id, cwd, markerSeq, signal);
|
|
1146
|
+
if (r === undefined) { await beforeWrite(); return { files: false }; }
|
|
1147
|
+
const keep = r.files.filter((f) => overwrite || !f.changed);
|
|
1148
|
+
const result = await writeTree(r.prepared, cwd, r.tree, keep, beforeWrite);
|
|
1149
|
+
return { ...result, changed: r.files.filter((f) => !overwrite && f.changed).map((f) => f.path) };
|
|
1150
|
+
}),
|
|
1151
|
+
/** Changes → This chat: `paths` (absolute, from `chatWrites`) now against the oldest snapshot of the chat, inside the
|
|
1152
|
+
* chat's folder. Read-only for the store and the repository: the files go into a private index and object folder in a
|
|
1153
|
+
* temp folder (alternates: the store, then the repository), removed after. Not in the chat's queue, so a tool never
|
|
1154
|
+
* waits for a view; a snapshot pruned meanwhile fails this view only. `{ snapshot: false }` when the chat has none. */
|
|
1155
|
+
chatView: async (id, cwd, paths, signal, { ws = false, file } = {}) => {
|
|
1156
|
+
const store = storeOf(id);
|
|
1157
|
+
if (!(await stat(join(store, 'HEAD')).then(() => true, () => false))) return { snapshot: false };
|
|
1158
|
+
const repo = await repository(cwd, signal);
|
|
1159
|
+
if (repo === undefined) return { snapshot: false };
|
|
1160
|
+
const refs = (await runGit(subprocess, dir, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/s/'], signal, { env: { GIT_DIR: store, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } }))
|
|
1161
|
+
.stdout.split('\n').filter(Boolean).map((line) => line.split(' ')).sort((a, b) => Number(a[0].split('/').at(-1)) - Number(b[0].split('/').at(-1)));
|
|
1162
|
+
if (refs.length === 0) return { snapshot: false };
|
|
1163
|
+
const [oldest, base] = refs[0];
|
|
1164
|
+
const { rel, outside } = await topRelative(repo.top, paths, repo.inside, cwd);
|
|
1165
|
+
const scratch = await mkdtemp(join(dir, 'view-'));
|
|
920
1166
|
try {
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1167
|
+
const view = join(scratch, 'v.git');
|
|
1168
|
+
ok(await runGit(subprocess, scratch, ['init', '-q', '--bare', '--template=', view], signal, { env: { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } }), 'git init');
|
|
1169
|
+
await mkdir(join(view, 'info'), { recursive: true }); await mkdir(join(view, 'objects', 'info'), { recursive: true });
|
|
1170
|
+
// Byte for byte as the snapshots, but with git's own text/binary detection for the counts.
|
|
1171
|
+
await writeFile(join(view, 'info', 'attributes'), '* -text -filter -ident\n');
|
|
1172
|
+
await writeFile(join(view, 'objects', 'info', 'alternates'), `${join(store, 'objects')}\n`);
|
|
1173
|
+
const run = (args, stdin) => runGit(subprocess, repo.top, [...STORE_CONFIG, ...args], signal, { env: storeEnv(view, repo.top, join(scratch, 'index')), stdin, timeoutMs });
|
|
1174
|
+
ok(await run(['read-tree', base]), 'git read-tree');
|
|
1175
|
+
// Only regular files, symlinks and missing paths (deleted); git refuses a FIFO or a device and would fail them all.
|
|
1176
|
+
// A path below a symlinked folder is skipped too: git refuses each one "beyond a symbolic link" (also when the link
|
|
1177
|
+
// points inside the repository or nowhere), so one odd path never fails the whole view. Each folder of the path is
|
|
1178
|
+
// checked with lstat from the top; a folder that is gone ends the walk (the file is a delete).
|
|
1179
|
+
const viaLink = async (parts) => {
|
|
1180
|
+
let at = repo.top;
|
|
1181
|
+
for (const part of parts) {
|
|
1182
|
+
at = join(at, part);
|
|
1183
|
+
const info = await lstat(at).catch(() => undefined);
|
|
1184
|
+
if (info === undefined) return false;
|
|
1185
|
+
if (!info.isDirectory()) return true;
|
|
1186
|
+
}
|
|
1187
|
+
return false;
|
|
1188
|
+
};
|
|
1189
|
+
const listed = [];
|
|
1190
|
+
for (const r of rel) {
|
|
1191
|
+
const target = join(repo.top, ...r.split('/'));
|
|
1192
|
+
if (await viaLink(r.split('/').slice(0, -1))) continue;
|
|
1193
|
+
const info = await lstat(target).catch(() => undefined);
|
|
1194
|
+
if (info === undefined || info.isFile() || info.isSymbolicLink()) listed.push(r);
|
|
931
1195
|
}
|
|
932
|
-
await
|
|
933
|
-
|
|
1196
|
+
if (listed.length > 0) ok(await run(['update-index', '--add', '--remove', '-z', '--stdin'], `${listed.join('\0')}\0`), 'git update-index');
|
|
1197
|
+
const now = ok(await run(['write-tree']), 'git write-tree').stdout.trim();
|
|
1198
|
+
const spec = listed.map((r) => `:(literal)${r}`);
|
|
1199
|
+
const numstat = listed.length === 0 ? { stdout: '' } : ok(await run(['diff-tree', '-r', '-z', '--numstat', '-M', '--no-ext-diff', '--no-textconv', ...wsArgs(ws), base, now, '--', ...spec]), 'git diff-tree');
|
|
1200
|
+
const files = parseNumstat(numstat.stdout).map((f) => (repo.inside === '' ? f : { ...f, path: f.path.slice(repo.inside.length + 1), ...(f.from === undefined ? {} : { from: f.from.slice(repo.inside.length + 1) }) }));
|
|
1201
|
+
if (file === undefined) return { ...fileSummary(files), since: Number(oldest.split('/').at(-1)), outside, skipped: rel.length - listed.length };
|
|
1202
|
+
const hit = files.find((f) => f.path === file);
|
|
1203
|
+
if (hit === undefined) return { snapshot: true, diff: undefined }; // only files this view listed
|
|
1204
|
+
const top = (p) => (repo.inside === '' ? p : `${repo.inside}/${p}`);
|
|
1205
|
+
const patch = await run(['diff-tree', '-p', '-M', '--no-color', '--no-ext-diff', '--no-textconv', ...wsArgs(ws), base, now, '--', ...[hit.from, hit.path].filter((x) => x !== undefined).map((x) => `:(literal)${top(x)}`)]);
|
|
1206
|
+
return { snapshot: true, diff: { path: file, patch: patch.stdout, truncated: patch.truncated, binary: hit.binary } };
|
|
934
1207
|
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
935
|
-
}
|
|
1208
|
+
},
|
|
936
1209
|
forget: (id) => enqueue(id, () => rm(storeOf(id), { recursive: true, force: true })),
|
|
937
1210
|
};
|
|
938
1211
|
}
|
|
@@ -1051,6 +1324,8 @@ export function apply(ctx) {
|
|
|
1051
1324
|
} catch { return json({ error: 'continuation failed' }, 500); }
|
|
1052
1325
|
},
|
|
1053
1326
|
});
|
|
1327
|
+
// Turn rollback's per-chat git snapshots (also the base of Changes → This chat).
|
|
1328
|
+
const snapshots = snapshotStore({ subprocess: ctx.subprocess, dir: join(home, 'cache', 'composer-plus', 'rollback'), log: (text) => ctx.logger?.warn?.(`dsh-composer-plus: ${text}`) });
|
|
1054
1329
|
/** The session's own working directory (the endpoint never takes a path from the browser) and its newest
|
|
1055
1330
|
* `workspace/changes` event, which dsh's own review tab opens as "Last turn". */
|
|
1056
1331
|
const sessionFacts = async (sessionId, signal) => {
|
|
@@ -1062,15 +1337,23 @@ export function apply(ctx) {
|
|
|
1062
1337
|
const e = events[i];
|
|
1063
1338
|
if (e.type === 'workspace/changes' && Number.isSafeInteger(e.data?.turn)) { lastTurn = { seq: e.seq, turn: e.data.turn }; break; }
|
|
1064
1339
|
}
|
|
1065
|
-
|
|
1340
|
+
const cwd = observation.header.cwd;
|
|
1341
|
+
// A fork (dsh's Branch, Redo) starts with its parent's events: its own first message comes after them.
|
|
1342
|
+
const inherited = Number.isSafeInteger(observation.inheritedEventCount) && observation.inheritedEventCount > 0 ? observation.inheritedEventCount : 0;
|
|
1343
|
+
const first = events.slice(inherited).find((e) => e.type === 'user/message' && e.data?.source?.kind === 'user')?.seq;
|
|
1344
|
+
return { cwd, lastTurn, writes: () => chatWrites(events, cwd), first };
|
|
1066
1345
|
} finally { observation[Symbol.dispose]?.(); }
|
|
1067
1346
|
};
|
|
1068
|
-
const cwdOf = async (sessionId, signal) => (await sessionFacts(sessionId, signal)).cwd;
|
|
1069
1347
|
const params = (request) => {
|
|
1070
1348
|
const q = new URL(request.url).searchParams;
|
|
1071
1349
|
const sessionId = q.get('sessionId');
|
|
1072
1350
|
const mode = q.get('mode') ?? 'uncommitted';
|
|
1073
|
-
|
|
1351
|
+
const commit = q.get('commit') ?? undefined;
|
|
1352
|
+
const ws = q.get('ws');
|
|
1353
|
+
// Commit mode only with a full commit id (never an option or a revision expression); other modes take none.
|
|
1354
|
+
if (!isText(sessionId) || !MODES.includes(mode) || (mode === 'commit' ? !SHA_RE.test(commit ?? '') : commit !== undefined)) return undefined;
|
|
1355
|
+
if (ws !== null && ws !== '1') return undefined; // `ws=1`: hide whitespace-only changes
|
|
1356
|
+
return { sessionId, mode, path: q.get('path'), commit, opts: { ws: ws === '1' } };
|
|
1074
1357
|
};
|
|
1075
1358
|
ctx.connection.fetch.register({
|
|
1076
1359
|
path: CHANGES_PATH, methods: ['GET'], requestBody: 'buffered',
|
|
@@ -1078,10 +1361,11 @@ export function apply(ctx) {
|
|
|
1078
1361
|
const p = params(request);
|
|
1079
1362
|
if (p === undefined) return json({ error: 'bad request' }, 400);
|
|
1080
1363
|
try {
|
|
1081
|
-
const { cwd, lastTurn } = await sessionFacts(p.sessionId, request.signal);
|
|
1364
|
+
const { cwd, lastTurn, writes, first } = await sessionFacts(p.sessionId, request.signal);
|
|
1082
1365
|
const extra = lastTurn === undefined ? {} : { lastTurn };
|
|
1083
1366
|
if (cwd === undefined) return json({ repo: false, ...extra });
|
|
1084
|
-
return json({ ...(await
|
|
1367
|
+
if (p.mode === 'chat') return json({ ...(await chatChanges(ctx.subprocess, snapshots, p.sessionId, cwd, writes(), request.signal, { ...p.opts, first })), ...extra });
|
|
1368
|
+
return json({ ...(await changes(ctx.subprocess, cwd, p.mode, request.signal, p.commit, p.opts)), ...extra });
|
|
1085
1369
|
} catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
1086
1370
|
},
|
|
1087
1371
|
});
|
|
@@ -1091,12 +1375,27 @@ export function apply(ctx) {
|
|
|
1091
1375
|
const p = params(request);
|
|
1092
1376
|
if (p === undefined || !isText(p.path, 4096)) return json({ error: 'bad request' }, 400);
|
|
1093
1377
|
try {
|
|
1094
|
-
const cwd = await
|
|
1095
|
-
const diff = cwd === undefined ? undefined : await
|
|
1378
|
+
const { cwd, writes } = await sessionFacts(p.sessionId, request.signal);
|
|
1379
|
+
const diff = cwd === undefined ? undefined : p.mode === 'chat' ? await chatChanges(ctx.subprocess, snapshots, p.sessionId, cwd, writes(), request.signal, { ...p.opts, file: p.path })
|
|
1380
|
+
: await fileDiff(ctx.subprocess, cwd, p.mode, p.path, request.signal, p.commit, p.opts);
|
|
1096
1381
|
return diff === undefined ? json({ error: 'not found' }, 404) : json(diff);
|
|
1097
1382
|
} catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
1098
1383
|
},
|
|
1099
1384
|
});
|
|
1385
|
+
// Branch tree of the chat's repository (read-only git), plus Last Turn like the changes route.
|
|
1386
|
+
ctx.connection.fetch.register({
|
|
1387
|
+
path: GRAPH_PATH, methods: ['GET'], requestBody: 'buffered',
|
|
1388
|
+
fetch: async (request) => {
|
|
1389
|
+
const sessionId = new URL(request.url).searchParams.get('sessionId');
|
|
1390
|
+
if (!isText(sessionId)) return json({ error: 'bad request' }, 400);
|
|
1391
|
+
try {
|
|
1392
|
+
const { cwd, lastTurn } = await sessionFacts(sessionId, request.signal);
|
|
1393
|
+
const extra = lastTurn === undefined ? {} : { lastTurn };
|
|
1394
|
+
if (cwd === undefined) return json({ repo: false, ...extra });
|
|
1395
|
+
return json({ ...(await commitGraph(ctx.subprocess, cwd, request.signal)), ...extra });
|
|
1396
|
+
} catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1100
1399
|
ctx.connection.fetch.register({
|
|
1101
1400
|
path: SHOT_PATH, methods: ['GET'], requestBody: 'buffered',
|
|
1102
1401
|
fetch: async (request) => {
|
|
@@ -1116,16 +1415,23 @@ export function apply(ctx) {
|
|
|
1116
1415
|
try { return json(await checkUpdate(fetch, PACKAGE, request.signal)); } catch (error) { return json({ error: String(error?.message ?? error) }, 502); }
|
|
1117
1416
|
},
|
|
1118
1417
|
});
|
|
1119
|
-
|
|
1418
|
+
// Allow for this chat. `GET` answers the old shape (`{sessionId: [tool]}`, only grants that still run: the gateway and an
|
|
1419
|
+
// older client read it); `?detail=1` adds the time setting and each grant's time left (Settings and this client).
|
|
1420
|
+
const store = grantsStore(join(home, 'storages', 'dsh-composer-plus', 'chat-grants.json'));
|
|
1120
1421
|
ctx.connection.fetch.register({
|
|
1121
1422
|
path: GRANTS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
1122
1423
|
fetch: async (request) => {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1424
|
+
const view = (state) => json(new URL(request.url).searchParams.get('detail') === '1' ? store.detail(state) : store.names(state));
|
|
1425
|
+
try {
|
|
1426
|
+
if (request.method === 'GET') return view(await store.read());
|
|
1427
|
+
let body;
|
|
1428
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
1429
|
+
if (body?.action === 'grant' && isText(body.sessionId) && isText(body.tool)) return view(await store.grant(body.sessionId, body.tool));
|
|
1430
|
+
if (body?.action === 'revoke' && isText(body.sessionId)) return view(await store.revoke(body.sessionId));
|
|
1431
|
+
// The time setting (Settings only: the phone gateway refuses a body with `ttlMs`).
|
|
1432
|
+
if (body !== null && typeof body === 'object' && Object.hasOwn(body, 'ttlMs') && GRANT_TTLS.includes(body.ttlMs)) return view(await store.setTtl(body.ttlMs));
|
|
1433
|
+
return json({ error: 'bad request' }, 400);
|
|
1434
|
+
} catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
1129
1435
|
},
|
|
1130
1436
|
});
|
|
1131
1437
|
// Mobile connect (Tether). The phone gateway needs dsh's web server for its loopback URL; a profile without one
|
|
@@ -1199,7 +1505,6 @@ export function apply(ctx) {
|
|
|
1199
1505
|
installResume(ctx, home);
|
|
1200
1506
|
installIdeas(ctx, home);
|
|
1201
1507
|
installContentSearch(ctx, home);
|
|
1202
|
-
const snapshots = snapshotStore({ subprocess: ctx.subprocess, dir: join(home, 'cache', 'composer-plus', 'rollback') });
|
|
1203
1508
|
installRollback(ctx, home, snapshots);
|
|
1204
1509
|
installDeleteChat(ctx, home, snapshots);
|
|
1205
1510
|
installQueueMove(ctx);
|
|
@@ -1270,6 +1575,32 @@ async function idleWithin(agent, ms) {
|
|
|
1270
1575
|
const STOP_WAIT_MS = 15000;
|
|
1271
1576
|
const isRootChat = (session) => session?.header?.origin !== 'subagent' && (session?.header?.delegationDepth ?? 0) === 0;
|
|
1272
1577
|
|
|
1578
|
+
/** Redo is offered for the newest rollback of a chat only while nothing came after it: its marker is the chat's last
|
|
1579
|
+
* user/message (a later message of yours, or another rollback, ends the offer). Answers the marker seq or undefined. */
|
|
1580
|
+
export function redoTarget(events) {
|
|
1581
|
+
const last = events.findLast((e) => e.type === 'user/message');
|
|
1582
|
+
return last?.data?.source?.kind === ROLLBACK_SOURCE && Array.isArray(last.sourceEventSeqs) && last.sourceEventSeqs.length > 0 ? last.seq : undefined;
|
|
1583
|
+
}
|
|
1584
|
+
/** The rollbacks that were redone (each once), `{ [sessionId]: [markerSeq] }` in one owner-only file; a missing or broken
|
|
1585
|
+
* file is empty. At most 50 per chat (only the newest rollback of a chat can be redone). */
|
|
1586
|
+
export function redoneStore(file) {
|
|
1587
|
+
const store = settingsStore(file, (v) => Object.fromEntries(Object.entries(v !== null && typeof v === 'object' ? v : {})
|
|
1588
|
+
.filter(([, seqs]) => Array.isArray(seqs)).map(([sid, seqs]) => [sid, seqs.filter(Number.isSafeInteger)])));
|
|
1589
|
+
let data = {};
|
|
1590
|
+
const ready = store.read().then((v) => { data = v; });
|
|
1591
|
+
return {
|
|
1592
|
+
ready,
|
|
1593
|
+
has: (sessionId, seq) => seq !== undefined && Object.hasOwn(data, sessionId) && data[sessionId].includes(seq),
|
|
1594
|
+
/** Marks it in memory first, so a failed file write still refuses a second redo in this run. */
|
|
1595
|
+
add: async (sessionId, seq) => {
|
|
1596
|
+
await ready;
|
|
1597
|
+
const plus = (d) => ({ ...d, ...Object.fromEntries([[sessionId, [...(Object.hasOwn(d, sessionId) ? d[sessionId] : []), seq].slice(-50)]]) });
|
|
1598
|
+
data = plus(data);
|
|
1599
|
+
data = await store.change(plus);
|
|
1600
|
+
},
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1273
1604
|
/** Turn rollback (Host half): a git snapshot of the chat's work tree before each user message, and `/api/composer-plus.rollback`:
|
|
1274
1605
|
* `GET ?sessionId` → `{ on, spans, git }`; `POST {on}` moves the switch; `POST {sessionId, seq, preview: true}` → the files a
|
|
1275
1606
|
* rollback with files would change; `POST {sessionId, seq, files}` stops a running turn, withdraws the message and everything
|
|
@@ -1284,6 +1615,7 @@ function installRollback(ctx, home, store) {
|
|
|
1284
1615
|
const rewindLoaded = (agent) => { try { return get('commands')?.find?.(agent, 'rewind-plugin') !== undefined; } catch { return false; } };
|
|
1285
1616
|
const busy = new Set(); // chats with a rollback running now
|
|
1286
1617
|
const log = (text) => ctx.logger?.warn?.(`dsh-composer-plus: ${text}`);
|
|
1618
|
+
const redone = redoneStore(join(home, 'storages', 'dsh-composer-plus', 'redone.json'));
|
|
1287
1619
|
ctx.on('session/event', (session, event) => {
|
|
1288
1620
|
if (!on || event.type !== 'user/message' || event.data?.source?.kind !== 'user' || !isRootChat(session)) return;
|
|
1289
1621
|
const cwd = session.header?.cwd;
|
|
@@ -1315,7 +1647,7 @@ function installRollback(ctx, home, store) {
|
|
|
1315
1647
|
try { await done; } catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
1316
1648
|
return json({ on });
|
|
1317
1649
|
}
|
|
1318
|
-
await read;
|
|
1650
|
+
await read; await redone.ready;
|
|
1319
1651
|
if (body?.sessionId === undefined && request.method === 'GET') return json({ on });
|
|
1320
1652
|
if (!isText(body?.sessionId)) return json({ error: 'bad request' }, 400);
|
|
1321
1653
|
try {
|
|
@@ -1324,9 +1656,33 @@ function installRollback(ctx, home, store) {
|
|
|
1324
1656
|
const { agent } = found;
|
|
1325
1657
|
const session = agent.session;
|
|
1326
1658
|
const rewind = rewindLoaded(agent);
|
|
1327
|
-
if (request.method === 'GET')
|
|
1659
|
+
if (request.method === 'GET') {
|
|
1660
|
+
const events = session.snapshotEvents();
|
|
1661
|
+
return json({ on, rewind, spans: rollbackSpans(events), redo: on && !rewind && isRootChat(session) && !redone.has(session.id, redoTarget(events)) ? redoTarget(events) ?? null : null });
|
|
1662
|
+
}
|
|
1328
1663
|
if (!on) return json({ error: 'Turn rollback is switched off' }, 409);
|
|
1329
1664
|
if (!isRootChat(session)) return json({ error: 'a subagent chat cannot be rolled back' }, 409);
|
|
1665
|
+
// Redo: a new chat from the moment just before the newest rollback (dsh's own fork), and the files back.
|
|
1666
|
+
if (body.redo !== undefined) {
|
|
1667
|
+
const events = session.snapshotEvents();
|
|
1668
|
+
const markerSeq = redoTarget(events);
|
|
1669
|
+
if (!Number.isSafeInteger(body.redo) || body.redo !== markerSeq || redone.has(session.id, markerSeq)) return json({ error: 'this rollback can no longer be redone' }, 409);
|
|
1670
|
+
const cwd = session.header?.cwd;
|
|
1671
|
+
if (body.preview === true) return json(isText(cwd, 4096) ? await store.redoPreview(session.id, cwd, markerSeq, request.signal) : { files: false });
|
|
1672
|
+
if (busy.has(session.id)) return json({ error: 'a rollback of this chat is already running' }, 409);
|
|
1673
|
+
if (agent.status !== 'idle') return json({ error: 'wait until the chat stops working' }, 409);
|
|
1674
|
+
busy.add(session.id);
|
|
1675
|
+
try {
|
|
1676
|
+
let created;
|
|
1677
|
+
// The new chat exists after the fork: a failed save of "redone" is only logged (this run still refuses a second redo).
|
|
1678
|
+
const fork = async () => {
|
|
1679
|
+
created = (await ctx.sessionController.fork({ sessionId: session.id, atSeq: markerSeq - 1 })).sessionId;
|
|
1680
|
+
await redone.add(session.id, markerSeq).catch((error) => log(`redo not saved: ${error?.message ?? error}`));
|
|
1681
|
+
};
|
|
1682
|
+
const files = body.files === true && isText(cwd, 4096) ? await store.redo(session.id, cwd, markerSeq, request.signal, { overwrite: body.overwrite === true }, fork) : (await fork(), undefined);
|
|
1683
|
+
return json({ sessionId: created, ...(files === undefined ? {} : { files }) });
|
|
1684
|
+
} finally { busy.delete(session.id); }
|
|
1685
|
+
}
|
|
1330
1686
|
if (!Number.isSafeInteger(body.seq)) return json({ error: 'bad request' }, 400);
|
|
1331
1687
|
const target = session.snapshotEvents().find((e) => e.seq === body.seq);
|
|
1332
1688
|
if (target?.type !== 'user/message' || target.data?.source?.kind !== 'user') return json({ error: 'not a message of yours' }, 400);
|
|
@@ -1344,7 +1700,7 @@ function installRollback(ctx, home, store) {
|
|
|
1344
1700
|
}
|
|
1345
1701
|
for (const message of [...(agent.inbox?.nextStep ?? [])]) agent.inbox.remove(message.id); // unread steering goes with the turn
|
|
1346
1702
|
let plan; let marker;
|
|
1347
|
-
const withdraw = () => { plan = rollbackPlan([...session.surface.nodes], body.seq); marker = session.append('user/message', rollbackMarker(), plan.options); };
|
|
1703
|
+
const withdraw = () => { plan = rollbackPlan([...session.surface.nodes], body.seq); marker = session.append('user/message', rollbackMarker(), plan.options); return marker.seq; };
|
|
1348
1704
|
const text = (target.data.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('');
|
|
1349
1705
|
const attachments = (target.data.content ?? []).filter((b) => b.type !== 'text').length;
|
|
1350
1706
|
let files;
|