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/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
- export function diffArgs(mode, base) {
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
- /** Repository facts and the changed files for one mode. Untracked files count as additions outside the staged view. */
121
- export async function changes(subprocess, cwd, mode, signal) {
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 { repo: false };
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 upstream = up.code === 0 ? up.stdout.trim() : undefined;
128
- const hasHead = (await runGit(subprocess, root, ['rev-parse', '--verify', '-q', 'HEAD'], signal)).code === 0;
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
- files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
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 summary = await changes(subprocess, cwd, mode, signal);
156
- if (!summary.repo) return undefined;
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 hasHead = (await runGit(subprocess, summary.root, ['rev-parse', '--verify', '-q', 'HEAD'], signal)).code === 0;
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
- /** Grants file: { [sessionId]: string[] } of exact tool names allowed without asking. */
173
- export function grantsStore(file) {
174
- let queue = Promise.resolve();
175
- const read = async () => {
176
- try {
177
- const data = JSON.parse(await readFile(file, 'utf8'));
178
- return data !== null && typeof data === 'object' && !Array.isArray(data) ? data : {};
179
- } catch { return {}; }
180
- };
181
- // Serialize writes and replace atomically so a crash never leaves a half-written file.
182
- const update = (fn) => (queue = queue.then(async () => {
183
- const data = await read();
184
- fn(data);
185
- await mkdir(dirname(file), { recursive: true });
186
- const tmp = `${file}.${process.pid}.tmp`;
187
- await writeFile(tmp, JSON.stringify(data), { mode: 0o600 });
188
- await rename(tmp, file);
189
- return data;
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) => update((d) => { d[sessionId] = [...new Set([...(d[sessionId] ?? []), tool])]; }),
194
- revoke: (sessionId) => update((d) => { delete d[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 nothing. */
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
- const scratch = await mkdtemp(join(dir, 'restore-'));
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 index = join(scratch, 'index');
922
- const local = { cwd: scratch, workTree: scratch, index };
923
- ok(await p.prepared.run(['read-tree', p.tree], local), 'git read-tree');
924
- const wanted = p.files.filter((f) => f.action !== 'delete');
925
- const modes = new Map();
926
- if (wanted.length > 0) {
927
- // A blob that the repository lost (gc) fails only its own file: writeBack lists it as skipped.
928
- await p.prepared.run(['checkout-index', `--prefix=${join(scratch, 'files')}${sep}`, '-z', '--stdin'], { ...local, stdin: `${wanted.map((f) => f.top).join('\0')}\0` });
929
- const listed = await p.prepared.run(['ls-files', '-s', '-z', '--', ...wanted.map((f) => `:(literal)${f.top}`)], local);
930
- 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]); }
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 beforeWrite();
933
- return { git: true, snapshot: true, ...(await writeBack(cwd, p.files, (f) => ({ source: join(scratch, 'files', ...f.top.split('/')), mode: modes.get(f.top) }))) };
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
- return { cwd: observation.header.cwd, lastTurn };
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
- return isText(sessionId) && MODES.includes(mode) ? { sessionId, mode, path: q.get('path') } : undefined;
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 changes(ctx.subprocess, cwd, p.mode, request.signal)), ...extra });
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 cwdOf(p.sessionId, request.signal);
1095
- const diff = cwd === undefined ? undefined : await fileDiff(ctx.subprocess, cwd, p.mode, p.path, request.signal);
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
- const store = grantsStore(join(home, 'storages', 'dsh-composer-plus', 'grants.json'));
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
- if (request.method === 'GET') return json(await store.read());
1124
- let body;
1125
- try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
1126
- if (body?.action === 'grant' && isText(body.sessionId) && isText(body.tool)) return json(await store.grant(body.sessionId, body.tool));
1127
- if (body?.action === 'revoke' && isText(body.sessionId)) return json(await store.revoke(body.sessionId));
1128
- return json({ error: 'bad request' }, 400);
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') return json({ on, rewind, spans: rollbackSpans(session.snapshotEvents()) });
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;