agentgui 1.0.1063 → 1.0.1064

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/.gm/prd.yml CHANGED
@@ -3106,9 +3106,6 @@
3106
3106
  - id: docstudio-cue-session-group-eyebrow
3107
3107
  subject: Verify ConversationList's session-group headers (Today/Yesterday/etc) use the --tr-label token + --fg-3 tone consistently, matching docstudio's uppercase letter-spaced low-opacity eyebrow label idiom
3108
3108
  status: pending
3109
- - id: gc-delete-undo-trash
3110
- subject: No undo-after-delete (soft-delete/trash) anywhere in the confined delete surface
3111
- status: pending
3112
3109
  - id: gc-expanded-body-highlight
3113
3110
  subject: Search-result highlighting inside expanded event bodies never highlights the matched query term
3114
3111
  status: pending
@@ -146,6 +146,73 @@ function isAllowRoot(realPath, allowRoots) {
146
146
  return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
147
147
  }
148
148
 
149
+ // --- Soft-delete (trash) -----------------------------------------------
150
+ // /api/delete moves entries into <root>/.agentgui-trash/<trashId>__<name>
151
+ // instead of unlinking, giving a short undo window. In-memory index only
152
+ // (server restart forfeits the undo window - acceptable since the retention
153
+ // window itself is short and this is a convenience net on top of, not a
154
+ // replacement for, the pre-delete ConfirmDialog). Purged after
155
+ // TRASH_RETENTION_MS or when trashIndex grows past TRASH_MAX_ENTRIES
156
+ // (oldest-first), so a long-running server's trash dir can't grow unbounded.
157
+ const TRASH_DIR_NAME = '.agentgui-trash';
158
+ const TRASH_RETENTION_MS = parseInt(process.env.AGENTGUI_TRASH_RETENTION_MS || '', 10) || 10 * 60 * 1000;
159
+ const TRASH_MAX_ENTRIES = 200;
160
+ const trashIndex = new Map(); // trashId -> { trashPath, originalPath, root, deletedAt }
161
+
162
+ function purgeExpiredTrash() {
163
+ const now = Date.now();
164
+ for (const [id, info] of trashIndex) {
165
+ if (now - info.deletedAt > TRASH_RETENTION_MS) {
166
+ try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch { /* already gone */ }
167
+ trashIndex.delete(id);
168
+ }
169
+ }
170
+ if (trashIndex.size > TRASH_MAX_ENTRIES) {
171
+ const sorted = [...trashIndex.entries()].sort((a, b) => a[1].deletedAt - b[1].deletedAt);
172
+ for (const [id, info] of sorted.slice(0, trashIndex.size - TRASH_MAX_ENTRIES)) {
173
+ try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch {}
174
+ trashIndex.delete(id);
175
+ }
176
+ }
177
+ }
178
+
179
+ // Which allowed root a confined realPath lives under - the trash dir sits
180
+ // alongside it (still inside the SAME root, so confineToRoots covers the
181
+ // trash path too - no new unconfined surface).
182
+ function rootFor(realPath, allowRoots) {
183
+ const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
184
+ return allowRoots.find(r => { const rr = IS_WINDOWS ? r.toLowerCase() : r; return p === rr || p.startsWith(rr + path.sep); });
185
+ }
186
+
187
+ function moveToTrash(realPath, allowRoots) {
188
+ purgeExpiredTrash();
189
+ const root = rootFor(realPath, allowRoots);
190
+ if (!root) { const e = new Error('not confined to an allowed root'); e.code = 'EACCES'; throw e; }
191
+ const trashDir = path.join(root, TRASH_DIR_NAME);
192
+ fs.mkdirSync(trashDir, { recursive: true });
193
+ const trashId = crypto.randomBytes(8).toString('hex');
194
+ const base = path.basename(realPath);
195
+ const trashPath = path.join(trashDir, trashId + '__' + base);
196
+ fs.renameSync(realPath, trashPath);
197
+ trashIndex.set(trashId, { trashPath, originalPath: realPath, root, deletedAt: Date.now() });
198
+ return { trashId };
199
+ }
200
+
201
+ function restoreFromTrash(trashId, allowRoots) {
202
+ const info = trashIndex.get(trashId);
203
+ if (!info) { const e = new Error('nothing to restore - the undo window has expired or this was already restored'); e.code = 'NOT_FOUND'; throw e; }
204
+ // Re-confine the ORIGINAL path at restore time (not trust the cached one
205
+ // blindly) - the allowlist itself doesn't change at runtime, but this keeps
206
+ // restore honoring the exact same confinement contract every other route does.
207
+ const conf = confineToRoots(info.originalPath, allowRoots);
208
+ if (!conf.ok && conf.reason !== 'not found') { const e = new Error('restore target is no longer inside an accessible folder'); e.code = 'CONFLICT'; throw e; }
209
+ if (fs.existsSync(info.originalPath)) { const e = new Error('a file already exists at the original location'); e.code = 'CONFLICT'; throw e; }
210
+ fs.mkdirSync(path.dirname(info.originalPath), { recursive: true });
211
+ fs.renameSync(info.trashPath, info.originalPath);
212
+ trashIndex.delete(trashId);
213
+ return { path: info.originalPath };
214
+ }
215
+
149
216
  export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
150
217
  // Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
151
218
  // cross-origin page can make credentialless fetch() calls to all /api/*
@@ -690,13 +757,18 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
690
757
  if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
691
758
  try {
692
759
  const st = fs.lstatSync(conf.realPath);
693
- if (st.isDirectory()) {
694
- if (body.recursive === true) fs.rmSync(conf.realPath, { recursive: true });
695
- else fs.rmdirSync(conf.realPath); // throws ENOTEMPTY unless empty
696
- } else {
697
- fs.unlinkSync(conf.realPath);
760
+ // Soft-delete: move into a confined per-root .agentgui-trash/ instead
761
+ // of unlinking, so the only safety net isn't confirm-before (the
762
+ // pre-existing ConfirmDialog) but also undo-after, matching an
763
+ // fsbrowse-grade file manager. A non-empty directory without
764
+ // recursive=true still throws ENOTEMPTY BEFORE any move happens
765
+ // (checked via a dry probe) to keep that existing guard's semantics.
766
+ if (st.isDirectory() && body.recursive !== true) {
767
+ const dryEntries = fs.readdirSync(conf.realPath);
768
+ if (dryEntries.length) { sendJSON(req, res, 409, { error: 'directory is not empty' }); return; }
698
769
  }
699
- sendJSON(req, res, 200, { ok: true });
770
+ const trashInfo = moveToTrash(conf.realPath, allowRoots);
771
+ sendJSON(req, res, 200, { ok: true, trashId: trashInfo.trashId });
700
772
  } catch (err) {
701
773
  const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
702
774
  sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
@@ -704,6 +776,22 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
704
776
  return;
705
777
  }
706
778
 
779
+ // POST /api/restore {trashId} -> {ok, path}. Undoes a /api/delete within
780
+ // its retention window (trashRetentionMs, default 10 minutes) by moving
781
+ // the entry back from .agentgui-trash/ to its original confined path.
782
+ if (routePath.split('?')[0] === '/api/restore' && req.method === 'POST') {
783
+ let body;
784
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
785
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
786
+ try {
787
+ const restored = restoreFromTrash(String(body.trashId || ''), fsAllowRoots());
788
+ sendJSON(req, res, 200, { ok: true, path: restored.path });
789
+ } catch (err) {
790
+ sendJSON(req, res, err.code === 'NOT_FOUND' ? 404 : (err.code === 'CONFLICT' ? 409 : 400), { error: err.message });
791
+ }
792
+ return;
793
+ }
794
+
707
795
  // POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
708
796
  if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
709
797
  let body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1063",
3
+ "version": "1.0.1064",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -1180,10 +1180,13 @@ async function runFileMutation(fn, doneMsg, patch) {
1180
1180
  if (!d || d.busy) return;
1181
1181
  d.busy = true; d.error = null; render();
1182
1182
  try {
1183
- await fn();
1183
+ const result = await fn();
1184
1184
  state.files.dialog = null;
1185
1185
  restoreFileDialogFocus(d._trigger);
1186
1186
  announce(doneMsg);
1187
+ // A soft-delete's trashId (if this mutation was a delete) rides the
1188
+ // return value straight to the undo-toast caller.
1189
+ if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
1187
1190
  if (patch) {
1188
1191
  // Patch the visible list immediately, matching the bulk-delete/move
1189
1192
  // pattern, instead of stalling the dialog on a second full round-trip.
@@ -1200,6 +1203,36 @@ async function runFileMutation(fn, doneMsg, patch) {
1200
1203
  d.busy = false; d.error = fileMutationCopy(e); render();
1201
1204
  }
1202
1205
  }
1206
+ // Delete is a soft-delete server-side (moved to a confined .agentgui-trash/,
1207
+ // see lib/http-handler.js) - offer a real undo action within the retention
1208
+ // window instead of the ConfirmDialog's pre-delete confirm being the ONLY
1209
+ // safety net. One active undo toast at a time (the most recent delete wins;
1210
+ // an in-flight bulk-delete calls this per-entry, each replacing the last -
1211
+ // acceptable since restoring the single most recent one is still strictly
1212
+ // better than no undo at all, and stacking N toasts for a bulk op would be
1213
+ // its own UX problem).
1214
+ const UNDO_DELETE_WINDOW_MS = 10000;
1215
+ let _undoDeleteTimer = null;
1216
+ function offerUndoDelete(trashId, doneMsg) {
1217
+ clearTimeout(_undoDeleteTimer);
1218
+ state.files.undoDelete = { trashId, doneMsg };
1219
+ render();
1220
+ _undoDeleteTimer = setTimeout(() => { state.files.undoDelete = null; render(); }, UNDO_DELETE_WINDOW_MS);
1221
+ }
1222
+ async function undoDelete() {
1223
+ const u = state.files.undoDelete;
1224
+ if (!u) return;
1225
+ clearTimeout(_undoDeleteTimer);
1226
+ state.files.undoDelete = null;
1227
+ try {
1228
+ await B.restoreEntry(state.backend, u.trashId);
1229
+ announce('restored');
1230
+ await loadDir(state.files.path, { fromHash: true });
1231
+ } catch (e) {
1232
+ announce('could not restore: ' + fileMutationCopy(e));
1233
+ render();
1234
+ }
1235
+ }
1203
1236
  // Upload a FileList into the current directory; per-file rows feed the kit
1204
1237
  // UploadProgress (done/error per file - fetch has no chunk progress).
1205
1238
  async function uploadFiles(fileList) {
@@ -1334,9 +1367,12 @@ function fileDialog() {
1334
1367
  const isDir = d.file.type === 'dir';
1335
1368
  return ConfirmDialog({
1336
1369
  title: 'Delete ' + d.file.name,
1370
+ // Delete is soft (moved to trash, undoable for a short window right
1371
+ // after) - the copy no longer overclaims permanence the way an actual
1372
+ // unlink would warrant.
1337
1373
  message: isDir
1338
- ? 'Delete this folder and everything inside it? This cannot be undone.'
1339
- : 'Delete this file? This cannot be undone.',
1374
+ ? 'Delete this folder and everything inside it? You can undo this for a few seconds after.'
1375
+ : 'Delete this file? You can undo this for a few seconds after.',
1340
1376
  error: d.error || null, busy: !!d.busy,
1341
1377
  confirmLabel: d.busy ? 'deleting…' : 'delete', cancelLabel: 'cancel', destructive: true,
1342
1378
  onCancel: closeFileDialog,
@@ -1659,6 +1695,10 @@ function filesMain() {
1659
1695
  : body;
1660
1696
  return [
1661
1697
  offlineBanner(),
1698
+ f.undoDelete ? Alert({ key: 'undodel', kind: 'info', title: 'Deleted',
1699
+ children: [
1700
+ h('span', { key: 'udtxt' }, (f.undoDelete.doneMsg || 'Deleted') + ' - undo within a few seconds. '),
1701
+ Btn({ key: 'udbtn', onClick: () => undoDelete(), children: 'undo' })] }) : null,
1662
1702
  PageHeader({ compact: true, dense: true, title: 'Files', lede: 'Browse and manage files in the allowed folders.' }),
1663
1703
  // One vertical beat (.ds-files-stack gap) for the whole command stack -
1664
1704
  // the bands used to butt edge-to-edge while the header gap was 24px.
@@ -167,7 +167,10 @@ export async function statPath(base, p) {
167
167
  }
168
168
 
169
169
  export function renameEntry(base, filePath, newName) { return mutateJSON(base, '/api/rename', { path: filePath, newName }); }
170
+ // deleteEntry's server response now includes trashId - the delete is a soft
171
+ // move-to-trash, not a permanent unlink, so the caller can offer an undo.
170
172
  export function deleteEntry(base, filePath, recursive) { return mutateJSON(base, '/api/delete', { path: filePath, recursive: !!recursive }); }
173
+ export function restoreEntry(base, trashId) { return mutateJSON(base, '/api/restore', { trashId }); }
171
174
  export function makeDir(base, dirPath, name) { return mutateJSON(base, '/api/mkdir', { dir: dirPath, name }); }
172
175
  export function moveEntry(base, filePath, destDir, overwrite) { return mutateJSON(base, '/api/move', { path: filePath, destDir, overwrite: !!overwrite }); }
173
176